@copilotkit/react-core 1.56.1 → 1.56.2-canary.pin-to-send

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.
Files changed (48) hide show
  1. package/dist/{copilotkit-Cj2ZIxVr.mjs → copilotkit-BBYbekCa.mjs} +234 -60
  2. package/dist/copilotkit-BBYbekCa.mjs.map +1 -0
  3. package/dist/{copilotkit-CSJw5BG8.cjs → copilotkit-D5JT2Pu3.cjs} +233 -59
  4. package/dist/copilotkit-D5JT2Pu3.cjs.map +1 -0
  5. package/dist/{copilotkit-CCbxm6JM.d.mts → copilotkit-DArT2Iuw.d.mts} +62 -18
  6. package/dist/copilotkit-DArT2Iuw.d.mts.map +1 -0
  7. package/dist/{copilotkit-BtP7w7cT.d.cts → copilotkit-KEc28l8G.d.cts} +62 -18
  8. package/dist/copilotkit-KEc28l8G.d.cts.map +1 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.cts +1 -1
  11. package/dist/index.d.mts +1 -1
  12. package/dist/index.mjs +1 -1
  13. package/dist/index.umd.js +16 -40
  14. package/dist/index.umd.js.map +1 -1
  15. package/dist/v2/index.cjs +1 -1
  16. package/dist/v2/index.css +1 -1
  17. package/dist/v2/index.d.cts +2 -2
  18. package/dist/v2/index.d.mts +2 -2
  19. package/dist/v2/index.mjs +1 -1
  20. package/dist/v2/index.umd.js +232 -62
  21. package/dist/v2/index.umd.js.map +1 -1
  22. package/package.json +6 -6
  23. package/src/v2/components/chat/CopilotChat.tsx +80 -4
  24. package/src/v2/components/chat/CopilotChatInput.tsx +22 -0
  25. package/src/v2/components/chat/CopilotChatView.tsx +206 -11
  26. package/src/v2/components/chat/__tests__/CopilotChat.absentThreadConnect.test.tsx +66 -0
  27. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +300 -2
  28. package/src/v2/components/chat/__tests__/CopilotChatView.connectingGate.test.tsx +56 -0
  29. package/src/v2/components/chat/__tests__/CopilotChatView.pinToSend.test.tsx +94 -0
  30. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +0 -1
  31. package/src/v2/components/chat/__tests__/normalize-auto-scroll.test.ts +37 -0
  32. package/src/v2/components/chat/index.ts +2 -0
  33. package/src/v2/components/chat/last-user-message-context.ts +21 -0
  34. package/src/v2/components/chat/normalize-auto-scroll.ts +17 -0
  35. package/src/v2/components/license-warning-banner.tsx +20 -1
  36. package/src/v2/hooks/__tests__/use-agent-stability.test.tsx +6 -0
  37. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +6 -0
  38. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +76 -50
  39. package/src/v2/hooks/__tests__/use-pin-to-send.test.tsx +219 -0
  40. package/src/v2/hooks/__tests__/use-threads.test.tsx +68 -0
  41. package/src/v2/hooks/use-agent.tsx +34 -77
  42. package/src/v2/hooks/use-pin-to-send.ts +94 -0
  43. package/src/v2/hooks/use-threads.tsx +55 -12
  44. package/src/v2/providers/CopilotKitProvider.tsx +2 -11
  45. package/dist/copilotkit-BtP7w7cT.d.cts.map +0 -1
  46. package/dist/copilotkit-CCbxm6JM.d.mts.map +0 -1
  47. package/dist/copilotkit-CSJw5BG8.cjs.map +0 -1
  48. package/dist/copilotkit-Cj2ZIxVr.mjs.map +0 -1
@@ -4,7 +4,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
4
  import { useAgent, UseAgentUpdate } from "../use-agent";
5
5
  import { useCopilotKit } from "../../providers/CopilotKitProvider";
6
6
  import { MockStepwiseAgent } from "../../__tests__/utils/test-helpers";
7
- import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
7
+ import {
8
+ CopilotKitCore,
9
+ CopilotKitCoreRuntimeConnectionStatus,
10
+ } from "@copilotkit/core";
8
11
  import type { Message } from "@ag-ui/core";
9
12
  import type { RunAgentInput } from "@ag-ui/client";
10
13
 
@@ -126,11 +129,19 @@ function createTestComponent(
126
129
  };
127
130
  }
128
131
 
129
- /** Factory for the mock return value of useCopilotKit */
132
+ /** Factory for the mock return value of useCopilotKit.
133
+ * Uses a real CopilotKitCore instance so subscribeToAgentWithOptions (with its throttle
134
+ * logic) is exercised end-to-end rather than mocked. */
130
135
  function createMockContext(
131
136
  agent: MockStepwiseAgent,
132
137
  overrides: { defaultThrottleMs?: number } = {},
133
138
  ) {
139
+ const core = new CopilotKitCore({
140
+ runtimeUrl: "http://localhost:3000/api/copilot",
141
+ });
142
+ if (overrides.defaultThrottleMs !== undefined) {
143
+ core.setDefaultThrottleMs(overrides.defaultThrottleMs);
144
+ }
134
145
  return {
135
146
  copilotkit: {
136
147
  getAgent: () => agent,
@@ -139,7 +150,8 @@ function createMockContext(
139
150
  runtimeTransport: "rest",
140
151
  headers: {},
141
152
  agents: { [String(agent.agentId)]: agent },
142
- defaultThrottleMs: overrides.defaultThrottleMs,
153
+ defaultThrottleMs: core.defaultThrottleMs,
154
+ subscribeToAgentWithOptions: core.subscribeToAgentWithOptions.bind(core),
143
155
  },
144
156
  executingToolCallIds: new Set(),
145
157
  };
@@ -313,7 +325,7 @@ describe("useAgent throttleMs", () => {
313
325
  expect(screen.getByTestId("count").textContent).toBe("3");
314
326
  });
315
327
 
316
- it("with throttleMs, onStateChanged still fires immediately", async () => {
328
+ it("with throttleMs, onStateChanged is also throttled (shared window)", async () => {
317
329
  const TestComponent = createTestComponent({
318
330
  updates: [
319
331
  UseAgentUpdate.OnMessagesChanged,
@@ -324,19 +336,29 @@ describe("useAgent throttleMs", () => {
324
336
 
325
337
  render(<TestComponent />);
326
338
 
327
- // Fire onMessagesChanged to start the throttle window
339
+ // Fire onMessagesChanged to start the throttle window (leading edge)
328
340
  act(() => {
329
341
  mockAgent.messages = [userMsg("1", "a")];
330
342
  notifyMessagesChanged(mockAgent);
331
343
  });
344
+ expect(screen.getByTestId("count").textContent).toBe("1");
332
345
 
333
- // Fire onStateChanged 10ms later — fires via microtask batch (not synchronously)
334
- await act(async () => {
346
+ // Fire onStateChanged 10ms later — should be deferred (within throttle window)
347
+ act(() => {
335
348
  vi.advanceTimersByTime(10);
336
349
  mockAgent.state = { count: 42 };
337
350
  notifyStateChanged(mockAgent);
338
351
  });
339
352
 
353
+ // State update is pending, not yet rendered
354
+ expect(screen.getByTestId("state").textContent).toBe("{}");
355
+
356
+ // Trailing edge fires after the window — await so microtask from
357
+ // batchedForceUpdate flushes and triggers the React re-render.
358
+ await act(async () => {
359
+ vi.advanceTimersByTime(100);
360
+ });
361
+
340
362
  expect(screen.getByTestId("state").textContent).toBe('{"count":42}');
341
363
  });
342
364
 
@@ -372,7 +394,7 @@ describe("useAgent throttleMs", () => {
372
394
  expect(renderCount.current).toBe(countBeforeUnmount);
373
395
  });
374
396
 
375
- it("with throttleMs and updates excluding OnMessagesChanged, throttle is a no-op", async () => {
397
+ it("with throttleMs and only OnStateChanged subscribed, first state fires on leading edge", async () => {
376
398
  const TestComponent = createTestComponent({
377
399
  updates: [UseAgentUpdate.OnStateChanged],
378
400
  throttleMs: 100,
@@ -380,7 +402,8 @@ describe("useAgent throttleMs", () => {
380
402
 
381
403
  render(<TestComponent />);
382
404
 
383
- // Only onStateChanged is subscribed fires via microtask batch
405
+ // First onStateChanged fires immediately (leading edge) await so
406
+ // microtask from batchedForceUpdate flushes.
384
407
  await act(async () => {
385
408
  mockAgent.state = { value: "test" };
386
409
  notifyStateChanged(mockAgent);
@@ -388,15 +411,13 @@ describe("useAgent throttleMs", () => {
388
411
 
389
412
  expect(screen.getByTestId("state").textContent).toBe('{"value":"test"}');
390
413
 
391
- // No onMessagesChanged subscription should exist
414
+ // No onMessagesChanged subscription should exist — messages notification
415
+ // does nothing because the handler was never registered.
392
416
  act(() => {
393
417
  mockAgent.messages = [userMsg("1", "a")];
394
418
  notifyMessagesChanged(mockAgent);
395
419
  });
396
420
 
397
- // onMessagesChanged was sent but no handler is subscribed, so no
398
- // re-render is triggered. We verify by checking state still shows the
399
- // last rendered value.
400
421
  expect(screen.getByTestId("state").textContent).toBe('{"value":"test"}');
401
422
  });
402
423
 
@@ -418,6 +439,7 @@ describe("useAgent throttleMs", () => {
418
439
  expect.stringContaining(
419
440
  "throttleMs must be a non-negative finite number",
420
441
  ),
442
+ expect.any(Error),
421
443
  );
422
444
 
423
445
  // Should behave as unthrottled — every notification fires immediately
@@ -864,23 +886,23 @@ describe("useAgent defaultThrottleMs from provider", () => {
864
886
  ])(
865
887
  "with invalid provider defaultThrottleMs ($label), falls back to unthrottled and warns",
866
888
  ({ value }) => {
889
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
890
+
867
891
  mockUseCopilotKit.mockReturnValue(
868
892
  createMockContext(mockAgent, { defaultThrottleMs: value }),
869
893
  );
870
894
 
871
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
872
895
  const TestComponent = createTestComponent({ throttleMs: undefined });
873
896
 
874
897
  render(<TestComponent />);
875
898
 
876
- expect(errorSpy).toHaveBeenCalledWith(
877
- expect.stringContaining("provider-level defaultThrottleMs"),
878
- );
899
+ // The core setter rejects invalid values and logs an error
879
900
  expect(errorSpy).toHaveBeenCalledWith(
880
901
  expect.stringContaining("must be a non-negative finite number"),
902
+ expect.any(Error),
881
903
  );
882
904
 
883
- // Should behave as unthrottled
905
+ // Should behave as unthrottled (setter rejected the value)
884
906
  act(() => {
885
907
  mockAgent.messages = [userMsg("1", "a")];
886
908
  notifyMessagesChanged(mockAgent);
@@ -963,41 +985,45 @@ describe("useAgent defaultThrottleMs from provider", () => {
963
985
  });
964
986
  });
965
987
 
966
- describe("CopilotKitCore.setDefaultThrottleMs validation", () => {
988
+ describe("CopilotKitCore.setDefaultThrottleMs", () => {
989
+ it("stores valid values", () => {
990
+ const core = new CopilotKitCore({});
991
+ core.setDefaultThrottleMs(100);
992
+ expect(core.defaultThrottleMs).toBe(100);
993
+ });
994
+
995
+ it("stores 0", () => {
996
+ const core = new CopilotKitCore({});
997
+ core.setDefaultThrottleMs(100);
998
+ core.setDefaultThrottleMs(0);
999
+ expect(core.defaultThrottleMs).toBe(0);
1000
+ });
1001
+
1002
+ it("stores undefined", () => {
1003
+ const core = new CopilotKitCore({});
1004
+ core.setDefaultThrottleMs(100);
1005
+ core.setDefaultThrottleMs(undefined);
1006
+ expect(core.defaultThrottleMs).toBeUndefined();
1007
+ });
1008
+
967
1009
  it.each([
968
1010
  { label: "NaN", value: NaN },
969
1011
  { label: "Infinity", value: Infinity },
970
1012
  { label: "-1", value: -1 },
971
1013
  { label: "-Infinity", value: -Infinity },
972
- ])("rejects invalid value ($label) and stores undefined", ({ value }) => {
973
- // Simulate the core setter behavior: invalid values are rejected
974
- // and the stored value becomes undefined (no default configured).
975
- // This is tested via the mock context to verify that the hook
976
- // correctly handles a sanitized undefined from the core.
977
- const mockAgent = new MockStepwiseAgent();
978
- mockAgent.agentId = "test-agent";
979
-
980
- // After the core setter rejects an invalid value, hooks see undefined
981
- mockUseCopilotKit.mockReturnValue(
982
- createMockContext(mockAgent, { defaultThrottleMs: undefined }),
983
- );
984
-
985
- vi.useFakeTimers();
986
- const TestComponent = createTestComponent({ throttleMs: undefined });
987
- render(<TestComponent />);
988
-
989
- // Should behave as unthrottled (no provider default in effect)
990
- act(() => {
991
- mockAgent.messages = [userMsg("1", "a")];
992
- notifyMessagesChanged(mockAgent);
993
- });
994
- expect(screen.getByTestId("count").textContent).toBe("1");
995
-
996
- act(() => {
997
- mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
998
- notifyMessagesChanged(mockAgent);
999
- });
1000
- expect(screen.getByTestId("count").textContent).toBe("2");
1001
- vi.useRealTimers();
1002
- });
1014
+ ])(
1015
+ "rejects invalid value ($label) and preserves previous value",
1016
+ ({ value }) => {
1017
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1018
+ const core = new CopilotKitCore({});
1019
+ core.setDefaultThrottleMs(200);
1020
+ core.setDefaultThrottleMs(value);
1021
+ expect(core.defaultThrottleMs).toBe(200);
1022
+ expect(errorSpy).toHaveBeenCalledWith(
1023
+ expect.stringContaining("must be a non-negative finite number"),
1024
+ expect.any(Error),
1025
+ );
1026
+ errorSpy.mockRestore();
1027
+ },
1028
+ );
1003
1029
  });
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { render, act } from "@testing-library/react";
3
+ import React, { useRef } from "react";
4
+ import { usePinToSend } from "../use-pin-to-send";
5
+ import { LastUserMessageContext } from "../../components/chat/last-user-message-context";
6
+
7
+ // Small harness that wires the hook up against an in-memory DOM.
8
+ // Height mocks are applied via Object.defineProperty because jsdom doesn't run layout.
9
+ function setHeight(el: HTMLElement, height: number) {
10
+ Object.defineProperty(el, "clientHeight", {
11
+ configurable: true,
12
+ value: height,
13
+ });
14
+ Object.defineProperty(el, "offsetHeight", {
15
+ configurable: true,
16
+ value: height,
17
+ });
18
+ el.getBoundingClientRect = () =>
19
+ ({
20
+ top: 0,
21
+ left: 0,
22
+ right: 0,
23
+ bottom: height,
24
+ width: 100,
25
+ height,
26
+ x: 0,
27
+ y: 0,
28
+ toJSON: () => ({}),
29
+ }) as DOMRect;
30
+ }
31
+
32
+ // Inner component so the hook is mounted inside the Provider and can read context.
33
+ function HarnessInner({ topOffset }: { topOffset: number }) {
34
+ const scrollRef = useRef<HTMLDivElement>(null);
35
+ const contentRef = useRef<HTMLDivElement>(null);
36
+ const spacerRef = useRef<HTMLDivElement>(null);
37
+
38
+ usePinToSend({ scrollRef, contentRef, spacerRef, topOffset });
39
+
40
+ return (
41
+ <div ref={scrollRef} data-testid="scroll">
42
+ <div ref={contentRef} data-testid="content">
43
+ <div data-message-id="m1" data-role="user">
44
+ user msg 1
45
+ </div>
46
+ <div data-message-id="m2" data-role="assistant">
47
+ asst msg 1
48
+ </div>
49
+ <div data-message-id="m3" data-role="user">
50
+ user msg 2
51
+ </div>
52
+ </div>
53
+ <div ref={spacerRef} data-testid="spacer" style={{ height: 0 }} />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ function Harness({
59
+ lastUserMessage,
60
+ topOffset = 16,
61
+ }: {
62
+ lastUserMessage: { id: string | null; sendNonce: number };
63
+ topOffset?: number;
64
+ }) {
65
+ return (
66
+ <LastUserMessageContext.Provider value={lastUserMessage}>
67
+ <HarnessInner topOffset={topOffset} />
68
+ </LastUserMessageContext.Provider>
69
+ );
70
+ }
71
+
72
+ beforeEach(() => {
73
+ HTMLElement.prototype.scrollTo = vi.fn();
74
+ // jsdom does not run rAF callbacks — stub it to fire synchronously so scroll assertions work.
75
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
76
+ cb(0);
77
+ return 0;
78
+ });
79
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
80
+ });
81
+
82
+ describe("usePinToSend", () => {
83
+ it("sets spacer height to viewportHeight - userMessageHeight - topOffset on new send", async () => {
84
+ const { rerender, getByTestId } = render(
85
+ <Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
86
+ );
87
+
88
+ const scroll = getByTestId("scroll");
89
+ const spacer = getByTestId("spacer");
90
+ setHeight(scroll, 800);
91
+
92
+ const userMsg = scroll.querySelector(
93
+ '[data-message-id="m3"]',
94
+ ) as HTMLElement;
95
+ setHeight(userMsg, 40);
96
+
97
+ act(() => {
98
+ rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
99
+ });
100
+
101
+ // viewport=800, userMsg=40, topOffset=16
102
+ // spacer = max(0, 800 - 40 - 16) = 744
103
+ expect(spacer.style.height).toBe("744px");
104
+ });
105
+
106
+ it("calls scrollTo with targetEl.offsetTop - topOffset on new send", async () => {
107
+ const { rerender, getByTestId } = render(
108
+ <Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
109
+ );
110
+
111
+ const scroll = getByTestId("scroll");
112
+ setHeight(scroll, 800);
113
+ const scrollTo = scroll.scrollTo as unknown as ReturnType<typeof vi.fn>;
114
+
115
+ const userMsg = scroll.querySelector(
116
+ '[data-message-id="m3"]',
117
+ ) as HTMLElement;
118
+ setHeight(userMsg, 40);
119
+ // computeOffsetTop uses getBoundingClientRect; mock top=400 on userMsg and top=0 on scroll
120
+ // so that elRect.top - stopRect.top + scrollEl.scrollTop = 400 - 0 + 0 = 400.
121
+ userMsg.getBoundingClientRect = () =>
122
+ ({
123
+ top: 400,
124
+ left: 0,
125
+ right: 100,
126
+ bottom: 440,
127
+ width: 100,
128
+ height: 40,
129
+ x: 0,
130
+ y: 400,
131
+ toJSON: () => ({}),
132
+ }) as DOMRect;
133
+
134
+ act(() => {
135
+ rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
136
+ });
137
+
138
+ // Allow rAF to fire
139
+ await act(async () => {
140
+ await new Promise((r) => setTimeout(r, 0));
141
+ });
142
+
143
+ expect(scrollTo).toHaveBeenCalledWith({
144
+ top: 400 - 16,
145
+ behavior: "smooth",
146
+ });
147
+ });
148
+
149
+ it("shrinks spacer as content height grows (does not grow it)", async () => {
150
+ let observed: (() => void) | null = null;
151
+ const ROStub = vi.fn().mockImplementation((cb: () => void) => {
152
+ observed = cb;
153
+ return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() };
154
+ });
155
+ const prevRO = global.ResizeObserver;
156
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
157
+ global.ResizeObserver = ROStub as any;
158
+
159
+ try {
160
+ const { rerender, getByTestId } = render(
161
+ <Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
162
+ );
163
+ const scroll = getByTestId("scroll");
164
+ const content = getByTestId("content");
165
+ const spacer = getByTestId("spacer");
166
+ setHeight(scroll, 800);
167
+ const userMsg = scroll.querySelector(
168
+ '[data-message-id="m3"]',
169
+ ) as HTMLElement;
170
+ setHeight(userMsg, 40);
171
+ setHeight(content, 200);
172
+
173
+ act(() => {
174
+ rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
175
+ });
176
+
177
+ // Initial: 800 - 40 - 16 = 744
178
+ expect(spacer.style.height).toBe("744px");
179
+
180
+ // Simulate content growing — spacer should shrink
181
+ setHeight(content, 600);
182
+ act(() => observed?.());
183
+ expect(parseInt(spacer.style.height, 10)).toBeLessThan(744);
184
+
185
+ // Simulate content shrinking — spacer should NOT grow back
186
+ setHeight(content, 100);
187
+ const shrunkHeight = spacer.style.height;
188
+ act(() => observed?.());
189
+ expect(spacer.style.height).toBe(shrunkHeight);
190
+ } finally {
191
+ global.ResizeObserver = prevRO;
192
+ }
193
+ });
194
+
195
+ it("cancels the scheduled rAF on unmount (cleanup)", async () => {
196
+ // Use a real rAF handle so we can assert the cancel was issued with it.
197
+ const cancelSpy = vi.spyOn(global, "cancelAnimationFrame");
198
+ try {
199
+ const { rerender, unmount, getByTestId } = render(
200
+ <Harness lastUserMessage={{ id: null, sendNonce: 0 }} />,
201
+ );
202
+ const scroll = getByTestId("scroll");
203
+ setHeight(scroll, 800);
204
+ const userMsg = scroll.querySelector(
205
+ '[data-message-id="m3"]',
206
+ ) as HTMLElement;
207
+ setHeight(userMsg, 40);
208
+
209
+ act(() => {
210
+ rerender(<Harness lastUserMessage={{ id: "m3", sendNonce: 1 }} />);
211
+ });
212
+
213
+ unmount();
214
+ expect(cancelSpy).toHaveBeenCalled();
215
+ } finally {
216
+ cancelSpy.mockRestore();
217
+ }
218
+ });
219
+ });
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { act, renderHook, waitFor } from "@testing-library/react";
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { useCopilotKit } from "../../providers/CopilotKitProvider";
5
+ import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
5
6
 
6
7
  vi.mock("../../providers/CopilotKitProvider", () => ({
7
8
  useCopilotKit: vi.fn(),
@@ -148,6 +149,7 @@ function setupCopilotKit(runtimeUrl = "http://localhost:4000") {
148
149
  mockUseCopilotKit.mockReturnValue({
149
150
  copilotkit: {
150
151
  runtimeUrl,
152
+ runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
151
153
  headers: { Authorization: "Bearer test-token" },
152
154
  intelligence: {
153
155
  wsUrl: "ws://localhost:4000/client",
@@ -484,4 +486,70 @@ describe("useThreads", () => {
484
486
  expect(channel.left).toBe(true);
485
487
  expect(socket.disconnected).toBe(true);
486
488
  });
489
+
490
+ it("waits for runtimeConnectionStatus=Connected before fetching /threads", async () => {
491
+ // Start in Connecting — hook should hold off on dispatching any request
492
+ // so the initial list fetch includes wsUrl and avoids a redundant second
493
+ // call once /info resolves.
494
+ mockUseCopilotKit.mockReturnValue({
495
+ copilotkit: {
496
+ runtimeUrl: "http://localhost:4000",
497
+ runtimeConnectionStatus:
498
+ CopilotKitCoreRuntimeConnectionStatus.Connecting,
499
+ headers: { Authorization: "Bearer test-token" },
500
+ intelligence: undefined,
501
+ },
502
+ });
503
+
504
+ fetchMock
505
+ .mockReturnValueOnce(
506
+ jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
507
+ )
508
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
509
+
510
+ const { result, rerender } = renderHook(() => useThreads(defaultInput));
511
+
512
+ // Give effects a tick to settle; no fetch should occur while Connecting.
513
+ await new Promise((resolve) => setTimeout(resolve, 20));
514
+ expect(fetchMock).not.toHaveBeenCalled();
515
+
516
+ // While waiting for Connected, the hook must surface isLoading=true so
517
+ // consumers don't render an empty-state flash before the first fetch
518
+ // is even dispatched. The store's own isLoading is false at this
519
+ // point (no contextChanged action yet), so the hook synthesizes it.
520
+ expect(result.current.isLoading).toBe(true);
521
+ expect(result.current.threads).toEqual([]);
522
+
523
+ // Flip to Connected with wsUrl populated, re-render. The effect now
524
+ // dispatches exactly one list fetch (+ one subscribe after it lands).
525
+ mockUseCopilotKit.mockReturnValue({
526
+ copilotkit: {
527
+ runtimeUrl: "http://localhost:4000",
528
+ runtimeConnectionStatus:
529
+ CopilotKitCoreRuntimeConnectionStatus.Connected,
530
+ headers: { Authorization: "Bearer test-token" },
531
+ intelligence: { wsUrl: "ws://localhost:4000/client" },
532
+ },
533
+ });
534
+
535
+ rerender();
536
+
537
+ await waitFor(() => {
538
+ expect(fetchMock).toHaveBeenCalledWith(
539
+ expect.stringContaining("/threads?agentId=agent-1"),
540
+ expect.objectContaining({ method: "GET" }),
541
+ );
542
+ });
543
+
544
+ // Exactly the expected pair — no speculative list call before Connected.
545
+ const listCalls = fetchMock.mock.calls.filter(
546
+ ([url]) => typeof url === "string" && /\/threads\?agentId=/.test(url),
547
+ );
548
+ expect(listCalls).toHaveLength(1);
549
+
550
+ // After the fetch settles, isLoading returns to false.
551
+ await waitFor(() => {
552
+ expect(result.current.isLoading).toBe(false);
553
+ });
554
+ });
487
555
  });