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
130 changes: 130 additions & 0 deletions packages/react/src/hooks/__tests__/use-human-in-the-loop.e2e.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<div data-testid="primary-tool">
<div data-testid="primary-status">{status}</div>
<div data-testid="primary-action">{args.action ?? ""}</div>
{respond && (
<button
data-testid="primary-respond"
onClick={() => respond(JSON.stringify({ approved: true }))}
>
Respond Primary
</button>
)}
{result && <div data-testid="primary-result">{result}</div>}
</div>
),
};

const secondaryTool: ReactHumanInTheLoop<{ detail: string }> = {
name: "secondaryTool",
description: "Secondary approval tool",
parameters: z.object({ detail: z.string() }),
render: ({ status, args, respond, result }) => (
<div data-testid="secondary-tool">
<div data-testid="secondary-status">{status}</div>
<div data-testid="secondary-detail">{args.detail ?? ""}</div>
{respond && (
<button
data-testid="secondary-respond"
onClick={() => respond(JSON.stringify({ confirmed: true }))}
>
Respond Secondary
</button>
)}
{result && <div data-testid="secondary-result">{result}</div>}
</div>
),
};

useHumanInTheLoop(primaryTool);
useHumanInTheLoop(secondaryTool);
return null;
};

renderWithCopilotKit({
agent,
children: (
<>
<DualHookComponent />
<div style={{ height: 400 }}>
<CopilotChat />
</div>
</>
),
});

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();
Expand Down
23 changes: 7 additions & 16 deletions packages/react/src/hooks/use-human-in-the-loop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,51 @@ 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<T extends Record<string, unknown> = Record<string, unknown>>(
tool: ReactHumanInTheLoop<T>
tool: ReactHumanInTheLoop<T>,
) {
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;
});
}, []);

const RenderComponent: ReactToolCallRenderer<T>["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,
description: tool.description || "",
respond: undefined,
};
return React.createElement(ToolComponent, enhancedProps);
} else if (currentStatus === "executing" && props.status === "executing") {
} else if (props.status === "executing") {
const enhancedProps = {
...props,
name: tool.name,
description: tool.description || "",
respond,
};
return React.createElement(ToolComponent, enhancedProps);
} else if (currentStatus === "complete" && props.status === "complete") {
} else if (props.status === "complete") {
const enhancedProps = {
...props,
name: tool.name,
Expand All @@ -69,7 +60,7 @@ export function useHumanInTheLoop<T extends Record<string, unknown> = Record<str
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return React.createElement(ToolComponent, props as any);
},
[tool.render, tool.name, tool.description, respond]
[tool.render, tool.name, tool.description, respond],
);

const frontendTool: ReactFrontendTool<T> = {
Expand All @@ -87,7 +78,7 @@ export function useHumanInTheLoop<T extends Record<string, unknown> = Record<str
const keyOf = (rc: ReactToolCallRenderer<any>) => `${rc.agentId ?? ""}:${rc.name}`;
const currentRenderToolCalls = copilotkit.renderToolCalls as ReactToolCallRenderer<any>[];
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);
};
Expand Down