@copilotkit/react-core 1.55.0-next.8 → 1.55.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 (94) hide show
  1. package/CHANGELOG.md +48 -5
  2. package/dist/{copilotkit-DNYSFuz5.mjs → copilotkit-BY5S1-0P.mjs} +2772 -858
  3. package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
  4. package/dist/{copilotkit-Dy5w3qEV.d.mts → copilotkit-BuhSUZHb.d.mts} +230 -17
  5. package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
  6. package/dist/{copilotkit-B3Mb1yVE.cjs → copilotkit-Bz5-ImDl.cjs} +2776 -832
  7. package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
  8. package/dist/{copilotkit-DBzgOMby.d.cts → copilotkit-dwDWYpya.d.cts} +230 -17
  9. package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
  10. package/dist/index.cjs +9 -4
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +1 -1
  13. package/dist/index.d.mts +1 -1
  14. package/dist/index.mjs +9 -4
  15. package/dist/index.mjs.map +1 -1
  16. package/dist/index.umd.js +1624 -396
  17. package/dist/index.umd.js.map +1 -1
  18. package/dist/v2/index.cjs +13 -1
  19. package/dist/v2/index.css +1 -1
  20. package/dist/v2/index.d.cts +3 -3
  21. package/dist/v2/index.d.mts +3 -3
  22. package/dist/v2/index.mjs +3 -2
  23. package/dist/v2/index.umd.js +2746 -790
  24. package/dist/v2/index.umd.js.map +1 -1
  25. package/package.json +62 -54
  26. package/scripts/scope-preflight.mjs +1 -2
  27. package/src/components/CopilotListeners.tsx +41 -8
  28. package/src/components/copilot-provider/__tests__/copilot-messages-key.test.tsx +92 -0
  29. package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
  30. package/src/components/copilot-provider/copilotkit.tsx +3 -3
  31. package/src/components/toast/toast-provider.tsx +269 -194
  32. package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +27 -16
  33. package/src/hooks/use-copilot-chat_internal.ts +15 -4
  34. package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
  35. package/src/v2/__tests__/utils/test-helpers.tsx +107 -7
  36. package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
  37. package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
  38. package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
  39. package/src/v2/components/CopilotKitInspector.tsx +2 -0
  40. package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
  41. package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
  42. package/src/v2/components/chat/CopilotChat.tsx +197 -52
  43. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
  44. package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
  45. package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
  46. package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
  47. package/src/v2/components/chat/CopilotChatMessageView.tsx +260 -151
  48. package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
  49. package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
  50. package/src/v2/components/chat/CopilotChatView.tsx +179 -66
  51. package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
  52. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
  53. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
  54. package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
  55. package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
  56. package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx +5 -2
  57. package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +5 -2
  58. package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +60 -3
  59. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
  60. package/src/v2/components/chat/index.ts +9 -0
  61. package/src/v2/components/chat/scroll-element-context.ts +13 -0
  62. package/src/v2/hooks/__tests__/use-agent-context-timing.e2e.test.tsx +8 -0
  63. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +327 -0
  64. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
  65. package/src/v2/hooks/__tests__/use-agent.e2e.test.tsx +13 -2
  66. package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
  67. package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.tsx +23 -4
  68. package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
  69. package/src/v2/hooks/index.ts +5 -0
  70. package/src/v2/hooks/use-agent.tsx +220 -15
  71. package/src/v2/hooks/use-attachments.tsx +269 -0
  72. package/src/v2/hooks/use-frontend-tool.tsx +5 -2
  73. package/src/v2/hooks/use-render-activity-message.tsx +9 -2
  74. package/src/v2/hooks/use-render-custom-messages.tsx +6 -1
  75. package/src/v2/hooks/use-threads.tsx +35 -15
  76. package/src/v2/index.ts +5 -1
  77. package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
  78. package/src/v2/lib/__tests__/slots.test.ts +56 -0
  79. package/src/v2/lib/processPartialHtml.ts +45 -0
  80. package/src/v2/lib/slots.tsx +42 -1
  81. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
  82. package/src/v2/providers/CopilotKitProvider.tsx +268 -32
  83. package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
  84. package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
  85. package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
  86. package/src/v2/providers/index.ts +7 -0
  87. package/src/v2/styles/globals.css +2 -1
  88. package/src/v2/types/index.ts +1 -0
  89. package/src/v2/types/sandbox-function.ts +11 -0
  90. package/dist/copilotkit-B3Mb1yVE.cjs.map +0 -1
  91. package/dist/copilotkit-DBzgOMby.d.cts.map +0 -1
  92. package/dist/copilotkit-DNYSFuz5.mjs.map +0 -1
  93. package/dist/copilotkit-Dy5w3qEV.d.mts.map +0 -1
  94. package/src/v2/components/__tests__/license-warning-banner.test.tsx +0 -46
@@ -19,10 +19,21 @@ import { CopilotChat } from "../../components/chat/CopilotChat";
19
19
  * Mock agent that captures RunAgentInput to verify state is passed correctly
20
20
  */
21
21
  class StateCapturingMockAgent extends MockStepwiseAgent {
22
- public lastRunInput?: RunAgentInput;
22
+ // Shared via a container so the clone and original both see the same value
23
+ private _capture: { lastRunInput?: RunAgentInput } = {};
24
+
25
+ get lastRunInput(): RunAgentInput | undefined {
26
+ return this._capture.lastRunInput;
27
+ }
28
+
29
+ clone(): this {
30
+ const cloned = super.clone();
31
+ (cloned as unknown as StateCapturingMockAgent)._capture = this._capture;
32
+ return cloned;
33
+ }
23
34
 
24
35
  run(input: RunAgentInput): Observable<BaseEvent> {
25
- this.lastRunInput = input;
36
+ this._capture.lastRunInput = input;
26
37
  return super.run(input);
27
38
  }
28
39
  }
@@ -0,0 +1,169 @@
1
+ import React, { useRef, useEffect } from "react";
2
+ import { renderHook, act } from "@testing-library/react";
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import { useAttachments } from "../use-attachments";
5
+
6
+ describe("useAttachments", () => {
7
+ // -----------------------------------------------------------------------
8
+ // Referential stability — callbacks must not change between renders
9
+ // -----------------------------------------------------------------------
10
+
11
+ describe("referential stability", () => {
12
+ it("all callbacks are stable across re-renders with same config", () => {
13
+ const config = { enabled: true, accept: "image/*" };
14
+ const { result, rerender } = renderHook(() => useAttachments({ config }));
15
+
16
+ const first = result.current;
17
+ rerender();
18
+ const second = result.current;
19
+
20
+ expect(second.processFiles).toBe(first.processFiles);
21
+ expect(second.handleFileUpload).toBe(first.handleFileUpload);
22
+ expect(second.handleDragOver).toBe(first.handleDragOver);
23
+ expect(second.handleDragLeave).toBe(first.handleDragLeave);
24
+ expect(second.handleDrop).toBe(first.handleDrop);
25
+ expect(second.removeAttachment).toBe(first.removeAttachment);
26
+ expect(second.consumeAttachments).toBe(first.consumeAttachments);
27
+ });
28
+
29
+ it("callbacks remain stable when config object reference changes", () => {
30
+ let config = { enabled: true, accept: "image/*" };
31
+ const { result, rerender } = renderHook(() => useAttachments({ config }));
32
+
33
+ const first = result.current;
34
+
35
+ // Create a new config with same values — different reference
36
+ config = { enabled: true, accept: "image/*" };
37
+ rerender();
38
+ const second = result.current;
39
+
40
+ expect(second.processFiles).toBe(first.processFiles);
41
+ expect(second.handleFileUpload).toBe(first.handleFileUpload);
42
+ expect(second.handleDragOver).toBe(first.handleDragOver);
43
+ expect(second.handleDragLeave).toBe(first.handleDragLeave);
44
+ expect(second.handleDrop).toBe(first.handleDrop);
45
+ expect(second.removeAttachment).toBe(first.removeAttachment);
46
+ expect(second.consumeAttachments).toBe(first.consumeAttachments);
47
+ });
48
+
49
+ it("refs are stable across re-renders", () => {
50
+ const { result, rerender } = renderHook(() =>
51
+ useAttachments({ config: undefined }),
52
+ );
53
+
54
+ const first = result.current;
55
+ rerender();
56
+ const second = result.current;
57
+
58
+ expect(second.fileInputRef).toBe(first.fileInputRef);
59
+ expect(second.containerRef).toBe(first.containerRef);
60
+ });
61
+ });
62
+
63
+ // -----------------------------------------------------------------------
64
+ // Re-render counting — hook should not cause unnecessary renders
65
+ // -----------------------------------------------------------------------
66
+
67
+ describe("re-render counting", () => {
68
+ it("does not re-render when consumeAttachments is called on empty queue", () => {
69
+ let renderCount = 0;
70
+
71
+ const { result } = renderHook(() => {
72
+ renderCount++;
73
+ return useAttachments({ config: undefined });
74
+ });
75
+
76
+ const initialRenderCount = renderCount;
77
+
78
+ act(() => {
79
+ result.current.consumeAttachments();
80
+ });
81
+
82
+ // consumeAttachments on empty queue should not trigger a state update
83
+ expect(renderCount).toBe(initialRenderCount);
84
+ });
85
+
86
+ it("does not re-render on repeated consumeAttachments with empty queue", () => {
87
+ let renderCount = 0;
88
+
89
+ const { result } = renderHook(() => {
90
+ renderCount++;
91
+ return useAttachments({ config: undefined });
92
+ });
93
+
94
+ const initialRenderCount = renderCount;
95
+
96
+ act(() => {
97
+ result.current.consumeAttachments();
98
+ result.current.consumeAttachments();
99
+ result.current.consumeAttachments();
100
+ });
101
+
102
+ expect(renderCount).toBe(initialRenderCount);
103
+ });
104
+ });
105
+
106
+ // -----------------------------------------------------------------------
107
+ // State defaults
108
+ // -----------------------------------------------------------------------
109
+
110
+ describe("initial state", () => {
111
+ it("returns empty attachments and disabled by default", () => {
112
+ const { result } = renderHook(() =>
113
+ useAttachments({ config: undefined }),
114
+ );
115
+
116
+ expect(result.current.attachments).toEqual([]);
117
+ expect(result.current.enabled).toBe(false);
118
+ expect(result.current.dragOver).toBe(false);
119
+ });
120
+
121
+ it("returns enabled when config.enabled is true", () => {
122
+ const { result } = renderHook(() =>
123
+ useAttachments({ config: { enabled: true } }),
124
+ );
125
+
126
+ expect(result.current.enabled).toBe(true);
127
+ });
128
+ });
129
+
130
+ // -----------------------------------------------------------------------
131
+ // consumeAttachments behavior
132
+ // -----------------------------------------------------------------------
133
+
134
+ describe("consumeAttachments", () => {
135
+ it("returns empty array when no attachments", () => {
136
+ const { result } = renderHook(() =>
137
+ useAttachments({ config: undefined }),
138
+ );
139
+
140
+ let consumed: any[];
141
+ act(() => {
142
+ consumed = result.current.consumeAttachments();
143
+ });
144
+
145
+ expect(consumed!).toEqual([]);
146
+ });
147
+ });
148
+
149
+ // -----------------------------------------------------------------------
150
+ // removeAttachment
151
+ // -----------------------------------------------------------------------
152
+
153
+ describe("removeAttachment", () => {
154
+ it("is a no-op when id does not exist", () => {
155
+ const { result } = renderHook(() =>
156
+ useAttachments({ config: undefined }),
157
+ );
158
+
159
+ const before = result.current.attachments;
160
+
161
+ act(() => {
162
+ result.current.removeAttachment("nonexistent");
163
+ });
164
+
165
+ // Should still be empty, no crash
166
+ expect(result.current.attachments).toEqual([]);
167
+ });
168
+ });
169
+ });
@@ -502,13 +502,24 @@ describe("useFrontendTool E2E - Dynamic Registration", () => {
502
502
  describe("Agent input plumbing", () => {
503
503
  it("forwards registered frontend tools to runAgent input", async () => {
504
504
  class InstrumentedMockAgent extends MockStepwiseAgent {
505
- public lastRunParameters?: RunAgentParameters;
505
+ // Shared so the clone and original both see the captured parameters
506
+ private _capture: { lastRunParameters?: RunAgentParameters } = {};
507
+
508
+ get lastRunParameters(): RunAgentParameters | undefined {
509
+ return this._capture.lastRunParameters;
510
+ }
511
+
512
+ clone(): this {
513
+ const cloned = super.clone();
514
+ (cloned as unknown as InstrumentedMockAgent)._capture = this._capture;
515
+ return cloned;
516
+ }
506
517
 
507
518
  async runAgent(
508
519
  parameters?: RunAgentParameters,
509
520
  subscriber?: AgentSubscriber,
510
521
  ) {
511
- this.lastRunParameters = parameters;
522
+ this._capture.lastRunParameters = parameters;
512
523
  return super.runAgent(parameters, subscriber);
513
524
  }
514
525
  }
@@ -568,8 +579,16 @@ describe("useFrontendTool E2E - Dynamic Registration", () => {
568
579
  class OneShotToolCallAgent extends AbstractAgent {
569
580
  private runCount = 0;
570
581
  clone(): OneShotToolCallAgent {
571
- // Keep state across runs so the second run emits different args
572
- return this;
582
+ const cloned = new OneShotToolCallAgent();
583
+ cloned.agentId = this.agentId;
584
+ // Share runCount via reference so the second run emits different args
585
+ Object.defineProperty(cloned, "runCount", {
586
+ get: () => this.runCount,
587
+ set: (v: number) => {
588
+ this.runCount = v;
589
+ },
590
+ });
591
+ return cloned;
573
592
  }
574
593
  async detachActiveRun(): Promise<void> {}
575
594
  run(_input: RunAgentInput): Observable<BaseEvent> {
@@ -383,6 +383,60 @@ describe("useThreads", () => {
383
383
  });
384
384
  });
385
385
 
386
+ it("exposes thread-scoped pagination properties", async () => {
387
+ fetchMock
388
+ .mockReturnValueOnce(
389
+ jsonResponse({
390
+ threads: sampleThreads,
391
+ joinCode: "jc-1",
392
+ nextCursor: "cursor-abc",
393
+ }),
394
+ )
395
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
396
+
397
+ const { result } = renderHook(() => useThreads(defaultInput));
398
+
399
+ await waitFor(() => {
400
+ expect(result.current.isLoading).toBe(false);
401
+ });
402
+
403
+ expect(result.current).toHaveProperty("hasMoreThreads");
404
+ expect(result.current).toHaveProperty("isFetchingMoreThreads");
405
+ expect(result.current).toHaveProperty("fetchMoreThreads");
406
+ expect(result.current).not.toHaveProperty("hasNextPage");
407
+ expect(result.current).not.toHaveProperty("isFetchingNextPage");
408
+ expect(result.current).not.toHaveProperty("fetchNextPage");
409
+
410
+ expect(result.current.hasMoreThreads).toBe(true);
411
+ expect(result.current.isFetchingMoreThreads).toBe(false);
412
+ expect(typeof result.current.fetchMoreThreads).toBe("function");
413
+ });
414
+
415
+ it("does not expose organizationId or createdById on threads", async () => {
416
+ fetchMock
417
+ .mockReturnValueOnce(
418
+ jsonResponse({ threads: sampleThreads, joinCode: "jc-1" }),
419
+ )
420
+ .mockReturnValueOnce(jsonResponse({ joinToken: "jt-1" }));
421
+
422
+ const { result } = renderHook(() => useThreads(defaultInput));
423
+
424
+ await waitFor(() => {
425
+ expect(result.current.isLoading).toBe(false);
426
+ });
427
+
428
+ for (const thread of result.current.threads) {
429
+ expect(thread).not.toHaveProperty("organizationId");
430
+ expect(thread).not.toHaveProperty("createdById");
431
+ expect(thread).toHaveProperty("id");
432
+ expect(thread).toHaveProperty("agentId");
433
+ expect(thread).toHaveProperty("name");
434
+ expect(thread).toHaveProperty("archived");
435
+ expect(thread).toHaveProperty("createdAt");
436
+ expect(thread).toHaveProperty("updatedAt");
437
+ }
438
+ });
439
+
386
440
  it("tears down sockets after repeated connection failures", async () => {
387
441
  fetchMock
388
442
  .mockReturnValueOnce(
@@ -16,3 +16,8 @@ export { useInterrupt } from "./use-interrupt";
16
16
  export type { UseInterruptConfig } from "./use-interrupt";
17
17
  export { useThreads } from "./use-threads";
18
18
  export type { Thread, UseThreadsInput, UseThreadsResult } from "./use-threads";
19
+ export { useAttachments } from "./use-attachments";
20
+ export type {
21
+ UseAttachmentsProps,
22
+ UseAttachmentsReturn,
23
+ } from "./use-attachments";
@@ -1,7 +1,8 @@
1
1
  import { useCopilotKit } from "../providers/CopilotKitProvider";
2
+ import { useCopilotChatConfiguration } from "../providers/CopilotChatConfigurationProvider";
2
3
  import { useMemo, useEffect, useReducer, useRef } from "react";
3
4
  import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
4
- import { AbstractAgent } from "@ag-ui/client";
5
+ import { AbstractAgent, HttpAgent } from "@ag-ui/client";
5
6
  import {
6
7
  ProxiedCopilotRuntimeAgent,
7
8
  CopilotKitCoreRuntimeConnectionStatus,
@@ -21,13 +22,132 @@ const ALL_UPDATES: UseAgentUpdate[] = [
21
22
 
22
23
  export interface UseAgentProps {
23
24
  agentId?: string;
25
+ threadId?: string;
24
26
  updates?: UseAgentUpdate[];
27
+ /**
28
+ * Throttle interval (in milliseconds) for React re-renders triggered by
29
+ * `OnMessagesChanged` notifications. Useful to reduce re-render frequency
30
+ * during high-frequency message updates such as streaming.
31
+ *
32
+ * Uses leading+trailing: first update fires immediately, subsequent updates
33
+ * within the window are coalesced, and a trailing timer ensures the most
34
+ * recent update fires after the window expires. The trailing edge restarts
35
+ * the throttle window, so no two renders occur within `throttleMs` of each
36
+ * other. Cleanup on unmount cancels any pending trailing timer.
37
+ *
38
+ * Must be a non-negative finite number. Negative or non-finite values fall
39
+ * back to unthrottled behavior with a `console.error`. Only affects
40
+ * `OnMessagesChanged` updates — `OnStateChanged` and `OnRunStatusChanged`
41
+ * always fire immediately. If `updates` does not include
42
+ * `OnMessagesChanged`, this property has no effect.
43
+ *
44
+ * Default: `0` (no throttle).
45
+ */
46
+ throttleMs?: number;
25
47
  }
26
48
 
27
- export function useAgent({ agentId, updates }: UseAgentProps = {}) {
49
+ /**
50
+ * Clone a registry agent for per-thread isolation.
51
+ * Copies agent configuration (transport, headers, etc.) but resets conversation
52
+ * state (messages, threadId, state) so each thread starts fresh.
53
+ */
54
+ function cloneForThread(
55
+ source: AbstractAgent,
56
+ threadId: string,
57
+ headers: Record<string, string>,
58
+ ): AbstractAgent {
59
+ const clone = source.clone();
60
+ if (clone === source) {
61
+ throw new Error(
62
+ `useAgent: ${source.constructor.name}.clone() returned the same instance. ` +
63
+ `clone() must return a new, independent object.`,
64
+ );
65
+ }
66
+ clone.threadId = threadId;
67
+ clone.setMessages([]);
68
+ clone.setState({});
69
+ if (clone instanceof HttpAgent) {
70
+ clone.headers = { ...headers };
71
+ }
72
+ return clone;
73
+ }
74
+
75
+ /**
76
+ * Module-level WeakMap: registryAgent → (threadId → clone).
77
+ * Shared across all useAgent() calls so that every component using the same
78
+ * (agentId, threadId) pair receives the same agent instance. Using WeakMap
79
+ * ensures the clone map is garbage-collected when the registry agent is
80
+ * replaced (e.g. after reconnect or hot-reload).
81
+ */
82
+ export const globalThreadCloneMap = new WeakMap<
83
+ AbstractAgent,
84
+ Map<string, AbstractAgent>
85
+ >();
86
+
87
+ /**
88
+ * Look up an existing per-thread clone without creating one.
89
+ * Returns undefined when no clone has been created yet for this pair.
90
+ */
91
+ export function getThreadClone(
92
+ registryAgent: AbstractAgent | undefined | null,
93
+ threadId: string | undefined | null,
94
+ ): AbstractAgent | undefined {
95
+ if (!registryAgent || !threadId) return undefined;
96
+ return globalThreadCloneMap.get(registryAgent)?.get(threadId);
97
+ }
98
+
99
+ function getOrCreateThreadClone(
100
+ existing: AbstractAgent,
101
+ threadId: string,
102
+ headers: Record<string, string>,
103
+ ): AbstractAgent {
104
+ let byThread = globalThreadCloneMap.get(existing);
105
+ if (!byThread) {
106
+ byThread = new Map();
107
+ globalThreadCloneMap.set(existing, byThread);
108
+ }
109
+ const cached = byThread.get(threadId);
110
+ if (cached) return cached;
111
+
112
+ const clone = cloneForThread(existing, threadId, headers);
113
+ byThread.set(threadId, clone);
114
+ return clone;
115
+ }
116
+
117
+ export function useAgent({
118
+ agentId,
119
+ threadId,
120
+ updates,
121
+ throttleMs,
122
+ }: UseAgentProps = {}) {
28
123
  agentId ??= DEFAULT_AGENT_ID;
29
124
 
30
125
  const { copilotkit } = useCopilotKit();
126
+ const providerThrottleMs = copilotkit.defaultThrottleMs;
127
+ // Fall back to the enclosing CopilotChatConfigurationProvider's threadId so
128
+ // that useAgent() called without explicit threadId (e.g. inside a custom
129
+ // message renderer) automatically uses the same per-thread clone as the
130
+ // CopilotChat component it lives within.
131
+ const chatConfig = useCopilotChatConfiguration();
132
+ threadId ??= chatConfig?.threadId;
133
+
134
+ const effectiveThrottleMs = useMemo(() => {
135
+ const resolved = throttleMs ?? providerThrottleMs ?? 0;
136
+ if (!Number.isFinite(resolved) || resolved < 0) {
137
+ // When both throttleMs and providerThrottleMs are undefined, resolved
138
+ // is 0 which passes validation — so one of them must be defined here.
139
+ const source =
140
+ throttleMs !== undefined
141
+ ? "hook-level throttleMs"
142
+ : "provider-level defaultThrottleMs";
143
+ console.error(
144
+ `useAgent: ${source} must be a non-negative finite number, got ${resolved}. Falling back to unthrottled.`,
145
+ );
146
+ return 0;
147
+ }
148
+ return resolved;
149
+ }, [throttleMs, providerThrottleMs]);
150
+
31
151
  const [, forceUpdate] = useReducer((x) => x + 1, 0);
32
152
 
33
153
  const updateFlags = useMemo(
@@ -43,11 +163,29 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
43
163
  );
44
164
 
45
165
  const agent: AbstractAgent = useMemo(() => {
166
+ // Use a composite key when threadId is provided so that different threads
167
+ // for the same agent get independent instances.
168
+ const cacheKey = threadId ? `${agentId}:${threadId}` : agentId;
169
+
46
170
  const existing = copilotkit.getAgent(agentId);
47
171
  if (existing) {
48
- // Real agent found — clear any cached provisional for this ID
172
+ // Real agent found — clear any cached provisionals for this key and the
173
+ // bare agentId key (handles the case where a provisional was created
174
+ // before threadId was available, then the component re-renders with one).
175
+ provisionalAgentCache.current.delete(cacheKey);
49
176
  provisionalAgentCache.current.delete(agentId);
50
- return existing;
177
+
178
+ if (!threadId) {
179
+ // No threadId — return the shared registry agent (original behavior)
180
+ return existing;
181
+ }
182
+
183
+ // threadId provided — return the shared per-thread clone.
184
+ // The global WeakMap ensures all components using the same
185
+ // (registryAgent, threadId) pair receive the same instance, so state
186
+ // mutations (addMessage, setState) are visible everywhere. The WeakMap
187
+ // entry is GC-collected automatically when the registry agent is replaced.
188
+ return getOrCreateThreadClone(existing, threadId, copilotkit.headers);
51
189
  }
52
190
 
53
191
  const isRuntimeConfigured = copilotkit.runtimeUrl !== undefined;
@@ -60,7 +198,7 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
60
198
  status === CopilotKitCoreRuntimeConnectionStatus.Connecting)
61
199
  ) {
62
200
  // Return cached provisional if available (keeps reference stable)
63
- const cached = provisionalAgentCache.current.get(agentId);
201
+ const cached = provisionalAgentCache.current.get(cacheKey);
64
202
  if (cached) {
65
203
  // Update headers on the cached agent in case they changed
66
204
  cached.headers = { ...copilotkit.headers };
@@ -75,7 +213,10 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
75
213
  });
76
214
  // Apply current headers so runs/connects inherit them
77
215
  provisional.headers = { ...copilotkit.headers };
78
- provisionalAgentCache.current.set(agentId, provisional);
216
+ if (threadId) {
217
+ provisional.threadId = threadId;
218
+ }
219
+ provisionalAgentCache.current.set(cacheKey, provisional);
79
220
  return provisional;
80
221
  }
81
222
 
@@ -88,6 +229,14 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
88
229
  isRuntimeConfigured &&
89
230
  status === CopilotKitCoreRuntimeConnectionStatus.Error
90
231
  ) {
232
+ // Cache the provisional so that dep changes while in Error state (e.g.
233
+ // headers update) return the same agent reference, matching the
234
+ // Disconnected/Connecting path and preventing spurious re-subscriptions.
235
+ const cached = provisionalAgentCache.current.get(cacheKey);
236
+ if (cached) {
237
+ cached.headers = { ...copilotkit.headers };
238
+ return cached;
239
+ }
91
240
  const provisional = new ProxiedCopilotRuntimeAgent({
92
241
  runtimeUrl: copilotkit.runtimeUrl,
93
242
  agentId,
@@ -95,6 +244,10 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
95
244
  runtimeMode: "pending",
96
245
  });
97
246
  provisional.headers = { ...copilotkit.headers };
247
+ if (threadId) {
248
+ provisional.threadId = threadId;
249
+ }
250
+ provisionalAgentCache.current.set(cacheKey, provisional);
98
251
  return provisional;
99
252
  }
100
253
 
@@ -113,6 +266,7 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
113
266
  // eslint-disable-next-line react-hooks/exhaustive-deps
114
267
  }, [
115
268
  agentId,
269
+ threadId,
116
270
  copilotkit.agents,
117
271
  copilotkit.runtimeConnectionStatus,
118
272
  copilotkit.runtimeUrl,
@@ -121,17 +275,52 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
121
275
  ]);
122
276
 
123
277
  useEffect(() => {
124
- if (updateFlags.length === 0) {
125
- return;
126
- }
278
+ if (updateFlags.length === 0) return;
127
279
 
128
280
  const handlers: Parameters<AbstractAgent["subscribe"]>[0] = {};
281
+ let timerId: ReturnType<typeof setTimeout> | null = null;
282
+ let active = true;
129
283
 
130
284
  if (updateFlags.includes(UseAgentUpdate.OnMessagesChanged)) {
131
- // Content stripping for immutableContent renderers is handled by CopilotKitCoreReact
132
- handlers.onMessagesChanged = () => {
133
- forceUpdate();
134
- };
285
+ const ms = effectiveThrottleMs;
286
+ if (ms > 0) {
287
+ // Throttled onMessagesChanged: leading+trailing pattern.
288
+ // First notification fires immediately, subsequent ones within the
289
+ // window are coalesced. Trailing timer fires after the window to
290
+ // ensure the final state is rendered.
291
+ let throttleActive = false;
292
+ // Tracks whether a notification arrived during the throttle window,
293
+ // so the trailing timer knows whether a re-render is needed.
294
+ let pending = false;
295
+
296
+ const throttledNotify = () => {
297
+ if (!active) return;
298
+ if (!throttleActive) {
299
+ // Leading edge — fire immediately and start the throttle window
300
+ throttleActive = true;
301
+ pending = false;
302
+ forceUpdate();
303
+ timerId = setTimeout(function trailingEdge() {
304
+ timerId = null;
305
+ if (active && pending) {
306
+ // Trailing edge — fire and restart the window
307
+ pending = false;
308
+ forceUpdate();
309
+ timerId = setTimeout(trailingEdge, ms);
310
+ } else {
311
+ // No pending notifications — end the window
312
+ throttleActive = false;
313
+ }
314
+ }, ms);
315
+ } else {
316
+ pending = true;
317
+ }
318
+ };
319
+
320
+ handlers.onMessagesChanged = throttledNotify;
321
+ } else {
322
+ handlers.onMessagesChanged = forceUpdate;
323
+ }
135
324
  }
136
325
 
137
326
  if (updateFlags.includes(UseAgentUpdate.OnStateChanged)) {
@@ -145,9 +334,25 @@ export function useAgent({ agentId, updates }: UseAgentProps = {}) {
145
334
  }
146
335
 
147
336
  const subscription = agent.subscribe(handlers);
148
- return () => subscription.unsubscribe();
337
+ return () => {
338
+ active = false;
339
+ if (timerId !== null) {
340
+ clearTimeout(timerId);
341
+ }
342
+ subscription.unsubscribe();
343
+ };
344
+ // eslint-disable-next-line react-hooks/exhaustive-deps
345
+ }, [agent, forceUpdate, effectiveThrottleMs, updateFlags]);
346
+
347
+ // Keep HttpAgent headers fresh without mutating inside useMemo, which is
348
+ // unsafe in concurrent mode (React may invoke useMemo multiple times and
349
+ // discard intermediate results, but mutations always land).
350
+ useEffect(() => {
351
+ if (agent instanceof HttpAgent) {
352
+ agent.headers = { ...copilotkit.headers };
353
+ }
149
354
  // eslint-disable-next-line react-hooks/exhaustive-deps
150
- }, [agent, forceUpdate, JSON.stringify(updateFlags)]);
355
+ }, [agent, JSON.stringify(copilotkit.headers)]);
151
356
 
152
357
  return {
153
358
  agent,