@copilotkit/react-core 1.55.0-next.8 → 1.55.0-next.9

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 (42) hide show
  1. package/CHANGELOG.md +18 -5
  2. package/dist/{copilotkit-B3Mb1yVE.cjs → copilotkit-BDNjFNmk.cjs} +156 -92
  3. package/dist/copilotkit-BDNjFNmk.cjs.map +1 -0
  4. package/dist/{copilotkit-Dy5w3qEV.d.mts → copilotkit-BqcyhQjT.d.mts} +3 -1
  5. package/dist/{copilotkit-Dy5w3qEV.d.mts.map → copilotkit-BqcyhQjT.d.mts.map} +1 -1
  6. package/dist/{copilotkit-DNYSFuz5.mjs → copilotkit-DeOzjPsb.mjs} +157 -93
  7. package/dist/copilotkit-DeOzjPsb.mjs.map +1 -0
  8. package/dist/{copilotkit-DBzgOMby.d.cts → copilotkit-l-IBF4Xp.d.cts} +3 -1
  9. package/dist/{copilotkit-DBzgOMby.d.cts.map → copilotkit-l-IBF4Xp.d.cts.map} +1 -1
  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 +160 -94
  17. package/dist/index.umd.js.map +1 -1
  18. package/dist/v2/index.cjs +1 -1
  19. package/dist/v2/index.d.cts +1 -1
  20. package/dist/v2/index.d.mts +1 -1
  21. package/dist/v2/index.mjs +1 -1
  22. package/dist/v2/index.umd.js +160 -94
  23. package/dist/v2/index.umd.js.map +1 -1
  24. package/package.json +6 -6
  25. package/src/components/copilot-provider/__tests__/copilot-messages-key.test.tsx +92 -0
  26. package/src/components/copilot-provider/copilotkit.tsx +3 -3
  27. package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +27 -16
  28. package/src/hooks/use-copilot-chat_internal.ts +15 -4
  29. package/src/v2/__tests__/utils/test-helpers.tsx +40 -7
  30. package/src/v2/components/chat/CopilotChat.tsx +4 -2
  31. package/src/v2/components/chat/CopilotChatMessageView.tsx +7 -2
  32. package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx +5 -2
  33. package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +5 -2
  34. package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +17 -1
  35. package/src/v2/hooks/__tests__/use-agent-context-timing.e2e.test.tsx +8 -0
  36. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +327 -0
  37. package/src/v2/hooks/__tests__/use-agent.e2e.test.tsx +13 -2
  38. package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.tsx +23 -4
  39. package/src/v2/hooks/use-agent.tsx +126 -6
  40. package/src/v2/hooks/use-render-custom-messages.tsx +6 -1
  41. package/dist/copilotkit-B3Mb1yVE.cjs.map +0 -1
  42. package/dist/copilotkit-DNYSFuz5.mjs.map +0 -1
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "publishConfig": {
11
11
  "access": "public"
12
12
  },
13
- "version": "1.55.0-next.8",
13
+ "version": "1.55.0-next.9",
14
14
  "sideEffects": [
15
15
  "**/*.css"
16
16
  ],
@@ -86,11 +86,11 @@
86
86
  "untruncate-json": "^0.0.1",
87
87
  "use-stick-to-bottom": "^1.1.1",
88
88
  "rxjs": "7.8.1",
89
- "@copilotkit/a2ui-renderer": "1.55.0-next.8",
90
- "@copilotkit/core": "1.55.0-next.8",
91
- "@copilotkit/runtime-client-gql": "1.55.0-next.8",
92
- "@copilotkit/shared": "1.55.0-next.8",
93
- "@copilotkit/web-inspector": "1.55.0-next.8"
89
+ "@copilotkit/a2ui-renderer": "1.55.0-next.9",
90
+ "@copilotkit/core": "1.55.0-next.9",
91
+ "@copilotkit/runtime-client-gql": "1.55.0-next.9",
92
+ "@copilotkit/shared": "1.55.0-next.9",
93
+ "@copilotkit/web-inspector": "1.55.0-next.9"
94
94
  },
95
95
  "keywords": [
96
96
  "copilotkit",
@@ -0,0 +1,92 @@
1
+ import { render } from "@testing-library/react";
2
+ import React from "react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ /**
6
+ * Regression test: CopilotMessages children must have React keys.
7
+ *
8
+ * When CopilotMessages receives multiple children (e.g. memoizedChildren +
9
+ * RegisteredActionsRenderer), React treats them as a dynamic list and warns
10
+ * if they lack keys. This test verifies the fix by rendering a minimal
11
+ * reproduction using the same pattern as CopilotKitInternal.
12
+ */
13
+
14
+ // Minimal stand-in for CopilotMessages – renders children inside a provider-like wrapper.
15
+ function CopilotMessages({ children }: { children: React.ReactNode }) {
16
+ const memoized = React.useMemo(() => children, [children]);
17
+ return <div data-testid="messages">{memoized}</div>;
18
+ }
19
+
20
+ describe("CopilotMessages children keys", () => {
21
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
22
+ const keyWarnings: string[] = [];
23
+
24
+ beforeEach(() => {
25
+ keyWarnings.length = 0;
26
+ consoleErrorSpy = vi
27
+ .spyOn(console, "error")
28
+ .mockImplementation((...args: any[]) => {
29
+ const msg = args.map(String).join(" ");
30
+ if (
31
+ msg.includes('unique "key" prop') ||
32
+ msg.includes("unique 'key' prop") ||
33
+ msg.includes("unique key")
34
+ ) {
35
+ keyWarnings.push(msg);
36
+ }
37
+ });
38
+ });
39
+
40
+ afterEach(() => {
41
+ consoleErrorSpy.mockRestore();
42
+ });
43
+
44
+ it("warns about missing keys when children array lacks keys (pre-fix pattern)", () => {
45
+ const ChildA = () => <div>app content</div>;
46
+ const ChildB = () => <span>actions</span>;
47
+
48
+ // Passing an explicit array as children – this is what JSX compiles to
49
+ // when you write: <CopilotMessages>{memoizedChildren}<RegisteredActionsRenderer /></CopilotMessages>
50
+ // React sees: { children: [memoizedChildren, <RegisteredActionsRenderer />] }
51
+ render(<CopilotMessages>{[<ChildA />, <ChildB />]}</CopilotMessages>);
52
+
53
+ expect(keyWarnings.length).toBeGreaterThan(0);
54
+ });
55
+
56
+ it("does NOT warn when children array elements have keys (post-fix pattern)", () => {
57
+ const ChildA = () => <div>app content</div>;
58
+ const ChildB = () => <span>actions</span>;
59
+
60
+ // The fix: wrap in keyed elements
61
+ render(
62
+ <CopilotMessages>
63
+ {[
64
+ <React.Fragment key="children">
65
+ <ChildA />
66
+ </React.Fragment>,
67
+ <ChildB key="actions" />,
68
+ ]}
69
+ </CopilotMessages>,
70
+ );
71
+
72
+ expect(keyWarnings).toHaveLength(0);
73
+ });
74
+
75
+ it("does NOT warn with the actual fix pattern (keyed JSX children)", () => {
76
+ const MemoChildren = React.memo(() => <div>app content</div>);
77
+ MemoChildren.displayName = "MemoChildren";
78
+ const RegisteredActionsRenderer = React.memo(() => null);
79
+ RegisteredActionsRenderer.displayName = "RegisteredActionsRenderer";
80
+
81
+ render(
82
+ <CopilotMessages>
83
+ <React.Fragment key="children">
84
+ <MemoChildren />
85
+ </React.Fragment>
86
+ <RegisteredActionsRenderer key="actions" />
87
+ </CopilotMessages>,
88
+ );
89
+
90
+ expect(keyWarnings).toHaveLength(0);
91
+ });
92
+ });
@@ -14,7 +14,7 @@
14
14
  * ```
15
15
  */
16
16
 
17
- import {
17
+ import React, {
18
18
  useCallback,
19
19
  useEffect,
20
20
  useMemo,
@@ -761,8 +761,8 @@ export function CopilotKitInternal(cpkProps: CopilotKitProps) {
761
761
  <CoAgentStateRendersProvider>
762
762
  <MessagesTapProvider>
763
763
  <CopilotMessages>
764
- {memoizedChildren}
765
- <RegisteredActionsRenderer />
764
+ <React.Fragment key="children">{memoizedChildren}</React.Fragment>
765
+ <RegisteredActionsRenderer key="actions" />
766
766
  </CopilotMessages>
767
767
  </MessagesTapProvider>
768
768
  {bannerError && showDevConsole && (
@@ -178,18 +178,32 @@ describe("useCopilotChatInternal – connectAgent guard", () => {
178
178
  });
179
179
  });
180
180
 
181
- it("does not call connectAgent when threadId matches agent's threadId", () => {
181
+ it("does not call connectAgent when threadId matches (same agent instance, no re-render)", async () => {
182
+ // useAgent now returns a per-thread clone, so the wrapper guards via
183
+ // lastConnectedAgentRef: connect fires once per agent instance, not once
184
+ // per render. After the first connect, further re-renders with the same
185
+ // agent do not trigger another connect.
182
186
  mockRuntimeConnectionStatus =
183
187
  CopilotKitCoreRuntimeConnectionStatus.Connected;
184
- mockAgent.threadId = "config-thread-id"; // same as mockConfigThreadId
188
+ mockAgent.threadId = "config-thread-id";
185
189
  applyMocks();
186
190
 
187
- renderHook(() => useCopilotChatInternal(), { wrapper: createWrapper() });
191
+ const { rerender } = renderHook(() => useCopilotChatInternal(), {
192
+ wrapper: createWrapper(),
193
+ });
188
194
 
189
- expect(mockConnectAgent).not.toHaveBeenCalled();
195
+ await vi.waitFor(() => {
196
+ expect(mockConnectAgent).toHaveBeenCalledTimes(1);
197
+ });
198
+
199
+ // Re-render with same agent — should NOT connect again
200
+ rerender();
201
+ await vi.waitFor(() => {
202
+ expect(mockConnectAgent).toHaveBeenCalledTimes(1);
203
+ });
190
204
  });
191
205
 
192
- it("does not call connectAgent when config threadId is missing", () => {
206
+ it("calls connectAgent when config threadId is missing", async () => {
193
207
  mockRuntimeConnectionStatus =
194
208
  CopilotKitCoreRuntimeConnectionStatus.Connected;
195
209
  mockConfigThreadId = undefined;
@@ -197,10 +211,12 @@ describe("useCopilotChatInternal – connectAgent guard", () => {
197
211
 
198
212
  renderHook(() => useCopilotChatInternal(), { wrapper: createWrapper() });
199
213
 
200
- expect(mockConnectAgent).not.toHaveBeenCalled();
214
+ await vi.waitFor(() => {
215
+ expect(mockConnectAgent).toHaveBeenCalledTimes(1);
216
+ });
201
217
  });
202
218
 
203
- it("calls connectAgent when all guard conditions are met", async () => {
219
+ it("calls connectAgent when status is Connected and threadIds differ", async () => {
204
220
  mockRuntimeConnectionStatus =
205
221
  CopilotKitCoreRuntimeConnectionStatus.Connected;
206
222
  mockAgent.threadId = "old-thread-id"; // differs from config
@@ -214,18 +230,13 @@ describe("useCopilotChatInternal – connectAgent guard", () => {
214
230
  });
215
231
  });
216
232
 
217
- it("sets agent.threadId to config threadId before calling connectAgent", async () => {
218
- mockRuntimeConnectionStatus =
219
- CopilotKitCoreRuntimeConnectionStatus.Connected;
220
- mockAgent.threadId = "old-thread-id";
233
+ it("passes config threadId to useAgent", () => {
221
234
  applyMocks();
222
235
 
223
236
  renderHook(() => useCopilotChatInternal(), { wrapper: createWrapper() });
224
237
 
225
- await vi.waitFor(() => {
226
- expect(mockConnectAgent).toHaveBeenCalledTimes(1);
227
- });
228
-
229
- expect(mockAgent.threadId).toBe("config-thread-id");
238
+ expect(vi.mocked(useAgent)).toHaveBeenCalledWith(
239
+ expect.objectContaining({ threadId: "config-thread-id" }),
240
+ );
230
241
  });
231
242
  });
@@ -337,7 +337,17 @@ export function useCopilotChatInternal({
337
337
 
338
338
  // Apply priority: props > existing config > defaults
339
339
  const resolvedAgentId = existingConfig?.agentId ?? "default";
340
- const { agent } = useAgent({ agentId: resolvedAgentId });
340
+ const { agent } = useAgent({
341
+ agentId: resolvedAgentId,
342
+ threadId: existingConfig?.threadId,
343
+ });
344
+
345
+ // Track the last agent instance we called connect() on. Without this,
346
+ // connect() fires on every render where status is Connected — including
347
+ // unrelated context re-renders and StrictMode double-invocations.
348
+ // The ref is reset in the cleanup so that remounts (StrictMode, real
349
+ // unmount/remount) always trigger a fresh connect.
350
+ const lastConnectedAgentRef = useRef<AbstractAgent | null>(null);
341
351
 
342
352
  useEffect(() => {
343
353
  let detached = false;
@@ -372,18 +382,19 @@ export function useCopilotChatInternal({
372
382
  };
373
383
  if (
374
384
  agent &&
375
- existingConfig?.threadId &&
376
- agent.threadId !== existingConfig.threadId &&
385
+ agent !== lastConnectedAgentRef.current &&
377
386
  copilotkit.runtimeConnectionStatus ===
378
387
  CopilotKitCoreRuntimeConnectionStatus.Connected
379
388
  ) {
380
- agent.threadId = existingConfig.threadId;
389
+ lastConnectedAgentRef.current = agent;
381
390
  connect(agent);
382
391
  }
383
392
  return () => {
384
393
  // Abort the HTTP request and detach the active run.
385
394
  // This is critical for React StrictMode which unmounts+remounts in dev,
386
395
  // preventing duplicate /connect requests from reaching the server.
396
+ // Reset the ref so remounts always trigger a fresh connect.
397
+ lastConnectedAgentRef.current = null;
387
398
  detached = true;
388
399
  connectAbortController.abort();
389
400
  agent?.detachActiveRun();
@@ -51,9 +51,18 @@ export class MockStepwiseAgent extends AbstractAgent {
51
51
  });
52
52
  }
53
53
 
54
- clone(): MockStepwiseAgent {
55
- // For tests, return same instance so we can keep controlling it
56
- return this;
54
+ clone(): this {
55
+ // Return a new instance that shares the same subject so tests can keep
56
+ // controlling events via the original reference while satisfying the
57
+ // clone() contract (must return a distinct object).
58
+ // Use the concrete constructor so subclasses (e.g. FailingConnectAgent)
59
+ // preserve their overridden methods.
60
+ const cloned = new (this
61
+ .constructor as new () => MockStepwiseAgent)() as this;
62
+ cloned.agentId = this.agentId;
63
+ (cloned as unknown as { subject: Subject<BaseEvent> }).subject =
64
+ this.subject;
65
+ return cloned;
57
66
  }
58
67
 
59
68
  // No-op: prevent the base class from tearing down the Subject
@@ -110,7 +119,21 @@ export class MockReconnectableAgent extends AbstractAgent {
110
119
  }
111
120
 
112
121
  clone(): MockReconnectableAgent {
113
- return this;
122
+ const cloned = new MockReconnectableAgent();
123
+ cloned.agentId = this.agentId;
124
+ (
125
+ cloned as unknown as {
126
+ subject: Subject<BaseEvent>;
127
+ storedEvents: BaseEvent[];
128
+ }
129
+ ).subject = this.subject;
130
+ (
131
+ cloned as unknown as {
132
+ subject: Subject<BaseEvent>;
133
+ storedEvents: BaseEvent[];
134
+ }
135
+ ).storedEvents = this.storedEvents;
136
+ return cloned;
114
137
  }
115
138
 
116
139
  // No-op: prevent the base class from tearing down the Subject
@@ -440,10 +463,20 @@ export function emitSuggestionToolCall(
440
463
  * A MockStepwiseAgent that emits suggestion events when run() is called
441
464
  */
442
465
  export class SuggestionsProviderAgent extends MockStepwiseAgent {
443
- private _suggestions: Array<{ title: string; message: string }> = [];
466
+ // Shared via a container so clone() and original see the same value even
467
+ // when setSuggestions() is called after the clone is created.
468
+ private _shared: { suggestions: Array<{ title: string; message: string }> } =
469
+ { suggestions: [] };
444
470
 
445
471
  setSuggestions(suggestions: Array<{ title: string; message: string }>) {
446
- this._suggestions = suggestions;
472
+ this._shared.suggestions = suggestions;
473
+ }
474
+
475
+ clone(): this {
476
+ const cloned = super.clone();
477
+ (cloned as unknown as { _shared: typeof this._shared })._shared =
478
+ this._shared;
479
+ return cloned;
447
480
  }
448
481
 
449
482
  run(_input: RunAgentInput): Observable<BaseEvent> {
@@ -458,7 +491,7 @@ export class SuggestionsProviderAgent extends MockStepwiseAgent {
458
491
  emitSuggestionToolCall(this, {
459
492
  toolCallId: testId("tc"),
460
493
  parentMessageId: messageId,
461
- suggestions: this._suggestions,
494
+ suggestions: this._shared.suggestions,
462
495
  });
463
496
 
464
497
  this.emit({ type: EventType.RUN_FINISHED } as BaseEvent);
@@ -71,7 +71,10 @@ export function CopilotChat({
71
71
  [threadId, existingConfig?.threadId],
72
72
  );
73
73
 
74
- const { agent } = useAgent({ agentId: resolvedAgentId });
74
+ const { agent } = useAgent({
75
+ agentId: resolvedAgentId,
76
+ threadId: resolvedThreadId,
77
+ });
75
78
  const { copilotkit } = useCopilotKit();
76
79
  const { suggestions: autoSuggestions } = useSuggestions({
77
80
  agentId: resolvedAgentId,
@@ -164,7 +167,6 @@ export function CopilotChat({
164
167
  console.error("CopilotChat: connectAgent failed", error);
165
168
  }
166
169
  };
167
- agent.threadId = resolvedThreadId;
168
170
  connect(agent);
169
171
  return () => {
170
172
  // Abort the HTTP request and detach the active run.
@@ -12,6 +12,7 @@ import {
12
12
  } from "@ag-ui/core";
13
13
  import { twMerge } from "tailwind-merge";
14
14
  import { useRenderActivityMessage, useRenderCustomMessages } from "../../hooks";
15
+ import { getThreadClone } from "../../hooks/use-agent";
15
16
  import { useCopilotKit } from "../../providers/CopilotKitProvider";
16
17
  import { useCopilotChatConfiguration } from "../../providers/CopilotChatConfigurationProvider";
17
18
 
@@ -313,14 +314,18 @@ export function CopilotChatMessageView({
313
314
  // Subscribe to state changes so custom message renderers re-render when state updates.
314
315
  useEffect(() => {
315
316
  if (!config?.agentId) return;
316
- const agent = copilotkit.getAgent(config.agentId);
317
+ const registryAgent = copilotkit.getAgent(config.agentId);
318
+ // Prefer the per-thread clone so that state changes from the running agent
319
+ // (which is the clone, not the registry) trigger re-renders.
320
+ const agent =
321
+ getThreadClone(registryAgent, config.threadId) ?? registryAgent;
317
322
  if (!agent) return;
318
323
 
319
324
  const subscription = agent.subscribe({
320
325
  onStateChanged: forceUpdate,
321
326
  });
322
327
  return () => subscription.unsubscribe();
323
- }, [config?.agentId, copilotkit, forceUpdate]);
328
+ }, [config?.agentId, config?.threadId, copilotkit, forceUpdate]);
324
329
 
325
330
  // Subscribe to interrupt element changes for in-chat rendering.
326
331
  const [interruptElement, setInterruptElement] =
@@ -250,8 +250,11 @@ class MockStepwiseAgent extends AbstractAgent {
250
250
  }
251
251
 
252
252
  clone(): MockStepwiseAgent {
253
- // For tests, return same instance so we can keep controlling it.
254
- return this;
253
+ const cloned = new MockStepwiseAgent();
254
+ cloned.agentId = this.agentId;
255
+ (cloned as unknown as { subject: Subject<BaseEvent> }).subject =
256
+ this.subject;
257
+ return cloned;
255
258
  }
256
259
 
257
260
  async detachActiveRun(): Promise<void> {}
@@ -52,8 +52,11 @@ class MockStepwiseAgent extends AbstractAgent {
52
52
  }
53
53
 
54
54
  clone(): MockStepwiseAgent {
55
- // For tests, return same instance so we can keep controlling it.
56
- return this;
55
+ const cloned = new MockStepwiseAgent();
56
+ cloned.agentId = this.agentId;
57
+ (cloned as unknown as { subject: Subject<BaseEvent> }).subject =
58
+ this.subject;
59
+ return cloned;
57
60
  }
58
61
 
59
62
  async detachActiveRun(): Promise<void> {}
@@ -78,7 +78,23 @@ class MockMCPProxyAgent extends AbstractAgent {
78
78
  }
79
79
 
80
80
  clone(): MockMCPProxyAgent {
81
- return this;
81
+ const cloned = new MockMCPProxyAgent();
82
+ cloned.agentId = this.agentId;
83
+ type Internal = {
84
+ subject: Subject<BaseEvent>;
85
+ runAgentCalls: Array<{ input: Partial<RunAgentInput> }>;
86
+ runAgentResponses: Map<string, unknown>;
87
+ };
88
+ (cloned as unknown as Internal).subject = (
89
+ this as unknown as Internal
90
+ ).subject;
91
+ (cloned as unknown as Internal).runAgentCalls = (
92
+ this as unknown as Internal
93
+ ).runAgentCalls;
94
+ (cloned as unknown as Internal).runAgentResponses = (
95
+ this as unknown as Internal
96
+ ).runAgentResponses;
97
+ return cloned;
82
98
  }
83
99
 
84
100
  async detachActiveRun(): Promise<void> {}
@@ -42,8 +42,16 @@ describe("useAgentContext timing - follow-up run sees updated context", () => {
42
42
  * with no new messages — which is fine; we only need to capture context.
43
43
  */
44
44
  class ContextCapturingAgent extends MockStepwiseAgent {
45
+ // Shared so the clone and original both see the captured contexts
45
46
  public contextPerRun: Context[][] = [];
46
47
 
48
+ clone(): this {
49
+ const cloned = super.clone();
50
+ (cloned as unknown as ContextCapturingAgent).contextPerRun =
51
+ this.contextPerRun;
52
+ return cloned;
53
+ }
54
+
47
55
  async runAgent(
48
56
  parameters?: RunAgentParameters,
49
57
  subscriber?: AgentSubscriber,