>
),
});
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "Request approval" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
await waitFor(() => {
expect(screen.getByText("Request approval")).toBeDefined();
});
const messageId = testId("msg");
const toolCallId = testId("tc");
// Emit tool call events (HITL tool call without response)
agent.emit(runStartedEvent());
agent.emit(
toolCallChunkEvent({
toolCallId,
toolCallName: "approvalTool",
parentMessageId: messageId,
delta: JSON.stringify({ action: "delete" }),
}),
);
await waitFor(() => {
expect(screen.getByTestId("hitl-status").textContent).toBe(
ToolCallStatus.InProgress,
);
});
// Complete run WITHOUT responding to HITL (simulating user refresh before clicking)
agent.emit(runFinishedEvent());
agent.complete();
// Verify status is Executing (the tool handler should be running waiting for response)
await waitFor(() => {
expect(screen.getByTestId("hitl-status").textContent).toBe(
ToolCallStatus.Executing,
);
});
// Phase 2: Unmount and remount (simulating page reload + reconnect)
unmount();
agent.reset();
// Re-render with same thread (simulates reconnection)
renderWithCopilotKit({
agent,
children: (
<>
>
),
});
// Wait for the HITL tool to render from replayed events
await waitFor(() => {
expect(screen.getByTestId("hitl-tool")).toBeDefined();
});
// Verify tool call args are correctly replayed from connect() events
await waitFor(() => {
expect(screen.getByTestId("hitl-action").textContent).toBe("delete");
});
// After reconnection, status should be 'executing' with respond available
// The tool handler is re-invoked for pending HITL tools that were never responded to.
await waitFor(() => {
expect(screen.getByTestId("hitl-status").textContent).toBe(
ToolCallStatus.Executing,
);
});
// respond button should be present so user can interact
expect(screen.getByTestId("hitl-respond")).toBeDefined();
});
it("should handle tool call after connect (fresh run)", async () => {
// Tests that normal tool calls work correctly after connecting to a thread.
// This ensures the fix for reconnection doesn't break the normal flow.
const agent = new MockReconnectableAgent();
const HITLComponent: React.FC = () => {
const hitlTool: ReactHumanInTheLoop<{ task: string }> = {
name: "taskTool",
description: "Task approval",
parameters: z.object({ task: z.string() }),
render: ({ status, args, respond }) => (
>
),
});
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "Multiple tools" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
await waitFor(() => {
expect(screen.getByText("Multiple tools")).toBeDefined();
});
const messageId = testId("msg");
const tc1 = testId("tc1");
const tc2 = testId("tc2");
// Emit both tool calls
agent.emit(runStartedEvent());
agent.emit(
toolCallChunkEvent({
toolCallId: tc1,
toolCallName: "tool1",
parentMessageId: messageId,
delta: JSON.stringify({ id: "first" }),
}),
);
agent.emit(
toolCallChunkEvent({
toolCallId: tc2,
toolCallName: "tool2",
parentMessageId: messageId,
delta: JSON.stringify({ id: "second" }),
}),
);
// Both should be inProgress (tool calls received but not yet executed)
await waitFor(() => {
expect(screen.getByTestId("tool1-status").textContent).toBe(
ToolCallStatus.InProgress,
);
expect(screen.getByTestId("tool2-status").textContent).toBe(
ToolCallStatus.InProgress,
);
});
// Complete run - FIRST tool starts executing, second remains inProgress
// (HITL tools execute sequentially via processAgentResult)
agent.emit(runFinishedEvent());
agent.complete();
await waitFor(() => {
expect(screen.getByTestId("tool1-status").textContent).toBe(
ToolCallStatus.Executing,
);
// Tool2 is still inProgress because tool1 hasn't completed yet
expect(screen.getByTestId("tool2-status").textContent).toBe(
ToolCallStatus.InProgress,
);
});
// Respond to first tool
fireEvent.click(screen.getByTestId("tool1-respond"));
// After first tool completes, second tool starts executing
await waitFor(() => {
expect(screen.getByTestId("tool1-status").textContent).toBe(
ToolCallStatus.Complete,
);
expect(screen.getByTestId("tool2-status").textContent).toBe(
ToolCallStatus.Executing,
);
});
// Respond to second tool
fireEvent.click(screen.getByTestId("tool2-respond"));
await waitFor(() => {
expect(screen.getByTestId("tool2-status").textContent).toBe(
ToolCallStatus.Complete,
);
});
});
it("should handle late-mounting component that renders executing tool", async () => {
// Tests that a component which mounts AFTER a tool starts executing
// still sees the correct 'executing' status.
// This is similar to the reconnection bug but without actual reconnection.
const agent = new MockStepwiseAgent();
let showTool = false;
let setShowTool: (show: boolean) => void;
const ToggleableHITL: React.FC = () => {
const [show, setShow] = useState(false);
showTool = show;
setShowTool = setShow;
const hitlTool: ReactHumanInTheLoop<{ data: string }> = {
name: "lateTool",
description: "Late mounting tool",
parameters: z.object({ data: z.string() }),
render: ({ status, args }) => (
{status}
{args.data ?? ""}
),
};
useHumanInTheLoop(hitlTool);
// Only render the tool view if show is true
// The tool is registered regardless, but rendering is conditional
return show ? (
>
),
});
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "Test late mount" } });
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
await waitFor(() => {
expect(screen.getByText("Test late mount")).toBeDefined();
});
const messageId = testId("msg");
const toolCallId = testId("tc");
// Emit tool call and complete run BEFORE showing the component
agent.emit(runStartedEvent());
agent.emit(
toolCallChunkEvent({
toolCallId,
toolCallName: "lateTool",
parentMessageId: messageId,
delta: JSON.stringify({ data: "late-data" }),
}),
);
agent.emit(runFinishedEvent());
agent.complete();
// Wait for tool execution to start
await waitFor(() => {
// The tool should be rendered by CopilotChat even if our custom component isn't shown
expect(screen.getByTestId("late-status").textContent).toBe(
ToolCallStatus.Executing,
);
});
// Now show our custom component - it should also see the executing status
// (This tests that the provider-level tracking works for late-mounting components)
act(() => {
setShowTool(true);
});
await waitFor(() => {
expect(screen.getByTestId("late-tool-container")).toBeDefined();
});
// The status should still be executing (tracked at provider level)
expect(screen.getByTestId("late-status").textContent).toBe(
ToolCallStatus.Executing,
);
});
it("should maintain executing state across component remount", async () => {
// Tests that if a tool rendering component unmounts and remounts while
// a tool is executing, it still sees the correct 'executing' status.
// This verifies that executingToolCallIds is tracked at the provider level.
//
// Note: After remount, the HITL handler is recreated, so respond functionality
// is tested separately. This test focuses on state visibility.
const agent = new MockStepwiseAgent();
let toggleRemount: () => void;
const RemountableHITL: React.FC = () => {
const [key, setKey] = useState(0);
toggleRemount = () => setKey((k) => k + 1);
return ;
};
const HITLChild: React.FC = () => {
const hitlTool: ReactHumanInTheLoop<{ action: string }> = {
name: "remountTool",
description: "Remountable tool",
parameters: z.object({ action: z.string() }),
render: ({ status, args, respond }) => (