@copilotkit/react-core 1.54.1 → 1.55.0-next.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +127 -116
- package/dist/copilotkit-B3Mb1yVE.cjs +7975 -0
- package/dist/copilotkit-B3Mb1yVE.cjs.map +1 -0
- package/dist/copilotkit-DBzgOMby.d.cts +2182 -0
- package/dist/copilotkit-DBzgOMby.d.cts.map +1 -0
- package/dist/copilotkit-DNYSFuz5.mjs +7562 -0
- package/dist/copilotkit-DNYSFuz5.mjs.map +1 -0
- package/dist/copilotkit-Dy5w3qEV.d.mts +2182 -0
- package/dist/copilotkit-Dy5w3qEV.d.mts.map +1 -0
- package/dist/index.cjs +27 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +4 -5
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +1941 -35
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +77 -7
- package/dist/v2/index.css +1 -2
- package/dist/v2/index.d.cts +6 -4
- package/dist/v2/index.d.mts +6 -4
- package/dist/v2/index.mjs +7 -4
- package/dist/v2/index.umd.js +5725 -24
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +37 -9
- package/scripts/scope-preflight.mjs +101 -0
- package/src/components/CopilotListeners.tsx +2 -6
- package/src/components/copilot-provider/copilot-messages.tsx +1 -1
- package/src/components/copilot-provider/copilotkit-props.tsx +1 -1
- package/src/components/copilot-provider/copilotkit.tsx +4 -4
- package/src/context/copilot-messages-context.tsx +1 -1
- package/src/hooks/__tests__/use-coagent-config.test.ts +2 -2
- package/src/hooks/__tests__/use-coagent-state-render.e2e.test.tsx +2 -2
- package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +3 -7
- package/src/hooks/__tests__/use-frontend-tool-available.test.tsx +1 -1
- package/src/hooks/__tests__/use-frontend-tool-remount.e2e.test.tsx +4 -4
- package/src/hooks/use-agent-nodename.ts +1 -1
- package/src/hooks/use-coagent-state-render-bridge.tsx +1 -4
- package/src/hooks/use-coagent.ts +1 -1
- package/src/hooks/use-configure-chat-suggestions.tsx +2 -2
- package/src/hooks/use-copilot-chat-suggestions.tsx +2 -2
- package/src/hooks/use-copilot-chat_internal.ts +2 -2
- package/src/hooks/use-copilot-readable.ts +1 -1
- package/src/hooks/use-frontend-tool.ts +2 -2
- package/src/hooks/use-human-in-the-loop.ts +2 -2
- package/src/hooks/use-langgraph-interrupt.ts +2 -5
- package/src/hooks/use-lazy-tool-renderer.tsx +1 -1
- package/src/hooks/use-render-tool-call.ts +1 -1
- package/src/lib/copilot-task.ts +1 -1
- package/src/setupTests.ts +18 -14
- package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +176 -0
- package/src/v2/__tests__/globalSetup.ts +14 -0
- package/src/v2/__tests__/setup.ts +93 -0
- package/src/v2/__tests__/utils/test-helpers.tsx +470 -0
- package/src/v2/a2ui/A2UIMessageRenderer.tsx +206 -0
- package/src/v2/components/CopilotKitInspector.tsx +50 -0
- package/src/v2/components/MCPAppsActivityRenderer.tsx +785 -0
- package/src/v2/components/WildcardToolCallRender.tsx +86 -0
- package/src/v2/components/__tests__/license-warning-banner.test.tsx +46 -0
- package/src/v2/components/chat/CopilotChat.tsx +431 -0
- package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +375 -0
- package/src/v2/components/chat/CopilotChatAudioRecorder.tsx +350 -0
- package/src/v2/components/chat/CopilotChatInput.tsx +1302 -0
- package/src/v2/components/chat/CopilotChatMessageView.tsx +556 -0
- package/src/v2/components/chat/CopilotChatReasoningMessage.tsx +252 -0
- package/src/v2/components/chat/CopilotChatSuggestionPill.tsx +59 -0
- package/src/v2/components/chat/CopilotChatSuggestionView.tsx +133 -0
- package/src/v2/components/chat/CopilotChatToggleButton.tsx +171 -0
- package/src/v2/components/chat/CopilotChatToolCallsView.tsx +40 -0
- package/src/v2/components/chat/CopilotChatUserMessage.tsx +388 -0
- package/src/v2/components/chat/CopilotChatView.tsx +598 -0
- package/src/v2/components/chat/CopilotModalHeader.tsx +129 -0
- package/src/v2/components/chat/CopilotPopup.tsx +81 -0
- package/src/v2/components/chat/CopilotPopupView.tsx +317 -0
- package/src/v2/components/chat/CopilotSidebar.tsx +76 -0
- package/src/v2/components/chat/CopilotSidebarView.tsx +255 -0
- package/src/v2/components/chat/__tests__/CopilotChat.e2e.test.tsx +1113 -0
- package/src/v2/components/chat/__tests__/CopilotChat.onError.test.tsx +73 -0
- package/src/v2/components/chat/__tests__/CopilotChat.slots.e2e.test.tsx +432 -0
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +150 -0
- package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.slots.e2e.test.tsx +624 -0
- package/src/v2/components/chat/__tests__/CopilotChatAssistantMessage.test.tsx +702 -0
- package/src/v2/components/chat/__tests__/CopilotChatCssClasses.test.tsx +107 -0
- package/src/v2/components/chat/__tests__/CopilotChatInput.slots.e2e.test.tsx +929 -0
- package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +986 -0
- package/src/v2/components/chat/__tests__/CopilotChatMessageView.slots.e2e.test.tsx +1004 -0
- package/src/v2/components/chat/__tests__/CopilotChatMessageView.test.tsx +169 -0
- package/src/v2/components/chat/__tests__/CopilotChatSuggestionView.slots.e2e.test.tsx +530 -0
- package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx +782 -0
- package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +2413 -0
- package/src/v2/components/chat/__tests__/CopilotChatUserMessage.slots.e2e.test.tsx +621 -0
- package/src/v2/components/chat/__tests__/CopilotChatView.onClick.e2e.test.tsx +853 -0
- package/src/v2/components/chat/__tests__/CopilotChatView.slots.e2e.test.tsx +1050 -0
- package/src/v2/components/chat/__tests__/CopilotModalHeader.slots.e2e.test.tsx +484 -0
- package/src/v2/components/chat/__tests__/CopilotPopupView.slots.e2e.test.tsx +612 -0
- package/src/v2/components/chat/__tests__/CopilotSidebarView.slots.e2e.test.tsx +502 -0
- package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +1011 -0
- package/src/v2/components/chat/__tests__/setup.ts +1 -0
- package/src/v2/components/chat/index.ts +79 -0
- package/src/v2/components/index.ts +7 -0
- package/src/v2/components/license-warning-banner.tsx +198 -0
- package/src/v2/components/ui/button.tsx +123 -0
- package/src/v2/components/ui/dropdown-menu.tsx +258 -0
- package/src/v2/components/ui/tooltip.tsx +60 -0
- package/src/v2/hooks/__tests__/standard-schema-types.test.tsx +152 -0
- package/src/v2/hooks/__tests__/standard-schema.test.tsx +282 -0
- package/src/v2/hooks/__tests__/use-agent-context-timing.e2e.test.tsx +132 -0
- package/src/v2/hooks/__tests__/use-agent-context.test.tsx +401 -0
- package/src/v2/hooks/__tests__/use-agent-error-state.test.tsx +44 -0
- package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +205 -0
- package/src/v2/hooks/__tests__/use-agent.e2e.test.tsx +148 -0
- package/src/v2/hooks/__tests__/use-component.test.tsx +123 -0
- package/src/v2/hooks/__tests__/use-configure-suggestions.e2e.test.tsx +696 -0
- package/src/v2/hooks/__tests__/use-default-render-tool.test.tsx +153 -0
- package/src/v2/hooks/__tests__/use-frontend-tool-available.test.tsx +167 -0
- package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.tsx +2129 -0
- package/src/v2/hooks/__tests__/use-human-in-the-loop.e2e.test.tsx +1261 -0
- package/src/v2/hooks/__tests__/use-interrupt.test.tsx +397 -0
- package/src/v2/hooks/__tests__/use-katex-styles.test.tsx +56 -0
- package/src/v2/hooks/__tests__/use-keyboard-height.test.tsx +192 -0
- package/src/v2/hooks/__tests__/use-render-tool.test.tsx +259 -0
- package/src/v2/hooks/__tests__/use-suggestions.e2e.test.tsx +524 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +433 -0
- package/src/v2/hooks/__tests__/zod-regression.test.tsx +311 -0
- package/src/v2/hooks/index.ts +18 -0
- package/src/v2/hooks/use-agent-context.tsx +45 -0
- package/src/v2/hooks/use-agent.tsx +155 -0
- package/src/v2/hooks/use-component.tsx +89 -0
- package/src/v2/hooks/use-configure-suggestions.tsx +187 -0
- package/src/v2/hooks/use-default-render-tool.tsx +254 -0
- package/src/v2/hooks/use-frontend-tool.tsx +43 -0
- package/src/v2/hooks/use-human-in-the-loop.tsx +81 -0
- package/src/v2/hooks/use-interrupt.tsx +305 -0
- package/src/v2/hooks/use-keyboard-height.tsx +67 -0
- package/src/v2/hooks/use-render-activity-message.tsx +73 -0
- package/src/v2/hooks/use-render-custom-messages.tsx +93 -0
- package/src/v2/hooks/use-render-tool-call.tsx +175 -0
- package/src/v2/hooks/use-render-tool.tsx +181 -0
- package/src/v2/hooks/use-suggestions.tsx +91 -0
- package/src/v2/hooks/use-threads.tsx +256 -0
- package/src/v2/hooks/useKatexStyles.ts +27 -0
- package/src/v2/index.css +1 -1
- package/src/v2/index.ts +18 -2
- package/src/v2/lib/__tests__/completePartialMarkdown.test.ts +495 -0
- package/src/v2/lib/__tests__/renderSlot.test.tsx +588 -0
- package/src/v2/lib/react-core.ts +156 -0
- package/src/v2/lib/slots.tsx +143 -0
- package/src/v2/lib/transcription-client.ts +184 -0
- package/src/v2/lib/utils.ts +8 -0
- package/src/v2/providers/CopilotChatConfigurationProvider.tsx +162 -0
- package/src/v2/providers/CopilotKitProvider.tsx +600 -0
- package/src/v2/providers/__tests__/CopilotChatConfigurationProvider.test.tsx +546 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.license.test.tsx +101 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.onError.test.tsx +69 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.renderCustomMessages.e2e.test.tsx +881 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.stability.test.tsx +740 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +642 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.wildcard.test.tsx +294 -0
- package/src/v2/providers/index.ts +14 -0
- package/src/v2/styles/globals.css +230 -0
- package/src/v2/types/__tests__/defineToolCallRenderer.test.tsx +525 -0
- package/src/v2/types/defineToolCallRenderer.ts +65 -0
- package/src/v2/types/frontend-tool.ts +8 -0
- package/src/v2/types/human-in-the-loop.ts +33 -0
- package/src/v2/types/index.ts +7 -0
- package/src/v2/types/interrupt.ts +15 -0
- package/src/v2/types/react-activity-message-renderer.ts +27 -0
- package/src/v2/types/react-custom-message-renderer.ts +17 -0
- package/src/v2/types/react-tool-call-renderer.ts +32 -0
- package/tsdown.config.ts +34 -10
- package/vitest.config.mjs +4 -3
- package/LICENSE +0 -21
- package/dist/copilotkit-BRPQ2sqS.d.cts +0 -670
- package/dist/copilotkit-BRPQ2sqS.d.cts.map +0 -1
- package/dist/copilotkit-C94ayZbs.cjs +0 -2161
- package/dist/copilotkit-C94ayZbs.cjs.map +0 -1
- package/dist/copilotkit-CwZMFmSK.d.mts +0 -670
- package/dist/copilotkit-CwZMFmSK.d.mts.map +0 -1
- package/dist/copilotkit-Yh_Ld_FX.mjs +0 -2031
- package/dist/copilotkit-Yh_Ld_FX.mjs.map +0 -1
- package/dist/v2/index.css.map +0 -1
|
@@ -0,0 +1,1261 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { screen, fireEvent, waitFor, act } from "@testing-library/react";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { useHumanInTheLoop } from "../use-human-in-the-loop";
|
|
5
|
+
import { ReactHumanInTheLoop } from "../../types";
|
|
6
|
+
import { ToolCallStatus } from "@copilotkit/core";
|
|
7
|
+
import { CopilotChat } from "../../components/chat/CopilotChat";
|
|
8
|
+
import CopilotChatToolCallsView from "../../components/chat/CopilotChatToolCallsView";
|
|
9
|
+
import { AssistantMessage, Message } from "@ag-ui/core";
|
|
10
|
+
import {
|
|
11
|
+
MockStepwiseAgent,
|
|
12
|
+
MockReconnectableAgent,
|
|
13
|
+
renderWithCopilotKit,
|
|
14
|
+
runStartedEvent,
|
|
15
|
+
runFinishedEvent,
|
|
16
|
+
toolCallChunkEvent,
|
|
17
|
+
testId,
|
|
18
|
+
} from "../../__tests__/utils/test-helpers";
|
|
19
|
+
|
|
20
|
+
describe("useHumanInTheLoop E2E - HITL Tool Rendering", () => {
|
|
21
|
+
describe("HITL Renderer with Status Transitions", () => {
|
|
22
|
+
it("should show InProgress → Complete transitions for HITL tool", async () => {
|
|
23
|
+
const agent = new MockStepwiseAgent();
|
|
24
|
+
const statusHistory: ToolCallStatus[] = [];
|
|
25
|
+
|
|
26
|
+
const HITLComponent: React.FC = () => {
|
|
27
|
+
const hitlTool: ReactHumanInTheLoop<{
|
|
28
|
+
action: string;
|
|
29
|
+
reason: string;
|
|
30
|
+
}> = {
|
|
31
|
+
name: "approvalTool",
|
|
32
|
+
description: "Requires human approval",
|
|
33
|
+
parameters: z.object({
|
|
34
|
+
action: z.string(),
|
|
35
|
+
reason: z.string(),
|
|
36
|
+
}),
|
|
37
|
+
render: ({ status, args, result, respond, name, description }) => {
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (statusHistory[statusHistory.length - 1] !== status) {
|
|
40
|
+
statusHistory.push(status);
|
|
41
|
+
}
|
|
42
|
+
}, [status]);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div data-testid="hitl-tool">
|
|
46
|
+
<div data-testid="hitl-name">{name}</div>
|
|
47
|
+
<div data-testid="hitl-description">{description}</div>
|
|
48
|
+
<div data-testid="hitl-status">{status}</div>
|
|
49
|
+
<div data-testid="hitl-action">{args.action ?? ""}</div>
|
|
50
|
+
<div data-testid="hitl-reason">{args.reason ?? ""}</div>
|
|
51
|
+
{respond && (
|
|
52
|
+
<button
|
|
53
|
+
data-testid="hitl-approve"
|
|
54
|
+
onClick={() => respond(JSON.stringify({ approved: true }))}
|
|
55
|
+
>
|
|
56
|
+
Approve
|
|
57
|
+
</button>
|
|
58
|
+
)}
|
|
59
|
+
{result && <div data-testid="hitl-result">{result}</div>}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
useHumanInTheLoop(hitlTool);
|
|
66
|
+
return null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
renderWithCopilotKit({
|
|
70
|
+
agent,
|
|
71
|
+
children: (
|
|
72
|
+
<>
|
|
73
|
+
<HITLComponent />
|
|
74
|
+
<div style={{ height: 400 }}>
|
|
75
|
+
<CopilotChat welcomeScreen={false} />
|
|
76
|
+
</div>
|
|
77
|
+
</>
|
|
78
|
+
),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const input = await screen.findByRole("textbox");
|
|
82
|
+
fireEvent.change(input, { target: { value: "Request approval" } });
|
|
83
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
84
|
+
|
|
85
|
+
await waitFor(() => {
|
|
86
|
+
expect(screen.getByText("Request approval")).toBeDefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const messageId = testId("msg");
|
|
90
|
+
const toolCallId = testId("tc");
|
|
91
|
+
|
|
92
|
+
agent.emit(runStartedEvent());
|
|
93
|
+
agent.emit(
|
|
94
|
+
toolCallChunkEvent({
|
|
95
|
+
toolCallId,
|
|
96
|
+
toolCallName: "approvalTool",
|
|
97
|
+
parentMessageId: messageId,
|
|
98
|
+
delta: JSON.stringify({ action: "delete", reason: "cleanup" }),
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(screen.getByTestId("hitl-status").textContent).toBe(
|
|
104
|
+
ToolCallStatus.InProgress,
|
|
105
|
+
);
|
|
106
|
+
expect(screen.getByTestId("hitl-action").textContent).toBe("delete");
|
|
107
|
+
expect(screen.getByTestId("hitl-reason").textContent).toBe("cleanup");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
agent.emit(runFinishedEvent());
|
|
111
|
+
agent.complete();
|
|
112
|
+
|
|
113
|
+
const approveButton = await screen.findByTestId("hitl-approve");
|
|
114
|
+
expect(screen.getByTestId("hitl-status").textContent).toBe(
|
|
115
|
+
ToolCallStatus.Executing,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
fireEvent.click(approveButton);
|
|
119
|
+
|
|
120
|
+
await waitFor(() => {
|
|
121
|
+
expect(screen.getByTestId("hitl-status").textContent).toBe(
|
|
122
|
+
ToolCallStatus.Complete,
|
|
123
|
+
);
|
|
124
|
+
expect(screen.getByTestId("hitl-result").textContent).toContain(
|
|
125
|
+
"approved",
|
|
126
|
+
);
|
|
127
|
+
// Also wait for the useEffect to update statusHistory
|
|
128
|
+
expect(statusHistory).toEqual([
|
|
129
|
+
ToolCallStatus.InProgress,
|
|
130
|
+
ToolCallStatus.Executing,
|
|
131
|
+
ToolCallStatus.Complete,
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("HITL with Interactive Respond", () => {
|
|
138
|
+
it("should handle interactive respond callback during Executing state", async () => {
|
|
139
|
+
const agent = new MockStepwiseAgent();
|
|
140
|
+
const respondSelections: string[] = [];
|
|
141
|
+
|
|
142
|
+
const InteractiveHITLComponent: React.FC = () => {
|
|
143
|
+
const hitlTool: ReactHumanInTheLoop<{
|
|
144
|
+
question: string;
|
|
145
|
+
options: string[];
|
|
146
|
+
}> = {
|
|
147
|
+
name: "interactiveTool",
|
|
148
|
+
description: "Interactive human-in-the-loop tool",
|
|
149
|
+
parameters: z.object({
|
|
150
|
+
question: z.string(),
|
|
151
|
+
options: z.array(z.string()),
|
|
152
|
+
}),
|
|
153
|
+
render: ({ status, args, result, respond, name }) => (
|
|
154
|
+
<div data-testid="interactive-hitl">
|
|
155
|
+
<div data-testid="interactive-name">{name}</div>
|
|
156
|
+
<div data-testid="interactive-status">{status}</div>
|
|
157
|
+
<div data-testid="interactive-question">
|
|
158
|
+
{args.question ?? ""}
|
|
159
|
+
</div>
|
|
160
|
+
<div data-testid="interactive-options">
|
|
161
|
+
{args.options?.join(", ") ?? ""}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{status === ToolCallStatus.Executing && respond && (
|
|
165
|
+
<div data-testid="respond-section">
|
|
166
|
+
<button
|
|
167
|
+
data-testid="respond-yes"
|
|
168
|
+
onClick={() => {
|
|
169
|
+
respondSelections.push("yes");
|
|
170
|
+
void respond(JSON.stringify({ answer: "yes" }));
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
Respond Yes
|
|
174
|
+
</button>
|
|
175
|
+
<button
|
|
176
|
+
data-testid="respond-no"
|
|
177
|
+
onClick={() => {
|
|
178
|
+
respondSelections.push("no");
|
|
179
|
+
void respond(JSON.stringify({ answer: "no" }));
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
Respond No
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{result && <div data-testid="interactive-result">{result}</div>}
|
|
188
|
+
</div>
|
|
189
|
+
),
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
useHumanInTheLoop(hitlTool);
|
|
193
|
+
return null;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
renderWithCopilotKit({
|
|
197
|
+
agent,
|
|
198
|
+
children: (
|
|
199
|
+
<>
|
|
200
|
+
<InteractiveHITLComponent />
|
|
201
|
+
<div style={{ height: 400 }}>
|
|
202
|
+
<CopilotChat welcomeScreen={false} />
|
|
203
|
+
</div>
|
|
204
|
+
</>
|
|
205
|
+
),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const input = await screen.findByRole("textbox");
|
|
209
|
+
fireEvent.change(input, { target: { value: "Interactive question" } });
|
|
210
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
211
|
+
|
|
212
|
+
await waitFor(() => {
|
|
213
|
+
expect(screen.getByText("Interactive question")).toBeDefined();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const messageId = testId("msg");
|
|
217
|
+
const toolCallId = testId("tc");
|
|
218
|
+
|
|
219
|
+
agent.emit(runStartedEvent());
|
|
220
|
+
agent.emit(
|
|
221
|
+
toolCallChunkEvent({
|
|
222
|
+
toolCallId,
|
|
223
|
+
toolCallName: "interactiveTool",
|
|
224
|
+
parentMessageId: messageId,
|
|
225
|
+
delta: JSON.stringify({
|
|
226
|
+
question: "Proceed with operation?",
|
|
227
|
+
options: ["yes", "no"],
|
|
228
|
+
}),
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
await waitFor(() => {
|
|
233
|
+
expect(
|
|
234
|
+
screen.getByTestId("interactive-question").textContent,
|
|
235
|
+
).toContain("Proceed with operation?");
|
|
236
|
+
expect(screen.getByTestId("interactive-options").textContent).toContain(
|
|
237
|
+
"yes",
|
|
238
|
+
);
|
|
239
|
+
expect(screen.getByTestId("interactive-options").textContent).toContain(
|
|
240
|
+
"no",
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
agent.emit(runFinishedEvent());
|
|
245
|
+
agent.complete();
|
|
246
|
+
|
|
247
|
+
await waitFor(() => {
|
|
248
|
+
expect(screen.getByTestId("interactive-status").textContent).toBe(
|
|
249
|
+
ToolCallStatus.Executing,
|
|
250
|
+
);
|
|
251
|
+
expect(screen.getByTestId("respond-section")).toBeDefined();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
fireEvent.click(screen.getByTestId("respond-yes"));
|
|
255
|
+
|
|
256
|
+
await waitFor(() => {
|
|
257
|
+
expect(screen.getByTestId("interactive-status").textContent).toBe(
|
|
258
|
+
ToolCallStatus.Complete,
|
|
259
|
+
);
|
|
260
|
+
expect(screen.getByTestId("interactive-result").textContent).toContain(
|
|
261
|
+
"yes",
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(respondSelections).toEqual(["yes"]);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("Multiple HITL Tools", () => {
|
|
270
|
+
it("should handle multiple HITL tools registered simultaneously", async () => {
|
|
271
|
+
const agent = new MockStepwiseAgent();
|
|
272
|
+
|
|
273
|
+
const MultipleHITLComponent: React.FC = () => {
|
|
274
|
+
const reviewTool: ReactHumanInTheLoop<{ changes: string[] }> = {
|
|
275
|
+
name: "reviewTool",
|
|
276
|
+
description: "Review changes",
|
|
277
|
+
parameters: z.object({ changes: z.array(z.string()) }),
|
|
278
|
+
render: ({ name, description, args, status }) => (
|
|
279
|
+
<div data-testid="review-tool">
|
|
280
|
+
{name} - {description} | Status: {status} | Changes:{" "}
|
|
281
|
+
{args.changes?.length ?? 0}
|
|
282
|
+
</div>
|
|
283
|
+
),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const confirmTool: ReactHumanInTheLoop<{ action: string }> = {
|
|
287
|
+
name: "confirmTool",
|
|
288
|
+
description: "Confirm action",
|
|
289
|
+
parameters: z.object({ action: z.string() }),
|
|
290
|
+
render: ({ name, description, args, status }) => (
|
|
291
|
+
<div data-testid="confirm-tool">
|
|
292
|
+
{name} - {description} | Status: {status} | Action:{" "}
|
|
293
|
+
{args.action ?? ""}
|
|
294
|
+
</div>
|
|
295
|
+
),
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
useHumanInTheLoop(reviewTool);
|
|
299
|
+
useHumanInTheLoop(confirmTool);
|
|
300
|
+
return null;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
renderWithCopilotKit({
|
|
304
|
+
agent,
|
|
305
|
+
children: (
|
|
306
|
+
<>
|
|
307
|
+
<MultipleHITLComponent />
|
|
308
|
+
<div style={{ height: 400 }}>
|
|
309
|
+
<CopilotChat welcomeScreen={false} />
|
|
310
|
+
</div>
|
|
311
|
+
</>
|
|
312
|
+
),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const input = await screen.findByRole("textbox");
|
|
316
|
+
fireEvent.change(input, { target: { value: "Multiple HITL" } });
|
|
317
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
318
|
+
|
|
319
|
+
await waitFor(() => {
|
|
320
|
+
expect(screen.getByText("Multiple HITL")).toBeDefined();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const messageId = testId("msg");
|
|
324
|
+
const toolCallId1 = testId("tc1");
|
|
325
|
+
const toolCallId2 = testId("tc2");
|
|
326
|
+
|
|
327
|
+
agent.emit(runStartedEvent());
|
|
328
|
+
agent.emit(
|
|
329
|
+
toolCallChunkEvent({
|
|
330
|
+
toolCallId: toolCallId1,
|
|
331
|
+
toolCallName: "reviewTool",
|
|
332
|
+
parentMessageId: messageId,
|
|
333
|
+
delta: JSON.stringify({ changes: ["file1.ts", "file2.ts"] }),
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
agent.emit(
|
|
337
|
+
toolCallChunkEvent({
|
|
338
|
+
toolCallId: toolCallId2,
|
|
339
|
+
toolCallName: "confirmTool",
|
|
340
|
+
parentMessageId: messageId,
|
|
341
|
+
delta: JSON.stringify({ action: "deploy" }),
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
await waitFor(() => {
|
|
346
|
+
const reviewTool = screen.getByTestId("review-tool");
|
|
347
|
+
const confirmTool = screen.getByTestId("confirm-tool");
|
|
348
|
+
expect(reviewTool.textContent).toContain("Changes: 2");
|
|
349
|
+
expect(confirmTool.textContent).toContain("Action: deploy");
|
|
350
|
+
expect(reviewTool.textContent).toContain(ToolCallStatus.InProgress);
|
|
351
|
+
expect(confirmTool.textContent).toContain(ToolCallStatus.InProgress);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
agent.emit(runFinishedEvent());
|
|
355
|
+
agent.complete();
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("Multiple Hook Instances", () => {
|
|
360
|
+
it("should isolate state across two useHumanInTheLoop registrations", async () => {
|
|
361
|
+
const agent = new MockStepwiseAgent();
|
|
362
|
+
|
|
363
|
+
const DualHookComponent: React.FC = () => {
|
|
364
|
+
const primaryTool: ReactHumanInTheLoop<{ action: string }> = {
|
|
365
|
+
name: "primaryTool",
|
|
366
|
+
description: "Primary approval tool",
|
|
367
|
+
parameters: z.object({ action: z.string() }),
|
|
368
|
+
render: ({ status, args, respond, result }) => (
|
|
369
|
+
<div data-testid="primary-tool">
|
|
370
|
+
<div data-testid="primary-status">{status}</div>
|
|
371
|
+
<div data-testid="primary-action">{args.action ?? ""}</div>
|
|
372
|
+
{respond && (
|
|
373
|
+
<button
|
|
374
|
+
data-testid="primary-respond"
|
|
375
|
+
onClick={() => respond(JSON.stringify({ approved: true }))}
|
|
376
|
+
>
|
|
377
|
+
Respond Primary
|
|
378
|
+
</button>
|
|
379
|
+
)}
|
|
380
|
+
{result && <div data-testid="primary-result">{result}</div>}
|
|
381
|
+
</div>
|
|
382
|
+
),
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const secondaryTool: ReactHumanInTheLoop<{ detail: string }> = {
|
|
386
|
+
name: "secondaryTool",
|
|
387
|
+
description: "Secondary approval tool",
|
|
388
|
+
parameters: z.object({ detail: z.string() }),
|
|
389
|
+
render: ({ status, args, respond, result }) => (
|
|
390
|
+
<div data-testid="secondary-tool">
|
|
391
|
+
<div data-testid="secondary-status">{status}</div>
|
|
392
|
+
<div data-testid="secondary-detail">{args.detail ?? ""}</div>
|
|
393
|
+
{respond && (
|
|
394
|
+
<button
|
|
395
|
+
data-testid="secondary-respond"
|
|
396
|
+
onClick={() => respond(JSON.stringify({ confirmed: true }))}
|
|
397
|
+
>
|
|
398
|
+
Respond Secondary
|
|
399
|
+
</button>
|
|
400
|
+
)}
|
|
401
|
+
{result && <div data-testid="secondary-result">{result}</div>}
|
|
402
|
+
</div>
|
|
403
|
+
),
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
useHumanInTheLoop(primaryTool);
|
|
407
|
+
useHumanInTheLoop(secondaryTool);
|
|
408
|
+
return null;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
renderWithCopilotKit({
|
|
412
|
+
agent,
|
|
413
|
+
children: (
|
|
414
|
+
<>
|
|
415
|
+
<DualHookComponent />
|
|
416
|
+
<div style={{ height: 400 }}>
|
|
417
|
+
<CopilotChat welcomeScreen={false} />
|
|
418
|
+
</div>
|
|
419
|
+
</>
|
|
420
|
+
),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const input = await screen.findByRole("textbox");
|
|
424
|
+
fireEvent.change(input, { target: { value: "Dual hook instance" } });
|
|
425
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
426
|
+
|
|
427
|
+
await waitFor(() => {
|
|
428
|
+
expect(screen.getByText("Dual hook instance")).toBeDefined();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const messageId = testId("msg");
|
|
432
|
+
const primaryToolCallId = testId("tc-primary");
|
|
433
|
+
const secondaryToolCallId = testId("tc-secondary");
|
|
434
|
+
|
|
435
|
+
agent.emit(runStartedEvent());
|
|
436
|
+
agent.emit(
|
|
437
|
+
toolCallChunkEvent({
|
|
438
|
+
toolCallId: primaryToolCallId,
|
|
439
|
+
toolCallName: "primaryTool",
|
|
440
|
+
parentMessageId: messageId,
|
|
441
|
+
delta: JSON.stringify({ action: "archive" }),
|
|
442
|
+
}),
|
|
443
|
+
);
|
|
444
|
+
agent.emit(
|
|
445
|
+
toolCallChunkEvent({
|
|
446
|
+
toolCallId: secondaryToolCallId,
|
|
447
|
+
toolCallName: "secondaryTool",
|
|
448
|
+
parentMessageId: messageId,
|
|
449
|
+
delta: JSON.stringify({ detail: "requires confirmation" }),
|
|
450
|
+
}),
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
await waitFor(() => {
|
|
454
|
+
expect(screen.getByTestId("primary-status").textContent).toBe(
|
|
455
|
+
ToolCallStatus.InProgress,
|
|
456
|
+
);
|
|
457
|
+
expect(screen.getByTestId("primary-action").textContent).toBe(
|
|
458
|
+
"archive",
|
|
459
|
+
);
|
|
460
|
+
expect(screen.getByTestId("secondary-status").textContent).toBe(
|
|
461
|
+
ToolCallStatus.InProgress,
|
|
462
|
+
);
|
|
463
|
+
expect(screen.getByTestId("secondary-detail").textContent).toBe(
|
|
464
|
+
"requires confirmation",
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
agent.emit(runFinishedEvent());
|
|
469
|
+
agent.complete();
|
|
470
|
+
|
|
471
|
+
const primaryRespondButton = await screen.findByTestId("primary-respond");
|
|
472
|
+
|
|
473
|
+
expect(screen.getByTestId("primary-status").textContent).toBe(
|
|
474
|
+
ToolCallStatus.Executing,
|
|
475
|
+
);
|
|
476
|
+
expect(screen.getByTestId("secondary-status").textContent).toBe(
|
|
477
|
+
ToolCallStatus.InProgress,
|
|
478
|
+
);
|
|
479
|
+
expect(screen.queryByTestId("secondary-respond")).toBeNull();
|
|
480
|
+
|
|
481
|
+
fireEvent.click(primaryRespondButton);
|
|
482
|
+
|
|
483
|
+
await waitFor(() => {
|
|
484
|
+
expect(screen.getByTestId("primary-status").textContent).toBe(
|
|
485
|
+
ToolCallStatus.Complete,
|
|
486
|
+
);
|
|
487
|
+
expect(screen.getByTestId("primary-result").textContent).toContain(
|
|
488
|
+
"approved",
|
|
489
|
+
);
|
|
490
|
+
expect(screen.getByTestId("secondary-status").textContent).toBe(
|
|
491
|
+
ToolCallStatus.Executing,
|
|
492
|
+
);
|
|
493
|
+
expect(screen.queryByTestId("secondary-result")).toBeNull();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const secondaryRespondButton =
|
|
497
|
+
await screen.findByTestId("secondary-respond");
|
|
498
|
+
|
|
499
|
+
fireEvent.click(secondaryRespondButton);
|
|
500
|
+
|
|
501
|
+
await waitFor(() => {
|
|
502
|
+
expect(screen.getByTestId("secondary-status").textContent).toBe(
|
|
503
|
+
ToolCallStatus.Complete,
|
|
504
|
+
);
|
|
505
|
+
expect(screen.getByTestId("secondary-result").textContent).toContain(
|
|
506
|
+
"confirmed",
|
|
507
|
+
);
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
describe("HITL Tool with Dynamic Registration", () => {
|
|
513
|
+
it("should support dynamic registration and unregistration of HITL tools", async () => {
|
|
514
|
+
const agent = new MockStepwiseAgent();
|
|
515
|
+
|
|
516
|
+
const DynamicHITLComponent: React.FC = () => {
|
|
517
|
+
const dynamicHitl: ReactHumanInTheLoop<{ data: string }> = {
|
|
518
|
+
name: "dynamicHitl",
|
|
519
|
+
description: "Dynamically registered HITL",
|
|
520
|
+
parameters: z.object({ data: z.string() }),
|
|
521
|
+
render: ({ args, name, description }) => (
|
|
522
|
+
<div data-testid="dynamic-hitl">
|
|
523
|
+
{name}: {description} | Data: {args.data ?? ""}
|
|
524
|
+
</div>
|
|
525
|
+
),
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
useHumanInTheLoop(dynamicHitl);
|
|
529
|
+
return <div data-testid="hitl-enabled">HITL Enabled</div>;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const TestWrapper: React.FC = () => {
|
|
533
|
+
const [enabled, setEnabled] = useState(false);
|
|
534
|
+
|
|
535
|
+
return (
|
|
536
|
+
<>
|
|
537
|
+
<button
|
|
538
|
+
data-testid="toggle-hitl"
|
|
539
|
+
onClick={() => setEnabled((prev) => !prev)}
|
|
540
|
+
>
|
|
541
|
+
Toggle HITL
|
|
542
|
+
</button>
|
|
543
|
+
{enabled && <DynamicHITLComponent />}
|
|
544
|
+
<div style={{ height: 400 }}>
|
|
545
|
+
<CopilotChat welcomeScreen={false} />
|
|
546
|
+
</div>
|
|
547
|
+
</>
|
|
548
|
+
);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
renderWithCopilotKit({
|
|
552
|
+
agent,
|
|
553
|
+
children: <TestWrapper />,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
expect(screen.queryByTestId("hitl-enabled")).toBeNull();
|
|
557
|
+
|
|
558
|
+
const toggleButton = screen.getByTestId("toggle-hitl");
|
|
559
|
+
fireEvent.click(toggleButton);
|
|
560
|
+
|
|
561
|
+
await waitFor(() => {
|
|
562
|
+
expect(screen.getByTestId("hitl-enabled")).toBeDefined();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const input = await screen.findByRole("textbox");
|
|
566
|
+
fireEvent.change(input, { target: { value: "Test dynamic HITL" } });
|
|
567
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
568
|
+
|
|
569
|
+
await waitFor(() => {
|
|
570
|
+
expect(screen.getByText("Test dynamic HITL")).toBeDefined();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const messageId = testId("msg");
|
|
574
|
+
const toolCallId = testId("tc");
|
|
575
|
+
|
|
576
|
+
agent.emit(runStartedEvent());
|
|
577
|
+
agent.emit(
|
|
578
|
+
toolCallChunkEvent({
|
|
579
|
+
toolCallId,
|
|
580
|
+
toolCallName: "dynamicHitl",
|
|
581
|
+
parentMessageId: messageId,
|
|
582
|
+
delta: JSON.stringify({ data: "test data" }),
|
|
583
|
+
}),
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
await waitFor(() => {
|
|
587
|
+
const dynamicHitl = screen.getByTestId("dynamic-hitl");
|
|
588
|
+
expect(dynamicHitl.textContent).toContain("dynamicHitl");
|
|
589
|
+
expect(dynamicHitl.textContent).toContain("test data");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
agent.emit(runFinishedEvent());
|
|
593
|
+
|
|
594
|
+
fireEvent.click(toggleButton);
|
|
595
|
+
|
|
596
|
+
await waitFor(() => {
|
|
597
|
+
expect(screen.queryByTestId("hitl-enabled")).toBeNull();
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
fireEvent.change(input, { target: { value: "Test after disable" } });
|
|
601
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
602
|
+
|
|
603
|
+
await waitFor(() => {
|
|
604
|
+
expect(screen.getByText("Test after disable")).toBeDefined();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const messageId2 = testId("msg2");
|
|
608
|
+
const toolCallId2 = testId("tc2");
|
|
609
|
+
|
|
610
|
+
agent.emit(runStartedEvent());
|
|
611
|
+
agent.emit(
|
|
612
|
+
toolCallChunkEvent({
|
|
613
|
+
toolCallId: toolCallId2,
|
|
614
|
+
toolCallName: "dynamicHitl",
|
|
615
|
+
parentMessageId: messageId2,
|
|
616
|
+
delta: JSON.stringify({ data: "should not render" }),
|
|
617
|
+
}),
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
// Wait and verify that dynamic HITL does not render
|
|
621
|
+
await waitFor(
|
|
622
|
+
() => {
|
|
623
|
+
const dynamicRenders = screen.queryAllByTestId("dynamic-hitl");
|
|
624
|
+
expect(dynamicRenders.length).toBe(0);
|
|
625
|
+
expect(screen.queryByText(/should not render/)).toBeNull();
|
|
626
|
+
},
|
|
627
|
+
{ timeout: 200 },
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
agent.emit(runFinishedEvent());
|
|
631
|
+
agent.complete();
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
describe("useHumanInTheLoop dependencies", () => {
|
|
636
|
+
it("updates HITL renderer when optional deps change", async () => {
|
|
637
|
+
const DependencyDrivenHITLComponent: React.FC = () => {
|
|
638
|
+
const [version, setVersion] = useState(0);
|
|
639
|
+
|
|
640
|
+
const hitlTool: ReactHumanInTheLoop<{ message: string }> = {
|
|
641
|
+
name: "dependencyHitlTool",
|
|
642
|
+
description: "Dependency-driven HITL tool",
|
|
643
|
+
parameters: z.object({ message: z.string() }),
|
|
644
|
+
render: ({ args }) => (
|
|
645
|
+
<div data-testid="dependency-hitl-render">
|
|
646
|
+
{args.message} (v{version})
|
|
647
|
+
</div>
|
|
648
|
+
),
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
useHumanInTheLoop(hitlTool, [version]);
|
|
652
|
+
|
|
653
|
+
const toolCallId = testId("hitl_dep_tc");
|
|
654
|
+
const assistantMessage: AssistantMessage = {
|
|
655
|
+
id: testId("hitl_dep_a"),
|
|
656
|
+
role: "assistant",
|
|
657
|
+
content: "",
|
|
658
|
+
toolCalls: [
|
|
659
|
+
{
|
|
660
|
+
id: toolCallId,
|
|
661
|
+
type: "function",
|
|
662
|
+
function: {
|
|
663
|
+
name: "dependencyHitlTool",
|
|
664
|
+
arguments: JSON.stringify({ message: "hello" }),
|
|
665
|
+
},
|
|
666
|
+
} as any,
|
|
667
|
+
],
|
|
668
|
+
} as any;
|
|
669
|
+
const messages: Message[] = [];
|
|
670
|
+
|
|
671
|
+
return (
|
|
672
|
+
<>
|
|
673
|
+
<button
|
|
674
|
+
data-testid="hitl-bump-version"
|
|
675
|
+
type="button"
|
|
676
|
+
onClick={() => setVersion((v) => v + 1)}
|
|
677
|
+
>
|
|
678
|
+
Bump
|
|
679
|
+
</button>
|
|
680
|
+
<CopilotChatToolCallsView
|
|
681
|
+
message={assistantMessage}
|
|
682
|
+
messages={messages}
|
|
683
|
+
/>
|
|
684
|
+
</>
|
|
685
|
+
);
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
renderWithCopilotKit({
|
|
689
|
+
children: <DependencyDrivenHITLComponent />,
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
await waitFor(() => {
|
|
693
|
+
const el = screen.getByTestId("dependency-hitl-render");
|
|
694
|
+
expect(el).toBeDefined();
|
|
695
|
+
expect(el.textContent).toContain("hello");
|
|
696
|
+
expect(el.textContent).toContain("(v0)");
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
fireEvent.click(screen.getByTestId("hitl-bump-version"));
|
|
700
|
+
|
|
701
|
+
await waitFor(() => {
|
|
702
|
+
const el = screen.getByTestId("dependency-hitl-render");
|
|
703
|
+
expect(el.textContent).toContain("(v1)");
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
describe("HITL Thread Reconnection Bug", () => {
|
|
710
|
+
it("should show executing status when reconnecting to thread with pending HITL", async () => {
|
|
711
|
+
// This test verifies that HITL tool calls work correctly when reconnecting
|
|
712
|
+
// to a thread with pending (unanswered) tool calls.
|
|
713
|
+
//
|
|
714
|
+
// The key challenge is timing: when events are replayed asynchronously via connect(),
|
|
715
|
+
// the onToolExecutionStart event may fire before the tool rendering component mounts.
|
|
716
|
+
// The fix ensures executingToolCallIds is tracked at the CopilotKitProvider level,
|
|
717
|
+
// so the executing state is captured early and available when components mount.
|
|
718
|
+
|
|
719
|
+
const agent = new MockReconnectableAgent();
|
|
720
|
+
|
|
721
|
+
const HITLComponent: React.FC = () => {
|
|
722
|
+
const hitlTool: ReactHumanInTheLoop<{ action: string }> = {
|
|
723
|
+
name: "approvalTool",
|
|
724
|
+
description: "Requires human approval",
|
|
725
|
+
parameters: z.object({ action: z.string() }),
|
|
726
|
+
render: ({ status, args, respond }) => {
|
|
727
|
+
return (
|
|
728
|
+
<div data-testid="hitl-tool">
|
|
729
|
+
<div data-testid="hitl-status">{status}</div>
|
|
730
|
+
<div data-testid="hitl-action">{args.action ?? "no-action"}</div>
|
|
731
|
+
{respond && <button data-testid="hitl-respond">Respond</button>}
|
|
732
|
+
</div>
|
|
733
|
+
);
|
|
734
|
+
},
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
useHumanInTheLoop(hitlTool);
|
|
738
|
+
return null;
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
// Phase 1: Initial render and run (user starts interaction)
|
|
742
|
+
const { unmount } = renderWithCopilotKit({
|
|
743
|
+
agent,
|
|
744
|
+
children: (
|
|
745
|
+
<>
|
|
746
|
+
<HITLComponent />
|
|
747
|
+
<div style={{ height: 400 }}>
|
|
748
|
+
<CopilotChat welcomeScreen={false} />
|
|
749
|
+
</div>
|
|
750
|
+
</>
|
|
751
|
+
),
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
const input = await screen.findByRole("textbox");
|
|
755
|
+
fireEvent.change(input, { target: { value: "Request approval" } });
|
|
756
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
757
|
+
|
|
758
|
+
await waitFor(() => {
|
|
759
|
+
expect(screen.getByText("Request approval")).toBeDefined();
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const messageId = testId("msg");
|
|
763
|
+
const toolCallId = testId("tc");
|
|
764
|
+
|
|
765
|
+
// Emit tool call events (HITL tool call without response)
|
|
766
|
+
agent.emit(runStartedEvent());
|
|
767
|
+
agent.emit(
|
|
768
|
+
toolCallChunkEvent({
|
|
769
|
+
toolCallId,
|
|
770
|
+
toolCallName: "approvalTool",
|
|
771
|
+
parentMessageId: messageId,
|
|
772
|
+
delta: JSON.stringify({ action: "delete" }),
|
|
773
|
+
}),
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
await waitFor(() => {
|
|
777
|
+
expect(screen.getByTestId("hitl-status").textContent).toBe(
|
|
778
|
+
ToolCallStatus.InProgress,
|
|
779
|
+
);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// Complete run WITHOUT responding to HITL (simulating user refresh before clicking)
|
|
783
|
+
agent.emit(runFinishedEvent());
|
|
784
|
+
agent.complete();
|
|
785
|
+
|
|
786
|
+
// Verify status is Executing (the tool handler should be running waiting for response)
|
|
787
|
+
await waitFor(() => {
|
|
788
|
+
expect(screen.getByTestId("hitl-status").textContent).toBe(
|
|
789
|
+
ToolCallStatus.Executing,
|
|
790
|
+
);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// Phase 2: Unmount and remount (simulating page reload + reconnect)
|
|
794
|
+
unmount();
|
|
795
|
+
agent.reset();
|
|
796
|
+
|
|
797
|
+
// Re-render with same thread (simulates reconnection)
|
|
798
|
+
renderWithCopilotKit({
|
|
799
|
+
agent,
|
|
800
|
+
children: (
|
|
801
|
+
<>
|
|
802
|
+
<HITLComponent />
|
|
803
|
+
<div style={{ height: 400 }}>
|
|
804
|
+
<CopilotChat welcomeScreen={false} />
|
|
805
|
+
</div>
|
|
806
|
+
</>
|
|
807
|
+
),
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
// Wait for the HITL tool to render from replayed events
|
|
811
|
+
await waitFor(() => {
|
|
812
|
+
expect(screen.getByTestId("hitl-tool")).toBeDefined();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// Verify tool call args are correctly replayed from connect() events
|
|
816
|
+
await waitFor(() => {
|
|
817
|
+
expect(screen.getByTestId("hitl-action").textContent).toBe("delete");
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// After reconnection, status should be 'executing' with respond available
|
|
821
|
+
// The tool handler is re-invoked for pending HITL tools that were never responded to.
|
|
822
|
+
await waitFor(() => {
|
|
823
|
+
expect(screen.getByTestId("hitl-status").textContent).toBe(
|
|
824
|
+
ToolCallStatus.Executing,
|
|
825
|
+
);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// respond button should be present so user can interact
|
|
829
|
+
expect(screen.getByTestId("hitl-respond")).toBeDefined();
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it("should handle tool call after connect (fresh run)", async () => {
|
|
833
|
+
// Tests that normal tool calls work correctly after connecting to a thread.
|
|
834
|
+
// This ensures the fix for reconnection doesn't break the normal flow.
|
|
835
|
+
|
|
836
|
+
const agent = new MockReconnectableAgent();
|
|
837
|
+
|
|
838
|
+
const HITLComponent: React.FC = () => {
|
|
839
|
+
const hitlTool: ReactHumanInTheLoop<{ task: string }> = {
|
|
840
|
+
name: "taskTool",
|
|
841
|
+
description: "Task approval",
|
|
842
|
+
parameters: z.object({ task: z.string() }),
|
|
843
|
+
render: ({ status, args, respond }) => (
|
|
844
|
+
<div data-testid="task-tool">
|
|
845
|
+
<div data-testid="task-status">{status}</div>
|
|
846
|
+
<div data-testid="task-name">{args.task ?? "no-task"}</div>
|
|
847
|
+
{respond && (
|
|
848
|
+
<button
|
|
849
|
+
data-testid="task-respond"
|
|
850
|
+
onClick={() => respond("done")}
|
|
851
|
+
>
|
|
852
|
+
Done
|
|
853
|
+
</button>
|
|
854
|
+
)}
|
|
855
|
+
</div>
|
|
856
|
+
),
|
|
857
|
+
};
|
|
858
|
+
useHumanInTheLoop(hitlTool);
|
|
859
|
+
return null;
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
renderWithCopilotKit({
|
|
863
|
+
agent,
|
|
864
|
+
children: (
|
|
865
|
+
<>
|
|
866
|
+
<HITLComponent />
|
|
867
|
+
<div style={{ height: 400 }}>
|
|
868
|
+
<CopilotChat welcomeScreen={false} />
|
|
869
|
+
</div>
|
|
870
|
+
</>
|
|
871
|
+
),
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
// Send a message to trigger a run
|
|
875
|
+
const input = await screen.findByRole("textbox");
|
|
876
|
+
fireEvent.change(input, { target: { value: "Start task" } });
|
|
877
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
878
|
+
|
|
879
|
+
await waitFor(() => {
|
|
880
|
+
expect(screen.getByText("Start task")).toBeDefined();
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
const messageId = testId("msg");
|
|
884
|
+
const toolCallId = testId("tc");
|
|
885
|
+
|
|
886
|
+
// Emit tool call
|
|
887
|
+
agent.emit(runStartedEvent());
|
|
888
|
+
agent.emit(
|
|
889
|
+
toolCallChunkEvent({
|
|
890
|
+
toolCallId,
|
|
891
|
+
toolCallName: "taskTool",
|
|
892
|
+
parentMessageId: messageId,
|
|
893
|
+
delta: JSON.stringify({ task: "review PR" }),
|
|
894
|
+
}),
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
// Should show inProgress while streaming
|
|
898
|
+
await waitFor(() => {
|
|
899
|
+
expect(screen.getByTestId("task-status").textContent).toBe(
|
|
900
|
+
ToolCallStatus.InProgress,
|
|
901
|
+
);
|
|
902
|
+
expect(screen.getByTestId("task-name").textContent).toBe("review PR");
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
// Complete run - should transition to executing
|
|
906
|
+
agent.emit(runFinishedEvent());
|
|
907
|
+
agent.complete();
|
|
908
|
+
|
|
909
|
+
await waitFor(() => {
|
|
910
|
+
expect(screen.getByTestId("task-status").textContent).toBe(
|
|
911
|
+
ToolCallStatus.Executing,
|
|
912
|
+
);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// Respond - should transition to complete
|
|
916
|
+
const respondButton = screen.getByTestId("task-respond");
|
|
917
|
+
fireEvent.click(respondButton);
|
|
918
|
+
|
|
919
|
+
await waitFor(() => {
|
|
920
|
+
expect(screen.getByTestId("task-status").textContent).toBe(
|
|
921
|
+
ToolCallStatus.Complete,
|
|
922
|
+
);
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it("should handle multiple sequential tool calls (HITL executes one at a time)", async () => {
|
|
927
|
+
// Tests that multiple HITL tools execute sequentially.
|
|
928
|
+
// The second tool only starts executing after the first completes.
|
|
929
|
+
// This is the expected behavior for HITL tools with followUp: true (default).
|
|
930
|
+
|
|
931
|
+
const agent = new MockStepwiseAgent();
|
|
932
|
+
|
|
933
|
+
const MultiToolComponent: React.FC = () => {
|
|
934
|
+
const tool1: ReactHumanInTheLoop<{ id: string }> = {
|
|
935
|
+
name: "tool1",
|
|
936
|
+
description: "First tool",
|
|
937
|
+
parameters: z.object({ id: z.string() }),
|
|
938
|
+
render: ({ status, args, respond }) => (
|
|
939
|
+
<div data-testid="tool1">
|
|
940
|
+
<div data-testid="tool1-status">{status}</div>
|
|
941
|
+
<div data-testid="tool1-id">{args.id ?? ""}</div>
|
|
942
|
+
{respond && (
|
|
943
|
+
<button data-testid="tool1-respond" onClick={() => respond("ok")}>
|
|
944
|
+
OK
|
|
945
|
+
</button>
|
|
946
|
+
)}
|
|
947
|
+
</div>
|
|
948
|
+
),
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
const tool2: ReactHumanInTheLoop<{ id: string }> = {
|
|
952
|
+
name: "tool2",
|
|
953
|
+
description: "Second tool",
|
|
954
|
+
parameters: z.object({ id: z.string() }),
|
|
955
|
+
render: ({ status, args, respond }) => (
|
|
956
|
+
<div data-testid="tool2">
|
|
957
|
+
<div data-testid="tool2-status">{status}</div>
|
|
958
|
+
<div data-testid="tool2-id">{args.id ?? ""}</div>
|
|
959
|
+
{respond && (
|
|
960
|
+
<button data-testid="tool2-respond" onClick={() => respond("ok")}>
|
|
961
|
+
OK
|
|
962
|
+
</button>
|
|
963
|
+
)}
|
|
964
|
+
</div>
|
|
965
|
+
),
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
useHumanInTheLoop(tool1);
|
|
969
|
+
useHumanInTheLoop(tool2);
|
|
970
|
+
return null;
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
renderWithCopilotKit({
|
|
974
|
+
agent,
|
|
975
|
+
children: (
|
|
976
|
+
<>
|
|
977
|
+
<MultiToolComponent />
|
|
978
|
+
<div style={{ height: 400 }}>
|
|
979
|
+
<CopilotChat welcomeScreen={false} />
|
|
980
|
+
</div>
|
|
981
|
+
</>
|
|
982
|
+
),
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
const input = await screen.findByRole("textbox");
|
|
986
|
+
fireEvent.change(input, { target: { value: "Multiple tools" } });
|
|
987
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
988
|
+
|
|
989
|
+
await waitFor(() => {
|
|
990
|
+
expect(screen.getByText("Multiple tools")).toBeDefined();
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
const messageId = testId("msg");
|
|
994
|
+
const tc1 = testId("tc1");
|
|
995
|
+
const tc2 = testId("tc2");
|
|
996
|
+
|
|
997
|
+
// Emit both tool calls
|
|
998
|
+
agent.emit(runStartedEvent());
|
|
999
|
+
agent.emit(
|
|
1000
|
+
toolCallChunkEvent({
|
|
1001
|
+
toolCallId: tc1,
|
|
1002
|
+
toolCallName: "tool1",
|
|
1003
|
+
parentMessageId: messageId,
|
|
1004
|
+
delta: JSON.stringify({ id: "first" }),
|
|
1005
|
+
}),
|
|
1006
|
+
);
|
|
1007
|
+
agent.emit(
|
|
1008
|
+
toolCallChunkEvent({
|
|
1009
|
+
toolCallId: tc2,
|
|
1010
|
+
toolCallName: "tool2",
|
|
1011
|
+
parentMessageId: messageId,
|
|
1012
|
+
delta: JSON.stringify({ id: "second" }),
|
|
1013
|
+
}),
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
// Both should be inProgress (tool calls received but not yet executed)
|
|
1017
|
+
await waitFor(() => {
|
|
1018
|
+
expect(screen.getByTestId("tool1-status").textContent).toBe(
|
|
1019
|
+
ToolCallStatus.InProgress,
|
|
1020
|
+
);
|
|
1021
|
+
expect(screen.getByTestId("tool2-status").textContent).toBe(
|
|
1022
|
+
ToolCallStatus.InProgress,
|
|
1023
|
+
);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// Complete run - FIRST tool starts executing, second remains inProgress
|
|
1027
|
+
// (HITL tools execute sequentially via processAgentResult)
|
|
1028
|
+
agent.emit(runFinishedEvent());
|
|
1029
|
+
agent.complete();
|
|
1030
|
+
|
|
1031
|
+
await waitFor(() => {
|
|
1032
|
+
expect(screen.getByTestId("tool1-status").textContent).toBe(
|
|
1033
|
+
ToolCallStatus.Executing,
|
|
1034
|
+
);
|
|
1035
|
+
// Tool2 is still inProgress because tool1 hasn't completed yet
|
|
1036
|
+
expect(screen.getByTestId("tool2-status").textContent).toBe(
|
|
1037
|
+
ToolCallStatus.InProgress,
|
|
1038
|
+
);
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// Respond to first tool
|
|
1042
|
+
fireEvent.click(screen.getByTestId("tool1-respond"));
|
|
1043
|
+
|
|
1044
|
+
// After first tool completes, second tool starts executing
|
|
1045
|
+
await waitFor(() => {
|
|
1046
|
+
expect(screen.getByTestId("tool1-status").textContent).toBe(
|
|
1047
|
+
ToolCallStatus.Complete,
|
|
1048
|
+
);
|
|
1049
|
+
expect(screen.getByTestId("tool2-status").textContent).toBe(
|
|
1050
|
+
ToolCallStatus.Executing,
|
|
1051
|
+
);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
// Respond to second tool
|
|
1055
|
+
fireEvent.click(screen.getByTestId("tool2-respond"));
|
|
1056
|
+
|
|
1057
|
+
await waitFor(() => {
|
|
1058
|
+
expect(screen.getByTestId("tool2-status").textContent).toBe(
|
|
1059
|
+
ToolCallStatus.Complete,
|
|
1060
|
+
);
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
it("should handle late-mounting component that renders executing tool", async () => {
|
|
1065
|
+
// Tests that a component which mounts AFTER a tool starts executing
|
|
1066
|
+
// still sees the correct 'executing' status.
|
|
1067
|
+
// This is similar to the reconnection bug but without actual reconnection.
|
|
1068
|
+
|
|
1069
|
+
const agent = new MockStepwiseAgent();
|
|
1070
|
+
let showTool = false;
|
|
1071
|
+
let setShowTool: (show: boolean) => void;
|
|
1072
|
+
|
|
1073
|
+
const ToggleableHITL: React.FC = () => {
|
|
1074
|
+
const [show, setShow] = useState(false);
|
|
1075
|
+
showTool = show;
|
|
1076
|
+
setShowTool = setShow;
|
|
1077
|
+
|
|
1078
|
+
const hitlTool: ReactHumanInTheLoop<{ data: string }> = {
|
|
1079
|
+
name: "lateTool",
|
|
1080
|
+
description: "Late mounting tool",
|
|
1081
|
+
parameters: z.object({ data: z.string() }),
|
|
1082
|
+
render: ({ status, args }) => (
|
|
1083
|
+
<div data-testid="late-tool">
|
|
1084
|
+
<div data-testid="late-status">{status}</div>
|
|
1085
|
+
<div data-testid="late-data">{args.data ?? ""}</div>
|
|
1086
|
+
</div>
|
|
1087
|
+
),
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
useHumanInTheLoop(hitlTool);
|
|
1091
|
+
|
|
1092
|
+
// Only render the tool view if show is true
|
|
1093
|
+
// The tool is registered regardless, but rendering is conditional
|
|
1094
|
+
return show ? (
|
|
1095
|
+
<div data-testid="late-tool-container">Tool is visible</div>
|
|
1096
|
+
) : null;
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
renderWithCopilotKit({
|
|
1100
|
+
agent,
|
|
1101
|
+
children: (
|
|
1102
|
+
<>
|
|
1103
|
+
<ToggleableHITL />
|
|
1104
|
+
<div style={{ height: 400 }}>
|
|
1105
|
+
<CopilotChat welcomeScreen={false} />
|
|
1106
|
+
</div>
|
|
1107
|
+
</>
|
|
1108
|
+
),
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
const input = await screen.findByRole("textbox");
|
|
1112
|
+
fireEvent.change(input, { target: { value: "Test late mount" } });
|
|
1113
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
1114
|
+
|
|
1115
|
+
await waitFor(() => {
|
|
1116
|
+
expect(screen.getByText("Test late mount")).toBeDefined();
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
const messageId = testId("msg");
|
|
1120
|
+
const toolCallId = testId("tc");
|
|
1121
|
+
|
|
1122
|
+
// Emit tool call and complete run BEFORE showing the component
|
|
1123
|
+
agent.emit(runStartedEvent());
|
|
1124
|
+
agent.emit(
|
|
1125
|
+
toolCallChunkEvent({
|
|
1126
|
+
toolCallId,
|
|
1127
|
+
toolCallName: "lateTool",
|
|
1128
|
+
parentMessageId: messageId,
|
|
1129
|
+
delta: JSON.stringify({ data: "late-data" }),
|
|
1130
|
+
}),
|
|
1131
|
+
);
|
|
1132
|
+
agent.emit(runFinishedEvent());
|
|
1133
|
+
agent.complete();
|
|
1134
|
+
|
|
1135
|
+
// Wait for tool execution to start
|
|
1136
|
+
await waitFor(() => {
|
|
1137
|
+
// The tool should be rendered by CopilotChat even if our custom component isn't shown
|
|
1138
|
+
expect(screen.getByTestId("late-status").textContent).toBe(
|
|
1139
|
+
ToolCallStatus.Executing,
|
|
1140
|
+
);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
// Now show our custom component - it should also see the executing status
|
|
1144
|
+
// (This tests that the provider-level tracking works for late-mounting components)
|
|
1145
|
+
act(() => {
|
|
1146
|
+
setShowTool(true);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
await waitFor(() => {
|
|
1150
|
+
expect(screen.getByTestId("late-tool-container")).toBeDefined();
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// The status should still be executing (tracked at provider level)
|
|
1154
|
+
expect(screen.getByTestId("late-status").textContent).toBe(
|
|
1155
|
+
ToolCallStatus.Executing,
|
|
1156
|
+
);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it("should maintain executing state across component remount", async () => {
|
|
1160
|
+
// Tests that if a tool rendering component unmounts and remounts while
|
|
1161
|
+
// a tool is executing, it still sees the correct 'executing' status.
|
|
1162
|
+
// This verifies that executingToolCallIds is tracked at the provider level.
|
|
1163
|
+
//
|
|
1164
|
+
// Note: After remount, the HITL handler is recreated, so respond functionality
|
|
1165
|
+
// is tested separately. This test focuses on state visibility.
|
|
1166
|
+
|
|
1167
|
+
const agent = new MockStepwiseAgent();
|
|
1168
|
+
let toggleRemount: () => void;
|
|
1169
|
+
|
|
1170
|
+
const RemountableHITL: React.FC = () => {
|
|
1171
|
+
const [key, setKey] = useState(0);
|
|
1172
|
+
toggleRemount = () => setKey((k) => k + 1);
|
|
1173
|
+
|
|
1174
|
+
return <HITLChild key={key} />;
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
const HITLChild: React.FC = () => {
|
|
1178
|
+
const hitlTool: ReactHumanInTheLoop<{ action: string }> = {
|
|
1179
|
+
name: "remountTool",
|
|
1180
|
+
description: "Remountable tool",
|
|
1181
|
+
parameters: z.object({ action: z.string() }),
|
|
1182
|
+
render: ({ status, args, respond }) => (
|
|
1183
|
+
<div data-testid="remount-tool">
|
|
1184
|
+
<div data-testid="remount-status">{status}</div>
|
|
1185
|
+
<div data-testid="remount-action">{args.action ?? ""}</div>
|
|
1186
|
+
{respond && <button data-testid="remount-respond">Done</button>}
|
|
1187
|
+
</div>
|
|
1188
|
+
),
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
useHumanInTheLoop(hitlTool);
|
|
1192
|
+
return null;
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
renderWithCopilotKit({
|
|
1196
|
+
agent,
|
|
1197
|
+
children: (
|
|
1198
|
+
<>
|
|
1199
|
+
<RemountableHITL />
|
|
1200
|
+
<div style={{ height: 400 }}>
|
|
1201
|
+
<CopilotChat welcomeScreen={false} />
|
|
1202
|
+
</div>
|
|
1203
|
+
</>
|
|
1204
|
+
),
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
const input = await screen.findByRole("textbox");
|
|
1208
|
+
fireEvent.change(input, { target: { value: "Test remount" } });
|
|
1209
|
+
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
1210
|
+
|
|
1211
|
+
await waitFor(() => {
|
|
1212
|
+
expect(screen.getByText("Test remount")).toBeDefined();
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
const messageId = testId("msg");
|
|
1216
|
+
const toolCallId = testId("tc");
|
|
1217
|
+
|
|
1218
|
+
// Emit tool call and complete run
|
|
1219
|
+
agent.emit(runStartedEvent());
|
|
1220
|
+
agent.emit(
|
|
1221
|
+
toolCallChunkEvent({
|
|
1222
|
+
toolCallId,
|
|
1223
|
+
toolCallName: "remountTool",
|
|
1224
|
+
parentMessageId: messageId,
|
|
1225
|
+
delta: JSON.stringify({ action: "test-action" }),
|
|
1226
|
+
}),
|
|
1227
|
+
);
|
|
1228
|
+
agent.emit(runFinishedEvent());
|
|
1229
|
+
agent.complete();
|
|
1230
|
+
|
|
1231
|
+
// Verify executing status before remount
|
|
1232
|
+
await waitFor(() => {
|
|
1233
|
+
expect(screen.getByTestId("remount-status").textContent).toBe(
|
|
1234
|
+
ToolCallStatus.Executing,
|
|
1235
|
+
);
|
|
1236
|
+
expect(screen.getByTestId("remount-action").textContent).toBe(
|
|
1237
|
+
"test-action",
|
|
1238
|
+
);
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
// Remount the component by changing its key
|
|
1242
|
+
act(() => {
|
|
1243
|
+
toggleRemount();
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
// After remount, should STILL see executing status
|
|
1247
|
+
// This is the key assertion: executingToolCallIds survives component remounts
|
|
1248
|
+
// because it's tracked at the CopilotKitProvider level
|
|
1249
|
+
await waitFor(() => {
|
|
1250
|
+
expect(screen.getByTestId("remount-status").textContent).toBe(
|
|
1251
|
+
ToolCallStatus.Executing,
|
|
1252
|
+
);
|
|
1253
|
+
expect(screen.getByTestId("remount-action").textContent).toBe(
|
|
1254
|
+
"test-action",
|
|
1255
|
+
);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
// The respond button should be present (status is executing)
|
|
1259
|
+
expect(screen.getByTestId("remount-respond")).toBeDefined();
|
|
1260
|
+
});
|
|
1261
|
+
});
|