Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slow-dancers-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ai-elements": patch
---

feat: add tool approval state and tool approval component
297 changes: 297 additions & 0 deletions packages/elements/__tests__/tool-approval.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { CheckIcon, XIcon } from "lucide-react";
import { describe, expect, it, vi } from "vitest";
import {
ToolApproval,
ToolApprovalAccepted,
ToolApprovalAction,
ToolApprovalActions,
ToolApprovalContent,
ToolApprovalRejected,
ToolApprovalRequest,
} from "../src/tool-approval";

describe("ToolApproval", () => {
it("renders children when approval is present", () => {
render(
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
<div>Approval Content</div>
</ToolApproval>
);
expect(screen.getByText("Approval Content")).toBeInTheDocument();
});

it("does not render when approval is not present", () => {
const { container } = render(
<ToolApproval state="input-streaming">
<div>Approval Content</div>
</ToolApproval>
);
expect(container.firstChild).toBeNull();
});

it("does not render in input-streaming state", () => {
const { container } = render(
<ToolApproval approval={{ id: "test-id" }} state="input-streaming">
<div>Approval Content</div>
</ToolApproval>
);
expect(container.firstChild).toBeNull();
});

it("does not render in input-available state", () => {
const { container } = render(
<ToolApproval approval={{ id: "test-id" }} state="input-available">
<div>Approval Content</div>
</ToolApproval>
);
expect(container.firstChild).toBeNull();
});

it("applies custom className", () => {
const { container } = render(
<ToolApproval
approval={{ id: "test-id" }}
className="custom-class"
state="approval-requested"
>
<div>Content</div>
</ToolApproval>
);
expect(container.firstChild).toHaveClass("custom-class");
});
});

describe("ToolApprovalContent", () => {
it("renders ToolApprovalRequest when state is approval-requested", () => {
render(
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
<ToolApprovalContent>
<ToolApprovalRequest>Custom approval message</ToolApprovalRequest>
<ToolApprovalAccepted>Accepted</ToolApprovalAccepted>
<ToolApprovalRejected>Rejected</ToolApprovalRejected>
</ToolApprovalContent>
</ToolApproval>
);
expect(screen.getByText("Custom approval message")).toBeInTheDocument();
expect(screen.queryByText("Accepted")).not.toBeInTheDocument();
expect(screen.queryByText("Rejected")).not.toBeInTheDocument();
});

it("renders ToolApprovalAccepted when approved and state is approval-responded", () => {
render(
<ToolApproval
approval={{ id: "test-id", approved: true }}
state="approval-responded"
>
<ToolApprovalContent>
<ToolApprovalRequest>Custom approval message</ToolApprovalRequest>
<ToolApprovalAccepted>
<CheckIcon />
<span>Accepted</span>
</ToolApprovalAccepted>
<ToolApprovalRejected>
<XIcon />
<span>Rejected</span>
</ToolApprovalRejected>
</ToolApprovalContent>
</ToolApproval>
);
expect(screen.getByText("Accepted")).toBeInTheDocument();
expect(
screen.queryByText("Custom approval message")
).not.toBeInTheDocument();
expect(screen.queryByText("Rejected")).not.toBeInTheDocument();
});

it("renders ToolApprovalRejected when not approved and state is output-denied", () => {
render(
<ToolApproval
approval={{ id: "test-id", approved: false }}
state="output-denied"
>
<ToolApprovalContent>
<ToolApprovalRequest>Custom approval message</ToolApprovalRequest>
<ToolApprovalAccepted>
<CheckIcon />
<span>Accepted</span>
</ToolApprovalAccepted>
<ToolApprovalRejected>
<XIcon />
<span>Rejected</span>
</ToolApprovalRejected>
</ToolApprovalContent>
</ToolApproval>
);
expect(screen.getByText("Rejected")).toBeInTheDocument();
expect(
screen.queryByText("Custom approval message")
).not.toBeInTheDocument();
expect(screen.queryByText("Accepted")).not.toBeInTheDocument();
});

it("applies custom className", () => {
const { container } = render(
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
<ToolApprovalContent className="custom-class">
<ToolApprovalRequest>Custom approval message</ToolApprovalRequest>
<ToolApprovalAccepted>Accepted</ToolApprovalAccepted>
<ToolApprovalRejected>Rejected</ToolApprovalRejected>
</ToolApprovalContent>
</ToolApproval>
);
const content = container.querySelector(".custom-class");
expect(content).toBeInTheDocument();
expect(content).toHaveTextContent("Custom approval message");
});
});

describe("ToolApprovalActions", () => {
it("renders custom children buttons", () => {
render(
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
<ToolApprovalActions>
<ToolApprovalAction variant="outline">Reject</ToolApprovalAction>
<ToolApprovalAction variant="default">Accept</ToolApprovalAction>
</ToolApprovalActions>
</ToolApproval>
);
expect(screen.getByText("Accept")).toBeInTheDocument();
expect(screen.getByText("Reject")).toBeInTheDocument();
});

it("hides when state is not approval-requested", () => {
render(
<ToolApproval approval={{ id: "test-id" }} state="approval-responded">
<ToolApprovalActions>
<ToolApprovalAction variant="outline">Reject</ToolApprovalAction>
<ToolApprovalAction variant="default">Accept</ToolApprovalAction>
</ToolApprovalActions>
</ToolApproval>
);
expect(screen.queryByText("Accept")).not.toBeInTheDocument();
expect(screen.queryByText("Reject")).not.toBeInTheDocument();
});

it("shows when state is approval-requested", () => {
render(
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
<ToolApprovalActions>
<ToolApprovalAction variant="outline">Reject</ToolApprovalAction>
<ToolApprovalAction variant="default">Accept</ToolApprovalAction>
</ToolApprovalActions>
</ToolApproval>
);
expect(screen.getByText("Accept")).toBeInTheDocument();
expect(screen.getByText("Reject")).toBeInTheDocument();
});

it("calls onClick when accept button is clicked", async () => {
const user = userEvent.setup();
const handleAccept = vi.fn();
render(
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
<ToolApprovalActions>
<ToolApprovalAction variant="outline">Reject</ToolApprovalAction>
<ToolApprovalAction onClick={handleAccept} variant="default">
Accept
</ToolApprovalAction>
</ToolApprovalActions>
</ToolApproval>
);

await user.click(screen.getByText("Accept"));
expect(handleAccept).toHaveBeenCalledTimes(1);
});

it("calls onClick when reject button is clicked", async () => {
const user = userEvent.setup();
const handleReject = vi.fn();
render(
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
<ToolApprovalActions>
<ToolApprovalAction onClick={handleReject} variant="outline">
Reject
</ToolApprovalAction>
<ToolApprovalAction variant="default">Accept</ToolApprovalAction>
</ToolApprovalActions>
</ToolApproval>
);

await user.click(screen.getByText("Reject"));
expect(handleReject).toHaveBeenCalledTimes(1);
});

it("disables buttons when disabled prop is true", () => {
render(
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
<ToolApprovalActions>
<ToolApprovalAction disabled variant="outline">
Reject
</ToolApprovalAction>
<ToolApprovalAction disabled variant="default">
Accept
</ToolApprovalAction>
</ToolApprovalActions>
</ToolApproval>
);
expect(screen.getByText("Accept")).toBeDisabled();
expect(screen.getByText("Reject")).toBeDisabled();
});

it("applies custom className", () => {
render(
<ToolApproval approval={{ id: "test-id" }} state="approval-requested">
<ToolApprovalActions className="custom-class">
<ToolApprovalAction variant="outline">Reject</ToolApprovalAction>
<ToolApprovalAction variant="default">Accept</ToolApprovalAction>
</ToolApprovalActions>
</ToolApproval>
);
const actionsContainer = screen.getByText("Accept").parentElement;
expect(actionsContainer).toHaveClass("custom-class");
});
});

describe("ToolApprovalAccepted", () => {
it("renders accepted status with icon", () => {
render(
<ToolApproval
approval={{ id: "test-id", approved: true }}
state="approval-responded"
>
<ToolApprovalContent>
<ToolApprovalRequest>Request</ToolApprovalRequest>
<ToolApprovalAccepted>
<CheckIcon className="size-4" />
<span>Accepted</span>
</ToolApprovalAccepted>
<ToolApprovalRejected>Rejected</ToolApprovalRejected>
</ToolApprovalContent>
</ToolApproval>
);
expect(screen.getByText("Accepted")).toBeInTheDocument();
});
});

describe("ToolApprovalRejected", () => {
it("renders rejected status with icon", () => {
render(
<ToolApproval
approval={{ id: "test-id", approved: false }}
state="output-denied"
>
<ToolApprovalContent>
<ToolApprovalRequest>Request</ToolApprovalRequest>
<ToolApprovalAccepted>Accepted</ToolApprovalAccepted>
<ToolApprovalRejected>
<XIcon className="size-4" />
<span>Rejected</span>
</ToolApprovalRejected>
</ToolApprovalContent>
</ToolApproval>
);
expect(screen.getByText("Rejected")).toBeInTheDocument();
});
});
39 changes: 39 additions & 0 deletions packages/elements/__tests__/tool.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,45 @@ describe("ToolHeader", () => {
expect(screen.getByText("Error")).toBeInTheDocument();
});

it("shows awaiting approval status", () => {
render(
<Tool>
<ToolHeader
state={"approval-requested" as any}
title="test"
type="tool-test"
/>
</Tool>
);
expect(screen.getByText("Awaiting Approval")).toBeInTheDocument();
});

it("shows responded status", () => {
render(
<Tool>
<ToolHeader
state={"approval-responded" as any}
title="test"
type="tool-test"
/>
</Tool>
);
expect(screen.getByText("Responded")).toBeInTheDocument();
});

it("shows denied status", () => {
render(
<Tool>
<ToolHeader
state={"output-denied" as any}
title="test"
type="tool-test"
/>
</Tool>
);
expect(screen.getByText("Denied")).toBeInTheDocument();
});

it("has wrench icon", () => {
const { container } = render(
<Tool>
Expand Down
2 changes: 1 addition & 1 deletion packages/elements/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@repo/shadcn-ui": "workspace:*",
"@xyflow/react": "^12.9.0",
"ai": "5.0.81",
"ai": "5.1.0-beta.22",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
Expand Down
Loading
Loading