@copilotkit/react-core 1.55.0-next.8 → 1.55.0-next.9
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 +18 -5
- package/dist/{copilotkit-B3Mb1yVE.cjs → copilotkit-BDNjFNmk.cjs} +156 -92
- package/dist/copilotkit-BDNjFNmk.cjs.map +1 -0
- package/dist/{copilotkit-Dy5w3qEV.d.mts → copilotkit-BqcyhQjT.d.mts} +3 -1
- package/dist/{copilotkit-Dy5w3qEV.d.mts.map → copilotkit-BqcyhQjT.d.mts.map} +1 -1
- package/dist/{copilotkit-DNYSFuz5.mjs → copilotkit-DeOzjPsb.mjs} +157 -93
- package/dist/copilotkit-DeOzjPsb.mjs.map +1 -0
- package/dist/{copilotkit-DBzgOMby.d.cts → copilotkit-l-IBF4Xp.d.cts} +3 -1
- package/dist/{copilotkit-DBzgOMby.d.cts.map → copilotkit-l-IBF4Xp.d.cts.map} +1 -1
- package/dist/index.cjs +9 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +9 -4
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +160 -94
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +1 -1
- package/dist/v2/index.d.cts +1 -1
- package/dist/v2/index.d.mts +1 -1
- package/dist/v2/index.mjs +1 -1
- package/dist/v2/index.umd.js +160 -94
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/components/copilot-provider/__tests__/copilot-messages-key.test.tsx +92 -0
- package/src/components/copilot-provider/copilotkit.tsx +3 -3
- package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +27 -16
- package/src/hooks/use-copilot-chat_internal.ts +15 -4
- package/src/v2/__tests__/utils/test-helpers.tsx +40 -7
- package/src/v2/components/chat/CopilotChat.tsx +4 -2
- package/src/v2/components/chat/CopilotChatMessageView.tsx +7 -2
- package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx +5 -2
- package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +5 -2
- package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +17 -1
- package/src/v2/hooks/__tests__/use-agent-context-timing.e2e.test.tsx +8 -0
- package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +327 -0
- package/src/v2/hooks/__tests__/use-agent.e2e.test.tsx +13 -2
- package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.tsx +23 -4
- package/src/v2/hooks/use-agent.tsx +126 -6
- package/src/v2/hooks/use-render-custom-messages.tsx +6 -1
- package/dist/copilotkit-B3Mb1yVE.cjs.map +0 -1
- package/dist/copilotkit-DNYSFuz5.mjs.map +0 -1
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
AbstractAgent,
|
|
7
|
+
type BaseEvent,
|
|
8
|
+
type RunAgentInput,
|
|
9
|
+
} from "@ag-ui/client";
|
|
10
|
+
import { useCopilotKit } from "../../providers/CopilotKitProvider";
|
|
11
|
+
import { useAgent } from "../use-agent";
|
|
12
|
+
import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
|
|
13
|
+
import { Observable } from "rxjs";
|
|
14
|
+
|
|
15
|
+
vi.mock("../../providers/CopilotKitProvider", () => ({
|
|
16
|
+
useCopilotKit: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const mockUseCopilotKit = useCopilotKit as ReturnType<typeof vi.fn>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A minimal mock agent whose clone() returns a NEW instance and copies
|
|
23
|
+
* messages from the source. This is essential for testing per-thread
|
|
24
|
+
* isolation — each clone must be a distinct object that starts with the
|
|
25
|
+
* source's state so that cloneForThread's setMessages([]) / setState({})
|
|
26
|
+
* calls are meaningful (not vacuously true on an already-empty clone).
|
|
27
|
+
*/
|
|
28
|
+
class CloneableAgent extends AbstractAgent {
|
|
29
|
+
clone(): CloneableAgent {
|
|
30
|
+
const cloned = new CloneableAgent();
|
|
31
|
+
cloned.agentId = this.agentId;
|
|
32
|
+
// Copy messages so cloneForThread's setMessages([]) actually clears state
|
|
33
|
+
cloned.setMessages([...this.messages]);
|
|
34
|
+
return cloned;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
run(_input: RunAgentInput): Observable<BaseEvent> {
|
|
38
|
+
return new Observable();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("useAgent thread isolation", () => {
|
|
43
|
+
let mockCopilotkit: {
|
|
44
|
+
getAgent: ReturnType<typeof vi.fn>;
|
|
45
|
+
runtimeUrl: string | undefined;
|
|
46
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
|
|
47
|
+
runtimeTransport: string;
|
|
48
|
+
headers: Record<string, string>;
|
|
49
|
+
agents: Record<string, AbstractAgent>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let registeredAgent: CloneableAgent;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
registeredAgent = new CloneableAgent();
|
|
56
|
+
registeredAgent.agentId = "my-agent";
|
|
57
|
+
|
|
58
|
+
mockCopilotkit = {
|
|
59
|
+
getAgent: vi.fn((id: string) =>
|
|
60
|
+
id === "my-agent" ? registeredAgent : undefined,
|
|
61
|
+
),
|
|
62
|
+
runtimeUrl: "http://localhost:3000/api/copilotkit",
|
|
63
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
|
|
64
|
+
runtimeTransport: "rest",
|
|
65
|
+
headers: {},
|
|
66
|
+
agents: { "my-agent": registeredAgent },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
mockUseCopilotKit.mockReturnValue({
|
|
70
|
+
copilotkit: mockCopilotkit,
|
|
71
|
+
executingToolCallIds: new Set(),
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.restoreAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns different agent instances for different threadIds with the same agentId", () => {
|
|
80
|
+
const agents: Record<string, AbstractAgent> = {};
|
|
81
|
+
|
|
82
|
+
function TrackerA() {
|
|
83
|
+
const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-a" });
|
|
84
|
+
agents["a"] = agent;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function TrackerB() {
|
|
89
|
+
const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-b" });
|
|
90
|
+
agents["b"] = agent;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
render(
|
|
95
|
+
<>
|
|
96
|
+
<TrackerA />
|
|
97
|
+
<TrackerB />
|
|
98
|
+
</>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(agents["a"]).toBeDefined();
|
|
102
|
+
expect(agents["b"]).toBeDefined();
|
|
103
|
+
expect(agents["a"]).not.toBe(agents["b"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns the same cached instance for the same (agentId, threadId) across re-renders", () => {
|
|
107
|
+
const instances: AbstractAgent[] = [];
|
|
108
|
+
|
|
109
|
+
function Tracker() {
|
|
110
|
+
const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-x" });
|
|
111
|
+
instances.push(agent);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { rerender } = render(<Tracker />);
|
|
116
|
+
rerender(<Tracker />);
|
|
117
|
+
|
|
118
|
+
expect(instances.length).toBe(2);
|
|
119
|
+
expect(instances[0]).toBe(instances[1]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns the shared registry agent when no threadId is provided (backward compat)", () => {
|
|
123
|
+
let captured: AbstractAgent | undefined;
|
|
124
|
+
|
|
125
|
+
function Tracker() {
|
|
126
|
+
const { agent } = useAgent({ agentId: "my-agent" });
|
|
127
|
+
captured = agent;
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
render(<Tracker />);
|
|
132
|
+
expect(captured).toBe(registeredAgent);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("isolates messages between thread-specific agents", () => {
|
|
136
|
+
// Pre-populate the source agent so CloneableAgent.clone() copies the
|
|
137
|
+
// message into each clone — this makes cloneForThread's setMessages([])
|
|
138
|
+
// meaningful rather than vacuously true on an already-empty clone.
|
|
139
|
+
registeredAgent.addMessage({
|
|
140
|
+
id: "source-msg",
|
|
141
|
+
role: "user",
|
|
142
|
+
content: "pre-existing on source",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const agents: Record<string, AbstractAgent> = {};
|
|
146
|
+
|
|
147
|
+
function TrackerA() {
|
|
148
|
+
const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-a" });
|
|
149
|
+
agents["a"] = agent;
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function TrackerB() {
|
|
154
|
+
const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-b" });
|
|
155
|
+
agents["b"] = agent;
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
render(
|
|
160
|
+
<>
|
|
161
|
+
<TrackerA />
|
|
162
|
+
<TrackerB />
|
|
163
|
+
</>,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Both clones should start empty even though the source had a message —
|
|
167
|
+
// cloneForThread must have called setMessages([]) on each clone.
|
|
168
|
+
expect(agents["a"]!.messages).toHaveLength(0);
|
|
169
|
+
expect(agents["b"]!.messages).toHaveLength(0);
|
|
170
|
+
|
|
171
|
+
// Adding a message to thread A must not affect thread B
|
|
172
|
+
agents["a"]!.addMessage({
|
|
173
|
+
id: "msg-1",
|
|
174
|
+
role: "user",
|
|
175
|
+
content: "hello from thread A",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(agents["a"]!.messages).toHaveLength(1);
|
|
179
|
+
expect(agents["b"]!.messages).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("sets threadId on cloned agents", () => {
|
|
183
|
+
const agents: Record<string, AbstractAgent> = {};
|
|
184
|
+
|
|
185
|
+
function TrackerA() {
|
|
186
|
+
const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-a" });
|
|
187
|
+
agents["a"] = agent;
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function TrackerB() {
|
|
192
|
+
const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-b" });
|
|
193
|
+
agents["b"] = agent;
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
render(
|
|
198
|
+
<>
|
|
199
|
+
<TrackerA />
|
|
200
|
+
<TrackerB />
|
|
201
|
+
</>,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(agents["a"]!.threadId).toBe("thread-a");
|
|
205
|
+
expect(agents["b"]!.threadId).toBe("thread-b");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("invalidates stale clone when the registry agent is replaced", () => {
|
|
209
|
+
// Simulates reconnect / hot-reload: copilotkit.agents holds a new object.
|
|
210
|
+
const { result, rerender } = renderHook(
|
|
211
|
+
({ tid }: { tid: string }) =>
|
|
212
|
+
useAgent({ agentId: "my-agent", threadId: tid }),
|
|
213
|
+
{ initialProps: { tid: "thread-a" } },
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const firstClone = result.current.agent;
|
|
217
|
+
expect(firstClone).not.toBe(registeredAgent); // it's a clone
|
|
218
|
+
|
|
219
|
+
// Replace the registry agent
|
|
220
|
+
const replacementAgent = new CloneableAgent();
|
|
221
|
+
replacementAgent.agentId = "my-agent";
|
|
222
|
+
|
|
223
|
+
mockCopilotkit.agents = { "my-agent": replacementAgent };
|
|
224
|
+
mockCopilotkit.getAgent.mockImplementation((id: string) =>
|
|
225
|
+
id === "my-agent" ? replacementAgent : undefined,
|
|
226
|
+
);
|
|
227
|
+
mockUseCopilotKit.mockReturnValue({
|
|
228
|
+
copilotkit: { ...mockCopilotkit },
|
|
229
|
+
executingToolCallIds: new Set(),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
rerender({ tid: "thread-a" });
|
|
233
|
+
|
|
234
|
+
const secondClone = result.current.agent;
|
|
235
|
+
expect(secondClone).not.toBe(firstClone); // stale clone was invalidated
|
|
236
|
+
expect(secondClone).not.toBe(replacementAgent); // still a clone, not the source
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("switching threadId returns a fresh clone; switching back returns the cached one", () => {
|
|
240
|
+
const { result, rerender } = renderHook(
|
|
241
|
+
({ tid }: { tid: string }) =>
|
|
242
|
+
useAgent({ agentId: "my-agent", threadId: tid }),
|
|
243
|
+
{ initialProps: { tid: "thread-a" } },
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const cloneA = result.current.agent;
|
|
247
|
+
|
|
248
|
+
rerender({ tid: "thread-b" });
|
|
249
|
+
const cloneB = result.current.agent;
|
|
250
|
+
expect(cloneB).not.toBe(cloneA);
|
|
251
|
+
|
|
252
|
+
// Switching back to thread-a should return the originally cached clone
|
|
253
|
+
rerender({ tid: "thread-a" });
|
|
254
|
+
expect(result.current.agent).toBe(cloneA);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("uses a fresh clone with correct threadId when provisional transitions to real agent", () => {
|
|
258
|
+
// Start in Disconnected state — a provisional is created
|
|
259
|
+
mockCopilotkit.runtimeConnectionStatus =
|
|
260
|
+
CopilotKitCoreRuntimeConnectionStatus.Disconnected;
|
|
261
|
+
mockCopilotkit.getAgent.mockReturnValue(undefined);
|
|
262
|
+
mockCopilotkit.agents = {};
|
|
263
|
+
mockUseCopilotKit.mockReturnValue({
|
|
264
|
+
copilotkit: { ...mockCopilotkit },
|
|
265
|
+
executingToolCallIds: new Set(),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const { result, rerender } = renderHook(() =>
|
|
269
|
+
useAgent({ agentId: "my-agent", threadId: "thread-a" }),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const provisional = result.current.agent;
|
|
273
|
+
expect(provisional.threadId).toBe("thread-a");
|
|
274
|
+
|
|
275
|
+
// Real agent appears (runtime connected and agent registered)
|
|
276
|
+
mockCopilotkit.runtimeConnectionStatus =
|
|
277
|
+
CopilotKitCoreRuntimeConnectionStatus.Connected;
|
|
278
|
+
mockCopilotkit.getAgent.mockImplementation((id: string) =>
|
|
279
|
+
id === "my-agent" ? registeredAgent : undefined,
|
|
280
|
+
);
|
|
281
|
+
mockCopilotkit.agents = { "my-agent": registeredAgent };
|
|
282
|
+
mockUseCopilotKit.mockReturnValue({
|
|
283
|
+
copilotkit: { ...mockCopilotkit },
|
|
284
|
+
executingToolCallIds: new Set(),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
rerender();
|
|
288
|
+
|
|
289
|
+
const realClone = result.current.agent;
|
|
290
|
+
expect(realClone).not.toBe(provisional); // provisional replaced by real clone
|
|
291
|
+
expect(realClone).not.toBe(registeredAgent); // it's a clone, not the source
|
|
292
|
+
expect(realClone.threadId).toBe("thread-a");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("uses composite key for provisional agents when threadId is provided", () => {
|
|
296
|
+
// Put runtime in Disconnected state so provisionals are created
|
|
297
|
+
mockCopilotkit.runtimeConnectionStatus =
|
|
298
|
+
CopilotKitCoreRuntimeConnectionStatus.Disconnected;
|
|
299
|
+
mockCopilotkit.getAgent.mockReturnValue(undefined);
|
|
300
|
+
mockCopilotkit.agents = {};
|
|
301
|
+
|
|
302
|
+
const agents: Record<string, AbstractAgent> = {};
|
|
303
|
+
|
|
304
|
+
function TrackerA() {
|
|
305
|
+
const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-a" });
|
|
306
|
+
agents["a"] = agent;
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function TrackerB() {
|
|
311
|
+
const { agent } = useAgent({ agentId: "my-agent", threadId: "thread-b" });
|
|
312
|
+
agents["b"] = agent;
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
render(
|
|
317
|
+
<>
|
|
318
|
+
<TrackerA />
|
|
319
|
+
<TrackerB />
|
|
320
|
+
</>,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
expect(agents["a"]).not.toBe(agents["b"]);
|
|
324
|
+
expect(agents["a"]!.threadId).toBe("thread-a");
|
|
325
|
+
expect(agents["b"]!.threadId).toBe("thread-b");
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -19,10 +19,21 @@ import { CopilotChat } from "../../components/chat/CopilotChat";
|
|
|
19
19
|
* Mock agent that captures RunAgentInput to verify state is passed correctly
|
|
20
20
|
*/
|
|
21
21
|
class StateCapturingMockAgent extends MockStepwiseAgent {
|
|
22
|
-
|
|
22
|
+
// Shared via a container so the clone and original both see the same value
|
|
23
|
+
private _capture: { lastRunInput?: RunAgentInput } = {};
|
|
24
|
+
|
|
25
|
+
get lastRunInput(): RunAgentInput | undefined {
|
|
26
|
+
return this._capture.lastRunInput;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
clone(): this {
|
|
30
|
+
const cloned = super.clone();
|
|
31
|
+
(cloned as unknown as StateCapturingMockAgent)._capture = this._capture;
|
|
32
|
+
return cloned;
|
|
33
|
+
}
|
|
23
34
|
|
|
24
35
|
run(input: RunAgentInput): Observable<BaseEvent> {
|
|
25
|
-
this.lastRunInput = input;
|
|
36
|
+
this._capture.lastRunInput = input;
|
|
26
37
|
return super.run(input);
|
|
27
38
|
}
|
|
28
39
|
}
|
|
@@ -502,13 +502,24 @@ describe("useFrontendTool E2E - Dynamic Registration", () => {
|
|
|
502
502
|
describe("Agent input plumbing", () => {
|
|
503
503
|
it("forwards registered frontend tools to runAgent input", async () => {
|
|
504
504
|
class InstrumentedMockAgent extends MockStepwiseAgent {
|
|
505
|
-
|
|
505
|
+
// Shared so the clone and original both see the captured parameters
|
|
506
|
+
private _capture: { lastRunParameters?: RunAgentParameters } = {};
|
|
507
|
+
|
|
508
|
+
get lastRunParameters(): RunAgentParameters | undefined {
|
|
509
|
+
return this._capture.lastRunParameters;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
clone(): this {
|
|
513
|
+
const cloned = super.clone();
|
|
514
|
+
(cloned as unknown as InstrumentedMockAgent)._capture = this._capture;
|
|
515
|
+
return cloned;
|
|
516
|
+
}
|
|
506
517
|
|
|
507
518
|
async runAgent(
|
|
508
519
|
parameters?: RunAgentParameters,
|
|
509
520
|
subscriber?: AgentSubscriber,
|
|
510
521
|
) {
|
|
511
|
-
this.lastRunParameters = parameters;
|
|
522
|
+
this._capture.lastRunParameters = parameters;
|
|
512
523
|
return super.runAgent(parameters, subscriber);
|
|
513
524
|
}
|
|
514
525
|
}
|
|
@@ -568,8 +579,16 @@ describe("useFrontendTool E2E - Dynamic Registration", () => {
|
|
|
568
579
|
class OneShotToolCallAgent extends AbstractAgent {
|
|
569
580
|
private runCount = 0;
|
|
570
581
|
clone(): OneShotToolCallAgent {
|
|
571
|
-
|
|
572
|
-
|
|
582
|
+
const cloned = new OneShotToolCallAgent();
|
|
583
|
+
cloned.agentId = this.agentId;
|
|
584
|
+
// Share runCount via reference so the second run emits different args
|
|
585
|
+
Object.defineProperty(cloned, "runCount", {
|
|
586
|
+
get: () => this.runCount,
|
|
587
|
+
set: (v: number) => {
|
|
588
|
+
this.runCount = v;
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
return cloned;
|
|
573
592
|
}
|
|
574
593
|
async detachActiveRun(): Promise<void> {}
|
|
575
594
|
run(_input: RunAgentInput): Observable<BaseEvent> {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCopilotKit } from "../providers/CopilotKitProvider";
|
|
2
|
+
import { useCopilotChatConfiguration } from "../providers/CopilotChatConfigurationProvider";
|
|
2
3
|
import { useMemo, useEffect, useReducer, useRef } from "react";
|
|
3
4
|
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
|
|
4
|
-
import { AbstractAgent } from "@ag-ui/client";
|
|
5
|
+
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
|
5
6
|
import {
|
|
6
7
|
ProxiedCopilotRuntimeAgent,
|
|
7
8
|
CopilotKitCoreRuntimeConnectionStatus,
|
|
@@ -21,13 +22,88 @@ const ALL_UPDATES: UseAgentUpdate[] = [
|
|
|
21
22
|
|
|
22
23
|
export interface UseAgentProps {
|
|
23
24
|
agentId?: string;
|
|
25
|
+
threadId?: string;
|
|
24
26
|
updates?: UseAgentUpdate[];
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Clone a registry agent for per-thread isolation.
|
|
31
|
+
* Copies agent configuration (transport, headers, etc.) but resets conversation
|
|
32
|
+
* state (messages, threadId, state) so each thread starts fresh.
|
|
33
|
+
*/
|
|
34
|
+
function cloneForThread(
|
|
35
|
+
source: AbstractAgent,
|
|
36
|
+
threadId: string,
|
|
37
|
+
headers: Record<string, string>,
|
|
38
|
+
): AbstractAgent {
|
|
39
|
+
const clone = source.clone();
|
|
40
|
+
if (clone === source) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`useAgent: ${source.constructor.name}.clone() returned the same instance. ` +
|
|
43
|
+
`clone() must return a new, independent object.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
clone.threadId = threadId;
|
|
47
|
+
clone.setMessages([]);
|
|
48
|
+
clone.setState({});
|
|
49
|
+
if (clone instanceof HttpAgent) {
|
|
50
|
+
clone.headers = { ...headers };
|
|
51
|
+
}
|
|
52
|
+
return clone;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Module-level WeakMap: registryAgent → (threadId → clone).
|
|
57
|
+
* Shared across all useAgent() calls so that every component using the same
|
|
58
|
+
* (agentId, threadId) pair receives the same agent instance. Using WeakMap
|
|
59
|
+
* ensures the clone map is garbage-collected when the registry agent is
|
|
60
|
+
* replaced (e.g. after reconnect or hot-reload).
|
|
61
|
+
*/
|
|
62
|
+
export const globalThreadCloneMap = new WeakMap<
|
|
63
|
+
AbstractAgent,
|
|
64
|
+
Map<string, AbstractAgent>
|
|
65
|
+
>();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Look up an existing per-thread clone without creating one.
|
|
69
|
+
* Returns undefined when no clone has been created yet for this pair.
|
|
70
|
+
*/
|
|
71
|
+
export function getThreadClone(
|
|
72
|
+
registryAgent: AbstractAgent | undefined | null,
|
|
73
|
+
threadId: string | undefined | null,
|
|
74
|
+
): AbstractAgent | undefined {
|
|
75
|
+
if (!registryAgent || !threadId) return undefined;
|
|
76
|
+
return globalThreadCloneMap.get(registryAgent)?.get(threadId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getOrCreateThreadClone(
|
|
80
|
+
existing: AbstractAgent,
|
|
81
|
+
threadId: string,
|
|
82
|
+
headers: Record<string, string>,
|
|
83
|
+
): AbstractAgent {
|
|
84
|
+
let byThread = globalThreadCloneMap.get(existing);
|
|
85
|
+
if (!byThread) {
|
|
86
|
+
byThread = new Map();
|
|
87
|
+
globalThreadCloneMap.set(existing, byThread);
|
|
88
|
+
}
|
|
89
|
+
const cached = byThread.get(threadId);
|
|
90
|
+
if (cached) return cached;
|
|
91
|
+
|
|
92
|
+
const clone = cloneForThread(existing, threadId, headers);
|
|
93
|
+
byThread.set(threadId, clone);
|
|
94
|
+
return clone;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function useAgent({ agentId, threadId, updates }: UseAgentProps = {}) {
|
|
28
98
|
agentId ??= DEFAULT_AGENT_ID;
|
|
29
99
|
|
|
30
100
|
const { copilotkit } = useCopilotKit();
|
|
101
|
+
// Fall back to the enclosing CopilotChatConfigurationProvider's threadId so
|
|
102
|
+
// that useAgent() called without explicit threadId (e.g. inside a custom
|
|
103
|
+
// message renderer) automatically uses the same per-thread clone as the
|
|
104
|
+
// CopilotChat component it lives within.
|
|
105
|
+
const chatConfig = useCopilotChatConfiguration();
|
|
106
|
+
threadId ??= chatConfig?.threadId;
|
|
31
107
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
|
32
108
|
|
|
33
109
|
const updateFlags = useMemo(
|
|
@@ -43,11 +119,29 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
43
119
|
);
|
|
44
120
|
|
|
45
121
|
const agent: AbstractAgent = useMemo(() => {
|
|
122
|
+
// Use a composite key when threadId is provided so that different threads
|
|
123
|
+
// for the same agent get independent instances.
|
|
124
|
+
const cacheKey = threadId ? `${agentId}:${threadId}` : agentId;
|
|
125
|
+
|
|
46
126
|
const existing = copilotkit.getAgent(agentId);
|
|
47
127
|
if (existing) {
|
|
48
|
-
// Real agent found — clear any cached
|
|
128
|
+
// Real agent found — clear any cached provisionals for this key and the
|
|
129
|
+
// bare agentId key (handles the case where a provisional was created
|
|
130
|
+
// before threadId was available, then the component re-renders with one).
|
|
131
|
+
provisionalAgentCache.current.delete(cacheKey);
|
|
49
132
|
provisionalAgentCache.current.delete(agentId);
|
|
50
|
-
|
|
133
|
+
|
|
134
|
+
if (!threadId) {
|
|
135
|
+
// No threadId — return the shared registry agent (original behavior)
|
|
136
|
+
return existing;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// threadId provided — return the shared per-thread clone.
|
|
140
|
+
// The global WeakMap ensures all components using the same
|
|
141
|
+
// (registryAgent, threadId) pair receive the same instance, so state
|
|
142
|
+
// mutations (addMessage, setState) are visible everywhere. The WeakMap
|
|
143
|
+
// entry is GC-collected automatically when the registry agent is replaced.
|
|
144
|
+
return getOrCreateThreadClone(existing, threadId, copilotkit.headers);
|
|
51
145
|
}
|
|
52
146
|
|
|
53
147
|
const isRuntimeConfigured = copilotkit.runtimeUrl !== undefined;
|
|
@@ -60,7 +154,7 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
60
154
|
status === CopilotKitCoreRuntimeConnectionStatus.Connecting)
|
|
61
155
|
) {
|
|
62
156
|
// Return cached provisional if available (keeps reference stable)
|
|
63
|
-
const cached = provisionalAgentCache.current.get(
|
|
157
|
+
const cached = provisionalAgentCache.current.get(cacheKey);
|
|
64
158
|
if (cached) {
|
|
65
159
|
// Update headers on the cached agent in case they changed
|
|
66
160
|
cached.headers = { ...copilotkit.headers };
|
|
@@ -75,7 +169,10 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
75
169
|
});
|
|
76
170
|
// Apply current headers so runs/connects inherit them
|
|
77
171
|
provisional.headers = { ...copilotkit.headers };
|
|
78
|
-
|
|
172
|
+
if (threadId) {
|
|
173
|
+
provisional.threadId = threadId;
|
|
174
|
+
}
|
|
175
|
+
provisionalAgentCache.current.set(cacheKey, provisional);
|
|
79
176
|
return provisional;
|
|
80
177
|
}
|
|
81
178
|
|
|
@@ -88,6 +185,14 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
88
185
|
isRuntimeConfigured &&
|
|
89
186
|
status === CopilotKitCoreRuntimeConnectionStatus.Error
|
|
90
187
|
) {
|
|
188
|
+
// Cache the provisional so that dep changes while in Error state (e.g.
|
|
189
|
+
// headers update) return the same agent reference, matching the
|
|
190
|
+
// Disconnected/Connecting path and preventing spurious re-subscriptions.
|
|
191
|
+
const cached = provisionalAgentCache.current.get(cacheKey);
|
|
192
|
+
if (cached) {
|
|
193
|
+
cached.headers = { ...copilotkit.headers };
|
|
194
|
+
return cached;
|
|
195
|
+
}
|
|
91
196
|
const provisional = new ProxiedCopilotRuntimeAgent({
|
|
92
197
|
runtimeUrl: copilotkit.runtimeUrl,
|
|
93
198
|
agentId,
|
|
@@ -95,6 +200,10 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
95
200
|
runtimeMode: "pending",
|
|
96
201
|
});
|
|
97
202
|
provisional.headers = { ...copilotkit.headers };
|
|
203
|
+
if (threadId) {
|
|
204
|
+
provisional.threadId = threadId;
|
|
205
|
+
}
|
|
206
|
+
provisionalAgentCache.current.set(cacheKey, provisional);
|
|
98
207
|
return provisional;
|
|
99
208
|
}
|
|
100
209
|
|
|
@@ -113,6 +222,7 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
113
222
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
114
223
|
}, [
|
|
115
224
|
agentId,
|
|
225
|
+
threadId,
|
|
116
226
|
copilotkit.agents,
|
|
117
227
|
copilotkit.runtimeConnectionStatus,
|
|
118
228
|
copilotkit.runtimeUrl,
|
|
@@ -149,6 +259,16 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
|
|
|
149
259
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
150
260
|
}, [agent, forceUpdate, JSON.stringify(updateFlags)]);
|
|
151
261
|
|
|
262
|
+
// Keep HttpAgent headers fresh without mutating inside useMemo, which is
|
|
263
|
+
// unsafe in concurrent mode (React may invoke useMemo multiple times and
|
|
264
|
+
// discard intermediate results, but mutations always land).
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (agent instanceof HttpAgent) {
|
|
267
|
+
agent.headers = { ...copilotkit.headers };
|
|
268
|
+
}
|
|
269
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
270
|
+
}, [agent, JSON.stringify(copilotkit.headers)]);
|
|
271
|
+
|
|
152
272
|
return {
|
|
153
273
|
agent,
|
|
154
274
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCopilotChatConfiguration, useCopilotKit } from "../providers";
|
|
2
|
+
import { getThreadClone } from "./use-agent";
|
|
2
3
|
import { ReactCustomMessageRendererPosition } from "../types/react-custom-message-renderer";
|
|
3
4
|
import { Message } from "@ag-ui/core";
|
|
4
5
|
|
|
@@ -38,7 +39,11 @@ export function useRenderCustomMessages() {
|
|
|
38
39
|
copilotkit.getRunIdForMessage(agentId, threadId, message.id) ??
|
|
39
40
|
copilotkit.getRunIdsForThread(agentId, threadId).slice(-1)[0];
|
|
40
41
|
const runId = resolvedRunId ?? `missing-run-id:${message.id}`;
|
|
41
|
-
|
|
42
|
+
// Prefer the per-thread clone so that agent.messages reflects the actual
|
|
43
|
+
// conversation state (messages live on the clone, not the registry agent).
|
|
44
|
+
// Fall back to the registry agent when no clone exists (no threadId).
|
|
45
|
+
const registryAgent = copilotkit.getAgent(agentId);
|
|
46
|
+
const agent = getThreadClone(registryAgent, threadId) ?? registryAgent;
|
|
42
47
|
if (!agent) {
|
|
43
48
|
throw new Error("Agent not found");
|
|
44
49
|
}
|