Skip to content

Commit 9e7e294

Browse files
authored
refactor(react): Simplify HITL to only look at prop status during render (#34)
1 parent 115fed3 commit 9e7e294

File tree

2 files changed

+137
-16
lines changed

2 files changed

+137
-16
lines changed

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

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,136 @@ describe("useHumanInTheLoop E2E - HITL Tool Rendering", () => {
327327
});
328328
});
329329

330+
describe("Multiple Hook Instances", () => {
331+
it("should isolate state across two useHumanInTheLoop registrations", async () => {
332+
const agent = new MockStepwiseAgent();
333+
334+
const DualHookComponent: React.FC = () => {
335+
const primaryTool: ReactHumanInTheLoop<{ action: string }> = {
336+
name: "primaryTool",
337+
description: "Primary approval tool",
338+
parameters: z.object({ action: z.string() }),
339+
render: ({ status, args, respond, result }) => (
340+
<div data-testid="primary-tool">
341+
<div data-testid="primary-status">{status}</div>
342+
<div data-testid="primary-action">{args.action ?? ""}</div>
343+
{respond && (
344+
<button
345+
data-testid="primary-respond"
346+
onClick={() => respond(JSON.stringify({ approved: true }))}
347+
>
348+
Respond Primary
349+
</button>
350+
)}
351+
{result && <div data-testid="primary-result">{result}</div>}
352+
</div>
353+
),
354+
};
355+
356+
const secondaryTool: ReactHumanInTheLoop<{ detail: string }> = {
357+
name: "secondaryTool",
358+
description: "Secondary approval tool",
359+
parameters: z.object({ detail: z.string() }),
360+
render: ({ status, args, respond, result }) => (
361+
<div data-testid="secondary-tool">
362+
<div data-testid="secondary-status">{status}</div>
363+
<div data-testid="secondary-detail">{args.detail ?? ""}</div>
364+
{respond && (
365+
<button
366+
data-testid="secondary-respond"
367+
onClick={() => respond(JSON.stringify({ confirmed: true }))}
368+
>
369+
Respond Secondary
370+
</button>
371+
)}
372+
{result && <div data-testid="secondary-result">{result}</div>}
373+
</div>
374+
),
375+
};
376+
377+
useHumanInTheLoop(primaryTool);
378+
useHumanInTheLoop(secondaryTool);
379+
return null;
380+
};
381+
382+
renderWithCopilotKit({
383+
agent,
384+
children: (
385+
<>
386+
<DualHookComponent />
387+
<div style={{ height: 400 }}>
388+
<CopilotChat />
389+
</div>
390+
</>
391+
),
392+
});
393+
394+
const input = await screen.findByRole("textbox");
395+
fireEvent.change(input, { target: { value: "Dual hook instance" } });
396+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
397+
398+
await waitFor(() => {
399+
expect(screen.getByText("Dual hook instance")).toBeDefined();
400+
});
401+
402+
const messageId = testId("msg");
403+
const primaryToolCallId = testId("tc-primary");
404+
const secondaryToolCallId = testId("tc-secondary");
405+
406+
agent.emit(runStartedEvent());
407+
agent.emit(
408+
toolCallChunkEvent({
409+
toolCallId: primaryToolCallId,
410+
toolCallName: "primaryTool",
411+
parentMessageId: messageId,
412+
delta: JSON.stringify({ action: "archive" }),
413+
})
414+
);
415+
agent.emit(
416+
toolCallChunkEvent({
417+
toolCallId: secondaryToolCallId,
418+
toolCallName: "secondaryTool",
419+
parentMessageId: messageId,
420+
delta: JSON.stringify({ detail: "requires confirmation" }),
421+
})
422+
);
423+
424+
await waitFor(() => {
425+
expect(screen.getByTestId("primary-status").textContent).toBe(ToolCallStatus.InProgress);
426+
expect(screen.getByTestId("primary-action").textContent).toBe("archive");
427+
expect(screen.getByTestId("secondary-status").textContent).toBe(ToolCallStatus.InProgress);
428+
expect(screen.getByTestId("secondary-detail").textContent).toBe("requires confirmation");
429+
});
430+
431+
agent.emit(runFinishedEvent());
432+
agent.complete();
433+
434+
const primaryRespondButton = await screen.findByTestId("primary-respond");
435+
436+
expect(screen.getByTestId("primary-status").textContent).toBe(ToolCallStatus.Executing);
437+
expect(screen.getByTestId("secondary-status").textContent).toBe(ToolCallStatus.InProgress);
438+
expect(screen.queryByTestId("secondary-respond")).toBeNull();
439+
440+
fireEvent.click(primaryRespondButton);
441+
442+
await waitFor(() => {
443+
expect(screen.getByTestId("primary-status").textContent).toBe(ToolCallStatus.Complete);
444+
expect(screen.getByTestId("primary-result").textContent).toContain("approved");
445+
expect(screen.getByTestId("secondary-status").textContent).toBe(ToolCallStatus.Executing);
446+
expect(screen.queryByTestId("secondary-result")).toBeNull();
447+
});
448+
449+
const secondaryRespondButton = await screen.findByTestId("secondary-respond");
450+
451+
fireEvent.click(secondaryRespondButton);
452+
453+
await waitFor(() => {
454+
expect(screen.getByTestId("secondary-status").textContent).toBe(ToolCallStatus.Complete);
455+
expect(screen.getByTestId("secondary-result").textContent).toContain("confirmed");
456+
});
457+
});
458+
});
459+
330460
describe("HITL Tool with Dynamic Registration", () => {
331461
it("should support dynamic registration and unregistration of HITL tools", async () => {
332462
const agent = new MockStepwiseAgent();

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

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,60 +2,51 @@ import { ReactToolCallRenderer } from "@/types/react-tool-call-renderer";
22
import { useFrontendTool } from "./use-frontend-tool";
33
import { ReactFrontendTool } from "@/types/frontend-tool";
44
import { ReactHumanInTheLoop } from "@/types/human-in-the-loop";
5-
import { useState, useCallback, useRef, useEffect } from "react";
5+
import { useCallback, useRef, useEffect } from "react";
66
import React from "react";
77
import { useCopilotKit } from "@/providers/CopilotKitProvider";
88

99
export function useHumanInTheLoop<T extends Record<string, unknown> = Record<string, unknown>>(
10-
tool: ReactHumanInTheLoop<T>
10+
tool: ReactHumanInTheLoop<T>,
1111
) {
1212
const { copilotkit } = useCopilotKit();
13-
const [status, setStatus] = useState<"inProgress" | "executing" | "complete">(
14-
"inProgress"
15-
);
16-
const statusRef = useRef(status);
1713
const resolvePromiseRef = useRef<((result: unknown) => void) | null>(null);
1814

19-
statusRef.current = status;
20-
2115
const respond = useCallback(async (result: unknown) => {
2216
if (resolvePromiseRef.current) {
2317
resolvePromiseRef.current(result);
24-
setStatus("complete");
2518
resolvePromiseRef.current = null;
2619
}
2720
}, []);
2821

2922
const handler = useCallback(async () => {
3023
return new Promise((resolve) => {
31-
setStatus("executing");
3224
resolvePromiseRef.current = resolve;
3325
});
3426
}, []);
3527

3628
const RenderComponent: ReactToolCallRenderer<T>["render"] = useCallback(
3729
(props) => {
3830
const ToolComponent = tool.render;
39-
const currentStatus = statusRef.current;
4031

4132
// Enhance props based on current status
42-
if (currentStatus === "inProgress" && props.status === "inProgress") {
33+
if (props.status === "inProgress") {
4334
const enhancedProps = {
4435
...props,
4536
name: tool.name,
4637
description: tool.description || "",
4738
respond: undefined,
4839
};
4940
return React.createElement(ToolComponent, enhancedProps);
50-
} else if (currentStatus === "executing" && props.status === "executing") {
41+
} else if (props.status === "executing") {
5142
const enhancedProps = {
5243
...props,
5344
name: tool.name,
5445
description: tool.description || "",
5546
respond,
5647
};
5748
return React.createElement(ToolComponent, enhancedProps);
58-
} else if (currentStatus === "complete" && props.status === "complete") {
49+
} else if (props.status === "complete") {
5950
const enhancedProps = {
6051
...props,
6152
name: tool.name,
@@ -69,7 +60,7 @@ export function useHumanInTheLoop<T extends Record<string, unknown> = Record<str
6960
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7061
return React.createElement(ToolComponent, props as any);
7162
},
72-
[tool.render, tool.name, tool.description, respond]
63+
[tool.render, tool.name, tool.description, respond],
7364
);
7465

7566
const frontendTool: ReactFrontendTool<T> = {
@@ -87,7 +78,7 @@ export function useHumanInTheLoop<T extends Record<string, unknown> = Record<str
8778
const keyOf = (rc: ReactToolCallRenderer<any>) => `${rc.agentId ?? ""}:${rc.name}`;
8879
const currentRenderToolCalls = copilotkit.renderToolCalls as ReactToolCallRenderer<any>[];
8980
const filtered = currentRenderToolCalls.filter(
90-
rc => keyOf(rc) !== keyOf({ name: tool.name, agentId: tool.agentId } as any)
81+
(rc) => keyOf(rc) !== keyOf({ name: tool.name, agentId: tool.agentId } as any),
9182
);
9283
copilotkit.setRenderToolCalls(filtered);
9384
};

0 commit comments

Comments
 (0)