@copilotkit/react-core 1.56.5 → 1.57.0-canary.1778082736
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/dist/{copilotkit-DFaI4j2r.d.mts → copilotkit-BNlJq5UO.d.mts} +60 -6
- package/dist/copilotkit-BNlJq5UO.d.mts.map +1 -0
- package/dist/{copilotkit-DMFu29Kx.cjs → copilotkit-B_k0HSNz.cjs} +322 -175
- package/dist/copilotkit-B_k0HSNz.cjs.map +1 -0
- package/dist/{copilotkit-Dg4r4Gi_.d.cts → copilotkit-DgC5oCFO.d.cts} +60 -6
- package/dist/copilotkit-DgC5oCFO.d.cts.map +1 -0
- package/dist/{copilotkit-OmIUrWym.mjs → copilotkit-ak8sGvQr.mjs} +317 -176
- package/dist/copilotkit-ak8sGvQr.mjs.map +1 -0
- package/dist/index.cjs +2 -5
- 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 +2 -5
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +117 -172
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +2 -1
- package/dist/v2/index.css +1 -1
- package/dist/v2/index.d.cts +2 -2
- package/dist/v2/index.d.mts +2 -2
- package/dist/v2/index.mjs +2 -2
- package/dist/v2/index.umd.js +332 -181
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +6 -6
- package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +5 -6
- package/src/hooks/use-copilot-chat_internal.ts +0 -1
- package/src/v2/components/chat/CopilotChat.tsx +2 -1
- package/src/v2/components/chat/CopilotChatMessageView.tsx +24 -9
- package/src/v2/components/chat/CopilotChatView.tsx +2 -2
- package/src/v2/components/chat/__tests__/CopilotChat.welcomeGate.test.tsx +1 -3
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +29 -25
- package/src/v2/components/chat/__tests__/MCPAppsUiMessage.e2e.test.tsx +5 -60
- package/src/v2/components/index.ts +1 -0
- package/src/v2/components/intelligence-indicator/IntelligenceIndicator.tsx +286 -0
- package/src/v2/components/intelligence-indicator/__tests__/IntelligenceIndicator.e2e.test.tsx +464 -0
- package/src/v2/components/intelligence-indicator/index.ts +2 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +229 -27
- package/src/v2/hooks/use-agent.tsx +7 -116
- package/src/v2/hooks/use-render-activity-message.tsx +3 -11
- package/src/v2/hooks/use-render-custom-messages.tsx +1 -6
- package/src/v2/hooks/use-threads.tsx +7 -1
- package/src/v2/styles/globals.css +118 -0
- package/dist/copilotkit-DFaI4j2r.d.mts.map +0 -1
- package/dist/copilotkit-DMFu29Kx.cjs.map +0 -1
- package/dist/copilotkit-Dg4r4Gi_.d.cts.map +0 -1
- package/dist/copilotkit-OmIUrWym.mjs.map +0 -1
- package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +0 -333
|
@@ -1,16 +1,38 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
3
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { useCopilotKit } from "../../providers/CopilotKitProvider";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
CopilotKitCoreRuntimeConnectionStatus,
|
|
7
|
+
ɵMAX_SOCKET_RETRIES,
|
|
8
|
+
} from "@copilotkit/core";
|
|
6
9
|
|
|
7
10
|
vi.mock("../../providers/CopilotKitProvider", () => ({
|
|
8
11
|
useCopilotKit: vi.fn(),
|
|
9
12
|
}));
|
|
10
13
|
|
|
11
14
|
const mockUseCopilotKit = useCopilotKit as ReturnType<typeof vi.fn>;
|
|
15
|
+
|
|
16
|
+
// Shape of the mock socket exposed via the hoisted `phoenix.sockets`
|
|
17
|
+
// array. Defined as a named type so test-side assertions can drop the
|
|
18
|
+
// blanket `any[]` cast and surface socket-API typos at compile time.
|
|
19
|
+
interface MockChannelLike {
|
|
20
|
+
topic: string;
|
|
21
|
+
params: Record<string, unknown>;
|
|
22
|
+
left: boolean;
|
|
23
|
+
serverPush(event: string, payload: unknown): void;
|
|
24
|
+
}
|
|
25
|
+
interface MockSocketLike {
|
|
26
|
+
url: string;
|
|
27
|
+
connected: boolean;
|
|
28
|
+
disconnected: boolean;
|
|
29
|
+
channels: MockChannelLike[];
|
|
30
|
+
triggerError(error?: unknown): void;
|
|
31
|
+
triggerOpen(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
const phoenix = vi.hoisted(() => ({
|
|
13
|
-
sockets: [] as
|
|
35
|
+
sockets: [] as MockSocketLike[],
|
|
14
36
|
}));
|
|
15
37
|
|
|
16
38
|
vi.mock("phoenix", () => {
|
|
@@ -31,13 +53,11 @@ vi.mock("phoenix", () => {
|
|
|
31
53
|
topic: string;
|
|
32
54
|
params: Record<string, unknown>;
|
|
33
55
|
left = false;
|
|
34
|
-
channels: MockChannel[] = [];
|
|
35
56
|
|
|
36
57
|
private handlers = new Map<
|
|
37
58
|
string,
|
|
38
59
|
Array<{ ref: number; callback: (payload: unknown) => void }>
|
|
39
60
|
>();
|
|
40
|
-
private joinPush = new MockPush();
|
|
41
61
|
private nextRef = 1;
|
|
42
62
|
|
|
43
63
|
constructor(topic = "", params: Record<string, unknown> = {}) {
|
|
@@ -63,14 +83,24 @@ vi.mock("phoenix", () => {
|
|
|
63
83
|
return;
|
|
64
84
|
}
|
|
65
85
|
|
|
86
|
+
// Re-check after the early returns above: `off(event)` deletes the
|
|
87
|
+
// entry, so a subsequent `off(event, ref)` would otherwise hit a
|
|
88
|
+
// non-null assertion that lies and `.filter` on undefined.
|
|
89
|
+
const entries = this.handlers.get(event);
|
|
90
|
+
if (entries === undefined) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
66
93
|
this.handlers.set(
|
|
67
94
|
event,
|
|
68
|
-
|
|
95
|
+
entries.filter((entry) => entry.ref !== ref),
|
|
69
96
|
);
|
|
70
97
|
}
|
|
71
98
|
|
|
72
99
|
join(): MockPush {
|
|
73
|
-
|
|
100
|
+
// Each rejoin must produce a distinct push instance — sharing
|
|
101
|
+
// one across joins lets stale "ok"/"error" callbacks from a
|
|
102
|
+
// prior join fire against a new join's listeners.
|
|
103
|
+
return new MockPush();
|
|
74
104
|
}
|
|
75
105
|
|
|
76
106
|
leave(): void {
|
|
@@ -101,10 +131,22 @@ vi.mock("phoenix", () => {
|
|
|
101
131
|
}
|
|
102
132
|
|
|
103
133
|
connect(): void {
|
|
134
|
+
// Phoenix sockets fire `onOpen` exactly once per WebSocket upgrade,
|
|
135
|
+
// and the upgrade is asynchronous. Tests must drive that transition
|
|
136
|
+
// explicitly via `triggerOpen()` so we exercise one open per
|
|
137
|
+
// connection — auto-firing here would either double-fire (when a
|
|
138
|
+
// test also calls `triggerOpen()`) or hide cases where production
|
|
139
|
+
// code forgets to await the open before joining a channel.
|
|
104
140
|
this.connected = true;
|
|
105
141
|
}
|
|
106
142
|
|
|
107
143
|
disconnect(): void {
|
|
144
|
+
// Real Phoenix sockets flip `connected` back to false on disconnect —
|
|
145
|
+
// a mock that only sets `disconnected = true` lets a regression that
|
|
146
|
+
// forgets to clear `connected` slip through, since assertions like
|
|
147
|
+
// `socket.connected === false` would be vacuously satisfied by the
|
|
148
|
+
// initial value but never re-checked after a reconnect cycle.
|
|
149
|
+
this.connected = false;
|
|
108
150
|
this.disconnected = true;
|
|
109
151
|
}
|
|
110
152
|
|
|
@@ -139,9 +181,12 @@ vi.mock("phoenix", () => {
|
|
|
139
181
|
});
|
|
140
182
|
|
|
141
183
|
const fetchMock = vi.fn();
|
|
142
|
-
|
|
184
|
+
// Use `vi.stubGlobal` so the original `fetch` is restored automatically
|
|
185
|
+
// by `vi.unstubAllGlobals()` below — direct `globalThis.fetch = ...`
|
|
186
|
+
// assignment leaks the mock across test files in the same vitest worker.
|
|
187
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
143
188
|
|
|
144
|
-
function getMockSockets():
|
|
189
|
+
function getMockSockets(): MockSocketLike[] {
|
|
145
190
|
return phoenix.sockets;
|
|
146
191
|
}
|
|
147
192
|
|
|
@@ -154,6 +199,8 @@ function setupCopilotKit(runtimeUrl = "http://localhost:4000") {
|
|
|
154
199
|
intelligence: {
|
|
155
200
|
wsUrl: "ws://localhost:4000/client",
|
|
156
201
|
},
|
|
202
|
+
registerThreadStore: vi.fn(),
|
|
203
|
+
unregisterThreadStore: vi.fn(),
|
|
157
204
|
},
|
|
158
205
|
});
|
|
159
206
|
}
|
|
@@ -198,9 +245,20 @@ describe("useThreads", () => {
|
|
|
198
245
|
beforeEach(() => {
|
|
199
246
|
phoenix.sockets.splice(0);
|
|
200
247
|
fetchMock.mockReset();
|
|
248
|
+
// Reset before re-priming. setupCopilotKit() uses mockReturnValue, so a
|
|
249
|
+
// future test that uses mockReturnValueOnce would otherwise leak any
|
|
250
|
+
// un-consumed queued returns into the next test.
|
|
251
|
+
mockUseCopilotKit.mockReset();
|
|
201
252
|
setupCopilotKit();
|
|
202
253
|
});
|
|
203
254
|
|
|
255
|
+
afterAll(() => {
|
|
256
|
+
// Pair with `vi.stubGlobal("fetch", fetchMock)` above. Without this
|
|
257
|
+
// restoration the mock leaks into any sibling test file that runs in
|
|
258
|
+
// the same vitest worker and assumes a real `fetch`.
|
|
259
|
+
vi.unstubAllGlobals();
|
|
260
|
+
});
|
|
261
|
+
|
|
204
262
|
it("fetches threads and subscribes to the user metadata channel", async () => {
|
|
205
263
|
fetchMock
|
|
206
264
|
.mockReturnValueOnce(
|
|
@@ -323,6 +381,9 @@ describe("useThreads", () => {
|
|
|
323
381
|
await waitFor(() => {
|
|
324
382
|
expect(result.current.threads).toHaveLength(1);
|
|
325
383
|
});
|
|
384
|
+
// Identity-check the remaining thread so a regression that removes
|
|
385
|
+
// the wrong thread (e.g. a swapped index) is caught.
|
|
386
|
+
expect(result.current.threads[0].id).toBe("t-1");
|
|
326
387
|
});
|
|
327
388
|
|
|
328
389
|
it("renames a thread through the runtime contract", async () => {
|
|
@@ -343,10 +404,18 @@ describe("useThreads", () => {
|
|
|
343
404
|
await result.current.renameThread("t-1", "Renamed");
|
|
344
405
|
});
|
|
345
406
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
407
|
+
// Find the PATCH call by URL+method rather than a hardcoded index —
|
|
408
|
+
// a future change to the fetch order (or an extra startup fetch) must
|
|
409
|
+
// not silently miss the actual rename request.
|
|
410
|
+
const renameCall = fetchMock.mock.calls.find(
|
|
411
|
+
(args: unknown[]) =>
|
|
412
|
+
typeof args[0] === "string" &&
|
|
413
|
+
(args[0] as string).includes("/threads/t-1") &&
|
|
414
|
+
(args[1] as { method?: string } | undefined)?.method === "PATCH",
|
|
415
|
+
);
|
|
416
|
+
expect(renameCall).toBeDefined();
|
|
417
|
+
const [, renameOptions] = renameCall!;
|
|
418
|
+
expect(JSON.parse((renameOptions as { body: string }).body)).toMatchObject({
|
|
350
419
|
agentId: "agent-1",
|
|
351
420
|
name: "Renamed",
|
|
352
421
|
});
|
|
@@ -372,17 +441,31 @@ describe("useThreads", () => {
|
|
|
372
441
|
await result.current.deleteThread("t-1");
|
|
373
442
|
});
|
|
374
443
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
444
|
+
// Filter by URL+method rather than fixed indices (mirrors the rename
|
|
445
|
+
// test above). A future change to the startup fetch order — adding an
|
|
446
|
+
// /info call, splitting the join token, etc. — must not silently miss
|
|
447
|
+
// the actual archive/delete requests.
|
|
448
|
+
const archiveCall = fetchMock.mock.calls.find(
|
|
449
|
+
(args: unknown[]) =>
|
|
450
|
+
typeof args[0] === "string" &&
|
|
451
|
+
(args[0] as string).includes("/threads/t-2/archive") &&
|
|
452
|
+
(args[1] as { method?: string } | undefined)?.method === "POST",
|
|
453
|
+
);
|
|
454
|
+
expect(archiveCall).toBeDefined();
|
|
455
|
+
expect(
|
|
456
|
+
JSON.parse((archiveCall![1] as { body: string }).body),
|
|
457
|
+
).toMatchObject({ agentId: "agent-1" });
|
|
458
|
+
|
|
459
|
+
const deleteCall = fetchMock.mock.calls.find(
|
|
460
|
+
(args: unknown[]) =>
|
|
461
|
+
typeof args[0] === "string" &&
|
|
462
|
+
(args[0] as string).includes("/threads/t-1") &&
|
|
463
|
+
(args[1] as { method?: string } | undefined)?.method === "DELETE",
|
|
464
|
+
);
|
|
465
|
+
expect(deleteCall).toBeDefined();
|
|
466
|
+
expect(JSON.parse((deleteCall![1] as { body: string }).body)).toMatchObject(
|
|
467
|
+
{ agentId: "agent-1" },
|
|
468
|
+
);
|
|
386
469
|
});
|
|
387
470
|
|
|
388
471
|
it("exposes thread-scoped pagination properties", async () => {
|
|
@@ -414,6 +497,63 @@ describe("useThreads", () => {
|
|
|
414
497
|
expect(typeof result.current.fetchMoreThreads).toBe("function");
|
|
415
498
|
});
|
|
416
499
|
|
|
500
|
+
it("fetchMoreThreads fetches the next page with the cursor and appends threads", async () => {
|
|
501
|
+
const nextPageThreads = [
|
|
502
|
+
{
|
|
503
|
+
id: "t-3",
|
|
504
|
+
organizationId: "org-1",
|
|
505
|
+
agentId: "agent-1",
|
|
506
|
+
createdById: "user-1",
|
|
507
|
+
name: "Thread Three",
|
|
508
|
+
archived: false,
|
|
509
|
+
createdAt: "2026-01-03T00:00:00Z",
|
|
510
|
+
updatedAt: "2026-01-03T00:00:00Z",
|
|
511
|
+
},
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
fetchMock
|
|
515
|
+
.mockReturnValueOnce(
|
|
516
|
+
jsonResponse({
|
|
517
|
+
threads: sampleThreads,
|
|
518
|
+
joinCode: "jc-1",
|
|
519
|
+
nextCursor: "cursor-abc",
|
|
520
|
+
}),
|
|
521
|
+
)
|
|
522
|
+
.mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }))
|
|
523
|
+
.mockReturnValueOnce(
|
|
524
|
+
jsonResponse({ threads: nextPageThreads, joinCode: "jc-1" }),
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
const { result } = renderHook(() => useThreads(defaultInput));
|
|
528
|
+
|
|
529
|
+
await waitFor(() => {
|
|
530
|
+
expect(result.current.isLoading).toBe(false);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
expect(result.current.threads).toHaveLength(2);
|
|
534
|
+
expect(result.current.hasMoreThreads).toBe(true);
|
|
535
|
+
|
|
536
|
+
act(() => {
|
|
537
|
+
result.current.fetchMoreThreads();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
await waitFor(() => {
|
|
541
|
+
expect(result.current.threads).toHaveLength(3);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const nextPageCall = fetchMock.mock.calls.find(
|
|
545
|
+
(args: unknown[]) =>
|
|
546
|
+
typeof args[0] === "string" &&
|
|
547
|
+
(args[0] as string).includes("cursor=cursor-abc"),
|
|
548
|
+
);
|
|
549
|
+
expect(nextPageCall).toBeDefined();
|
|
550
|
+
expect(nextPageCall![0]).toContain("agentId=agent-1");
|
|
551
|
+
expect(result.current.threads.map((t: { id: string }) => t.id)).toContain(
|
|
552
|
+
"t-3",
|
|
553
|
+
);
|
|
554
|
+
expect(result.current.threads).toHaveLength(3);
|
|
555
|
+
});
|
|
556
|
+
|
|
417
557
|
it("does not expose organizationId or createdById on threads", async () => {
|
|
418
558
|
fetchMock
|
|
419
559
|
.mockReturnValueOnce(
|
|
@@ -455,10 +595,20 @@ describe("useThreads", () => {
|
|
|
455
595
|
const socket = getMockSockets()[0];
|
|
456
596
|
const channel = socket.channels[0];
|
|
457
597
|
|
|
598
|
+
// Threshold is sourced from production (ɵMAX_SOCKET_RETRIES) so a
|
|
599
|
+
// future change to the retry budget cannot silently desync the test.
|
|
600
|
+
// We fire all errors inside a single act to keep the rxjs cleanup
|
|
601
|
+
// synchronous with the assertions, then check the pre-threshold and
|
|
602
|
+
// post-threshold states by inspecting the socket between iterations.
|
|
458
603
|
act(() => {
|
|
459
|
-
for (let index = 0; index <
|
|
604
|
+
for (let index = 0; index < ɵMAX_SOCKET_RETRIES - 1; index += 1) {
|
|
460
605
|
socket.triggerError();
|
|
461
606
|
}
|
|
607
|
+
// Pre-threshold: teardown must NOT be premature.
|
|
608
|
+
expect(channel.left).toBe(false);
|
|
609
|
+
expect(socket.disconnected).toBe(false);
|
|
610
|
+
// The Nth error crosses the threshold and triggers teardown.
|
|
611
|
+
socket.triggerError();
|
|
462
612
|
});
|
|
463
613
|
|
|
464
614
|
expect(channel.left).toBe(true);
|
|
@@ -487,6 +637,47 @@ describe("useThreads", () => {
|
|
|
487
637
|
expect(socket.disconnected).toBe(true);
|
|
488
638
|
});
|
|
489
639
|
|
|
640
|
+
it("registers thread store on mount and unregisters on unmount", async () => {
|
|
641
|
+
const registerThreadStore = vi.fn();
|
|
642
|
+
const unregisterThreadStore = vi.fn();
|
|
643
|
+
// Use mockReturnValue (not mockReturnValueOnce) so the same spies are
|
|
644
|
+
// returned across all renders, including the cleanup render where
|
|
645
|
+
// unmount triggers the effect's cleanup function.
|
|
646
|
+
// runtimeConnectionStatus is set explicitly to Connected — the hook
|
|
647
|
+
// treats anything other than Connected as "do not dispatch context",
|
|
648
|
+
// and we want this test to exercise a fully-wired flow.
|
|
649
|
+
mockUseCopilotKit.mockReturnValue({
|
|
650
|
+
copilotkit: {
|
|
651
|
+
runtimeUrl: "http://localhost:4000",
|
|
652
|
+
runtimeConnectionStatus:
|
|
653
|
+
CopilotKitCoreRuntimeConnectionStatus.Connected,
|
|
654
|
+
headers: { Authorization: "Bearer test-token" },
|
|
655
|
+
intelligence: { wsUrl: "ws://localhost:4000/client" },
|
|
656
|
+
registerThreadStore,
|
|
657
|
+
unregisterThreadStore,
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
fetchMock
|
|
662
|
+
.mockReturnValueOnce(
|
|
663
|
+
jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
|
|
664
|
+
)
|
|
665
|
+
.mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
|
|
666
|
+
|
|
667
|
+
const { unmount } = renderHook(() => useThreads(defaultInput));
|
|
668
|
+
|
|
669
|
+
await waitFor(() => {
|
|
670
|
+
expect(registerThreadStore).toHaveBeenCalledWith(
|
|
671
|
+
"agent-1",
|
|
672
|
+
expect.objectContaining({ select: expect.any(Function) }),
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
unmount();
|
|
677
|
+
|
|
678
|
+
expect(unregisterThreadStore).toHaveBeenCalledWith("agent-1");
|
|
679
|
+
});
|
|
680
|
+
|
|
490
681
|
it("waits for runtimeConnectionStatus=Connected before fetching /threads", async () => {
|
|
491
682
|
// Start in Connecting — hook should hold off on dispatching any request
|
|
492
683
|
// so the initial list fetch includes wsUrl and avoids a redundant second
|
|
@@ -498,6 +689,8 @@ describe("useThreads", () => {
|
|
|
498
689
|
CopilotKitCoreRuntimeConnectionStatus.Connecting,
|
|
499
690
|
headers: { Authorization: "Bearer test-token" },
|
|
500
691
|
intelligence: undefined,
|
|
692
|
+
registerThreadStore: vi.fn(),
|
|
693
|
+
unregisterThreadStore: vi.fn(),
|
|
501
694
|
},
|
|
502
695
|
});
|
|
503
696
|
|
|
@@ -509,8 +702,15 @@ describe("useThreads", () => {
|
|
|
509
702
|
|
|
510
703
|
const { result, rerender } = renderHook(() => useThreads(defaultInput));
|
|
511
704
|
|
|
512
|
-
//
|
|
513
|
-
|
|
705
|
+
// Flush React effects + microtasks deterministically. A bare
|
|
706
|
+
// setTimeout(20) raced the store-effect under load on slow machines.
|
|
707
|
+
// Chained microtask flushes inside `act` give every queued effect a
|
|
708
|
+
// chance to run without depending on real-time delay.
|
|
709
|
+
await act(async () => {
|
|
710
|
+
await Promise.resolve();
|
|
711
|
+
await Promise.resolve();
|
|
712
|
+
await Promise.resolve();
|
|
713
|
+
});
|
|
514
714
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
515
715
|
|
|
516
716
|
// While waiting for Connected, the hook must surface isLoading=true so
|
|
@@ -529,6 +729,8 @@ describe("useThreads", () => {
|
|
|
529
729
|
CopilotKitCoreRuntimeConnectionStatus.Connected,
|
|
530
730
|
headers: { Authorization: "Bearer test-token" },
|
|
531
731
|
intelligence: { wsUrl: "ws://localhost:4000/client" },
|
|
732
|
+
registerThreadStore: vi.fn(),
|
|
733
|
+
unregisterThreadStore: vi.fn(),
|
|
532
734
|
},
|
|
533
735
|
});
|
|
534
736
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useCopilotKit } from "../providers/CopilotKitProvider";
|
|
2
|
-
import { useCopilotChatConfiguration } from "../providers/CopilotChatConfigurationProvider";
|
|
3
2
|
import { useMemo, useEffect, useReducer, useRef } from "react";
|
|
4
3
|
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
|
|
5
4
|
import { AbstractAgent, HttpAgent } from "@ag-ui/client";
|
|
@@ -23,7 +22,6 @@ const ALL_UPDATES: UseAgentUpdate[] = [
|
|
|
23
22
|
|
|
24
23
|
export interface UseAgentProps {
|
|
25
24
|
agentId?: string;
|
|
26
|
-
threadId?: string;
|
|
27
25
|
updates?: UseAgentUpdate[];
|
|
28
26
|
/**
|
|
29
27
|
* Throttle interval (in milliseconds) for re-renders triggered by
|
|
@@ -50,80 +48,7 @@ export interface UseAgentProps {
|
|
|
50
48
|
throttleMs?: number;
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
* Clone a registry agent for per-thread isolation.
|
|
55
|
-
* Copies agent configuration (transport, headers, etc.) but resets conversation
|
|
56
|
-
* state (messages, threadId, state) so each thread starts fresh.
|
|
57
|
-
*/
|
|
58
|
-
function cloneForThread(
|
|
59
|
-
source: AbstractAgent,
|
|
60
|
-
threadId: string,
|
|
61
|
-
headers: Record<string, string>,
|
|
62
|
-
): AbstractAgent {
|
|
63
|
-
const clone = source.clone();
|
|
64
|
-
if (clone === source) {
|
|
65
|
-
throw new Error(
|
|
66
|
-
`useAgent: ${source.constructor.name}.clone() returned the same instance. ` +
|
|
67
|
-
`clone() must return a new, independent object.`,
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
clone.threadId = threadId;
|
|
71
|
-
clone.setMessages([]);
|
|
72
|
-
clone.setState({});
|
|
73
|
-
if (clone instanceof HttpAgent) {
|
|
74
|
-
clone.headers = { ...headers };
|
|
75
|
-
}
|
|
76
|
-
return clone;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Module-level WeakMap: registryAgent → (threadId → clone).
|
|
81
|
-
* Shared across all useAgent() calls so that every component using the same
|
|
82
|
-
* (agentId, threadId) pair receives the same agent instance. Using WeakMap
|
|
83
|
-
* ensures the clone map is garbage-collected when the registry agent is
|
|
84
|
-
* replaced (e.g. after reconnect or hot-reload).
|
|
85
|
-
*/
|
|
86
|
-
export const globalThreadCloneMap = new WeakMap<
|
|
87
|
-
AbstractAgent,
|
|
88
|
-
Map<string, AbstractAgent>
|
|
89
|
-
>();
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Look up an existing per-thread clone without creating one.
|
|
93
|
-
* Returns undefined when no clone has been created yet for this pair.
|
|
94
|
-
*/
|
|
95
|
-
export function getThreadClone(
|
|
96
|
-
registryAgent: AbstractAgent | undefined | null,
|
|
97
|
-
threadId: string | undefined | null,
|
|
98
|
-
): AbstractAgent | undefined {
|
|
99
|
-
if (!registryAgent || !threadId) return undefined;
|
|
100
|
-
return globalThreadCloneMap.get(registryAgent)?.get(threadId);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function getOrCreateThreadClone(
|
|
104
|
-
existing: AbstractAgent,
|
|
105
|
-
threadId: string,
|
|
106
|
-
headers: Record<string, string>,
|
|
107
|
-
): AbstractAgent {
|
|
108
|
-
let byThread = globalThreadCloneMap.get(existing);
|
|
109
|
-
if (!byThread) {
|
|
110
|
-
byThread = new Map();
|
|
111
|
-
globalThreadCloneMap.set(existing, byThread);
|
|
112
|
-
}
|
|
113
|
-
const cached = byThread.get(threadId);
|
|
114
|
-
if (cached) return cached;
|
|
115
|
-
|
|
116
|
-
const clone = cloneForThread(existing, threadId, headers);
|
|
117
|
-
byThread.set(threadId, clone);
|
|
118
|
-
return clone;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function useAgent({
|
|
122
|
-
agentId,
|
|
123
|
-
threadId,
|
|
124
|
-
updates,
|
|
125
|
-
throttleMs,
|
|
126
|
-
}: UseAgentProps = {}) {
|
|
51
|
+
export function useAgent({ agentId, updates, throttleMs }: UseAgentProps = {}) {
|
|
127
52
|
agentId ??= DEFAULT_AGENT_ID;
|
|
128
53
|
|
|
129
54
|
const { copilotkit } = useCopilotKit();
|
|
@@ -131,12 +56,6 @@ export function useAgent({
|
|
|
131
56
|
// subscribeToAgentWithOptions reads it from the core instance, but React needs the dep
|
|
132
57
|
// to know when to re-subscribe.
|
|
133
58
|
const providerThrottleMs = copilotkit.defaultThrottleMs;
|
|
134
|
-
// Fall back to the enclosing CopilotChatConfigurationProvider's threadId so
|
|
135
|
-
// that useAgent() called without explicit threadId (e.g. inside a custom
|
|
136
|
-
// message renderer) automatically uses the same per-thread clone as the
|
|
137
|
-
// CopilotChat component it lives within.
|
|
138
|
-
const chatConfig = useCopilotChatConfiguration();
|
|
139
|
-
threadId ??= chatConfig?.threadId;
|
|
140
59
|
|
|
141
60
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
|
142
61
|
|
|
@@ -153,29 +72,11 @@ export function useAgent({
|
|
|
153
72
|
);
|
|
154
73
|
|
|
155
74
|
const agent: AbstractAgent = useMemo(() => {
|
|
156
|
-
// Use a composite key when threadId is provided so that different threads
|
|
157
|
-
// for the same agent get independent instances.
|
|
158
|
-
const cacheKey = threadId ? `${agentId}:${threadId}` : agentId;
|
|
159
|
-
|
|
160
75
|
const existing = copilotkit.getAgent(agentId);
|
|
161
76
|
if (existing) {
|
|
162
|
-
// Real agent found — clear any cached
|
|
163
|
-
// bare agentId key (handles the case where a provisional was created
|
|
164
|
-
// before threadId was available, then the component re-renders with one).
|
|
165
|
-
provisionalAgentCache.current.delete(cacheKey);
|
|
77
|
+
// Real agent found — clear any cached provisional for this ID
|
|
166
78
|
provisionalAgentCache.current.delete(agentId);
|
|
167
|
-
|
|
168
|
-
if (!threadId) {
|
|
169
|
-
// No threadId — return the shared registry agent (original behavior)
|
|
170
|
-
return existing;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// threadId provided — return the shared per-thread clone.
|
|
174
|
-
// The global WeakMap ensures all components using the same
|
|
175
|
-
// (registryAgent, threadId) pair receive the same instance, so state
|
|
176
|
-
// mutations (addMessage, setState) are visible everywhere. The WeakMap
|
|
177
|
-
// entry is GC-collected automatically when the registry agent is replaced.
|
|
178
|
-
return getOrCreateThreadClone(existing, threadId, copilotkit.headers);
|
|
79
|
+
return existing;
|
|
179
80
|
}
|
|
180
81
|
|
|
181
82
|
const isRuntimeConfigured = copilotkit.runtimeUrl !== undefined;
|
|
@@ -188,7 +89,7 @@ export function useAgent({
|
|
|
188
89
|
status === CopilotKitCoreRuntimeConnectionStatus.Connecting)
|
|
189
90
|
) {
|
|
190
91
|
// Return cached provisional if available (keeps reference stable)
|
|
191
|
-
const cached = provisionalAgentCache.current.get(
|
|
92
|
+
const cached = provisionalAgentCache.current.get(agentId);
|
|
192
93
|
if (cached) {
|
|
193
94
|
// Update headers on the cached agent in case they changed
|
|
194
95
|
cached.headers = { ...copilotkit.headers };
|
|
@@ -203,10 +104,7 @@ export function useAgent({
|
|
|
203
104
|
});
|
|
204
105
|
// Apply current headers so runs/connects inherit them
|
|
205
106
|
provisional.headers = { ...copilotkit.headers };
|
|
206
|
-
|
|
207
|
-
provisional.threadId = threadId;
|
|
208
|
-
}
|
|
209
|
-
provisionalAgentCache.current.set(cacheKey, provisional);
|
|
107
|
+
provisionalAgentCache.current.set(agentId, provisional);
|
|
210
108
|
return provisional;
|
|
211
109
|
}
|
|
212
110
|
|
|
@@ -219,10 +117,7 @@ export function useAgent({
|
|
|
219
117
|
isRuntimeConfigured &&
|
|
220
118
|
status === CopilotKitCoreRuntimeConnectionStatus.Error
|
|
221
119
|
) {
|
|
222
|
-
|
|
223
|
-
// headers update) return the same agent reference, matching the
|
|
224
|
-
// Disconnected/Connecting path and preventing spurious re-subscriptions.
|
|
225
|
-
const cached = provisionalAgentCache.current.get(cacheKey);
|
|
120
|
+
const cached = provisionalAgentCache.current.get(agentId);
|
|
226
121
|
if (cached) {
|
|
227
122
|
cached.headers = { ...copilotkit.headers };
|
|
228
123
|
return cached;
|
|
@@ -234,10 +129,7 @@ export function useAgent({
|
|
|
234
129
|
runtimeMode: "pending",
|
|
235
130
|
});
|
|
236
131
|
provisional.headers = { ...copilotkit.headers };
|
|
237
|
-
|
|
238
|
-
provisional.threadId = threadId;
|
|
239
|
-
}
|
|
240
|
-
provisionalAgentCache.current.set(cacheKey, provisional);
|
|
132
|
+
provisionalAgentCache.current.set(agentId, provisional);
|
|
241
133
|
return provisional;
|
|
242
134
|
}
|
|
243
135
|
|
|
@@ -256,7 +148,6 @@ export function useAgent({
|
|
|
256
148
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
257
149
|
}, [
|
|
258
150
|
agentId,
|
|
259
|
-
threadId,
|
|
260
151
|
copilotkit.agents,
|
|
261
152
|
copilotkit.runtimeConnectionStatus,
|
|
262
153
|
copilotkit.runtimeUrl,
|
|
@@ -3,12 +3,10 @@ import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
|
|
|
3
3
|
import { useCopilotKit, useCopilotChatConfiguration } from "../providers";
|
|
4
4
|
import { useCallback, useMemo } from "react";
|
|
5
5
|
import { ReactActivityMessageRenderer } from "../types";
|
|
6
|
-
import { getThreadClone } from "./use-agent";
|
|
7
6
|
|
|
8
7
|
export function useRenderActivityMessage() {
|
|
9
8
|
const { copilotkit } = useCopilotKit();
|
|
10
|
-
const
|
|
11
|
-
const agentId = config?.agentId ?? DEFAULT_AGENT_ID;
|
|
9
|
+
const agentId = useCopilotChatConfiguration()?.agentId ?? DEFAULT_AGENT_ID;
|
|
12
10
|
|
|
13
11
|
const renderers = copilotkit.renderActivityMessages;
|
|
14
12
|
|
|
@@ -52,13 +50,7 @@ export function useRenderActivityMessage() {
|
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
const Component = renderer.render;
|
|
55
|
-
|
|
56
|
-
// calls runAgent on the same agent instance that CopilotChat renders from.
|
|
57
|
-
// Without this, button clicks accumulate messages on the registry agent
|
|
58
|
-
// while CopilotChat displays from the clone — responses appear to vanish.
|
|
59
|
-
const registryAgent = copilotkit.getAgent(agentId);
|
|
60
|
-
const agent =
|
|
61
|
-
getThreadClone(registryAgent, config?.threadId) ?? registryAgent;
|
|
53
|
+
const agent = copilotkit.getAgent(agentId);
|
|
62
54
|
|
|
63
55
|
return (
|
|
64
56
|
<Component
|
|
@@ -70,7 +62,7 @@ export function useRenderActivityMessage() {
|
|
|
70
62
|
/>
|
|
71
63
|
);
|
|
72
64
|
},
|
|
73
|
-
[agentId,
|
|
65
|
+
[agentId, copilotkit, findRenderer],
|
|
74
66
|
);
|
|
75
67
|
|
|
76
68
|
return useMemo(
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useCopilotChatConfiguration, useCopilotKit } from "../providers";
|
|
2
|
-
import { getThreadClone } from "./use-agent";
|
|
3
2
|
import { ReactCustomMessageRendererPosition } from "../types/react-custom-message-renderer";
|
|
4
3
|
import { Message } from "@ag-ui/core";
|
|
5
4
|
|
|
@@ -39,11 +38,7 @@ export function useRenderCustomMessages() {
|
|
|
39
38
|
copilotkit.getRunIdForMessage(agentId, threadId, message.id) ??
|
|
40
39
|
copilotkit.getRunIdsForThread(agentId, threadId).slice(-1)[0];
|
|
41
40
|
const runId = resolvedRunId ?? `missing-run-id:${message.id}`;
|
|
42
|
-
|
|
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;
|
|
41
|
+
const agent = copilotkit.getAgent(agentId);
|
|
47
42
|
if (!agent) {
|
|
48
43
|
return null;
|
|
49
44
|
}
|
|
@@ -253,6 +253,13 @@ export function useThreads({
|
|
|
253
253
|
// realtime subscription or cached thread list stays usable while the
|
|
254
254
|
// runtime recovers, and we don't re-trigger a fetch storm on transitions.
|
|
255
255
|
const runtimeStatus = copilotkit.runtimeConnectionStatus;
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
copilotkit.registerThreadStore(agentId, store);
|
|
258
|
+
return () => {
|
|
259
|
+
copilotkit.unregisterThreadStore(agentId);
|
|
260
|
+
};
|
|
261
|
+
}, [copilotkit, agentId, store]);
|
|
262
|
+
|
|
256
263
|
useEffect(() => {
|
|
257
264
|
if (!copilotkit.runtimeUrl) {
|
|
258
265
|
store.setContext(null);
|
|
@@ -283,7 +290,6 @@ export function useThreads({
|
|
|
283
290
|
headersKey,
|
|
284
291
|
copilotkit.intelligence?.wsUrl,
|
|
285
292
|
agentId,
|
|
286
|
-
copilotkit.headers,
|
|
287
293
|
includeArchived,
|
|
288
294
|
limit,
|
|
289
295
|
]);
|