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);
};