@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.
- package/CHANGELOG.md +34 -0
- package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -1
- package/dist/{copilotkit-BY5S1-0P.mjs → copilotkit-Cd-NrDyp.mjs} +46 -16
- package/dist/copilotkit-Cd-NrDyp.mjs.map +1 -0
- package/dist/{copilotkit-Bz5-ImDl.cjs → copilotkit-Dgdpbqjt.cjs} +46 -16
- package/dist/copilotkit-Dgdpbqjt.cjs.map +1 -0
- package/dist/copilotkit-dwDWYpya.d.cts.map +1 -1
- package/dist/index.cjs +6 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +6 -3
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +28 -29
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +1 -1
- package/dist/v2/index.mjs +1 -1
- package/dist/v2/index.umd.js +52 -28
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +7 -7
- package/src/components/copilot-provider/copilotkit.tsx +2 -2
- package/src/hooks/use-agent-nodename.ts +3 -0
- package/src/hooks/use-coagent-state-render-bridge.helpers.ts +2 -1
- package/src/hooks/use-coagent-state-render-registry.ts +6 -6
- package/src/hooks/use-copilot-chat_internal.ts +1 -1
- package/src/lib/copilot-task.ts +1 -1
- package/src/utils/utils.ts +0 -2
- package/src/v2/a2ui/A2UIMessageRenderer.tsx +1 -1
- package/src/v2/components/MCPAppsActivityRenderer.tsx +32 -2
- package/src/v2/components/chat/CopilotChatMessageView.tsx +41 -5
- package/src/v2/components/chat/__tests__/CopilotChatMessageView.test.tsx +192 -82
- package/src/v2/components/chat/__tests__/MCPAppsProxy.e2e.test.tsx +589 -0
- package/src/v2/components/chat/__tests__/MCPAppsUiMessage.e2e.test.tsx +458 -0
- package/src/v2/providers/CopilotChatConfigurationProvider.tsx +2 -2
- package/dist/copilotkit-BY5S1-0P.mjs.map +0 -1
- 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.
|
|
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
|
-
...
|
|
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
|
-
...
|
|
511
|
+
...prev[action.id],
|
|
512
512
|
...action,
|
|
513
513
|
} as LangGraphInterruptRender,
|
|
514
514
|
};
|
|
@@ -246,7 +246,8 @@ export function selectSnapshot({
|
|
|
246
246
|
caches,
|
|
247
247
|
}: SnapshotSelectionInput): SnapshotSelectionResult {
|
|
248
248
|
const lastAssistantId = agentMessages
|
|
249
|
-
? [...agentMessages].
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
.
|
|
484
|
+
.toReversed()
|
|
485
485
|
.find((msg) => msg.role === "user");
|
|
486
486
|
|
|
487
487
|
if (!lastUserMessageBeforeRegenerate) {
|
package/src/lib/copilot-task.ts
CHANGED
|
@@ -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
|
-
...
|
|
166
|
+
...this.forwardedParameters,
|
|
167
167
|
},
|
|
168
168
|
},
|
|
169
169
|
properties: context.copilotApiConfig.properties,
|
package/src/utils/utils.ts
CHANGED
|
@@ -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
|
|
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
|
-
() =>
|
|
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:
|
|
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
|
|
8
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
});
|