Skip to content

Commit 686577d

Browse files
feat: add tool approval state and tool approval component (#163)
* feat: add tool approval state and tool approval component * Delete apps/test/app/examples/tool-approval.tsx * Delete apps/test/app/examples/tool.tsx * Delete apps/test/app/page.tsx * Delete apps/test/package.json * Update pnpm-lock.yaml * Update pnpm-lock.yaml * Create slow-dancers-tap.md --------- Co-authored-by: Hayden Bleasel <[email protected]>
1 parent da5520f commit 686577d

File tree

9 files changed

+1021
-200
lines changed

9 files changed

+1021
-200
lines changed

.changeset/slow-dancers-tap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ai-elements": patch
3+
---
4+
5+
feat: add tool approval state and tool approval component
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { userEvent } from "@testing-library/user-event";
3+
import { CheckIcon, XIcon } from "lucide-react";
4+
import { describe, expect, it, vi } from "vitest";
5+
import {
6+
ToolApproval,
7+
ToolApprovalAccepted,
8+
ToolApprovalAction,
9+
ToolApprovalActions,
10+
ToolApprovalContent,
11+
ToolApprovalRejected,
12+
ToolApprovalRequest,
13+
} from "../src/tool-approval";
14+
15+
describe("ToolApproval", () => {
16+
it("renders children when approval is present", () => {
17+
render(
18+
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
19+
<div>Approval Content</div>
20+
</ToolApproval>
21+
);
22+
expect(screen.getByText("Approval Content")).toBeInTheDocument();
23+
});
24+
25+
it("does not render when approval is not present", () => {
26+
const { container } = render(
27+
<ToolApproval state="input-streaming">
28+
<div>Approval Content</div>
29+
</ToolApproval>
30+
);
31+
expect(container.firstChild).toBeNull();
32+
});
33+
34+
it("does not render in input-streaming state", () => {
35+
const { container } = render(
36+
<ToolApproval approval={{ id: "test-id" }} state="input-streaming">
37+
<div>Approval Content</div>
38+
</ToolApproval>
39+
);
40+
expect(container.firstChild).toBeNull();
41+
});
42+
43+
it("does not render in input-available state", () => {
44+
const { container } = render(
45+
<ToolApproval approval={{ id: "test-id" }} state="input-available">
46+
<div>Approval Content</div>
47+
</ToolApproval>
48+
);
49+
expect(container.firstChild).toBeNull();
50+
});
51+
52+
it("applies custom className", () => {
53+
const { container } = render(
54+
<ToolApproval
55+
approval={{ id: "test-id" }}
56+
className="custom-class"
57+
state="approval-requested"
58+
>
59+
<div>Content</div>
60+
</ToolApproval>
61+
);
62+
expect(container.firstChild).toHaveClass("custom-class");
63+
});
64+
});
65+
66+
describe("ToolApprovalContent", () => {
67+
it("renders ToolApprovalRequest when state is approval-requested", () => {
68+
render(
69+
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
70+
<ToolApprovalContent>
71+
<ToolApprovalRequest>Custom approval message</ToolApprovalRequest>
72+
<ToolApprovalAccepted>Accepted</ToolApprovalAccepted>
73+
<ToolApprovalRejected>Rejected</ToolApprovalRejected>
74+
</ToolApprovalContent>
75+
</ToolApproval>
76+
);
77+
expect(screen.getByText("Custom approval message")).toBeInTheDocument();
78+
expect(screen.queryByText("Accepted")).not.toBeInTheDocument();
79+
expect(screen.queryByText("Rejected")).not.toBeInTheDocument();
80+
});
81+
82+
it("renders ToolApprovalAccepted when approved and state is approval-responded", () => {
83+
render(
84+
<ToolApproval
85+
approval={{ id: "test-id", approved: true }}
86+
state="approval-responded"
87+
>
88+
<ToolApprovalContent>
89+
<ToolApprovalRequest>Custom approval message</ToolApprovalRequest>
90+
<ToolApprovalAccepted>
91+
<CheckIcon />
92+
<span>Accepted</span>
93+
</ToolApprovalAccepted>
94+
<ToolApprovalRejected>
95+
<XIcon />
96+
<span>Rejected</span>
97+
</ToolApprovalRejected>
98+
</ToolApprovalContent>
99+
</ToolApproval>
100+
);
101+
expect(screen.getByText("Accepted")).toBeInTheDocument();
102+
expect(
103+
screen.queryByText("Custom approval message")
104+
).not.toBeInTheDocument();
105+
expect(screen.queryByText("Rejected")).not.toBeInTheDocument();
106+
});
107+
108+
it("renders ToolApprovalRejected when not approved and state is output-denied", () => {
109+
render(
110+
<ToolApproval
111+
approval={{ id: "test-id", approved: false }}
112+
state="output-denied"
113+
>
114+
<ToolApprovalContent>
115+
<ToolApprovalRequest>Custom approval message</ToolApprovalRequest>
116+
<ToolApprovalAccepted>
117+
<CheckIcon />
118+
<span>Accepted</span>
119+
</ToolApprovalAccepted>
120+
<ToolApprovalRejected>
121+
<XIcon />
122+
<span>Rejected</span>
123+
</ToolApprovalRejected>
124+
</ToolApprovalContent>
125+
</ToolApproval>
126+
);
127+
expect(screen.getByText("Rejected")).toBeInTheDocument();
128+
expect(
129+
screen.queryByText("Custom approval message")
130+
).not.toBeInTheDocument();
131+
expect(screen.queryByText("Accepted")).not.toBeInTheDocument();
132+
});
133+
134+
it("applies custom className", () => {
135+
const { container } = render(
136+
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
137+
<ToolApprovalContent className="custom-class">
138+
<ToolApprovalRequest>Custom approval message</ToolApprovalRequest>
139+
<ToolApprovalAccepted>Accepted</ToolApprovalAccepted>
140+
<ToolApprovalRejected>Rejected</ToolApprovalRejected>
141+
</ToolApprovalContent>
142+
</ToolApproval>
143+
);
144+
const content = container.querySelector(".custom-class");
145+
expect(content).toBeInTheDocument();
146+
expect(content).toHaveTextContent("Custom approval message");
147+
});
148+
});
149+
150+
describe("ToolApprovalActions", () => {
151+
it("renders custom children buttons", () => {
152+
render(
153+
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
154+
<ToolApprovalActions>
155+
<ToolApprovalAction variant="outline">Reject</ToolApprovalAction>
156+
<ToolApprovalAction variant="default">Accept</ToolApprovalAction>
157+
</ToolApprovalActions>
158+
</ToolApproval>
159+
);
160+
expect(screen.getByText("Accept")).toBeInTheDocument();
161+
expect(screen.getByText("Reject")).toBeInTheDocument();
162+
});
163+
164+
it("hides when state is not approval-requested", () => {
165+
render(
166+
<ToolApproval approval={{ id: "test-id" }} state="approval-responded">
167+
<ToolApprovalActions>
168+
<ToolApprovalAction variant="outline">Reject</ToolApprovalAction>
169+
<ToolApprovalAction variant="default">Accept</ToolApprovalAction>
170+
</ToolApprovalActions>
171+
</ToolApproval>
172+
);
173+
expect(screen.queryByText("Accept")).not.toBeInTheDocument();
174+
expect(screen.queryByText("Reject")).not.toBeInTheDocument();
175+
});
176+
177+
it("shows when state is approval-requested", () => {
178+
render(
179+
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
180+
<ToolApprovalActions>
181+
<ToolApprovalAction variant="outline">Reject</ToolApprovalAction>
182+
<ToolApprovalAction variant="default">Accept</ToolApprovalAction>
183+
</ToolApprovalActions>
184+
</ToolApproval>
185+
);
186+
expect(screen.getByText("Accept")).toBeInTheDocument();
187+
expect(screen.getByText("Reject")).toBeInTheDocument();
188+
});
189+
190+
it("calls onClick when accept button is clicked", async () => {
191+
const user = userEvent.setup();
192+
const handleAccept = vi.fn();
193+
render(
194+
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
195+
<ToolApprovalActions>
196+
<ToolApprovalAction variant="outline">Reject</ToolApprovalAction>
197+
<ToolApprovalAction onClick={handleAccept} variant="default">
198+
Accept
199+
</ToolApprovalAction>
200+
</ToolApprovalActions>
201+
</ToolApproval>
202+
);
203+
204+
await user.click(screen.getByText("Accept"));
205+
expect(handleAccept).toHaveBeenCalledTimes(1);
206+
});
207+
208+
it("calls onClick when reject button is clicked", async () => {
209+
const user = userEvent.setup();
210+
const handleReject = vi.fn();
211+
render(
212+
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
213+
<ToolApprovalActions>
214+
<ToolApprovalAction onClick={handleReject} variant="outline">
215+
Reject
216+
</ToolApprovalAction>
217+
<ToolApprovalAction variant="default">Accept</ToolApprovalAction>
218+
</ToolApprovalActions>
219+
</ToolApproval>
220+
);
221+
222+
await user.click(screen.getByText("Reject"));
223+
expect(handleReject).toHaveBeenCalledTimes(1);
224+
});
225+
226+
it("disables buttons when disabled prop is true", () => {
227+
render(
228+
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
229+
<ToolApprovalActions>
230+
<ToolApprovalAction disabled variant="outline">
231+
Reject
232+
</ToolApprovalAction>
233+
<ToolApprovalAction disabled variant="default">
234+
Accept
235+
</ToolApprovalAction>
236+
</ToolApprovalActions>
237+
</ToolApproval>
238+
);
239+
expect(screen.getByText("Accept")).toBeDisabled();
240+
expect(screen.getByText("Reject")).toBeDisabled();
241+
});
242+
243+
it("applies custom className", () => {
244+
render(
245+
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
246+
<ToolApprovalActions className="custom-class">
247+
<ToolApprovalAction variant="outline">Reject</ToolApprovalAction>
248+
<ToolApprovalAction variant="default">Accept</ToolApprovalAction>
249+
</ToolApprovalActions>
250+
</ToolApproval>
251+
);
252+
const actionsContainer = screen.getByText("Accept").parentElement;
253+
expect(actionsContainer).toHaveClass("custom-class");
254+
});
255+
});
256+
257+
describe("ToolApprovalAccepted", () => {
258+
it("renders accepted status with icon", () => {
259+
render(
260+
<ToolApproval
261+
approval={{ id: "test-id", approved: true }}
262+
state="approval-responded"
263+
>
264+
<ToolApprovalContent>
265+
<ToolApprovalRequest>Request</ToolApprovalRequest>
266+
<ToolApprovalAccepted>
267+
<CheckIcon className="size-4" />
268+
<span>Accepted</span>
269+
</ToolApprovalAccepted>
270+
<ToolApprovalRejected>Rejected</ToolApprovalRejected>
271+
</ToolApprovalContent>
272+
</ToolApproval>
273+
);
274+
expect(screen.getByText("Accepted")).toBeInTheDocument();
275+
});
276+
});
277+
278+
describe("ToolApprovalRejected", () => {
279+
it("renders rejected status with icon", () => {
280+
render(
281+
<ToolApproval
282+
approval={{ id: "test-id", approved: false }}
283+
state="output-denied"
284+
>
285+
<ToolApprovalContent>
286+
<ToolApprovalRequest>Request</ToolApprovalRequest>
287+
<ToolApprovalAccepted>Accepted</ToolApprovalAccepted>
288+
<ToolApprovalRejected>
289+
<XIcon className="size-4" />
290+
<span>Rejected</span>
291+
</ToolApprovalRejected>
292+
</ToolApprovalContent>
293+
</ToolApproval>
294+
);
295+
expect(screen.getByText("Rejected")).toBeInTheDocument();
296+
});
297+
});

packages/elements/__tests__/tool.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,45 @@ describe("ToolHeader", () => {
7575
expect(screen.getByText("Error")).toBeInTheDocument();
7676
});
7777

78+
it("shows awaiting approval status", () => {
79+
render(
80+
<Tool>
81+
<ToolHeader
82+
state={"approval-requested" as any}
83+
title="test"
84+
type="tool-test"
85+
/>
86+
</Tool>
87+
);
88+
expect(screen.getByText("Awaiting Approval")).toBeInTheDocument();
89+
});
90+
91+
it("shows responded status", () => {
92+
render(
93+
<Tool>
94+
<ToolHeader
95+
state={"approval-responded" as any}
96+
title="test"
97+
type="tool-test"
98+
/>
99+
</Tool>
100+
);
101+
expect(screen.getByText("Responded")).toBeInTheDocument();
102+
});
103+
104+
it("shows denied status", () => {
105+
render(
106+
<Tool>
107+
<ToolHeader
108+
state={"output-denied" as any}
109+
title="test"
110+
type="tool-test"
111+
/>
112+
</Tool>
113+
);
114+
expect(screen.getByText("Denied")).toBeInTheDocument();
115+
});
116+
78117
it("has wrench icon", () => {
79118
const { container } = render(
80119
<Tool>

packages/elements/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"@radix-ui/react-use-controllable-state": "^1.2.2",
1616
"@repo/shadcn-ui": "workspace:*",
1717
"@xyflow/react": "^12.9.0",
18-
"ai": "5.0.81",
18+
"ai": "5.1.0-beta.22",
1919
"class-variance-authority": "^0.7.1",
2020
"lucide-react": "^0.546.0",
2121
"motion": "^12.23.24",

0 commit comments

Comments
 (0)