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)"); }); });