@copilotkit/react-core 1.55.0-next.9 → 1.55.1-next.0

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 (81) hide show
  1. package/CHANGELOG.md +46 -6
  2. package/dist/{copilotkit-DeOzjPsb.mjs → copilotkit-BY5S1-0P.mjs} +2402 -552
  3. package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
  4. package/dist/{copilotkit-BqcyhQjT.d.mts → copilotkit-BuhSUZHb.d.mts} +228 -17
  5. package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
  6. package/dist/{copilotkit-BDNjFNmk.cjs → copilotkit-Bz5-ImDl.cjs} +2421 -541
  7. package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
  8. package/dist/{copilotkit-l-IBF4Xp.d.cts → copilotkit-dwDWYpya.d.cts} +228 -17
  9. package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.cts +1 -1
  12. package/dist/index.d.mts +1 -1
  13. package/dist/index.mjs +1 -1
  14. package/dist/index.umd.js +1400 -238
  15. package/dist/index.umd.js.map +1 -1
  16. package/dist/v2/index.cjs +13 -1
  17. package/dist/v2/index.css +1 -1
  18. package/dist/v2/index.d.cts +3 -3
  19. package/dist/v2/index.d.mts +3 -3
  20. package/dist/v2/index.mjs +3 -2
  21. package/dist/v2/index.umd.js +2442 -552
  22. package/dist/v2/index.umd.js.map +1 -1
  23. package/package.json +62 -54
  24. package/scripts/scope-preflight.mjs +1 -2
  25. package/src/components/CopilotListeners.tsx +41 -8
  26. package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
  27. package/src/components/toast/toast-provider.tsx +269 -194
  28. package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
  29. package/src/v2/__tests__/utils/test-helpers.tsx +67 -0
  30. package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
  31. package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
  32. package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
  33. package/src/v2/components/CopilotKitInspector.tsx +2 -0
  34. package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
  35. package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
  36. package/src/v2/components/chat/CopilotChat.tsx +193 -50
  37. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
  38. package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
  39. package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
  40. package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
  41. package/src/v2/components/chat/CopilotChatMessageView.tsx +253 -149
  42. package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
  43. package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
  44. package/src/v2/components/chat/CopilotChatView.tsx +179 -66
  45. package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
  46. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
  47. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
  48. package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
  49. package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
  50. package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +43 -2
  51. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
  52. package/src/v2/components/chat/index.ts +9 -0
  53. package/src/v2/components/chat/scroll-element-context.ts +13 -0
  54. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
  55. package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
  56. package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
  57. package/src/v2/hooks/index.ts +5 -0
  58. package/src/v2/hooks/use-agent.tsx +95 -10
  59. package/src/v2/hooks/use-attachments.tsx +269 -0
  60. package/src/v2/hooks/use-frontend-tool.tsx +5 -2
  61. package/src/v2/hooks/use-render-activity-message.tsx +9 -2
  62. package/src/v2/hooks/use-threads.tsx +35 -15
  63. package/src/v2/index.ts +5 -1
  64. package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
  65. package/src/v2/lib/__tests__/slots.test.ts +56 -0
  66. package/src/v2/lib/processPartialHtml.ts +45 -0
  67. package/src/v2/lib/slots.tsx +42 -1
  68. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
  69. package/src/v2/providers/CopilotKitProvider.tsx +268 -32
  70. package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
  71. package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
  72. package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
  73. package/src/v2/providers/index.ts +7 -0
  74. package/src/v2/styles/globals.css +2 -1
  75. package/src/v2/types/index.ts +1 -0
  76. package/src/v2/types/sandbox-function.ts +11 -0
  77. package/dist/copilotkit-BDNjFNmk.cjs.map +0 -1
  78. package/dist/copilotkit-BqcyhQjT.d.mts.map +0 -1
  79. package/dist/copilotkit-DeOzjPsb.mjs.map +0 -1
  80. package/dist/copilotkit-l-IBF4Xp.d.cts.map +0 -1
  81. package/src/v2/components/__tests__/license-warning-banner.test.tsx +0 -46
@@ -0,0 +1,1003 @@
1
+ import React from "react";
2
+ import { render, act, screen } from "@testing-library/react";
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
+ import { useAgent, UseAgentUpdate } from "../use-agent";
5
+ import { useCopilotKit } from "../../providers/CopilotKitProvider";
6
+ import { MockStepwiseAgent } from "../../__tests__/utils/test-helpers";
7
+ import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
8
+ import type { Message } from "@ag-ui/core";
9
+ import type { RunAgentInput } from "@ag-ui/client";
10
+
11
+ vi.mock("../../providers/CopilotKitProvider", () => ({
12
+ useCopilotKit: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("../../providers/CopilotChatConfigurationProvider", () => ({
16
+ useCopilotChatConfiguration: vi.fn(() => undefined),
17
+ }));
18
+
19
+ const mockUseCopilotKit = useCopilotKit as ReturnType<typeof vi.fn>;
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Message factories — eliminates `as any` on every message literal
23
+ // ---------------------------------------------------------------------------
24
+
25
+ function userMsg(id: string, content = `msg-${id}`): Message {
26
+ return { id, role: "user" as const, content };
27
+ }
28
+
29
+ function assistantMsg(id: string, content = `msg-${id}`): Message {
30
+ return { id, role: "assistant" as const, content };
31
+ }
32
+
33
+ /** Create N alternating user/assistant messages (ids "1" … "N") */
34
+ function createMessages(count: number): Message[] {
35
+ return Array.from({ length: count }, (_, i) =>
36
+ i % 2 === 0
37
+ ? userMsg(String(i + 1), `tok${i + 1}`)
38
+ : assistantMsg(String(i + 1), `tok${i + 1}`),
39
+ );
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Subscriber notification helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /** Helper: fire onMessagesChanged on all agent subscribers */
47
+ function notifyMessagesChanged(agent: MockStepwiseAgent) {
48
+ agent.subscribers.forEach((s) =>
49
+ s.onMessagesChanged?.({
50
+ messages: agent.messages,
51
+ state: agent.state,
52
+ agent,
53
+ }),
54
+ );
55
+ }
56
+
57
+ /** Helper: fire onStateChanged on all agent subscribers */
58
+ function notifyStateChanged(agent: MockStepwiseAgent) {
59
+ agent.subscribers.forEach((s) =>
60
+ s.onStateChanged?.({
61
+ state: agent.state,
62
+ messages: agent.messages,
63
+ agent,
64
+ }),
65
+ );
66
+ }
67
+
68
+ function createMockRunAgentInput(
69
+ overrides?: Partial<RunAgentInput>,
70
+ ): RunAgentInput {
71
+ return {
72
+ threadId: "t-1",
73
+ runId: "r-1",
74
+ state: {},
75
+ messages: [],
76
+ tools: [],
77
+ context: [],
78
+ forwardedProps: {},
79
+ ...overrides,
80
+ };
81
+ }
82
+
83
+ /** Helper: fire onRunInitialized on all agent subscribers */
84
+ function notifyRunInitialized(agent: MockStepwiseAgent) {
85
+ agent.subscribers.forEach((s) =>
86
+ s.onRunInitialized?.({
87
+ messages: agent.messages,
88
+ state: agent.state,
89
+ agent,
90
+ input: createMockRunAgentInput(),
91
+ }),
92
+ );
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Test component factory
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** Helper: create a test component that tracks render count */
100
+ function createTestComponent(
101
+ options: {
102
+ updates?: UseAgentUpdate[];
103
+ throttleMs?: number;
104
+ renderCount?: { current: number };
105
+ } = {},
106
+ ) {
107
+ const {
108
+ updates = [UseAgentUpdate.OnMessagesChanged],
109
+ throttleMs,
110
+ renderCount,
111
+ } = options;
112
+
113
+ return function TestComponent() {
114
+ if (renderCount) renderCount.current++;
115
+ const { agent } = useAgent({
116
+ agentId: "test-agent",
117
+ updates,
118
+ throttleMs,
119
+ });
120
+ return (
121
+ <>
122
+ <div data-testid="count">{agent.messages.length}</div>
123
+ <div data-testid="state">{JSON.stringify(agent.state)}</div>
124
+ </>
125
+ );
126
+ };
127
+ }
128
+
129
+ /** Factory for the mock return value of useCopilotKit */
130
+ function createMockContext(
131
+ agent: MockStepwiseAgent,
132
+ overrides: { defaultThrottleMs?: number } = {},
133
+ ) {
134
+ return {
135
+ copilotkit: {
136
+ getAgent: () => agent,
137
+ runtimeUrl: "http://localhost:3000/api/copilot",
138
+ runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
139
+ runtimeTransport: "rest",
140
+ headers: {},
141
+ agents: { [String(agent.agentId)]: agent },
142
+ defaultThrottleMs: overrides.defaultThrottleMs,
143
+ },
144
+ executingToolCallIds: new Set(),
145
+ };
146
+ }
147
+
148
+ describe("useAgent throttleMs", () => {
149
+ let mockAgent: MockStepwiseAgent;
150
+
151
+ beforeEach(() => {
152
+ vi.useFakeTimers();
153
+ mockAgent = new MockStepwiseAgent();
154
+ mockAgent.agentId = "test-agent";
155
+
156
+ mockUseCopilotKit.mockReturnValue(createMockContext(mockAgent));
157
+ });
158
+
159
+ afterEach(() => {
160
+ vi.useRealTimers();
161
+ vi.restoreAllMocks();
162
+ });
163
+
164
+ it("without throttleMs, component reflects latest messages after notification", () => {
165
+ const TestComponent = createTestComponent();
166
+
167
+ render(<TestComponent />);
168
+ expect(screen.getByTestId("count").textContent).toBe("0");
169
+
170
+ act(() => {
171
+ mockAgent.messages = [userMsg("1", "hello")];
172
+ notifyMessagesChanged(mockAgent);
173
+ });
174
+
175
+ expect(screen.getByTestId("count").textContent).toBe("1");
176
+ });
177
+
178
+ it("with throttleMs: 0 (explicit), behaves identically to omitting throttleMs", () => {
179
+ const TestComponent = createTestComponent({ throttleMs: 0 });
180
+
181
+ render(<TestComponent />);
182
+ expect(screen.getByTestId("count").textContent).toBe("0");
183
+
184
+ act(() => {
185
+ mockAgent.messages = [userMsg("1", "hello")];
186
+ notifyMessagesChanged(mockAgent);
187
+ });
188
+
189
+ expect(screen.getByTestId("count").textContent).toBe("1");
190
+
191
+ // Second notification also fires immediately (no throttle)
192
+ act(() => {
193
+ mockAgent.messages = [userMsg("1", "hello"), assistantMsg("2", "world")];
194
+ notifyMessagesChanged(mockAgent);
195
+ });
196
+
197
+ expect(screen.getByTestId("count").textContent).toBe("2");
198
+ });
199
+
200
+ it("with throttleMs, first notification fires immediately (leading edge)", () => {
201
+ const TestComponent = createTestComponent({ throttleMs: 100 });
202
+
203
+ render(<TestComponent />);
204
+ expect(screen.getByTestId("count").textContent).toBe("0");
205
+
206
+ act(() => {
207
+ mockAgent.messages = [userMsg("1", "hello")];
208
+ notifyMessagesChanged(mockAgent);
209
+ });
210
+
211
+ expect(screen.getByTestId("count").textContent).toBe("1");
212
+ });
213
+
214
+ it("with throttleMs, second notification within window is deferred until trailing edge", () => {
215
+ const TestComponent = createTestComponent({ throttleMs: 100 });
216
+
217
+ render(<TestComponent />);
218
+
219
+ // First notification — leading edge, fires immediately
220
+ act(() => {
221
+ mockAgent.messages = [userMsg("1", "a")];
222
+ notifyMessagesChanged(mockAgent);
223
+ });
224
+ expect(screen.getByTestId("count").textContent).toBe("1");
225
+
226
+ // Second notification 10ms later — within throttle window
227
+ act(() => {
228
+ vi.advanceTimersByTime(10);
229
+ mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
230
+ notifyMessagesChanged(mockAgent);
231
+ });
232
+
233
+ // The throttle should have deferred this — component still shows 1
234
+ expect(screen.getByTestId("count").textContent).toBe("1");
235
+
236
+ // Advance past the throttle window — trailing edge fires
237
+ act(() => {
238
+ vi.advanceTimersByTime(100);
239
+ });
240
+ expect(screen.getByTestId("count").textContent).toBe("2");
241
+ });
242
+
243
+ it("with throttleMs, rapid burst of many notifications results in exactly 2 renders (leading + trailing)", () => {
244
+ const renderCount = { current: 0 };
245
+ const TestComponent = createTestComponent({ throttleMs: 100, renderCount });
246
+
247
+ render(<TestComponent />);
248
+ const rendersAfterMount = renderCount.current;
249
+
250
+ // Leading edge — fires immediately
251
+ act(() => {
252
+ mockAgent.messages = [userMsg("1", "tok1")];
253
+ notifyMessagesChanged(mockAgent);
254
+ });
255
+ expect(renderCount.current).toBe(rendersAfterMount + 1);
256
+
257
+ // Fire 10 rapid notifications within the throttle window (1ms apart)
258
+ for (let i = 2; i <= 11; i++) {
259
+ act(() => {
260
+ vi.advanceTimersByTime(1);
261
+ mockAgent.messages = createMessages(i);
262
+ notifyMessagesChanged(mockAgent);
263
+ });
264
+ }
265
+
266
+ // Should still be at leading-edge render count (burst was coalesced)
267
+ expect(renderCount.current).toBe(rendersAfterMount + 1);
268
+ expect(screen.getByTestId("count").textContent).toBe("1");
269
+
270
+ // Advance past the throttle window — trailing edge fires once
271
+ act(() => {
272
+ vi.advanceTimersByTime(100);
273
+ });
274
+ expect(renderCount.current).toBe(rendersAfterMount + 2);
275
+ expect(screen.getByTestId("count").textContent).toBe("11");
276
+ });
277
+
278
+ it("with throttleMs, new notification after trailing edge fires immediately (new cycle)", () => {
279
+ const TestComponent = createTestComponent({ throttleMs: 100 });
280
+
281
+ render(<TestComponent />);
282
+
283
+ // Leading edge
284
+ act(() => {
285
+ mockAgent.messages = [userMsg("1", "a")];
286
+ notifyMessagesChanged(mockAgent);
287
+ });
288
+ expect(screen.getByTestId("count").textContent).toBe("1");
289
+
290
+ // Second notification — deferred
291
+ act(() => {
292
+ vi.advanceTimersByTime(10);
293
+ mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
294
+ notifyMessagesChanged(mockAgent);
295
+ });
296
+
297
+ // Trailing edge fires
298
+ act(() => {
299
+ vi.advanceTimersByTime(100);
300
+ });
301
+ expect(screen.getByTestId("count").textContent).toBe("2");
302
+
303
+ // New notification well after the window — should fire immediately as a new leading edge
304
+ act(() => {
305
+ vi.advanceTimersByTime(200);
306
+ mockAgent.messages = [
307
+ userMsg("1", "a"),
308
+ assistantMsg("2", "b"),
309
+ userMsg("3", "c"),
310
+ ];
311
+ notifyMessagesChanged(mockAgent);
312
+ });
313
+ expect(screen.getByTestId("count").textContent).toBe("3");
314
+ });
315
+
316
+ it("with throttleMs, onStateChanged still fires immediately", () => {
317
+ const TestComponent = createTestComponent({
318
+ updates: [
319
+ UseAgentUpdate.OnMessagesChanged,
320
+ UseAgentUpdate.OnStateChanged,
321
+ ],
322
+ throttleMs: 100,
323
+ });
324
+
325
+ render(<TestComponent />);
326
+
327
+ // Fire onMessagesChanged to start the throttle window
328
+ act(() => {
329
+ mockAgent.messages = [userMsg("1", "a")];
330
+ notifyMessagesChanged(mockAgent);
331
+ });
332
+
333
+ // Fire onStateChanged 10ms later — should render immediately, not throttled
334
+ act(() => {
335
+ vi.advanceTimersByTime(10);
336
+ mockAgent.state = { count: 42 };
337
+ notifyStateChanged(mockAgent);
338
+ });
339
+
340
+ expect(screen.getByTestId("state").textContent).toBe('{"count":42}');
341
+ });
342
+
343
+ it("with throttleMs, pending trailing timer does not fire after unmount", () => {
344
+ const renderCount = { current: 0 };
345
+ const TestComponent = createTestComponent({ throttleMs: 100, renderCount });
346
+
347
+ const { unmount } = render(<TestComponent />);
348
+
349
+ // Leading edge — fires immediately
350
+ act(() => {
351
+ mockAgent.messages = [userMsg("1", "a")];
352
+ notifyMessagesChanged(mockAgent);
353
+ });
354
+
355
+ // Second notification — schedules trailing timer
356
+ act(() => {
357
+ vi.advanceTimersByTime(10);
358
+ mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
359
+ notifyMessagesChanged(mockAgent);
360
+ });
361
+
362
+ const countBeforeUnmount = renderCount.current;
363
+
364
+ // Unmount before trailing fires
365
+ unmount();
366
+
367
+ // Advancing past the window should NOT cause additional renders
368
+ act(() => {
369
+ vi.advanceTimersByTime(100);
370
+ });
371
+
372
+ expect(renderCount.current).toBe(countBeforeUnmount);
373
+ });
374
+
375
+ it("with throttleMs and updates excluding OnMessagesChanged, throttle is a no-op", () => {
376
+ const TestComponent = createTestComponent({
377
+ updates: [UseAgentUpdate.OnStateChanged],
378
+ throttleMs: 100,
379
+ });
380
+
381
+ render(<TestComponent />);
382
+
383
+ // Only onStateChanged is subscribed — should fire immediately
384
+ act(() => {
385
+ mockAgent.state = { value: "test" };
386
+ notifyStateChanged(mockAgent);
387
+ });
388
+
389
+ expect(screen.getByTestId("state").textContent).toBe('{"value":"test"}');
390
+
391
+ // No onMessagesChanged subscription should exist
392
+ act(() => {
393
+ mockAgent.messages = [userMsg("1", "a")];
394
+ notifyMessagesChanged(mockAgent);
395
+ });
396
+
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
+ expect(screen.getByTestId("state").textContent).toBe('{"value":"test"}');
401
+ });
402
+
403
+ it.each([
404
+ { label: "NaN", value: NaN },
405
+ { label: "Infinity", value: Infinity },
406
+ { label: "-1", value: -1 },
407
+ { label: "-Infinity", value: -Infinity },
408
+ ])(
409
+ "with invalid throttleMs ($label), falls back to unthrottled and warns",
410
+ ({ value }) => {
411
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
412
+ const TestComponent = createTestComponent({ throttleMs: value });
413
+
414
+ render(<TestComponent />);
415
+
416
+ // Should warn about the invalid value
417
+ expect(errorSpy).toHaveBeenCalledWith(
418
+ expect.stringContaining(
419
+ "throttleMs must be a non-negative finite number",
420
+ ),
421
+ );
422
+
423
+ // Should behave as unthrottled — every notification fires immediately
424
+ act(() => {
425
+ mockAgent.messages = [userMsg("1", "a")];
426
+ notifyMessagesChanged(mockAgent);
427
+ });
428
+ expect(screen.getByTestId("count").textContent).toBe("1");
429
+
430
+ act(() => {
431
+ mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
432
+ notifyMessagesChanged(mockAgent);
433
+ });
434
+ expect(screen.getByTestId("count").textContent).toBe("2");
435
+ },
436
+ );
437
+
438
+ it("trailing-edge render reflects the latest messages, not stale data", () => {
439
+ const TestComponent = createTestComponent({ throttleMs: 100 });
440
+ render(<TestComponent />);
441
+
442
+ // Leading edge
443
+ act(() => {
444
+ mockAgent.messages = [userMsg("1", "A")];
445
+ notifyMessagesChanged(mockAgent);
446
+ });
447
+ expect(screen.getByTestId("count").textContent).toBe("1");
448
+
449
+ // Multiple deferred notifications with increasing messages
450
+ act(() => {
451
+ vi.advanceTimersByTime(20);
452
+ mockAgent.messages = [userMsg("1", "A"), assistantMsg("2", "B")];
453
+ notifyMessagesChanged(mockAgent);
454
+ });
455
+
456
+ act(() => {
457
+ vi.advanceTimersByTime(20);
458
+ mockAgent.messages = [
459
+ userMsg("1", "A"),
460
+ assistantMsg("2", "B"),
461
+ assistantMsg("3", "C"),
462
+ ];
463
+ notifyMessagesChanged(mockAgent);
464
+ });
465
+
466
+ // Still deferred
467
+ expect(screen.getByTestId("count").textContent).toBe("1");
468
+
469
+ // Trailing edge fires — must show all 3 messages (latest state)
470
+ act(() => {
471
+ vi.advanceTimersByTime(100);
472
+ });
473
+ expect(screen.getByTestId("count").textContent).toBe("3");
474
+ });
475
+
476
+ it("trailing edge fires at exactly throttleMs after the leading edge", () => {
477
+ const TestComponent = createTestComponent({ throttleMs: 100 });
478
+ render(<TestComponent />);
479
+
480
+ // Leading edge at T=0
481
+ act(() => {
482
+ mockAgent.messages = [userMsg("1", "a")];
483
+ notifyMessagesChanged(mockAgent);
484
+ });
485
+ expect(screen.getByTestId("count").textContent).toBe("1");
486
+
487
+ // Deferred notification at T=40
488
+ act(() => {
489
+ vi.advanceTimersByTime(40);
490
+ mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
491
+ notifyMessagesChanged(mockAgent);
492
+ });
493
+ expect(screen.getByTestId("count").textContent).toBe("1");
494
+
495
+ // At T=99, trailing has NOT fired yet
496
+ act(() => {
497
+ vi.advanceTimersByTime(59);
498
+ });
499
+ expect(screen.getByTestId("count").textContent).toBe("1");
500
+
501
+ // At T=100, trailing fires
502
+ act(() => {
503
+ vi.advanceTimersByTime(1);
504
+ });
505
+ expect(screen.getByTestId("count").textContent).toBe("2");
506
+ });
507
+
508
+ it("changing throttleMs cleans up pending timers from the previous configuration", () => {
509
+ function DynamicThrottleComponent({ throttleMs }: { throttleMs: number }) {
510
+ const { agent } = useAgent({
511
+ agentId: "test-agent",
512
+ updates: [UseAgentUpdate.OnMessagesChanged],
513
+ throttleMs,
514
+ });
515
+ return <div data-testid="count">{agent.messages.length}</div>;
516
+ }
517
+
518
+ const { rerender } = render(<DynamicThrottleComponent throttleMs={200} />);
519
+
520
+ // Leading edge
521
+ act(() => {
522
+ mockAgent.messages = [userMsg("1", "a")];
523
+ notifyMessagesChanged(mockAgent);
524
+ });
525
+ expect(screen.getByTestId("count").textContent).toBe("1");
526
+
527
+ // Deferred notification — pending timer set for 200ms
528
+ act(() => {
529
+ vi.advanceTimersByTime(50);
530
+ mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
531
+ notifyMessagesChanged(mockAgent);
532
+ });
533
+ expect(screen.getByTestId("count").textContent).toBe("1");
534
+
535
+ // Change throttleMs — effect re-runs, old 200ms timer should be cleaned up
536
+ rerender(<DynamicThrottleComponent throttleMs={50} />);
537
+
538
+ // New notification fires as leading edge under the new 50ms throttle
539
+ act(() => {
540
+ mockAgent.messages = [
541
+ userMsg("1", "a"),
542
+ assistantMsg("2", "b"),
543
+ userMsg("3", "c"),
544
+ ];
545
+ notifyMessagesChanged(mockAgent);
546
+ });
547
+ expect(screen.getByTestId("count").textContent).toBe("3");
548
+
549
+ // Advance past what would have been the old 200ms trailing edge —
550
+ // no ghost render should occur from the old timer
551
+ act(() => {
552
+ vi.advanceTimersByTime(200);
553
+ });
554
+ expect(screen.getByTestId("count").textContent).toBe("3");
555
+ });
556
+
557
+ it("notification immediately after trailing edge is throttled (trailing restarts the window)", () => {
558
+ const renderCount = { current: 0 };
559
+ const TestComponent = createTestComponent({ throttleMs: 100, renderCount });
560
+
561
+ render(<TestComponent />);
562
+ const rendersAfterMount = renderCount.current;
563
+
564
+ // T=0: Leading edge fires immediately
565
+ act(() => {
566
+ mockAgent.messages = [userMsg("1", "a")];
567
+ notifyMessagesChanged(mockAgent);
568
+ });
569
+ expect(renderCount.current).toBe(rendersAfterMount + 1);
570
+ expect(screen.getByTestId("count").textContent).toBe("1");
571
+
572
+ // T=10: Deferred notification — schedules trailing
573
+ act(() => {
574
+ vi.advanceTimersByTime(10);
575
+ mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
576
+ notifyMessagesChanged(mockAgent);
577
+ });
578
+
579
+ // T=100: Trailing fires (render #2) and restarts window
580
+ act(() => {
581
+ vi.advanceTimersByTime(90);
582
+ });
583
+ expect(renderCount.current).toBe(rendersAfterMount + 2);
584
+ expect(screen.getByTestId("count").textContent).toBe("2");
585
+
586
+ // T=101: Notification 1ms after trailing — should be DEFERRED (within new window), not immediate
587
+ act(() => {
588
+ vi.advanceTimersByTime(1);
589
+ mockAgent.messages = [
590
+ userMsg("1", "a"),
591
+ assistantMsg("2", "b"),
592
+ userMsg("3", "c"),
593
+ ];
594
+ notifyMessagesChanged(mockAgent);
595
+ });
596
+ // Still 2 — the notification was deferred, not a new leading edge
597
+ expect(renderCount.current).toBe(rendersAfterMount + 2);
598
+ expect(screen.getByTestId("count").textContent).toBe("2");
599
+
600
+ // T=200: New trailing fires (render #3)
601
+ act(() => {
602
+ vi.advanceTimersByTime(99);
603
+ });
604
+ expect(renderCount.current).toBe(rendersAfterMount + 3);
605
+ expect(screen.getByTestId("count").textContent).toBe("3");
606
+ });
607
+
608
+ it("cleans up all subscriptions after unmount", () => {
609
+ const TestComponent = createTestComponent({
610
+ updates: [
611
+ UseAgentUpdate.OnMessagesChanged,
612
+ UseAgentUpdate.OnStateChanged,
613
+ ],
614
+ throttleMs: 100,
615
+ });
616
+
617
+ const subscriberCountBefore = mockAgent.subscribers.length;
618
+ const { unmount } = render(<TestComponent />);
619
+
620
+ // Should have added subscriber(s)
621
+ expect(mockAgent.subscribers.length).toBeGreaterThan(subscriberCountBefore);
622
+
623
+ unmount();
624
+
625
+ // All subscriptions should be cleaned up
626
+ expect(mockAgent.subscribers.length).toBe(subscriberCountBefore);
627
+ });
628
+
629
+ it("single notification within window does not trigger a trailing re-render", () => {
630
+ const renderCount = { current: 0 };
631
+ const TestComponent = createTestComponent({ throttleMs: 100, renderCount });
632
+
633
+ render(<TestComponent />);
634
+ const rendersAfterMount = renderCount.current;
635
+
636
+ // Leading edge — fires immediately
637
+ act(() => {
638
+ mockAgent.messages = [userMsg("1", "a")];
639
+ notifyMessagesChanged(mockAgent);
640
+ });
641
+ expect(renderCount.current).toBe(rendersAfterMount + 1);
642
+
643
+ // Advance well past the throttle window — no trailing should fire
644
+ act(() => {
645
+ vi.advanceTimersByTime(200);
646
+ });
647
+
648
+ // No additional render since there was no second notification
649
+ expect(renderCount.current).toBe(rendersAfterMount + 1);
650
+ });
651
+
652
+ it("with throttleMs, onRunInitialized still fires immediately during throttle window", () => {
653
+ const renderCount = { current: 0 };
654
+ const TestComponent = createTestComponent({
655
+ updates: [
656
+ UseAgentUpdate.OnMessagesChanged,
657
+ UseAgentUpdate.OnRunStatusChanged,
658
+ ],
659
+ throttleMs: 100,
660
+ renderCount,
661
+ });
662
+
663
+ render(<TestComponent />);
664
+ const rendersAfterMount = renderCount.current;
665
+
666
+ // Fire onMessagesChanged to start the throttle window
667
+ act(() => {
668
+ mockAgent.messages = [userMsg("1", "a")];
669
+ notifyMessagesChanged(mockAgent);
670
+ });
671
+ expect(renderCount.current).toBe(rendersAfterMount + 1);
672
+
673
+ // Fire onRunInitialized 10ms later — should render immediately
674
+ act(() => {
675
+ vi.advanceTimersByTime(10);
676
+ notifyRunInitialized(mockAgent);
677
+ });
678
+
679
+ // Run status notification is NOT throttled — renders immediately
680
+ expect(renderCount.current).toBe(rendersAfterMount + 2);
681
+ });
682
+
683
+ it("changing throttleMs from positive to 0 disables throttling immediately", () => {
684
+ function DynamicThrottleComponent({ throttleMs }: { throttleMs: number }) {
685
+ const { agent } = useAgent({
686
+ agentId: "test-agent",
687
+ updates: [UseAgentUpdate.OnMessagesChanged],
688
+ throttleMs,
689
+ });
690
+ return <div data-testid="count">{agent.messages.length}</div>;
691
+ }
692
+
693
+ const { rerender } = render(<DynamicThrottleComponent throttleMs={200} />);
694
+
695
+ // Leading edge with throttle active
696
+ act(() => {
697
+ mockAgent.messages = [userMsg("1", "a")];
698
+ notifyMessagesChanged(mockAgent);
699
+ });
700
+ expect(screen.getByTestId("count").textContent).toBe("1");
701
+
702
+ // Deferred notification — within throttle window
703
+ act(() => {
704
+ vi.advanceTimersByTime(50);
705
+ mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
706
+ notifyMessagesChanged(mockAgent);
707
+ });
708
+ expect(screen.getByTestId("count").textContent).toBe("1");
709
+
710
+ // Switch to unthrottled
711
+ rerender(<DynamicThrottleComponent throttleMs={0} />);
712
+
713
+ // Both notifications should fire immediately now
714
+ act(() => {
715
+ mockAgent.messages = [
716
+ userMsg("1", "a"),
717
+ assistantMsg("2", "b"),
718
+ userMsg("3", "c"),
719
+ ];
720
+ notifyMessagesChanged(mockAgent);
721
+ });
722
+ expect(screen.getByTestId("count").textContent).toBe("3");
723
+
724
+ // Second immediate notification also fires (no coalescing)
725
+ act(() => {
726
+ mockAgent.messages = [
727
+ userMsg("1", "a"),
728
+ assistantMsg("2", "b"),
729
+ userMsg("3", "c"),
730
+ assistantMsg("4", "d"),
731
+ ];
732
+ notifyMessagesChanged(mockAgent);
733
+ });
734
+ expect(screen.getByTestId("count").textContent).toBe("4");
735
+ });
736
+ });
737
+
738
+ describe("useAgent defaultThrottleMs from provider", () => {
739
+ let mockAgent: MockStepwiseAgent;
740
+
741
+ beforeEach(() => {
742
+ vi.useFakeTimers();
743
+ mockAgent = new MockStepwiseAgent();
744
+ mockAgent.agentId = "test-agent";
745
+ });
746
+
747
+ afterEach(() => {
748
+ vi.useRealTimers();
749
+ vi.restoreAllMocks();
750
+ });
751
+
752
+ it("uses provider defaultThrottleMs when no explicit throttleMs is passed", () => {
753
+ mockUseCopilotKit.mockReturnValue(
754
+ createMockContext(mockAgent, { defaultThrottleMs: 100 }),
755
+ );
756
+
757
+ const TestComponent = createTestComponent({ throttleMs: undefined });
758
+
759
+ render(<TestComponent />);
760
+
761
+ // Leading edge — fires immediately
762
+ act(() => {
763
+ mockAgent.messages = [userMsg("1", "hello")];
764
+ notifyMessagesChanged(mockAgent);
765
+ });
766
+ expect(screen.getByTestId("count").textContent).toBe("1");
767
+
768
+ // Second notification within 100ms window — should be deferred (throttled)
769
+ act(() => {
770
+ vi.advanceTimersByTime(10);
771
+ mockAgent.messages = [userMsg("1", "hello"), assistantMsg("2", "world")];
772
+ notifyMessagesChanged(mockAgent);
773
+ });
774
+ expect(screen.getByTestId("count").textContent).toBe("1");
775
+
776
+ // Trailing edge fires after 100ms
777
+ act(() => {
778
+ vi.advanceTimersByTime(100);
779
+ });
780
+ expect(screen.getByTestId("count").textContent).toBe("2");
781
+ });
782
+
783
+ it("explicit throttleMs overrides provider defaultThrottleMs", () => {
784
+ mockUseCopilotKit.mockReturnValue(
785
+ createMockContext(mockAgent, { defaultThrottleMs: 5000 }),
786
+ );
787
+
788
+ // Explicit throttleMs=100 should override provider's 5000
789
+ const TestComponent = createTestComponent({ throttleMs: 100 });
790
+
791
+ render(<TestComponent />);
792
+
793
+ // Leading edge
794
+ act(() => {
795
+ mockAgent.messages = [userMsg("1", "hello")];
796
+ notifyMessagesChanged(mockAgent);
797
+ });
798
+ expect(screen.getByTestId("count").textContent).toBe("1");
799
+
800
+ // Deferred within 100ms window
801
+ act(() => {
802
+ vi.advanceTimersByTime(10);
803
+ mockAgent.messages = [userMsg("1", "hello"), assistantMsg("2", "world")];
804
+ notifyMessagesChanged(mockAgent);
805
+ });
806
+ expect(screen.getByTestId("count").textContent).toBe("1");
807
+
808
+ // At 100ms trailing fires (not waiting for provider's 5000ms)
809
+ act(() => {
810
+ vi.advanceTimersByTime(100);
811
+ });
812
+ expect(screen.getByTestId("count").textContent).toBe("2");
813
+ });
814
+
815
+ it("without provider defaultThrottleMs or explicit throttleMs, behaves unthrottled", () => {
816
+ mockUseCopilotKit.mockReturnValue(createMockContext(mockAgent));
817
+
818
+ const TestComponent = createTestComponent({});
819
+
820
+ render(<TestComponent />);
821
+
822
+ act(() => {
823
+ mockAgent.messages = [userMsg("1", "hello")];
824
+ notifyMessagesChanged(mockAgent);
825
+ });
826
+ expect(screen.getByTestId("count").textContent).toBe("1");
827
+
828
+ // Immediately fires — no throttle
829
+ act(() => {
830
+ mockAgent.messages = [userMsg("1", "hello"), assistantMsg("2", "world")];
831
+ notifyMessagesChanged(mockAgent);
832
+ });
833
+ expect(screen.getByTestId("count").textContent).toBe("2");
834
+ });
835
+
836
+ it("explicit throttleMs: 0 overrides non-zero provider defaultThrottleMs (opt-out)", () => {
837
+ mockUseCopilotKit.mockReturnValue(
838
+ createMockContext(mockAgent, { defaultThrottleMs: 500 }),
839
+ );
840
+
841
+ const TestComponent = createTestComponent({ throttleMs: 0 });
842
+
843
+ render(<TestComponent />);
844
+
845
+ // Both notifications fire immediately — throttleMs: 0 means no throttle
846
+ act(() => {
847
+ mockAgent.messages = [userMsg("1", "hello")];
848
+ notifyMessagesChanged(mockAgent);
849
+ });
850
+ expect(screen.getByTestId("count").textContent).toBe("1");
851
+
852
+ act(() => {
853
+ mockAgent.messages = [userMsg("1", "hello"), assistantMsg("2", "world")];
854
+ notifyMessagesChanged(mockAgent);
855
+ });
856
+ expect(screen.getByTestId("count").textContent).toBe("2");
857
+ });
858
+
859
+ it.each([
860
+ { label: "NaN", value: NaN },
861
+ { label: "Infinity", value: Infinity },
862
+ { label: "-1", value: -1 },
863
+ { label: "-Infinity", value: -Infinity },
864
+ ])(
865
+ "with invalid provider defaultThrottleMs ($label), falls back to unthrottled and warns",
866
+ ({ value }) => {
867
+ mockUseCopilotKit.mockReturnValue(
868
+ createMockContext(mockAgent, { defaultThrottleMs: value }),
869
+ );
870
+
871
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
872
+ const TestComponent = createTestComponent({ throttleMs: undefined });
873
+
874
+ render(<TestComponent />);
875
+
876
+ expect(errorSpy).toHaveBeenCalledWith(
877
+ expect.stringContaining("provider-level defaultThrottleMs"),
878
+ );
879
+ expect(errorSpy).toHaveBeenCalledWith(
880
+ expect.stringContaining("must be a non-negative finite number"),
881
+ );
882
+
883
+ // Should behave as unthrottled
884
+ act(() => {
885
+ mockAgent.messages = [userMsg("1", "a")];
886
+ notifyMessagesChanged(mockAgent);
887
+ });
888
+ expect(screen.getByTestId("count").textContent).toBe("1");
889
+
890
+ act(() => {
891
+ mockAgent.messages = [userMsg("1", "a"), assistantMsg("2", "b")];
892
+ notifyMessagesChanged(mockAgent);
893
+ });
894
+ expect(screen.getByTestId("count").textContent).toBe("2");
895
+ },
896
+ );
897
+
898
+ it("dynamically changing provider defaultThrottleMs updates throttle behavior", () => {
899
+ // Start with 200ms throttle from provider
900
+ mockUseCopilotKit.mockReturnValue(
901
+ createMockContext(mockAgent, { defaultThrottleMs: 200 }),
902
+ );
903
+
904
+ const TestComponent = createTestComponent({ throttleMs: undefined });
905
+ const { rerender } = render(<TestComponent />);
906
+
907
+ // Leading edge fires immediately
908
+ act(() => {
909
+ mockAgent.messages = [userMsg("1", "hello")];
910
+ notifyMessagesChanged(mockAgent);
911
+ });
912
+ expect(screen.getByTestId("count").textContent).toBe("1");
913
+
914
+ // Deferred within 200ms window
915
+ act(() => {
916
+ vi.advanceTimersByTime(10);
917
+ mockAgent.messages = [userMsg("1", "hello"), assistantMsg("2", "world")];
918
+ notifyMessagesChanged(mockAgent);
919
+ });
920
+ expect(screen.getByTestId("count").textContent).toBe("1");
921
+
922
+ // Flush trailing edge
923
+ act(() => {
924
+ vi.advanceTimersByTime(200);
925
+ });
926
+ expect(screen.getByTestId("count").textContent).toBe("2");
927
+
928
+ // Change provider default to 50ms
929
+ mockUseCopilotKit.mockReturnValue(
930
+ createMockContext(mockAgent, { defaultThrottleMs: 50 }),
931
+ );
932
+ rerender(<TestComponent />);
933
+
934
+ // Leading edge fires immediately
935
+ act(() => {
936
+ mockAgent.messages = [
937
+ userMsg("1", "hello"),
938
+ assistantMsg("2", "world"),
939
+ userMsg("3", "new"),
940
+ ];
941
+ notifyMessagesChanged(mockAgent);
942
+ });
943
+ expect(screen.getByTestId("count").textContent).toBe("3");
944
+
945
+ // Deferred within 50ms window
946
+ act(() => {
947
+ vi.advanceTimersByTime(10);
948
+ mockAgent.messages = [
949
+ userMsg("1", "hello"),
950
+ assistantMsg("2", "world"),
951
+ userMsg("3", "new"),
952
+ assistantMsg("4", "reply"),
953
+ ];
954
+ notifyMessagesChanged(mockAgent);
955
+ });
956
+ expect(screen.getByTestId("count").textContent).toBe("3");
957
+
958
+ // Trailing fires after only 50ms (not 200ms)
959
+ act(() => {
960
+ vi.advanceTimersByTime(50);
961
+ });
962
+ expect(screen.getByTestId("count").textContent).toBe("4");
963
+ });
964
+ });
965
+
966
+ describe("CopilotKitCore.setDefaultThrottleMs validation", () => {
967
+ it.each([
968
+ { label: "NaN", value: NaN },
969
+ { label: "Infinity", value: Infinity },
970
+ { label: "-1", value: -1 },
971
+ { 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
+ });
1003
+ });