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
27 changes: 26 additions & 1 deletion apps/docs/reference/use-frontend-tool.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ function SearchComponent() {

## Parameters

The hook accepts a single `ReactFrontendTool` object with the following properties:
The hook accepts:

- A required `ReactFrontendTool` object describing the tool
- An optional dependency array to control when the tool is re-registered

### name

Expand Down Expand Up @@ -184,6 +187,28 @@ useFrontendTool({
});
```

### deps (second argument)

`ReadonlyArray<unknown>` **(optional)**

Additional dependencies that should trigger re-registration of the tool. By default, the hook only depends on the tool
name and CopilotKit instance to avoid re-register loops from object identity changes. Pass a dependency array as the
second argument when the tool's configuration is derived from changing props or state:

```tsx
function PriceTool({ currency }: { currency: string }) {
useFrontendTool(
{
name: "convertPrice",
handler: async (args) => convertPrice(args.amount, currency),
},
[currency],
);

return null;
}
```

## Lifecycle Management

### Automatic Registration
Expand Down
31 changes: 30 additions & 1 deletion apps/docs/reference/use-human-in-the-loop.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ function ApprovalComponent() {

## Parameters

The hook accepts a single `ReactHumanInTheLoop` object with the following properties:
The hook accepts:

- A required `ReactHumanInTheLoop` object describing the tool
- An optional `options` object for controlling dependency behavior

### name

Expand Down Expand Up @@ -182,6 +185,32 @@ useHumanInTheLoop({
});
```

## Options

### deps (second argument)

`ReadonlyArray<unknown>` **(optional)**

Additional dependencies that should trigger re-registration of the human-in-the-loop tool. By default, the hook only
depends on stable keys like the tool name and CopilotKit instance to avoid re-register loops from object identity
changes. Pass a dependency array as the second argument when the tool configuration is derived from changing props or
state:

```tsx
function VersionedApproval({ version }: { version: number }) {
useHumanInTheLoop(
{
name: "versionedApproval",
description: `Approve deployment v${version}`,
// ...
},
[version],
);

return null;
}
```

## Differences from useFrontendTool

While `useFrontendTool` executes immediately and returns results, `useHumanInTheLoop`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ const DependencyDrivenHarness: React.FC = () => {
});
const [version, setVersion] = useState(0);

useConfigureSuggestions(configRef.current, { deps: [version] });
useConfigureSuggestions(configRef.current, [version]);

const { suggestions } = useSuggestions();

Expand Down Expand Up @@ -370,7 +370,7 @@ const DynamicStreamingHarness: React.FC = () => {
consumerAgentId: "consumer",
available: "always",
},
{ deps: [topic] },
[topic],
);

const { suggestions, reloadSuggestions } = useSuggestions({ agentId: "consumer" });
Expand Down
72 changes: 72 additions & 0 deletions packages/react/src/hooks/__tests__/use-frontend-tool.e2e.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1622,6 +1622,78 @@ describe("useFrontendTool E2E - Dynamic Registration", () => {
});
});

describe("useFrontendTool dependencies", () => {
it("updates tool renderer when optional deps change", async () => {
const DependencyDrivenTool: React.FC = () => {
const [version, setVersion] = useState(0);

const tool: ReactFrontendTool<{ message: string }> = {
name: "dependencyTool",
parameters: z.object({ message: z.string() }),
render: ({ args }) => (
<div data-testid="dependency-tool-render">
{args.message} (v{version})
</div>
),
};

useFrontendTool(tool, [version]);

const toolCallId = testId("dep_tc");
const assistantMessage: AssistantMessage = {
id: testId("dep_a"),
role: "assistant",
content: "",
toolCalls: [
{
id: toolCallId,
type: "function",
function: {
name: "dependencyTool",
arguments: JSON.stringify({ message: "hello" }),
},
} as any,
],
} as any;
const messages: Message[] = [];

return (
<>
<button
data-testid="bump-version"
type="button"
onClick={() => setVersion((v) => v + 1)}
>
Bump
</button>
<CopilotChatToolCallsView
message={assistantMessage}
messages={messages}
/>
</>
);
};

renderWithCopilotKit({
children: <DependencyDrivenTool />,
});

await waitFor(() => {
const el = screen.getByTestId("dependency-tool-render");
expect(el).toBeDefined();
expect(el.textContent).toContain("hello");
expect(el.textContent).toContain("(v0)");
});

fireEvent.click(screen.getByTestId("bump-version"));

await waitFor(() => {
const el = screen.getByTestId("dependency-tool-render");
expect(el.textContent).toContain("(v1)");
});
});
});

describe("Error Propagation", () => {
it("should propagate handler errors to renderer", async () => {
const agent = new MockStepwiseAgent();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useHumanInTheLoop } from "../use-human-in-the-loop";
import { ReactHumanInTheLoop } from "@/types";
import { ToolCallStatus } from "@copilotkitnext/core";
import { CopilotChat } from "@/components/chat/CopilotChat";
import CopilotChatToolCallsView from "@/components/chat/CopilotChatToolCallsView";
import { AssistantMessage, Message } from "@ag-ui/core";
import {
MockStepwiseAgent,
renderWithCopilotKit,
Expand Down Expand Up @@ -573,4 +575,77 @@ describe("useHumanInTheLoop E2E - HITL Tool Rendering", () => {
agent.complete();
});
});

describe("useHumanInTheLoop dependencies", () => {
it("updates HITL renderer when optional deps change", async () => {
const DependencyDrivenHITLComponent: React.FC = () => {
const [version, setVersion] = useState(0);

const hitlTool: ReactHumanInTheLoop<{ message: string }> = {
name: "dependencyHitlTool",
description: "Dependency-driven HITL tool",
parameters: z.object({ message: z.string() }),
render: ({ args }) => (
<div data-testid="dependency-hitl-render">
{args.message} (v{version})
</div>
),
};

useHumanInTheLoop(hitlTool, [version]);

const toolCallId = testId("hitl_dep_tc");
const assistantMessage: AssistantMessage = {
id: testId("hitl_dep_a"),
role: "assistant",
content: "",
toolCalls: [
{
id: toolCallId,
type: "function",
function: {
name: "dependencyHitlTool",
arguments: JSON.stringify({ message: "hello" }),
},
} as any,
],
} as any;
const messages: Message[] = [];

return (
<>
<button
data-testid="hitl-bump-version"
type="button"
onClick={() => setVersion((v) => v + 1)}
>
Bump
</button>
<CopilotChatToolCallsView
message={assistantMessage}
messages={messages}
/>
</>
);
};

renderWithCopilotKit({
children: <DependencyDrivenHITLComponent />,
});

await waitFor(() => {
const el = screen.getByTestId("dependency-hitl-render");
expect(el).toBeDefined();
expect(el.textContent).toContain("hello");
expect(el.textContent).toContain("(v0)");
});

fireEvent.click(screen.getByTestId("hitl-bump-version"));

await waitFor(() => {
const el = screen.getByTestId("dependency-hitl-render");
expect(el.textContent).toContain("(v1)");
});
});
});
});
10 changes: 2 additions & 8 deletions packages/react/src/hooks/use-configure-suggestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,13 @@ type StaticSuggestionsConfigInput = Omit<StaticSuggestionsConfig, "suggestions">

type SuggestionsConfigInput = DynamicSuggestionsConfig | StaticSuggestionsConfigInput;

const EMPTY_DEPS: ReadonlyArray<unknown> = [];

export interface UseConfigureSuggestionsOptions {
deps?: ReadonlyArray<unknown>;
}

export function useConfigureSuggestions(
config: SuggestionsConfigInput | null | undefined,
options?: UseConfigureSuggestionsOptions,
deps?: ReadonlyArray<unknown>,
): void {
const { copilotkit } = useCopilotKit();
const chatConfig = useCopilotChatConfiguration();
const extraDeps = options?.deps ?? EMPTY_DEPS;
const extraDeps = deps ?? [];

const resolvedConsumerAgentId = useMemo(() => chatConfig?.agentId ?? DEFAULT_AGENT_ID, [chatConfig?.agentId]);

Expand Down
10 changes: 7 additions & 3 deletions packages/react/src/hooks/use-frontend-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import { useCopilotKit } from "../providers/CopilotKitProvider";
import { ReactFrontendTool } from "../types/frontend-tool";
import { ReactToolCallRenderer } from "../types/react-tool-call-renderer";

const EMPTY_DEPS: ReadonlyArray<unknown> = [];

export function useFrontendTool<
T extends Record<string, unknown> = Record<string, unknown>,
>(tool: ReactFrontendTool<T>) {
>(tool: ReactFrontendTool<T>, deps?: ReadonlyArray<unknown>) {
const { copilotkit } = useCopilotKit();
const extraDeps = deps ?? EMPTY_DEPS;

useEffect(() => {
const name = tool.name;
Expand Down Expand Up @@ -49,6 +52,7 @@ export function useFrontendTool<
copilotkit.removeTool(name, tool.agentId);
// we are intentionally not removing the render here so that the tools can still render in the chat history
};
// Depend only on stable keys to avoid re-register loops due to object identity
}, [tool.name, copilotkit]);
// Depend on stable keys by default and allow callers to opt into
// additional dependencies for dynamic tool configuration.
}, [tool.name, copilotkit, extraDeps.length, ...extraDeps]);
}
3 changes: 2 additions & 1 deletion packages/react/src/hooks/use-human-in-the-loop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useCopilotKit } from "@/providers/CopilotKitProvider";

export function useHumanInTheLoop<T extends Record<string, unknown> = Record<string, unknown>>(
tool: ReactHumanInTheLoop<T>,
deps?: ReadonlyArray<unknown>,
) {
const { copilotkit } = useCopilotKit();
const resolvePromiseRef = useRef<((result: unknown) => void) | null>(null);
Expand Down Expand Up @@ -69,7 +70,7 @@ export function useHumanInTheLoop<T extends Record<string, unknown> = Record<str
render: RenderComponent,
};

useFrontendTool(frontendTool);
useFrontendTool(frontendTool, deps);

// Human-in-the-loop tools should remove their renderer on unmount
// since they can't respond to user interactions anymore
Expand Down