import React from "react";
import { render, screen } from "@testing-library/react";
import { z } from "zod";
import { vi } from "vitest";
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
import CopilotChatMessageView, {
deduplicateMessages,
} from "../CopilotChatMessageView";
import type {
ActivityMessage,
AssistantMessage,
Message,
ToolCall,
UserMessage,
} from "@ag-ui/core";
import type { ReactActivityMessageRenderer } from "../../../types";
// ---------------------------------------------------------------------------
// Shared constants & helpers
// ---------------------------------------------------------------------------
const AGENT_ID = "default";
const THREAD_ID = "thread-test";
/** Typed factory — avoids `as UserMessage` casts everywhere. */
function userMsg(id: string, content: string) {
return { id, role: "user" as const, content };
}
/** Typed factory — avoids `as AssistantMessage` casts everywhere. */
function assistantMsg(id: string, content?: string, toolCalls?: ToolCall[]) {
return { id, role: "assistant" as const, content, toolCalls };
}
/** Typed factory — avoids `as ActivityMessage` casts everywhere. */
function activityMsg(
id: string,
activityType: string,
content: ActivityMessage["content"],
) {
return { id, role: "activity" as const, activityType, content };
}
/** Typed factory — avoids `as any` casts on tool call objects. */
function toolCall(id: string, name: string, args = "{}") {
return {
id,
type: "function" as const,
function: { name, arguments: args },
};
}
/**
* Renders CopilotChatMessageView wrapped in the required providers.
* Unified helper used by all describe blocks in this file.
*/
function renderMessageView({
messages,
renderActivityMessages,
}: {
messages: Message[];
renderActivityMessages?: ReactActivityMessageRenderer<{ percent: number }>[];
}) {
return render(
,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("CopilotChatMessageView activity rendering", () => {
it("renders activity messages via matching custom renderer", () => {
const messages: Message[] = [
activityMsg("act-1", "search-progress", { percent: 42 }),
];
const renderers: ReactActivityMessageRenderer<{ percent: number }>[] = [
{
activityType: "search-progress",
content: z.object({ percent: z.number() }),
render: ({ content }) => (
Progress: {content.percent}%
),
},
];
renderMessageView({ messages, renderActivityMessages: renderers });
expect(screen.getByTestId("activity-renderer").textContent).toContain("42");
});
it("skips rendering when no activity renderer matches", () => {
const messages: Message[] = [
activityMsg("act-2", "unknown-type", { message: "should not render" }),
];
renderMessageView({ messages, renderActivityMessages: [] });
expect(screen.queryByTestId("activity-renderer")).toBeNull();
});
});
describe("CopilotChatMessageView duplicate message deduplication", () => {
it("preserves assistant text content when later duplicate has empty content (multi-tool-call scenario)", () => {
const messages: Message[] = [
userMsg("user-1", "Record a headache"),
assistantMsg("assistant-1", "Let me record that..."),
assistantMsg("assistant-1", "", [toolCall("tc-1", "captureData")]),
assistantMsg("assistant-1", "", [
toolCall("tc-1", "captureData"),
toolCall("tc-2", "updateMemory"),
]),
];
renderMessageView({ messages });
// One merged assistant message (not three)
const assistantMessages = screen.getAllByTestId(
"copilot-assistant-message",
);
expect(assistantMessages).toHaveLength(1);
// Original text content must survive despite later empty-content duplicates
expect(assistantMessages[0].textContent).toContain("Let me record that...");
});
it("uses latest content when all assistant duplicates have non-empty content", () => {
const messages: Message[] = [
userMsg("user-1", "Hello"),
assistantMsg("assistant-1", "Partial response..."),
assistantMsg("assistant-1", "Full response from the assistant."),
];
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
renderMessageView({ messages });
// Should render only the last occurrence of assistant-1 (the complete one)
const assistantMessages = screen.getAllByTestId(
"copilot-assistant-message",
);
expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0].textContent).toContain(
"Full response from the assistant.",
);
// Should render the user message too
const userMessages = screen.getAllByTestId("copilot-user-message");
expect(userMessages).toHaveLength(1);
// Should NOT produce React duplicate key warnings
const duplicateKeyWarnings = consoleSpy.mock.calls.filter(
(call) =>
typeof call[0] === "string" && call[0].includes("duplicate key"),
);
expect(duplicateKeyWarnings).toHaveLength(0);
consoleSpy.mockRestore();
});
it("preserves order of unique messages (no duplicates)", () => {
const messages: Message[] = [
userMsg("user-1", "First question"),
assistantMsg("assistant-1", "First answer"),
userMsg("user-2", "Second question"),
assistantMsg("assistant-2", "Second answer"),
];
renderMessageView({ messages });
const userMessages = screen.getAllByTestId("copilot-user-message");
const assistantMessages = screen.getAllByTestId(
"copilot-assistant-message",
);
expect(userMessages).toHaveLength(2);
expect(assistantMessages).toHaveLength(2);
});
});
describe("deduplicateMessages", () => {
it("recovers non-empty content and keeps latest toolCalls when later duplicate clears content", () => {
const messages: Message[] = [
assistantMsg("assistant-1", "Let me record that..."),
assistantMsg("assistant-1", "", [toolCall("tc-1", "captureData")]),
assistantMsg("assistant-1", "", [
toolCall("tc-1", "captureData"),
toolCall("tc-2", "updateMemory"),
]),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
const merged = result[0] as AssistantMessage;
// Content recovered from the first occurrence
expect(merged.content).toBe("Let me record that...");
// toolCalls from the latest occurrence (both tc-1 and tc-2)
expect(merged.toolCalls).toHaveLength(2);
expect(merged.toolCalls?.map((tc) => tc.id)).toEqual(["tc-1", "tc-2"]);
});
it("uses content from a later occurrence when early occurrence has empty content", () => {
const messages: Message[] = [
assistantMsg("assistant-1", ""),
assistantMsg("assistant-1", "Here is the result."),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
expect((result[0] as AssistantMessage).content).toBe("Here is the result.");
});
it("recovers toolCalls when a later occurrence has non-empty content but undefined toolCalls", () => {
// A later streaming chunk may carry updated content but omit toolCalls entirely.
// The earlier accumulated toolCalls must survive rather than be wiped by the spread.
const messages: Message[] = [
assistantMsg("assistant-1", "", [toolCall("tc-1", "captureData")]),
assistantMsg("assistant-1", "Here is the result."),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
const merged = result[0] as AssistantMessage;
expect(merged.content).toBe("Here is the result.");
expect(merged.toolCalls).toHaveLength(1);
expect(merged.toolCalls?.[0]?.id).toBe("tc-1");
});
it("keeps empty toolCalls array from a later chunk (does not fall back to earlier toolCalls)", () => {
// [] means all tool calls completed — it is an intentional value, not absence.
// ?? must treat it as defined and keep it rather than falling back.
const messages: Message[] = [
assistantMsg("assistant-1", "", [toolCall("tc-1", "captureData")]),
assistantMsg("assistant-1", "Done.", []),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
expect((result[0] as AssistantMessage).toolCalls).toEqual([]);
});
it("handles undefined content on both occurrences without error", () => {
// assistantMsg with no content arg produces content: undefined.
// undefined || undefined = undefined — should not throw or produce garbage.
const messages: Message[] = [
assistantMsg("assistant-1"),
assistantMsg("assistant-1"),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
expect((result[0] as AssistantMessage).content).toBeUndefined();
});
it("keeps last entry for non-assistant roles", () => {
const messages: Message[] = [
userMsg("u-1", "Hello"),
userMsg("u-1", "Hello (updated)"),
];
const result = deduplicateMessages(messages);
expect(result).toHaveLength(1);
expect((result[0] as UserMessage).content).toBe("Hello (updated)");
});
});