diff --git a/apps/docs/reference/use-frontend-tool.mdx b/apps/docs/reference/use-frontend-tool.mdx index 91f94fe..b5666a8 100644 --- a/apps/docs/reference/use-frontend-tool.mdx +++ b/apps/docs/reference/use-frontend-tool.mdx @@ -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 @@ -184,6 +187,28 @@ useFrontendTool({ }); ``` +### deps (second argument) + +`ReadonlyArray` **(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 diff --git a/apps/docs/reference/use-human-in-the-loop.mdx b/apps/docs/reference/use-human-in-the-loop.mdx index dbdbb5d..a64f237 100644 --- a/apps/docs/reference/use-human-in-the-loop.mdx +++ b/apps/docs/reference/use-human-in-the-loop.mdx @@ -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 @@ -182,6 +185,32 @@ useHumanInTheLoop({ }); ``` +## Options + +### deps (second argument) + +`ReadonlyArray` **(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`: diff --git a/packages/react/src/hooks/__tests__/use-configure-suggestions.e2e.test.tsx b/packages/react/src/hooks/__tests__/use-configure-suggestions.e2e.test.tsx index 94741e6..4aaac78 100644 --- a/packages/react/src/hooks/__tests__/use-configure-suggestions.e2e.test.tsx +++ b/packages/react/src/hooks/__tests__/use-configure-suggestions.e2e.test.tsx @@ -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(); @@ -370,7 +370,7 @@ const DynamicStreamingHarness: React.FC = () => { consumerAgentId: "consumer", available: "always", }, - { deps: [topic] }, + [topic], ); const { suggestions, reloadSuggestions } = useSuggestions({ agentId: "consumer" }); diff --git a/packages/react/src/hooks/__tests__/use-frontend-tool.e2e.test.tsx b/packages/react/src/hooks/__tests__/use-frontend-tool.e2e.test.tsx index d82ac09..190d36c 100644 --- a/packages/react/src/hooks/__tests__/use-frontend-tool.e2e.test.tsx +++ b/packages/react/src/hooks/__tests__/use-frontend-tool.e2e.test.tsx @@ -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 }) => ( +
+ {args.message} (v{version}) +
+ ), + }; + + 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 ( + <> + + + + ); + }; + + renderWithCopilotKit({ + children: , + }); + + 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(); diff --git a/packages/react/src/hooks/__tests__/use-human-in-the-loop.e2e.test.tsx b/packages/react/src/hooks/__tests__/use-human-in-the-loop.e2e.test.tsx index bd5c39f..6e94e95 100644 --- a/packages/react/src/hooks/__tests__/use-human-in-the-loop.e2e.test.tsx +++ b/packages/react/src/hooks/__tests__/use-human-in-the-loop.e2e.test.tsx @@ -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, @@ -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 }) => ( +
+ {args.message} (v{version}) +
+ ), + }; + + 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 ( + <> + + + + ); + }; + + renderWithCopilotKit({ + children: , + }); + + 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)"); + }); + }); + }); }); diff --git a/packages/react/src/hooks/use-configure-suggestions.tsx b/packages/react/src/hooks/use-configure-suggestions.tsx index ec7b476..63bd741 100644 --- a/packages/react/src/hooks/use-configure-suggestions.tsx +++ b/packages/react/src/hooks/use-configure-suggestions.tsx @@ -17,19 +17,13 @@ type StaticSuggestionsConfigInput = Omit type SuggestionsConfigInput = DynamicSuggestionsConfig | StaticSuggestionsConfigInput; -const EMPTY_DEPS: ReadonlyArray = []; - -export interface UseConfigureSuggestionsOptions { - deps?: ReadonlyArray; -} - export function useConfigureSuggestions( config: SuggestionsConfigInput | null | undefined, - options?: UseConfigureSuggestionsOptions, + deps?: ReadonlyArray, ): 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]); diff --git a/packages/react/src/hooks/use-frontend-tool.tsx b/packages/react/src/hooks/use-frontend-tool.tsx index 8ebefa0..4e0122d 100644 --- a/packages/react/src/hooks/use-frontend-tool.tsx +++ b/packages/react/src/hooks/use-frontend-tool.tsx @@ -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 = []; + export function useFrontendTool< T extends Record = Record, ->(tool: ReactFrontendTool) { +>(tool: ReactFrontendTool, deps?: ReadonlyArray) { const { copilotkit } = useCopilotKit(); + const extraDeps = deps ?? EMPTY_DEPS; useEffect(() => { const name = tool.name; @@ -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]); } diff --git a/packages/react/src/hooks/use-human-in-the-loop.tsx b/packages/react/src/hooks/use-human-in-the-loop.tsx index d25b086..46ef7ec 100644 --- a/packages/react/src/hooks/use-human-in-the-loop.tsx +++ b/packages/react/src/hooks/use-human-in-the-loop.tsx @@ -8,6 +8,7 @@ import { useCopilotKit } from "@/providers/CopilotKitProvider"; export function useHumanInTheLoop = Record>( tool: ReactHumanInTheLoop, + deps?: ReadonlyArray, ) { const { copilotkit } = useCopilotKit(); const resolvePromiseRef = useRef<((result: unknown) => void) | null>(null); @@ -69,7 +70,7 @@ export function useHumanInTheLoop = Record