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 be732fdd..bd5c39fd 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 @@ -327,6 +327,136 @@ describe("useHumanInTheLoop E2E - HITL Tool Rendering", () => { }); }); + describe("Multiple Hook Instances", () => { + it("should isolate state across two useHumanInTheLoop registrations", async () => { + const agent = new MockStepwiseAgent(); + + const DualHookComponent: React.FC = () => { + const primaryTool: ReactHumanInTheLoop<{ action: string }> = { + name: "primaryTool", + description: "Primary approval tool", + parameters: z.object({ action: z.string() }), + render: ({ status, args, respond, result }) => ( +
+
{status}
+
{args.action ?? ""}
+ {respond && ( + + )} + {result &&
{result}
} +
+ ), + }; + + const secondaryTool: ReactHumanInTheLoop<{ detail: string }> = { + name: "secondaryTool", + description: "Secondary approval tool", + parameters: z.object({ detail: z.string() }), + render: ({ status, args, respond, result }) => ( +
+
{status}
+
{args.detail ?? ""}
+ {respond && ( + + )} + {result &&
{result}
} +
+ ), + }; + + useHumanInTheLoop(primaryTool); + useHumanInTheLoop(secondaryTool); + return null; + }; + + renderWithCopilotKit({ + agent, + children: ( + <> + +
+ +
+ + ), + }); + + const input = await screen.findByRole("textbox"); + fireEvent.change(input, { target: { value: "Dual hook instance" } }); + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); + + await waitFor(() => { + expect(screen.getByText("Dual hook instance")).toBeDefined(); + }); + + const messageId = testId("msg"); + const primaryToolCallId = testId("tc-primary"); + const secondaryToolCallId = testId("tc-secondary"); + + agent.emit(runStartedEvent()); + agent.emit( + toolCallChunkEvent({ + toolCallId: primaryToolCallId, + toolCallName: "primaryTool", + parentMessageId: messageId, + delta: JSON.stringify({ action: "archive" }), + }) + ); + agent.emit( + toolCallChunkEvent({ + toolCallId: secondaryToolCallId, + toolCallName: "secondaryTool", + parentMessageId: messageId, + delta: JSON.stringify({ detail: "requires confirmation" }), + }) + ); + + await waitFor(() => { + expect(screen.getByTestId("primary-status").textContent).toBe(ToolCallStatus.InProgress); + expect(screen.getByTestId("primary-action").textContent).toBe("archive"); + expect(screen.getByTestId("secondary-status").textContent).toBe(ToolCallStatus.InProgress); + expect(screen.getByTestId("secondary-detail").textContent).toBe("requires confirmation"); + }); + + agent.emit(runFinishedEvent()); + agent.complete(); + + const primaryRespondButton = await screen.findByTestId("primary-respond"); + + expect(screen.getByTestId("primary-status").textContent).toBe(ToolCallStatus.Executing); + expect(screen.getByTestId("secondary-status").textContent).toBe(ToolCallStatus.InProgress); + expect(screen.queryByTestId("secondary-respond")).toBeNull(); + + fireEvent.click(primaryRespondButton); + + await waitFor(() => { + expect(screen.getByTestId("primary-status").textContent).toBe(ToolCallStatus.Complete); + expect(screen.getByTestId("primary-result").textContent).toContain("approved"); + expect(screen.getByTestId("secondary-status").textContent).toBe(ToolCallStatus.Executing); + expect(screen.queryByTestId("secondary-result")).toBeNull(); + }); + + const secondaryRespondButton = await screen.findByTestId("secondary-respond"); + + fireEvent.click(secondaryRespondButton); + + await waitFor(() => { + expect(screen.getByTestId("secondary-status").textContent).toBe(ToolCallStatus.Complete); + expect(screen.getByTestId("secondary-result").textContent).toContain("confirmed"); + }); + }); + }); + describe("HITL Tool with Dynamic Registration", () => { it("should support dynamic registration and unregistration of HITL tools", async () => { const agent = new MockStepwiseAgent(); 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 d0fc9748..d25b0864 100644 --- a/packages/react/src/hooks/use-human-in-the-loop.tsx +++ b/packages/react/src/hooks/use-human-in-the-loop.tsx @@ -2,33 +2,25 @@ import { ReactToolCallRenderer } from "@/types/react-tool-call-renderer"; import { useFrontendTool } from "./use-frontend-tool"; import { ReactFrontendTool } from "@/types/frontend-tool"; import { ReactHumanInTheLoop } from "@/types/human-in-the-loop"; -import { useState, useCallback, useRef, useEffect } from "react"; +import { useCallback, useRef, useEffect } from "react"; import React from "react"; import { useCopilotKit } from "@/providers/CopilotKitProvider"; export function useHumanInTheLoop = Record>( - tool: ReactHumanInTheLoop + tool: ReactHumanInTheLoop, ) { const { copilotkit } = useCopilotKit(); - const [status, setStatus] = useState<"inProgress" | "executing" | "complete">( - "inProgress" - ); - const statusRef = useRef(status); const resolvePromiseRef = useRef<((result: unknown) => void) | null>(null); - statusRef.current = status; - const respond = useCallback(async (result: unknown) => { if (resolvePromiseRef.current) { resolvePromiseRef.current(result); - setStatus("complete"); resolvePromiseRef.current = null; } }, []); const handler = useCallback(async () => { return new Promise((resolve) => { - setStatus("executing"); resolvePromiseRef.current = resolve; }); }, []); @@ -36,10 +28,9 @@ export function useHumanInTheLoop = Record["render"] = useCallback( (props) => { const ToolComponent = tool.render; - const currentStatus = statusRef.current; // Enhance props based on current status - if (currentStatus === "inProgress" && props.status === "inProgress") { + if (props.status === "inProgress") { const enhancedProps = { ...props, name: tool.name, @@ -47,7 +38,7 @@ export function useHumanInTheLoop = Record = Record = Record = { @@ -87,7 +78,7 @@ export function useHumanInTheLoop = Record) => `${rc.agentId ?? ""}:${rc.name}`; const currentRenderToolCalls = copilotkit.renderToolCalls as ReactToolCallRenderer[]; const filtered = currentRenderToolCalls.filter( - rc => keyOf(rc) !== keyOf({ name: tool.name, agentId: tool.agentId } as any) + (rc) => keyOf(rc) !== keyOf({ name: tool.name, agentId: tool.agentId } as any), ); copilotkit.setRenderToolCalls(filtered); };