@copilotkit/react-core 1.55.1 → 1.55.2-canary.test-01

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 (34) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -1
  3. package/dist/{copilotkit-BY5S1-0P.mjs → copilotkit-Cd-NrDyp.mjs} +46 -16
  4. package/dist/copilotkit-Cd-NrDyp.mjs.map +1 -0
  5. package/dist/{copilotkit-Bz5-ImDl.cjs → copilotkit-Dgdpbqjt.cjs} +46 -16
  6. package/dist/copilotkit-Dgdpbqjt.cjs.map +1 -0
  7. package/dist/copilotkit-dwDWYpya.d.cts.map +1 -1
  8. package/dist/index.cjs +6 -3
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.mjs +6 -3
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/index.umd.js +28 -29
  13. package/dist/index.umd.js.map +1 -1
  14. package/dist/v2/index.cjs +1 -1
  15. package/dist/v2/index.mjs +1 -1
  16. package/dist/v2/index.umd.js +52 -28
  17. package/dist/v2/index.umd.js.map +1 -1
  18. package/package.json +7 -7
  19. package/src/components/copilot-provider/copilotkit.tsx +2 -2
  20. package/src/hooks/use-agent-nodename.ts +3 -0
  21. package/src/hooks/use-coagent-state-render-bridge.helpers.ts +2 -1
  22. package/src/hooks/use-coagent-state-render-registry.ts +6 -6
  23. package/src/hooks/use-copilot-chat_internal.ts +1 -1
  24. package/src/lib/copilot-task.ts +1 -1
  25. package/src/utils/utils.ts +0 -2
  26. package/src/v2/a2ui/A2UIMessageRenderer.tsx +1 -1
  27. package/src/v2/components/MCPAppsActivityRenderer.tsx +32 -2
  28. package/src/v2/components/chat/CopilotChatMessageView.tsx +41 -5
  29. package/src/v2/components/chat/__tests__/CopilotChatMessageView.test.tsx +192 -82
  30. package/src/v2/components/chat/__tests__/MCPAppsProxy.e2e.test.tsx +589 -0
  31. package/src/v2/components/chat/__tests__/MCPAppsUiMessage.e2e.test.tsx +458 -0
  32. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +2 -2
  33. package/dist/copilotkit-BY5S1-0P.mjs.map +0 -1
  34. package/dist/copilotkit-Bz5-ImDl.cjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@copilotkit/react-core",
3
- "version": "1.55.1",
3
+ "version": "1.55.2-canary.test-01",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "ai",
@@ -54,6 +54,11 @@
54
54
  "dependencies": {
55
55
  "@ag-ui/client": "0.0.52",
56
56
  "@ag-ui/core": "0.0.52",
57
+ "@copilotkit/a2ui-renderer": "1.55.2-canary.test-01",
58
+ "@copilotkit/core": "1.55.2-canary.test-01",
59
+ "@copilotkit/runtime-client-gql": "1.55.2-canary.test-01",
60
+ "@copilotkit/shared": "1.55.2-canary.test-01",
61
+ "@copilotkit/web-inspector": "1.55.2-canary.test-01",
57
62
  "@jetbrains/websandbox": "^1.1.3",
58
63
  "@lit-labs/react": "^2.0.2",
59
64
  "@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -72,12 +77,7 @@
72
77
  "tw-animate-css": "^1.3.5",
73
78
  "untruncate-json": "^0.0.1",
74
79
  "use-stick-to-bottom": "^1.1.1",
75
- "zod-to-json-schema": "^3.24.5",
76
- "@copilotkit/core": "1.55.1",
77
- "@copilotkit/a2ui-renderer": "1.55.1",
78
- "@copilotkit/runtime-client-gql": "1.55.1",
79
- "@copilotkit/shared": "1.55.1",
80
- "@copilotkit/web-inspector": "1.55.1"
80
+ "zod-to-json-schema": "^3.24.5"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@tailwindcss/cli": "^4.1.11",
@@ -346,7 +346,7 @@ export function CopilotKitInternal(cpkProps: CopilotKitProps) {
346
346
  }, {});
347
347
 
348
348
  return {
349
- ...(copilotApiConfig.headers || {}),
349
+ ...copilotApiConfig.headers,
350
350
  ...(copilotApiConfig.publicApiKey
351
351
  ? {
352
352
  [COPILOT_CLOUD_PUBLIC_API_KEY_HEADER]:
@@ -508,7 +508,7 @@ export function CopilotKitInternal(cpkProps: CopilotKitProps) {
508
508
  return {
509
509
  ...prev,
510
510
  [action.id]: {
511
- ...(prev[action.id] ?? {}),
511
+ ...prev[action.id],
512
512
  ...action,
513
513
  } as LangGraphInterruptRender,
514
514
  };
@@ -18,6 +18,9 @@ export function useAgentNodeName(agentName?: string) {
18
18
  onRunFinishedEvent: () => {
19
19
  nodeNameRef.current = "end";
20
20
  },
21
+ onRunErrorEvent: () => {
22
+ nodeNameRef.current = "end";
23
+ },
21
24
  };
22
25
 
23
26
  const subscription = agent.subscribe(subscriber);
@@ -246,7 +246,8 @@ export function selectSnapshot({
246
246
  caches,
247
247
  }: SnapshotSelectionInput): SnapshotSelectionResult {
248
248
  const lastAssistantId = agentMessages
249
- ? [...agentMessages].reverse().find((msg) => msg.role === "assistant")?.id
249
+ ? [...agentMessages].toReversed().find((msg) => msg.role === "assistant")
250
+ ?.id
250
251
  : undefined;
251
252
  const latestSnapshot =
252
253
  stateRenderId !== undefined
@@ -84,7 +84,7 @@ export function useStateRenderRegistry({
84
84
  Object.keys(existingClaim.stateSnapshot).length > 0
85
85
  ) {
86
86
  const snapshotCache = {
87
- ...(store[LAST_SNAPSHOTS_BY_RENDER_AND_RUN] ?? {}),
87
+ ...store[LAST_SNAPSHOTS_BY_RENDER_AND_RUN],
88
88
  };
89
89
  const cacheKey = `${existingClaim.stateRenderId}::${existingClaim.runId ?? "pending"}`;
90
90
  snapshotCache[cacheKey] = existingClaim.stateSnapshot;
@@ -93,7 +93,7 @@ export function useStateRenderRegistry({
93
93
  store[LAST_SNAPSHOTS_BY_RENDER_AND_RUN] = snapshotCache;
94
94
 
95
95
  const messageCache = {
96
- ...(store[LAST_SNAPSHOTS_BY_MESSAGE] ?? {}),
96
+ ...store[LAST_SNAPSHOTS_BY_MESSAGE],
97
97
  };
98
98
  messageCache[message.id] = {
99
99
  snapshot: existingClaim.stateSnapshot,
@@ -189,14 +189,14 @@ export function useStateRenderRegistry({
189
189
  if (!claimsRef.current[message.id].locked || snapshotChanged) {
190
190
  claimsRef.current[message.id].stateSnapshot = snapshot;
191
191
  const snapshotCache = {
192
- ...(store[LAST_SNAPSHOTS_BY_RENDER_AND_RUN] ?? {}),
192
+ ...store[LAST_SNAPSHOTS_BY_RENDER_AND_RUN],
193
193
  };
194
194
  const cacheKey = `${stateRenderId}::${effectiveRunId}`;
195
195
  snapshotCache[cacheKey] = snapshot;
196
196
  snapshotCache[`${stateRenderId}::latest`] = snapshot;
197
197
  store[LAST_SNAPSHOTS_BY_RENDER_AND_RUN] = snapshotCache;
198
198
  const messageCache = {
199
- ...(store[LAST_SNAPSHOTS_BY_MESSAGE] ?? {}),
199
+ ...store[LAST_SNAPSHOTS_BY_MESSAGE],
200
200
  };
201
201
  messageCache[message.id] = { snapshot, runId: effectiveRunId };
202
202
  store[LAST_SNAPSHOTS_BY_MESSAGE] = messageCache;
@@ -209,14 +209,14 @@ export function useStateRenderRegistry({
209
209
  if (!existingSnapshot) {
210
210
  claimsRef.current[message.id].stateSnapshot = snapshotForClaim;
211
211
  const snapshotCache = {
212
- ...(store[LAST_SNAPSHOTS_BY_RENDER_AND_RUN] ?? {}),
212
+ ...store[LAST_SNAPSHOTS_BY_RENDER_AND_RUN],
213
213
  };
214
214
  const cacheKey = `${stateRenderId}::${effectiveRunId}`;
215
215
  snapshotCache[cacheKey] = snapshotForClaim;
216
216
  snapshotCache[`${stateRenderId}::latest`] = snapshotForClaim;
217
217
  store[LAST_SNAPSHOTS_BY_RENDER_AND_RUN] = snapshotCache;
218
218
  const messageCache = {
219
- ...(store[LAST_SNAPSHOTS_BY_MESSAGE] ?? {}),
219
+ ...store[LAST_SNAPSHOTS_BY_MESSAGE],
220
220
  };
221
221
  messageCache[message.id] = {
222
222
  snapshot: snapshotForClaim,
@@ -481,7 +481,7 @@ export function useCopilotChatInternal({
481
481
  // Work backwards to find the first the closest user message
482
482
  const lastUserMessageBeforeRegenerate = messages
483
483
  .slice(0, reloadMessageIndex)
484
- .reverse()
484
+ .toReversed()
485
485
  .find((msg) => msg.role === "user");
486
486
 
487
487
  if (!lastUserMessageBeforeRegenerate) {
@@ -163,7 +163,7 @@ export class CopilotTask<T = any> {
163
163
  forwardedParameters: {
164
164
  // if forwardedParameters is provided, use it
165
165
  toolChoice: "required",
166
- ...(this.forwardedParameters ?? {}),
166
+ ...this.forwardedParameters,
167
167
  },
168
168
  },
169
169
  properties: context.copilotApiConfig.properties,
@@ -4,5 +4,3 @@ import {
4
4
  convertJsonSchemaToZodSchema,
5
5
  Parameter,
6
6
  } from "@copilotkit/shared";
7
-
8
- export {};
@@ -151,7 +151,7 @@ function ReactSurfaceHost({
151
151
 
152
152
  try {
153
153
  copilotkit.setProperties({
154
- ...(copilotkit.properties ?? {}),
154
+ ...copilotkit.properties,
155
155
  a2uiAction: message,
156
156
  });
157
157
 
@@ -3,6 +3,7 @@
3
3
  import React, { useEffect, useRef, useState, useCallback } from "react";
4
4
  import { z } from "zod";
5
5
  import type { AbstractAgent, RunAgentResult } from "@ag-ui/client";
6
+ import { useCopilotKit } from "../providers/CopilotKitProvider";
6
7
 
7
8
  // Protocol version supported
8
9
  const PROTOCOL_VERSION = "2025-06-18";
@@ -252,6 +253,7 @@ interface MCPAppsActivityRendererProps {
252
253
  */
253
254
  export const MCPAppsActivityRenderer: React.FC<MCPAppsActivityRendererProps> =
254
255
  function MCPAppsActivityRenderer({ content, agent }) {
256
+ const { copilotkit } = useCopilotKit();
255
257
  const containerRef = useRef<HTMLDivElement>(null);
256
258
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
257
259
  const [iframeReady, setIframeReady] = useState(false);
@@ -522,7 +524,7 @@ export const MCPAppsActivityRenderer: React.FC<MCPAppsActivityRendererProps> =
522
524
  }
523
525
 
524
526
  case "ui/message": {
525
- // Add message to CopilotKit chat
527
+ // Add message to CopilotKit chat and optionally invoke agent
526
528
  const currentAgent = agentRef.current;
527
529
 
528
530
  if (!currentAgent) {
@@ -537,8 +539,12 @@ export const MCPAppsActivityRenderer: React.FC<MCPAppsActivityRendererProps> =
537
539
  const params = msg.params as {
538
540
  role?: string;
539
541
  content?: Array<{ type: string; text?: string }>;
542
+ followUp?: boolean;
540
543
  };
541
544
 
545
+ const role =
546
+ (params.role as "user" | "assistant") || "user";
547
+
542
548
  // Extract text content from the message
543
549
  const textContent =
544
550
  params.content
@@ -549,11 +555,35 @@ export const MCPAppsActivityRenderer: React.FC<MCPAppsActivityRendererProps> =
549
555
  if (textContent) {
550
556
  currentAgent.addMessage({
551
557
  id: crypto.randomUUID(),
552
- role: (params.role as "user" | "assistant") || "user",
558
+ role,
553
559
  content: textContent,
554
560
  });
555
561
  }
562
+
563
+ // Acknowledge the message immediately — don't block on agent run
556
564
  sendResponse(msg.id, { isError: false });
565
+
566
+ // Determine whether to invoke the agent after adding message.
567
+ // followUp: true → always invoke agent
568
+ // followUp: false → display-only, skip agent
569
+ // not specified → invoke for user messages, skip for assistant
570
+ const shouldFollowUp = params.followUp ?? role === "user";
571
+
572
+ if (shouldFollowUp && textContent) {
573
+ // Use copilotkit.runAgent to go through RunHandler — provides
574
+ // frontend tools, context, tool execution, and abort support.
575
+ // Fire-and-forget: errors are handled by RunHandler's error emission.
576
+ mcpAppsRequestQueue
577
+ .enqueue(currentAgent, () =>
578
+ copilotkit.runAgent({ agent: currentAgent }),
579
+ )
580
+ .catch((err) =>
581
+ console.error(
582
+ "[MCPAppsRenderer] ui/message agent run failed:",
583
+ err,
584
+ ),
585
+ );
586
+ }
557
587
  } catch (err) {
558
588
  console.error("[MCPAppsRenderer] ui/message error:", err);
559
589
  sendResponse(msg.id, { isError: true });
@@ -305,6 +305,45 @@ const MemoizedCustomMessage = React.memo(
305
305
  },
306
306
  );
307
307
 
308
+ /**
309
+ * Deduplicates messages by ID. For assistant messages, merges occurrences:
310
+ * recovers non-empty content from any earlier occurrence if the latest wiped it
311
+ * (empty string means the streaming update cleared the field, not blank text),
312
+ * and similarly recovers toolCalls from earlier occurrences if the latest is
313
+ * undefined (an empty array [] is treated as intentional and kept as-is).
314
+ * For all other roles, keeps the last entry.
315
+ *
316
+ * @internal Exported for unit testing only — not part of the public API.
317
+ */
318
+ export function deduplicateMessages(messages: Message[]): Message[] {
319
+ const acc = new Map<string, Message>();
320
+ for (const message of messages) {
321
+ const existing = acc.get(message.id);
322
+ if (
323
+ existing &&
324
+ message.role === "assistant" &&
325
+ existing.role === "assistant"
326
+ ) {
327
+ // Empty string means the streaming update cleared the field — fall back to
328
+ // any non-empty content seen earlier. Use { ...existing, ...message } so
329
+ // fields present only in an earlier occurrence are not silently dropped.
330
+ const content = message.content || existing.content;
331
+ // undefined toolCalls means this chunk had no tool call activity — recover
332
+ // from earlier occurrences. An explicit [] means all tool calls completed.
333
+ const toolCalls = message.toolCalls ?? existing.toolCalls;
334
+ acc.set(message.id, {
335
+ ...existing,
336
+ ...message,
337
+ content,
338
+ toolCalls,
339
+ } as AssistantMessage);
340
+ } else {
341
+ acc.set(message.id, message);
342
+ }
343
+ }
344
+ return [...acc.values()];
345
+ }
346
+
308
347
  export type CopilotChatMessageViewProps = Omit<
309
348
  WithSlots<
310
349
  {
@@ -399,11 +438,8 @@ export function CopilotChatMessageView({
399
438
  );
400
439
  };
401
440
 
402
- // Deduplicate messages by id, keeping the last occurrence of each.
403
- // During streaming, AbstractAgent.addMessage() can push duplicate messages
404
- // (same id) which causes React "duplicate key" warnings and rendering glitches.
405
441
  const deduplicatedMessages = useMemo(
406
- () => [...new Map(messages.map((m) => [m.id, m])).values()],
442
+ () => deduplicateMessages(messages),
407
443
  [messages],
408
444
  );
409
445
 
@@ -412,7 +448,7 @@ export function CopilotChatMessageView({
412
448
  deduplicatedMessages.length < messages.length
413
449
  ) {
414
450
  console.warn(
415
- `CopilotChatMessageView: Deduplicated ${messages.length - deduplicatedMessages.length} message(s) with duplicate IDs.`,
451
+ `CopilotChatMessageView: Merged ${messages.length - deduplicatedMessages.length} message(s) with duplicate IDs.`,
416
452
  );
417
453
  }
418
454
 
@@ -4,43 +4,81 @@ import { z } from "zod";
4
4
  import { vi } from "vitest";
5
5
  import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
6
6
  import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
7
- import CopilotChatMessageView from "../CopilotChatMessageView";
8
- import {
7
+ import CopilotChatMessageView, {
8
+ deduplicateMessages,
9
+ } from "../CopilotChatMessageView";
10
+ import type {
9
11
  ActivityMessage,
10
12
  AssistantMessage,
11
13
  Message,
14
+ ToolCall,
12
15
  UserMessage,
13
16
  } from "@ag-ui/core";
14
- import { ReactActivityMessageRenderer } from "../../../types";
17
+ import type { ReactActivityMessageRenderer } from "../../../types";
15
18
 
16
- describe("CopilotChatMessageView activity rendering", () => {
17
- const agentId = "default";
18
- const threadId = "thread-test";
19
-
20
- function renderMessageView({
21
- messages,
22
- renderActivityMessages,
23
- }: {
24
- messages: Message[];
25
- renderActivityMessages?: ReactActivityMessageRenderer<any>[];
26
- }) {
27
- return render(
28
- <CopilotKitProvider renderActivityMessages={renderActivityMessages}>
29
- <CopilotChatConfigurationProvider agentId={agentId} threadId={threadId}>
30
- <CopilotChatMessageView messages={messages} />
31
- </CopilotChatConfigurationProvider>
32
- </CopilotKitProvider>,
33
- );
34
- }
19
+ // ---------------------------------------------------------------------------
20
+ // Shared constants & helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const AGENT_ID = "default";
24
+ const THREAD_ID = "thread-test";
25
+
26
+ /** Typed factory — avoids `as UserMessage` casts everywhere. */
27
+ function userMsg(id: string, content: string) {
28
+ return { id, role: "user" as const, content };
29
+ }
30
+
31
+ /** Typed factory — avoids `as AssistantMessage` casts everywhere. */
32
+ function assistantMsg(id: string, content?: string, toolCalls?: ToolCall[]) {
33
+ return { id, role: "assistant" as const, content, toolCalls };
34
+ }
35
+
36
+ /** Typed factory — avoids `as ActivityMessage` casts everywhere. */
37
+ function activityMsg(
38
+ id: string,
39
+ activityType: string,
40
+ content: ActivityMessage["content"],
41
+ ) {
42
+ return { id, role: "activity" as const, activityType, content };
43
+ }
44
+
45
+ /** Typed factory — avoids `as any` casts on tool call objects. */
46
+ function toolCall(id: string, name: string, args = "{}") {
47
+ return {
48
+ id,
49
+ type: "function" as const,
50
+ function: { name, arguments: args },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Renders CopilotChatMessageView wrapped in the required providers.
56
+ * Unified helper used by all describe blocks in this file.
57
+ */
58
+ function renderMessageView({
59
+ messages,
60
+ renderActivityMessages,
61
+ }: {
62
+ messages: Message[];
63
+ renderActivityMessages?: ReactActivityMessageRenderer<{ percent: number }>[];
64
+ }) {
65
+ return render(
66
+ <CopilotKitProvider renderActivityMessages={renderActivityMessages}>
67
+ <CopilotChatConfigurationProvider agentId={AGENT_ID} threadId={THREAD_ID}>
68
+ <CopilotChatMessageView messages={messages} />
69
+ </CopilotChatConfigurationProvider>
70
+ </CopilotKitProvider>,
71
+ );
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Tests
76
+ // ---------------------------------------------------------------------------
35
77
 
78
+ describe("CopilotChatMessageView activity rendering", () => {
36
79
  it("renders activity messages via matching custom renderer", () => {
37
80
  const messages: Message[] = [
38
- {
39
- id: "act-1",
40
- role: "activity",
41
- activityType: "search-progress",
42
- content: { percent: 42 },
43
- } as ActivityMessage,
81
+ activityMsg("act-1", "search-progress", { percent: 42 }),
44
82
  ];
45
83
 
46
84
  const renderers: ReactActivityMessageRenderer<{ percent: number }>[] = [
@@ -62,12 +100,7 @@ describe("CopilotChatMessageView activity rendering", () => {
62
100
 
63
101
  it("skips rendering when no activity renderer matches", () => {
64
102
  const messages: Message[] = [
65
- {
66
- id: "act-2",
67
- role: "activity",
68
- activityType: "unknown-type",
69
- content: { message: "should not render" },
70
- } as ActivityMessage,
103
+ activityMsg("act-2", "unknown-type", { message: "should not render" }),
71
104
  ];
72
105
 
73
106
  renderMessageView({ messages, renderActivityMessages: [] });
@@ -77,36 +110,34 @@ describe("CopilotChatMessageView activity rendering", () => {
77
110
  });
78
111
 
79
112
  describe("CopilotChatMessageView duplicate message deduplication", () => {
80
- const agentId = "default";
81
- const threadId = "thread-test";
82
-
83
- function renderMessageView({ messages }: { messages: Message[] }) {
84
- return render(
85
- <CopilotKitProvider>
86
- <CopilotChatConfigurationProvider agentId={agentId} threadId={threadId}>
87
- <CopilotChatMessageView messages={messages} />
88
- </CopilotChatConfigurationProvider>
89
- </CopilotKitProvider>,
113
+ it("preserves assistant text content when later duplicate has empty content (multi-tool-call scenario)", () => {
114
+ const messages: Message[] = [
115
+ userMsg("user-1", "Record a headache"),
116
+ assistantMsg("assistant-1", "Let me record that..."),
117
+ assistantMsg("assistant-1", "", [toolCall("tc-1", "captureData")]),
118
+ assistantMsg("assistant-1", "", [
119
+ toolCall("tc-1", "captureData"),
120
+ toolCall("tc-2", "updateMemory"),
121
+ ]),
122
+ ];
123
+
124
+ renderMessageView({ messages });
125
+
126
+ // One merged assistant message (not three)
127
+ const assistantMessages = screen.getAllByTestId(
128
+ "copilot-assistant-message",
90
129
  );
91
- }
130
+ expect(assistantMessages).toHaveLength(1);
92
131
 
93
- it("deduplicates messages with the same id, keeping the last occurrence", () => {
132
+ // Original text content must survive despite later empty-content duplicates
133
+ expect(assistantMessages[0].textContent).toContain("Let me record that...");
134
+ });
135
+
136
+ it("uses latest content when all assistant duplicates have non-empty content", () => {
94
137
  const messages: Message[] = [
95
- {
96
- id: "user-1",
97
- role: "user",
98
- content: "Hello",
99
- } as UserMessage,
100
- {
101
- id: "assistant-1",
102
- role: "assistant",
103
- content: "Partial response...",
104
- } as AssistantMessage,
105
- {
106
- id: "assistant-1",
107
- role: "assistant",
108
- content: "Full response from the assistant.",
109
- } as AssistantMessage,
138
+ userMsg("user-1", "Hello"),
139
+ assistantMsg("assistant-1", "Partial response..."),
140
+ assistantMsg("assistant-1", "Full response from the assistant."),
110
141
  ];
111
142
 
112
143
  const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -118,6 +149,9 @@ describe("CopilotChatMessageView duplicate message deduplication", () => {
118
149
  "copilot-assistant-message",
119
150
  );
120
151
  expect(assistantMessages).toHaveLength(1);
152
+ expect(assistantMessages[0].textContent).toContain(
153
+ "Full response from the assistant.",
154
+ );
121
155
 
122
156
  // Should render the user message too
123
157
  const userMessages = screen.getAllByTestId("copilot-user-message");
@@ -133,28 +167,12 @@ describe("CopilotChatMessageView duplicate message deduplication", () => {
133
167
  consoleSpy.mockRestore();
134
168
  });
135
169
 
136
- it("preserves order of unique messages", () => {
170
+ it("preserves order of unique messages (no duplicates)", () => {
137
171
  const messages: Message[] = [
138
- {
139
- id: "user-1",
140
- role: "user",
141
- content: "First question",
142
- } as UserMessage,
143
- {
144
- id: "assistant-1",
145
- role: "assistant",
146
- content: "First answer",
147
- } as AssistantMessage,
148
- {
149
- id: "user-2",
150
- role: "user",
151
- content: "Second question",
152
- } as UserMessage,
153
- {
154
- id: "assistant-2",
155
- role: "assistant",
156
- content: "Second answer",
157
- } as AssistantMessage,
172
+ userMsg("user-1", "First question"),
173
+ assistantMsg("assistant-1", "First answer"),
174
+ userMsg("user-2", "Second question"),
175
+ assistantMsg("assistant-2", "Second answer"),
158
176
  ];
159
177
 
160
178
  renderMessageView({ messages });
@@ -167,3 +185,95 @@ describe("CopilotChatMessageView duplicate message deduplication", () => {
167
185
  expect(assistantMessages).toHaveLength(2);
168
186
  });
169
187
  });
188
+
189
+ describe("deduplicateMessages", () => {
190
+ it("recovers non-empty content and keeps latest toolCalls when later duplicate clears content", () => {
191
+ const messages: Message[] = [
192
+ assistantMsg("assistant-1", "Let me record that..."),
193
+ assistantMsg("assistant-1", "", [toolCall("tc-1", "captureData")]),
194
+ assistantMsg("assistant-1", "", [
195
+ toolCall("tc-1", "captureData"),
196
+ toolCall("tc-2", "updateMemory"),
197
+ ]),
198
+ ];
199
+
200
+ const result = deduplicateMessages(messages);
201
+
202
+ expect(result).toHaveLength(1);
203
+ const merged = result[0] as AssistantMessage;
204
+ // Content recovered from the first occurrence
205
+ expect(merged.content).toBe("Let me record that...");
206
+ // toolCalls from the latest occurrence (both tc-1 and tc-2)
207
+ expect(merged.toolCalls).toHaveLength(2);
208
+ expect(merged.toolCalls?.map((tc) => tc.id)).toEqual(["tc-1", "tc-2"]);
209
+ });
210
+
211
+ it("uses content from a later occurrence when early occurrence has empty content", () => {
212
+ const messages: Message[] = [
213
+ assistantMsg("assistant-1", ""),
214
+ assistantMsg("assistant-1", "Here is the result."),
215
+ ];
216
+
217
+ const result = deduplicateMessages(messages);
218
+
219
+ expect(result).toHaveLength(1);
220
+ expect((result[0] as AssistantMessage).content).toBe("Here is the result.");
221
+ });
222
+
223
+ it("recovers toolCalls when a later occurrence has non-empty content but undefined toolCalls", () => {
224
+ // A later streaming chunk may carry updated content but omit toolCalls entirely.
225
+ // The earlier accumulated toolCalls must survive rather than be wiped by the spread.
226
+ const messages: Message[] = [
227
+ assistantMsg("assistant-1", "", [toolCall("tc-1", "captureData")]),
228
+ assistantMsg("assistant-1", "Here is the result."),
229
+ ];
230
+
231
+ const result = deduplicateMessages(messages);
232
+
233
+ expect(result).toHaveLength(1);
234
+ const merged = result[0] as AssistantMessage;
235
+ expect(merged.content).toBe("Here is the result.");
236
+ expect(merged.toolCalls).toHaveLength(1);
237
+ expect(merged.toolCalls?.[0]?.id).toBe("tc-1");
238
+ });
239
+
240
+ it("keeps empty toolCalls array from a later chunk (does not fall back to earlier toolCalls)", () => {
241
+ // [] means all tool calls completed — it is an intentional value, not absence.
242
+ // ?? must treat it as defined and keep it rather than falling back.
243
+ const messages: Message[] = [
244
+ assistantMsg("assistant-1", "", [toolCall("tc-1", "captureData")]),
245
+ assistantMsg("assistant-1", "Done.", []),
246
+ ];
247
+
248
+ const result = deduplicateMessages(messages);
249
+
250
+ expect(result).toHaveLength(1);
251
+ expect((result[0] as AssistantMessage).toolCalls).toEqual([]);
252
+ });
253
+
254
+ it("handles undefined content on both occurrences without error", () => {
255
+ // assistantMsg with no content arg produces content: undefined.
256
+ // undefined || undefined = undefined — should not throw or produce garbage.
257
+ const messages: Message[] = [
258
+ assistantMsg("assistant-1"),
259
+ assistantMsg("assistant-1"),
260
+ ];
261
+
262
+ const result = deduplicateMessages(messages);
263
+
264
+ expect(result).toHaveLength(1);
265
+ expect((result[0] as AssistantMessage).content).toBeUndefined();
266
+ });
267
+
268
+ it("keeps last entry for non-assistant roles", () => {
269
+ const messages: Message[] = [
270
+ userMsg("u-1", "Hello"),
271
+ userMsg("u-1", "Hello (updated)"),
272
+ ];
273
+
274
+ const result = deduplicateMessages(messages);
275
+
276
+ expect(result).toHaveLength(1);
277
+ expect((result[0] as UserMessage).content).toBe("Hello (updated)");
278
+ });
279
+ });