Skip to content

Commit 0fd1428

Browse files
authored
Add dependency arrays (#42)
1 parent 33cbb80 commit 0fd1428

File tree

8 files changed

+216
-16
lines changed

8 files changed

+216
-16
lines changed

apps/docs/reference/use-frontend-tool.mdx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ function SearchComponent() {
4343

4444
## Parameters
4545

46-
The hook accepts a single `ReactFrontendTool` object with the following properties:
46+
The hook accepts:
47+
48+
- A required `ReactFrontendTool` object describing the tool
49+
- An optional dependency array to control when the tool is re-registered
4750

4851
### name
4952

@@ -184,6 +187,28 @@ useFrontendTool({
184187
});
185188
```
186189

190+
### deps (second argument)
191+
192+
`ReadonlyArray<unknown>` **(optional)**
193+
194+
Additional dependencies that should trigger re-registration of the tool. By default, the hook only depends on the tool
195+
name and CopilotKit instance to avoid re-register loops from object identity changes. Pass a dependency array as the
196+
second argument when the tool's configuration is derived from changing props or state:
197+
198+
```tsx
199+
function PriceTool({ currency }: { currency: string }) {
200+
useFrontendTool(
201+
{
202+
name: "convertPrice",
203+
handler: async (args) => convertPrice(args.amount, currency),
204+
},
205+
[currency],
206+
);
207+
208+
return null;
209+
}
210+
```
211+
187212
## Lifecycle Management
188213

189214
### Automatic Registration

apps/docs/reference/use-human-in-the-loop.mdx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ function ApprovalComponent() {
5353

5454
## Parameters
5555

56-
The hook accepts a single `ReactHumanInTheLoop` object with the following properties:
56+
The hook accepts:
57+
58+
- A required `ReactHumanInTheLoop` object describing the tool
59+
- An optional `options` object for controlling dependency behavior
5760

5861
### name
5962

@@ -182,6 +185,32 @@ useHumanInTheLoop({
182185
});
183186
```
184187

188+
## Options
189+
190+
### deps (second argument)
191+
192+
`ReadonlyArray<unknown>` **(optional)**
193+
194+
Additional dependencies that should trigger re-registration of the human-in-the-loop tool. By default, the hook only
195+
depends on stable keys like the tool name and CopilotKit instance to avoid re-register loops from object identity
196+
changes. Pass a dependency array as the second argument when the tool configuration is derived from changing props or
197+
state:
198+
199+
```tsx
200+
function VersionedApproval({ version }: { version: number }) {
201+
useHumanInTheLoop(
202+
{
203+
name: "versionedApproval",
204+
description: `Approve deployment v${version}`,
205+
// ...
206+
},
207+
[version],
208+
);
209+
210+
return null;
211+
}
212+
```
213+
185214
## Differences from useFrontendTool
186215

187216
While `useFrontendTool` executes immediately and returns results, `useHumanInTheLoop`:

packages/react/src/hooks/__tests__/use-configure-suggestions.e2e.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ const DependencyDrivenHarness: React.FC = () => {
249249
});
250250
const [version, setVersion] = useState(0);
251251

252-
useConfigureSuggestions(configRef.current, { deps: [version] });
252+
useConfigureSuggestions(configRef.current, [version]);
253253

254254
const { suggestions } = useSuggestions();
255255

@@ -370,7 +370,7 @@ const DynamicStreamingHarness: React.FC = () => {
370370
consumerAgentId: "consumer",
371371
available: "always",
372372
},
373-
{ deps: [topic] },
373+
[topic],
374374
);
375375

376376
const { suggestions, reloadSuggestions } = useSuggestions({ agentId: "consumer" });

packages/react/src/hooks/__tests__/use-frontend-tool.e2e.test.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1622,6 +1622,78 @@ describe("useFrontendTool E2E - Dynamic Registration", () => {
16221622
});
16231623
});
16241624

1625+
describe("useFrontendTool dependencies", () => {
1626+
it("updates tool renderer when optional deps change", async () => {
1627+
const DependencyDrivenTool: React.FC = () => {
1628+
const [version, setVersion] = useState(0);
1629+
1630+
const tool: ReactFrontendTool<{ message: string }> = {
1631+
name: "dependencyTool",
1632+
parameters: z.object({ message: z.string() }),
1633+
render: ({ args }) => (
1634+
<div data-testid="dependency-tool-render">
1635+
{args.message} (v{version})
1636+
</div>
1637+
),
1638+
};
1639+
1640+
useFrontendTool(tool, [version]);
1641+
1642+
const toolCallId = testId("dep_tc");
1643+
const assistantMessage: AssistantMessage = {
1644+
id: testId("dep_a"),
1645+
role: "assistant",
1646+
content: "",
1647+
toolCalls: [
1648+
{
1649+
id: toolCallId,
1650+
type: "function",
1651+
function: {
1652+
name: "dependencyTool",
1653+
arguments: JSON.stringify({ message: "hello" }),
1654+
},
1655+
} as any,
1656+
],
1657+
} as any;
1658+
const messages: Message[] = [];
1659+
1660+
return (
1661+
<>
1662+
<button
1663+
data-testid="bump-version"
1664+
type="button"
1665+
onClick={() => setVersion((v) => v + 1)}
1666+
>
1667+
Bump
1668+
</button>
1669+
<CopilotChatToolCallsView
1670+
message={assistantMessage}
1671+
messages={messages}
1672+
/>
1673+
</>
1674+
);
1675+
};
1676+
1677+
renderWithCopilotKit({
1678+
children: <DependencyDrivenTool />,
1679+
});
1680+
1681+
await waitFor(() => {
1682+
const el = screen.getByTestId("dependency-tool-render");
1683+
expect(el).toBeDefined();
1684+
expect(el.textContent).toContain("hello");
1685+
expect(el.textContent).toContain("(v0)");
1686+
});
1687+
1688+
fireEvent.click(screen.getByTestId("bump-version"));
1689+
1690+
await waitFor(() => {
1691+
const el = screen.getByTestId("dependency-tool-render");
1692+
expect(el.textContent).toContain("(v1)");
1693+
});
1694+
});
1695+
});
1696+
16251697
describe("Error Propagation", () => {
16261698
it("should propagate handler errors to renderer", async () => {
16271699
const agent = new MockStepwiseAgent();

packages/react/src/hooks/__tests__/use-human-in-the-loop.e2e.test.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { useHumanInTheLoop } from "../use-human-in-the-loop";
55
import { ReactHumanInTheLoop } from "@/types";
66
import { ToolCallStatus } from "@copilotkitnext/core";
77
import { CopilotChat } from "@/components/chat/CopilotChat";
8+
import CopilotChatToolCallsView from "@/components/chat/CopilotChatToolCallsView";
9+
import { AssistantMessage, Message } from "@ag-ui/core";
810
import {
911
MockStepwiseAgent,
1012
renderWithCopilotKit,
@@ -573,4 +575,77 @@ describe("useHumanInTheLoop E2E - HITL Tool Rendering", () => {
573575
agent.complete();
574576
});
575577
});
578+
579+
describe("useHumanInTheLoop dependencies", () => {
580+
it("updates HITL renderer when optional deps change", async () => {
581+
const DependencyDrivenHITLComponent: React.FC = () => {
582+
const [version, setVersion] = useState(0);
583+
584+
const hitlTool: ReactHumanInTheLoop<{ message: string }> = {
585+
name: "dependencyHitlTool",
586+
description: "Dependency-driven HITL tool",
587+
parameters: z.object({ message: z.string() }),
588+
render: ({ args }) => (
589+
<div data-testid="dependency-hitl-render">
590+
{args.message} (v{version})
591+
</div>
592+
),
593+
};
594+
595+
useHumanInTheLoop(hitlTool, [version]);
596+
597+
const toolCallId = testId("hitl_dep_tc");
598+
const assistantMessage: AssistantMessage = {
599+
id: testId("hitl_dep_a"),
600+
role: "assistant",
601+
content: "",
602+
toolCalls: [
603+
{
604+
id: toolCallId,
605+
type: "function",
606+
function: {
607+
name: "dependencyHitlTool",
608+
arguments: JSON.stringify({ message: "hello" }),
609+
},
610+
} as any,
611+
],
612+
} as any;
613+
const messages: Message[] = [];
614+
615+
return (
616+
<>
617+
<button
618+
data-testid="hitl-bump-version"
619+
type="button"
620+
onClick={() => setVersion((v) => v + 1)}
621+
>
622+
Bump
623+
</button>
624+
<CopilotChatToolCallsView
625+
message={assistantMessage}
626+
messages={messages}
627+
/>
628+
</>
629+
);
630+
};
631+
632+
renderWithCopilotKit({
633+
children: <DependencyDrivenHITLComponent />,
634+
});
635+
636+
await waitFor(() => {
637+
const el = screen.getByTestId("dependency-hitl-render");
638+
expect(el).toBeDefined();
639+
expect(el.textContent).toContain("hello");
640+
expect(el.textContent).toContain("(v0)");
641+
});
642+
643+
fireEvent.click(screen.getByTestId("hitl-bump-version"));
644+
645+
await waitFor(() => {
646+
const el = screen.getByTestId("dependency-hitl-render");
647+
expect(el.textContent).toContain("(v1)");
648+
});
649+
});
650+
});
576651
});

packages/react/src/hooks/use-configure-suggestions.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,13 @@ type StaticSuggestionsConfigInput = Omit<StaticSuggestionsConfig, "suggestions">
1717

1818
type SuggestionsConfigInput = DynamicSuggestionsConfig | StaticSuggestionsConfigInput;
1919

20-
const EMPTY_DEPS: ReadonlyArray<unknown> = [];
21-
22-
export interface UseConfigureSuggestionsOptions {
23-
deps?: ReadonlyArray<unknown>;
24-
}
25-
2620
export function useConfigureSuggestions(
2721
config: SuggestionsConfigInput | null | undefined,
28-
options?: UseConfigureSuggestionsOptions,
22+
deps?: ReadonlyArray<unknown>,
2923
): void {
3024
const { copilotkit } = useCopilotKit();
3125
const chatConfig = useCopilotChatConfiguration();
32-
const extraDeps = options?.deps ?? EMPTY_DEPS;
26+
const extraDeps = deps ?? [];
3327

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

packages/react/src/hooks/use-frontend-tool.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import { useCopilotKit } from "../providers/CopilotKitProvider";
33
import { ReactFrontendTool } from "../types/frontend-tool";
44
import { ReactToolCallRenderer } from "../types/react-tool-call-renderer";
55

6+
const EMPTY_DEPS: ReadonlyArray<unknown> = [];
7+
68
export function useFrontendTool<
79
T extends Record<string, unknown> = Record<string, unknown>,
8-
>(tool: ReactFrontendTool<T>) {
10+
>(tool: ReactFrontendTool<T>, deps?: ReadonlyArray<unknown>) {
911
const { copilotkit } = useCopilotKit();
12+
const extraDeps = deps ?? EMPTY_DEPS;
1013

1114
useEffect(() => {
1215
const name = tool.name;
@@ -49,6 +52,7 @@ export function useFrontendTool<
4952
copilotkit.removeTool(name, tool.agentId);
5053
// we are intentionally not removing the render here so that the tools can still render in the chat history
5154
};
52-
// Depend only on stable keys to avoid re-register loops due to object identity
53-
}, [tool.name, copilotkit]);
55+
// Depend on stable keys by default and allow callers to opt into
56+
// additional dependencies for dynamic tool configuration.
57+
}, [tool.name, copilotkit, extraDeps.length, ...extraDeps]);
5458
}

packages/react/src/hooks/use-human-in-the-loop.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useCopilotKit } from "@/providers/CopilotKitProvider";
88

99
export function useHumanInTheLoop<T extends Record<string, unknown> = Record<string, unknown>>(
1010
tool: ReactHumanInTheLoop<T>,
11+
deps?: ReadonlyArray<unknown>,
1112
) {
1213
const { copilotkit } = useCopilotKit();
1314
const resolvePromiseRef = useRef<((result: unknown) => void) | null>(null);
@@ -69,7 +70,7 @@ export function useHumanInTheLoop<T extends Record<string, unknown> = Record<str
6970
render: RenderComponent,
7071
};
7172

72-
useFrontendTool(frontendTool);
73+
useFrontendTool(frontendTool, deps);
7374

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

0 commit comments

Comments
 (0)