@copilotkit/react-core 1.56.4 → 1.56.5-canary.1777664617

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.
@@ -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 { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
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 any[],
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
- this.handlers.get(event)!.filter((entry) => entry.ref !== ref),
95
+ entries.filter((entry) => entry.ref !== ref),
69
96
  );
70
97
  }
71
98
 
72
99
  join(): MockPush {
73
- return this.joinPush;
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
- globalThis.fetch = fetchMock;
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(): any[] {
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
- const [url, options] = fetchMock.mock.calls[2];
347
- expect(url).toContain("/threads/t-1");
348
- expect(options.method).toBe("PATCH");
349
- expect(JSON.parse(options.body)).toMatchObject({
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
- expect(fetchMock.mock.calls[2][0]).toContain("/threads/t-2/archive");
376
- expect(fetchMock.mock.calls[2][1].method).toBe("POST");
377
- expect(JSON.parse(fetchMock.mock.calls[2][1].body)).toMatchObject({
378
- agentId: "agent-1",
379
- });
380
-
381
- expect(fetchMock.mock.calls[3][0]).toContain("/threads/t-1");
382
- expect(fetchMock.mock.calls[3][1].method).toBe("DELETE");
383
- expect(JSON.parse(fetchMock.mock.calls[3][1].body)).toMatchObject({
384
- agentId: "agent-1",
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 < 5; index += 1) {
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
- // Give effects a tick to settle; no fetch should occur while Connecting.
513
- await new Promise((resolve) => setTimeout(resolve, 20));
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
 
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import { useCopilotKit } from "../providers/CopilotKitProvider";
3
3
  import { useCopilotChatConfiguration } from "../providers/CopilotChatConfigurationProvider";
4
4
  import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
5
- import {
5
+ import type {
6
6
  DynamicSuggestionsConfig,
7
7
  StaticSuggestionsConfig,
8
8
  SuggestionsConfig,
@@ -106,22 +106,37 @@ export function useConfigureSuggestions(
106
106
  const isGlobalConfig =
107
107
  rawConsumerAgentId === undefined || rawConsumerAgentId === "*";
108
108
 
109
+ const isDynamicConfigType = useMemo(
110
+ () => !!normalizedConfig && "instructions" in normalizedConfig,
111
+ [normalizedConfig],
112
+ );
113
+
109
114
  const requestReload = useCallback(() => {
110
115
  if (!normalizedConfig) {
111
116
  return;
112
117
  }
113
118
 
114
119
  if (isGlobalConfig) {
120
+ const seen = new Set<string>();
115
121
  const agents = Object.values(copilotkit.agents ?? {});
116
122
  for (const entry of agents) {
117
123
  const agentId = entry.agentId;
118
124
  if (!agentId) {
119
125
  continue;
120
126
  }
127
+ seen.add(agentId);
121
128
  if (!entry.isRunning) {
122
129
  copilotkit.reloadSuggestions(agentId);
123
130
  }
124
131
  }
132
+ // Also reload for the chat's resolved consumer agent. The registry can
133
+ // be empty at this point (e.g. runtime info still loading), in which
134
+ // case the loop above wouldn't have fired for the agent the user is
135
+ // actually chatting with — and the welcome screen would render with
136
+ // no suggestions until they navigate away and back.
137
+ if (targetAgentId && !seen.has(targetAgentId)) {
138
+ copilotkit.reloadSuggestions(targetAgentId);
139
+ }
125
140
  return;
126
141
  }
127
142
 
@@ -169,6 +184,40 @@ export function useConfigureSuggestions(
169
184
  }
170
185
  requestReload();
171
186
  }, [extraDeps.length, normalizedConfig, requestReload, ...extraDeps]);
187
+
188
+ // When agents arrive after the initial render (runtime info just landed),
189
+ // re-request a reload so dynamic configs that need a real agent can finally
190
+ // generate. Skip for static configs — they don't need an agent and the
191
+ // initial mount reload already handled them. Skip when the target agent
192
+ // is already in the registry — the initial reload already covered it, and
193
+ // re-firing on every subsequent `onAgentsChanged` (e.g. dev-mode hot
194
+ // reloads, sibling chat configs mounting) would stack overlapping
195
+ // generations.
196
+ useEffect(() => {
197
+ if (!normalizedConfig || !isDynamicConfigType) return;
198
+ if (!targetAgentId) return;
199
+
200
+ const initiallyPresent = !!copilotkit.getAgent(targetAgentId);
201
+ if (initiallyPresent) return;
202
+
203
+ const subscription = copilotkit.subscribe({
204
+ onAgentsChanged: () => {
205
+ if (copilotkit.getAgent(targetAgentId)) {
206
+ requestReload();
207
+ subscription.unsubscribe();
208
+ }
209
+ },
210
+ });
211
+ return () => {
212
+ subscription.unsubscribe();
213
+ };
214
+ }, [
215
+ copilotkit,
216
+ normalizedConfig,
217
+ isDynamicConfigType,
218
+ targetAgentId,
219
+ requestReload,
220
+ ]);
172
221
  }
173
222
 
174
223
  function isDynamicConfig(
@@ -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
  ]);