import React, { useRef, useState } from "react"; import { render, screen, fireEvent, waitFor, act, } from "@testing-library/react"; import { z } from "zod"; import { CopilotKitProvider } from "../../../providers/CopilotKitProvider"; import { CopilotChat } from "../CopilotChat"; import { AbstractAgent, EventType, type BaseEvent, type RunAgentInput, } from "@ag-ui/client"; import { Observable, Subject } from "rxjs"; import { defineToolCallRenderer, ReactToolCallRenderer } from "../../../types"; import { ToolCallStatus } from "@copilotkit/core"; import { CopilotChatMessageView } from "../CopilotChatMessageView"; import { CopilotChatView, CopilotChatViewProps } from "../CopilotChatView"; import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider"; import { ActivityMessage, AssistantMessage, Message } from "@ag-ui/core"; import { ReactActivityMessageRenderer, ReactCustomMessageRenderer, } from "../../../types"; import CopilotChatInput, { CopilotChatInputProps } from "../CopilotChatInput"; import { CopilotChatSuggestionView } from "../CopilotChatSuggestionView"; import { CopilotChatAssistantMessage } from "../CopilotChatAssistantMessage"; // A controllable streaming agent to step through events deterministically class MockStepwiseAgent extends AbstractAgent { private subject = new Subject(); emit(event: BaseEvent) { if (event.type === EventType.RUN_STARTED) { this.isRunning = true; } else if ( event.type === EventType.RUN_FINISHED || event.type === EventType.RUN_ERROR ) { this.isRunning = false; } this.subject.next(event); } complete() { this.isRunning = false; this.subject.complete(); } clone(): MockStepwiseAgent { const cloned = new MockStepwiseAgent(); cloned.agentId = this.agentId; (cloned as unknown as { subject: Subject }).subject = this.subject; return cloned; } async detachActiveRun(): Promise {} run(_input: RunAgentInput): Observable { return this.subject.asObservable(); } } describe("Tool Call Re-render Prevention", () => { it("should not re-render a completed tool call when subsequent text is streamed", async () => { const agent = new MockStepwiseAgent(); // Track render counts for the tool renderer let toolRenderCount = 0; let lastRenderStatus: string | null = null; let lastRenderArgs: Record | null = null; const renderToolCalls = [ defineToolCallRenderer({ name: "getWeather", args: z.object({ location: z.string(), }), render: ({ status, args, result }) => { toolRenderCount++; lastRenderStatus = status; lastRenderArgs = args as Record; return (
{toolRenderCount} {status} {args.location} {result ? String(result) : "pending"}
); }, }), ] as unknown as ReactToolCallRenderer[]; render(
, ); // Submit a user message to trigger runAgent const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "What's the weather?" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("What's the weather?")).toBeDefined(); }); const messageId = "m_rerender_test"; const toolCallId = "tc_rerender_test"; // Start the run agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); // Stream the tool call with complete args agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName: "getWeather", parentMessageId: messageId, delta: '{"location":"Paris"}', } as BaseEvent); // Wait for tool to render with InProgress status await waitFor(() => { const statusEl = screen.getByTestId("status"); expect(statusEl.textContent).toBe("inProgress"); expect(screen.getByTestId("location").textContent).toBe("Paris"); }); const renderCountAfterToolCall = toolRenderCount; // Send the tool result to complete the tool call agent.emit({ type: EventType.TOOL_CALL_RESULT, toolCallId, messageId: `${messageId}_result`, content: JSON.stringify({ temperature: 22, condition: "sunny" }), } as BaseEvent); // Wait for tool to show Complete status await waitFor(() => { const statusEl = screen.getByTestId("status"); expect(statusEl.textContent).toBe("complete"); }); const renderCountAfterComplete = toolRenderCount; // Sanity check: it should have re-rendered at least once to show complete status expect(renderCountAfterComplete).toBeGreaterThan(renderCountAfterToolCall); // Now stream additional text AFTER the tool call is complete // This should NOT cause the tool call renderer to re-render agent.emit({ type: EventType.TEXT_MESSAGE_CHUNK, messageId: "m_followup", delta: "The weather in Paris is ", } as BaseEvent); // Wait a moment for React to process await waitFor(() => { expect(screen.getByText(/The weather in Paris is/)).toBeDefined(); }); const renderCountAfterFirstTextChunk = toolRenderCount; // Stream more text chunks agent.emit({ type: EventType.TEXT_MESSAGE_CHUNK, messageId: "m_followup", delta: "currently sunny ", } as BaseEvent); await waitFor(() => { expect(screen.getByText(/currently sunny/)).toBeDefined(); }); agent.emit({ type: EventType.TEXT_MESSAGE_CHUNK, messageId: "m_followup", delta: "with a temperature of 22°C.", } as BaseEvent); await waitFor(() => { expect(screen.getByText(/22°C/)).toBeDefined(); }); const renderCountAfterAllText = toolRenderCount; // THE KEY ASSERTION: The tool should NOT have re-rendered after it was complete // and we started streaming text expect(renderCountAfterAllText).toBe(renderCountAfterComplete); // Verify the tool still shows the correct completed state expect(screen.getByTestId("status").textContent).toBe("complete"); expect(screen.getByTestId("location").textContent).toBe("Paris"); expect(screen.getByTestId("result").textContent).toContain("temperature"); agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); agent.complete(); }); it("should not re-render a tool call when its arguments have not changed during streaming", async () => { const agent = new MockStepwiseAgent(); // Track render counts let toolRenderCount = 0; const renderToolCalls = [ defineToolCallRenderer({ name: "search", args: z.object({ query: z.string(), }), render: ({ status, args }) => { toolRenderCount++; return (
{toolRenderCount} {status} {args.query}
); }, }), ] as unknown as ReactToolCallRenderer[]; render(
, ); const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Search for something" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Search for something")).toBeDefined(); }); const messageId = "m_search"; const toolCallId = "tc_search"; agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); // Stream complete tool call args agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName: "search", parentMessageId: messageId, delta: '{"query":"React hooks"}', } as BaseEvent); await waitFor(() => { expect(screen.getByTestId("search-query").textContent).toBe( "React hooks", ); }); const renderCountAfterToolCall = toolRenderCount; // Stream text in the same message (before tool result) // This simulates the agent adding explanation text while tool is in progress agent.emit({ type: EventType.TEXT_MESSAGE_CHUNK, messageId, delta: "Let me search for that...", } as BaseEvent); await waitFor(() => { expect(screen.getByText(/Let me search for that/)).toBeDefined(); }); const renderCountAfterText = toolRenderCount; // The tool call should NOT re-render just because text was added to the message // since its arguments haven't changed expect(renderCountAfterText).toBe(renderCountAfterToolCall); agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); agent.complete(); }); it("should re-render a tool call when its arguments change during streaming", async () => { const agent = new MockStepwiseAgent(); // Track render counts and captured args let toolRenderCount = 0; const capturedArgs: string[] = []; const renderToolCalls = [ defineToolCallRenderer({ name: "search", args: z.object({ query: z.string(), }), render: ({ args }) => { toolRenderCount++; capturedArgs.push(args.query!); return (
{toolRenderCount} {args.query}
); }, }), ] as unknown as ReactToolCallRenderer[]; render(
, ); const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Search for something" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Search for something")).toBeDefined(); }); const messageId = "m_search_update"; const toolCallId = "tc_search_update"; agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); // Stream partial args first agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName: "search", parentMessageId: messageId, delta: '{"query":"Rea', } as BaseEvent); await waitFor(() => { expect(screen.getByTestId("search-query").textContent).toBe("Rea"); }); const renderCountAfterFirstChunk = toolRenderCount; // Stream more args agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName: "search", parentMessageId: messageId, delta: 'ct hooks"}', } as BaseEvent); await waitFor(() => { expect(screen.getByTestId("search-query").textContent).toBe( "React hooks", ); }); const renderCountAfterSecondChunk = toolRenderCount; // THE KEY ASSERTION: Tool should re-render when arguments change expect(renderCountAfterSecondChunk).toBeGreaterThan( renderCountAfterFirstChunk, ); expect(capturedArgs).toContain("Rea"); expect(capturedArgs).toContain("React hooks"); agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); agent.complete(); }); it("should re-render a tool call when status changes to complete", async () => { const agent = new MockStepwiseAgent(); let toolRenderCount = 0; const capturedStatuses: string[] = []; const renderToolCalls = [ defineToolCallRenderer({ name: "getData", args: z.object({ id: z.string() }), render: ({ status, result }) => { toolRenderCount++; capturedStatuses.push(status); return (
{status} {result ? String(result) : "none"}
); }, }), ] as unknown as ReactToolCallRenderer[]; render(
, ); const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "Get data" } }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); await waitFor(() => { expect(screen.getByText("Get data")).toBeDefined(); }); const messageId = "m_data"; const toolCallId = "tc_data"; agent.emit({ type: EventType.RUN_STARTED } as BaseEvent); // Send tool call agent.emit({ type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName: "getData", parentMessageId: messageId, delta: '{"id":"123"}', } as BaseEvent); await waitFor(() => { expect(screen.getByTestId("data-status").textContent).toBe("inProgress"); }); const renderCountBeforeResult = toolRenderCount; // Send tool result agent.emit({ type: EventType.TOOL_CALL_RESULT, toolCallId, messageId: `${messageId}_result`, content: JSON.stringify({ data: "found" }), } as BaseEvent); await waitFor(() => { expect(screen.getByTestId("data-status").textContent).toBe("complete"); }); const renderCountAfterResult = toolRenderCount; // THE KEY ASSERTION: Tool should re-render when status changes expect(renderCountAfterResult).toBeGreaterThan(renderCountBeforeResult); expect(capturedStatuses).toContain("inProgress"); expect(capturedStatuses).toContain("complete"); agent.emit({ type: EventType.RUN_FINISHED } as BaseEvent); agent.complete(); }); }); describe("Text Message Re-render Prevention", () => { it("should not re-render a previous assistant message when a new message streams in", async () => { // Track render counts per message ID const renderCounts: Record = {}; // Custom assistant message component that tracks renders const TrackedAssistantMessage: React.FC<{ message: AssistantMessage; messages?: Message[]; isRunning?: boolean; }> = ({ message }) => { // Increment render count for this message renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; return (
{message.content} {renderCounts[message.id]}
); }; // Initial messages - one complete assistant message const initialMessages: Message[] = [ { id: "msg-1", role: "assistant", content: "Hello! How can I help you today?", } as AssistantMessage, ]; const { rerender } = render( , ); // Verify first message rendered await waitFor(() => { expect(screen.getByTestId("assistant-message-msg-1")).toBeDefined(); }); const firstMessageRenderCountAfterInitial = renderCounts["msg-1"]; expect(firstMessageRenderCountAfterInitial).toBe(1); // Simulate streaming a second message - first chunk const messagesWithSecondPartial: Message[] = [ ...initialMessages, { id: "msg-2", role: "assistant", content: "Let me help", } as AssistantMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("assistant-message-msg-2")).toBeDefined(); }); const firstMessageRenderCountAfterSecondMessage = renderCounts["msg-1"]; // Continue streaming the second message const messagesWithMoreContent: Message[] = [ ...initialMessages, { id: "msg-2", role: "assistant", content: "Let me help you with that task.", } as AssistantMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("content-msg-2").textContent).toBe( "Let me help you with that task.", ); }); // Stream even more content const messagesWithEvenMoreContent: Message[] = [ ...initialMessages, { id: "msg-2", role: "assistant", content: "Let me help you with that task. Here's what I found:", } as AssistantMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("content-msg-2").textContent).toContain( "Here's what I found", ); }); const firstMessageRenderCountAfterAllStreaming = renderCounts["msg-1"]; // THE KEY ASSERTION: The first message should NOT have re-rendered // when the second message was streaming expect(firstMessageRenderCountAfterAllStreaming).toBe( firstMessageRenderCountAfterInitial, ); // Verify the second message did update (it should have rendered multiple times) expect(renderCounts["msg-2"]).toBeGreaterThan(1); }); it("should not re-render a user message when assistant message streams", async () => { const renderCounts: Record = {}; const TrackedAssistantMessage: React.FC<{ message: AssistantMessage; messages?: Message[]; isRunning?: boolean; }> = ({ message }) => { renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; return (
{message.content}
); }; const TrackedUserMessage: React.FC<{ message: Message; }> = ({ message }) => { renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; return (
{typeof message.content === "string" ? message.content : ""} {renderCounts[message.id]}
); }; const initialMessages: Message[] = [ { id: "user-1", role: "user", content: "Hello!", }, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("user-message-user-1")).toBeDefined(); }); const userMessageRenderCountInitial = renderCounts["user-1"]; expect(userMessageRenderCountInitial).toBe(1); // Add assistant response and stream it const messagesWithAssistant: Message[] = [ ...initialMessages, { id: "assistant-1", role: "assistant", content: "Hi there!", } as AssistantMessage, ]; rerender( , ); // Stream more content const messagesWithMoreAssistant: Message[] = [ ...initialMessages, { id: "assistant-1", role: "assistant", content: "Hi there! How can I assist you today?", } as AssistantMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("content-assistant-1").textContent).toContain( "How can I assist", ); }); const userMessageRenderCountAfterStreaming = renderCounts["user-1"]; // THE KEY ASSERTION: User message should not re-render when assistant streams expect(userMessageRenderCountAfterStreaming).toBe( userMessageRenderCountInitial, ); }); it("should re-render an assistant message when its content changes", async () => { const renderCounts: Record = {}; const capturedContent: string[] = []; const TrackedAssistantMessage: React.FC<{ message: AssistantMessage; messages?: Message[]; isRunning?: boolean; }> = ({ message }) => { renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; capturedContent.push(message.content ?? ""); return (
{message.content}
); }; const initialMessages: Message[] = [ { id: "msg-1", role: "assistant", content: "Hello", } as AssistantMessage, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("assistant-message-msg-1")).toBeDefined(); }); const renderCountAfterInitial = renderCounts["msg-1"]!; expect(renderCountAfterInitial).toBe(1); // Update message content (streaming) const updatedMessages: Message[] = [ { id: "msg-1", role: "assistant", content: "Hello! How can I help", } as AssistantMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("content-msg-1").textContent).toBe( "Hello! How can I help", ); }); const renderCountAfterUpdate = renderCounts["msg-1"]!; // THE KEY ASSERTION: Message should re-render when content changes expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); expect(capturedContent).toContain("Hello"); expect(capturedContent).toContain("Hello! How can I help"); }); it("should re-render a user message when its content changes", async () => { const renderCounts: Record = {}; const capturedContent: string[] = []; const TrackedUserMessage: React.FC<{ message: Message; }> = ({ message }) => { renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; const content = typeof message.content === "string" ? message.content : ""; capturedContent.push(content); return (
{content}
); }; const initialMessages: Message[] = [ { id: "user-1", role: "user", content: "Initial message", }, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("user-message-user-1")).toBeDefined(); }); const renderCountAfterInitial = renderCounts["user-1"]!; expect(renderCountAfterInitial).toBe(1); // Update user message content (e.g., editing) const updatedMessages: Message[] = [ { id: "user-1", role: "user", content: "Updated message", }, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("user-content-user-1").textContent).toBe( "Updated message", ); }); const renderCountAfterUpdate = renderCounts["user-1"]!; // THE KEY ASSERTION: User message should re-render when content changes expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); expect(capturedContent).toContain("Initial message"); expect(capturedContent).toContain("Updated message"); }); }); describe("Activity Message Re-render Prevention", () => { it("should not re-render a previous activity message when a new message streams in", async () => { // Track render counts per message ID const renderCounts: Record = {}; // Custom activity renderer that tracks renders const activityRenderer: ReactActivityMessageRenderer<{ status: string; percent: number; }> = { activityType: "search-progress", content: z.object({ status: z.string(), percent: z.number() }), render: ({ content, message }) => { renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; return (
{content.status} - {content.percent}% {renderCounts[message.id]}
); }, }; // Initial messages - one activity message const initialMessages: Message[] = [ { id: "activity-1", role: "activity", activityType: "search-progress", content: { status: "Searching", percent: 50 }, } as ActivityMessage, ]; const { rerender } = render( , ); // Verify first activity rendered await waitFor(() => { expect(screen.getByTestId("activity-activity-1")).toBeDefined(); }); const firstActivityRenderCountAfterInitial = renderCounts["activity-1"]; expect(firstActivityRenderCountAfterInitial).toBe(1); // Add a second activity message const messagesWithSecondActivity: Message[] = [ ...initialMessages, { id: "activity-2", role: "activity", activityType: "search-progress", content: { status: "Processing", percent: 75 }, } as ActivityMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("activity-activity-2")).toBeDefined(); }); // Update the second activity message const messagesWithUpdatedSecondActivity: Message[] = [ initialMessages[0]!, { id: "activity-2", role: "activity", activityType: "search-progress", content: { status: "Almost done", percent: 90 }, } as ActivityMessage, ]; rerender( , ); await waitFor(() => { expect( screen.getByTestId("activity-content-activity-2").textContent, ).toContain("Almost done"); }); const firstActivityRenderCountAfterAllUpdates = renderCounts["activity-1"]; // THE KEY ASSERTION: The first activity should NOT have re-rendered // when the second activity was added or updated expect(firstActivityRenderCountAfterAllUpdates).toBe( firstActivityRenderCountAfterInitial, ); // Verify the second activity did update (it should have rendered multiple times) expect(renderCounts["activity-2"]).toBeGreaterThan(1); }); it("should not re-render an activity message when an assistant message streams", async () => { const renderCounts: Record = {}; const activityRenderer: ReactActivityMessageRenderer<{ status: string }> = { activityType: "progress", content: z.object({ status: z.string() }), render: ({ content, message }) => { renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; return (
{content.status}
); }, }; const TrackedAssistantMessage: React.FC<{ message: AssistantMessage; messages?: Message[]; isRunning?: boolean; }> = ({ message }) => { renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; return (
{message.content}
); }; const initialMessages: Message[] = [ { id: "activity-1", role: "activity", activityType: "progress", content: { status: "Loading..." }, } as ActivityMessage, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("activity-activity-1")).toBeDefined(); }); const activityRenderCountInitial = renderCounts["activity-1"]; expect(activityRenderCountInitial).toBe(1); // Add an assistant message and stream it const messagesWithAssistant: Message[] = [ ...initialMessages, { id: "assistant-1", role: "assistant", content: "Here's what I found...", } as AssistantMessage, ]; rerender( , ); // Stream more content const messagesWithMoreAssistant: Message[] = [ initialMessages[0]!, { id: "assistant-1", role: "assistant", content: "Here's what I found... The results show that...", } as AssistantMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("assistant-assistant-1").textContent).toContain( "The results show", ); }); const activityRenderCountAfterStreaming = renderCounts["activity-1"]; // THE KEY ASSERTION: Activity message should not re-render when assistant streams expect(activityRenderCountAfterStreaming).toBe(activityRenderCountInitial); }); it("should re-render an activity message when its content changes", async () => { const renderCounts: Record = {}; const capturedContent: { status: string; percent: number }[] = []; const activityRenderer: ReactActivityMessageRenderer<{ status: string; percent: number; }> = { activityType: "progress", content: z.object({ status: z.string(), percent: z.number() }), render: ({ content, message }) => { renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; capturedContent.push({ ...content }); return (
{content.status} {content.percent}
); }, }; const initialMessages: Message[] = [ { id: "activity-1", role: "activity", activityType: "progress", content: { status: "Starting", percent: 0 }, } as ActivityMessage, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("activity-activity-1")).toBeDefined(); }); const renderCountAfterInitial = renderCounts["activity-1"]!; expect(renderCountAfterInitial).toBe(1); // Update activity content (progress update) const updatedMessages: Message[] = [ { id: "activity-1", role: "activity", activityType: "progress", content: { status: "Processing", percent: 50 }, } as ActivityMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("activity-status-activity-1").textContent).toBe( "Processing", ); expect( screen.getByTestId("activity-percent-activity-1").textContent, ).toBe("50"); }); const renderCountAfterUpdate = renderCounts["activity-1"]!; // THE KEY ASSERTION: Activity should re-render when content changes expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); expect(capturedContent).toContainEqual({ status: "Starting", percent: 0 }); expect(capturedContent).toContainEqual({ status: "Processing", percent: 50, }); }); it("should re-render an activity message when its activityType changes", async () => { const renderCounts: Record = {}; const progressRenderer: ReactActivityMessageRenderer<{ status: string }> = { activityType: "progress", content: z.object({ status: z.string() }), render: ({ content, message }) => { renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; return (
progress {content.status}
); }, }; const completedRenderer: ReactActivityMessageRenderer<{ result: string }> = { activityType: "completed", content: z.object({ result: z.string() }), render: ({ content, message }) => { renderCounts[message.id] = (renderCounts[message.id] || 0) + 1; return (
completed {content.result}
); }, }; const initialMessages: Message[] = [ { id: "activity-1", role: "activity", activityType: "progress", content: { status: "Loading..." }, } as ActivityMessage, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("activity-type-activity-1").textContent).toBe( "progress", ); }); const renderCountAfterInitial = renderCounts["activity-1"]!; expect(renderCountAfterInitial).toBe(1); // Change activity type const updatedMessages: Message[] = [ { id: "activity-1", role: "activity", activityType: "completed", content: { result: "Done!" }, } as ActivityMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("activity-type-activity-1").textContent).toBe( "completed", ); }); const renderCountAfterTypeChange = renderCounts["activity-1"]!; // THE KEY ASSERTION: Activity should re-render when activityType changes expect(renderCountAfterTypeChange).toBeGreaterThan(renderCountAfterInitial); }); }); describe("Custom Message Re-render Prevention", () => { it("should not re-render a custom message for a previous message when a new message streams in", async () => { const agent = new MockStepwiseAgent(); // Track render counts by message ID and position const renderCounts: Record = {}; // Custom message renderer that tracks renders const customRenderer: ReactCustomMessageRenderer = { render: ({ message, position }) => { // Only render for assistant messages in "after" position if (message.role !== "assistant" || position !== "after") { return null; } const key = `${message.id}-${position}`; renderCounts[key] = (renderCounts[key] || 0) + 1; return (
Custom content for {message.id} {renderCounts[key]}
); }, }; // Initial messages - one assistant message const initialMessages: Message[] = [ { id: "assistant-1", role: "assistant", content: "Hello! How can I help you?", } as AssistantMessage, ]; const { rerender } = render( , ); // Verify first custom message rendered await waitFor(() => { expect(screen.getByTestId("custom-assistant-1")).toBeDefined(); }); const firstCustomRenderCountAfterInitial = renderCounts["assistant-1-after"]; expect(firstCustomRenderCountAfterInitial).toBe(1); // Add a second assistant message const messagesWithSecond: Message[] = [ ...initialMessages, { id: "assistant-2", role: "assistant", content: "Here's some more info...", } as AssistantMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("custom-assistant-2")).toBeDefined(); }); // Update the second message (streaming more content) const messagesWithUpdatedSecond: Message[] = [ initialMessages[0]!, { id: "assistant-2", role: "assistant", content: "Here's some more info... Let me explain in detail.", } as AssistantMessage, ]; rerender( , ); // Stream even more content const messagesWithMoreContent: Message[] = [ initialMessages[0]!, { id: "assistant-2", role: "assistant", content: "Here's some more info... Let me explain in detail. This is comprehensive.", } as AssistantMessage, ]; rerender( , ); const firstCustomRenderCountAfterAllUpdates = renderCounts["assistant-1-after"]; // THE KEY ASSERTION: The first custom message should NOT have re-rendered // when the second message was streaming expect(firstCustomRenderCountAfterAllUpdates).toBe( firstCustomRenderCountAfterInitial, ); // Verify the second custom message did update expect(renderCounts["assistant-2-after"]).toBeGreaterThan(1); }); it("should not re-render custom messages when isRunning changes but message content is the same", async () => { const agent = new MockStepwiseAgent(); const renderCounts: Record = {}; const customRenderer: ReactCustomMessageRenderer = { render: ({ message, position }) => { if (message.role !== "assistant" || position !== "after") { return null; } const key = `${message.id}-${position}`; renderCounts[key] = (renderCounts[key] || 0) + 1; return (
Render count: {renderCounts[key]}
); }, }; const messages: Message[] = [ { id: "assistant-1", role: "assistant", content: "Complete message", } as AssistantMessage, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("custom-assistant-1")).toBeDefined(); }); const renderCountWhileRunning = renderCounts["assistant-1-after"]!; expect(renderCountWhileRunning).toBe(1); // Change isRunning to false (but same messages) rerender( , ); const renderCountAfterRunningChanged = renderCounts["assistant-1-after"]!; // THE KEY ASSERTION: Custom message should not re-render just because isRunning changed expect(renderCountAfterRunningChanged).toBe(renderCountWhileRunning); }); it("should re-render a custom message when its message content changes", async () => { const agent = new MockStepwiseAgent(); const renderCounts: Record = {}; const capturedContent: string[] = []; const customRenderer: ReactCustomMessageRenderer = { render: ({ message, position }) => { if (message.role !== "assistant" || position !== "after") { return null; } const key = `${message.id}-${position}`; renderCounts[key] = (renderCounts[key] || 0) + 1; const content = typeof message.content === "string" ? message.content : ""; capturedContent.push(content); return (
{content} {renderCounts[key]}
); }, }; const initialMessages: Message[] = [ { id: "assistant-1", role: "assistant", content: "Hello", } as AssistantMessage, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("custom-assistant-1")).toBeDefined(); }); const renderCountAfterInitial = renderCounts["assistant-1-after"]!; expect(renderCountAfterInitial).toBe(1); // Update message content (streaming) const updatedMessages: Message[] = [ { id: "assistant-1", role: "assistant", content: "Hello! How can I help you today?", } as AssistantMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("custom-content-assistant-1").textContent).toBe( "Hello! How can I help you today?", ); }); const renderCountAfterUpdate = renderCounts["assistant-1-after"]!; // THE KEY ASSERTION: Custom message should re-render when content changes expect(renderCountAfterUpdate).toBeGreaterThan(renderCountAfterInitial); expect(capturedContent).toContain("Hello"); expect(capturedContent).toContain("Hello! How can I help you today?"); }); it("should re-render a custom message when its message role changes", async () => { const agent = new MockStepwiseAgent(); const renderCounts: Record = {}; const customRenderer: ReactCustomMessageRenderer = { render: ({ message, position }) => { if (position !== "after") { return null; } const key = `${message.id}-${position}`; renderCounts[key] = (renderCounts[key] || 0) + 1; return (
{message.role} {renderCounts[key]}
); }, }; const initialMessages: Message[] = [ { id: "msg-1", role: "user", content: "Hello", }, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("custom-role-msg-1").textContent).toBe("user"); }); const renderCountAfterInitial = renderCounts["msg-1-after"]!; expect(renderCountAfterInitial).toBe(1); // Change message role (unusual but possible) const updatedMessages: Message[] = [ { id: "msg-1", role: "assistant", content: "Hello", } as AssistantMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("custom-role-msg-1").textContent).toBe( "assistant", ); }); const renderCountAfterRoleChange = renderCounts["msg-1-after"]!; // THE KEY ASSERTION: Custom message should re-render when role changes expect(renderCountAfterRoleChange).toBeGreaterThan(renderCountAfterInitial); }); }); describe("Input Component Re-render Prevention", () => { it("should not re-render the input component when messages stream in", async () => { let inputRenderCount = 0; // Custom input component that tracks renders const TrackedInput: React.FC = (props) => { inputRenderCount++; return (
{inputRenderCount}
); }; // Use a stable callback reference to properly test memoization const stableOnSubmit = () => {}; const initialMessages: Message[] = [ { id: "msg-1", role: "assistant", content: "Hello!", } as AssistantMessage, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("tracked-input")).toBeDefined(); }); const renderCountAfterInitial = inputRenderCount; expect(renderCountAfterInitial).toBe(1); // Stream a new message (add more content) const updatedMessages: Message[] = [ ...initialMessages, { id: "msg-2", role: "assistant", content: "How can I help?", } as AssistantMessage, ]; rerender( , ); // Continue streaming const moreMessages: Message[] = [ ...initialMessages, { id: "msg-2", role: "assistant", content: "How can I help you today?", } as AssistantMessage, ]; rerender( , ); // Even more streaming const evenMoreMessages: Message[] = [ ...initialMessages, { id: "msg-2", role: "assistant", content: "How can I help you today? I'm here to assist.", } as AssistantMessage, ]; rerender( , ); const renderCountAfterStreaming = inputRenderCount; // THE KEY ASSERTION: Input should NOT re-render when messages change // (since input props haven't changed) expect(renderCountAfterStreaming).toBe(renderCountAfterInitial); }); it("should re-render a replaced input component when its internal state changes", async () => { let externalRenderCount = 0; // Custom input with internal state - uses useState to track clicks const InputWithInternalState: React.FC = (props) => { const [clickCount, setClickCount] = useState(0); externalRenderCount++; return (
{externalRenderCount} {clickCount}
); }; const messages: Message[] = [ { id: "msg-1", role: "assistant", content: "Hello!", } as AssistantMessage, ]; render( , ); await waitFor(() => { expect(screen.getByTestId("stateful-input")).toBeDefined(); }); // Initial state expect(screen.getByTestId("click-count").textContent).toBe("0"); const initialExternalRenderCount = externalRenderCount; expect(initialExternalRenderCount).toBe(1); // Click the button to trigger internal state change const incrementButton = screen.getByTestId("increment-button"); fireEvent.click(incrementButton); // THE KEY ASSERTION: Internal state changes SHOULD cause re-render await waitFor(() => { expect(screen.getByTestId("click-count").textContent).toBe("1"); }); // Verify the component actually re-rendered (not just DOM updated) expect(externalRenderCount).toBe(2); // Click again to confirm consistent behavior fireEvent.click(incrementButton); await waitFor(() => { expect(screen.getByTestId("click-count").textContent).toBe("2"); }); expect(externalRenderCount).toBe(3); }); it("should re-render the input component when its props change", async () => { let inputRenderCount = 0; const capturedModes: string[] = []; const TrackedInput: React.FC = (props) => { inputRenderCount++; capturedModes.push(props.mode || "default"); return (
{props.mode}
); }; const messages: Message[] = [ { id: "msg-1", role: "assistant", content: "Hello!", } as AssistantMessage, ]; const { rerender } = render( {}} inputMode="input" /> , ); await waitFor(() => { expect(screen.getByTestId("tracked-input")).toBeDefined(); }); const renderCountAfterInitial = inputRenderCount; expect(renderCountAfterInitial).toBe(1); // Change the mode prop rerender( {}} inputMode="processing" /> , ); await waitFor(() => { expect(screen.getByTestId("input-mode").textContent).toBe("processing"); }); const renderCountAfterModeChange = inputRenderCount; // THE KEY ASSERTION: Input SHOULD re-render when its props change expect(renderCountAfterModeChange).toBeGreaterThan(renderCountAfterInitial); expect(capturedModes).toContain("input"); expect(capturedModes).toContain("processing"); }); }); describe("Suggestion View Re-render Prevention", () => { it("should re-render a suggestion when its loading state changes", async () => { const suggestionRenderCounts: Record = {}; const TrackedSuggestionPill: React.FC<{ children: React.ReactNode; isLoading?: boolean; onClick?: () => void; }> = ({ children, isLoading, onClick }) => { const title = String(children); suggestionRenderCounts[title] = (suggestionRenderCounts[title] || 0) + 1; return ( ); }; const suggestions = [ { title: "Tell me a joke", message: "Tell me a joke", isLoading: false }, { title: "What's the weather?", message: "What's the weather?", isLoading: false, }, ]; const { rerender } = render( , ); await waitFor(() => { expect( screen.getByTestId("suggestion-loading-Tell me a joke").textContent, ).toBe("ready"); }); const initialRenderCount = suggestionRenderCounts["Tell me a joke"]!; // Set first suggestion to loading rerender( , ); await waitFor(() => { expect( screen.getByTestId("suggestion-loading-Tell me a joke").textContent, ).toBe("loading"); }); // THE KEY ASSERTION: Suggestion SHOULD re-render when loading state changes expect(suggestionRenderCounts["Tell me a joke"]).toBeGreaterThan( initialRenderCount, ); }); }); describe("Markdown Renderer Re-render Prevention", () => { it("should not re-render markdown when other messages change", async () => { const markdownRenderCounts: Record = {}; const TrackedMarkdownRenderer: React.FC<{ content: string; }> = ({ content }) => { markdownRenderCounts[content] = (markdownRenderCounts[content] || 0) + 1; return (
{content} {markdownRenderCounts[content]}
); }; const TrackedAssistantMessage: React.FC<{ message: AssistantMessage; messages?: Message[]; isRunning?: boolean; }> = ({ message, messages, isRunning }) => { return ( ); }; const initialMessages: Message[] = [ { id: "msg-1", role: "assistant", content: "Hello! How can I help?", } as AssistantMessage, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("markdown-Hello! How can I hel")).toBeDefined(); }); const initialRenderCount = markdownRenderCounts["Hello! How can I help?"]!; // Add a new message (simulating streaming) const messagesWithSecond: Message[] = [ ...initialMessages, { id: "msg-2", role: "assistant", content: "Let me help you with", } as AssistantMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("markdown-Let me help you with")).toBeDefined(); }); // THE KEY ASSERTION: First message's markdown should NOT re-render expect(markdownRenderCounts["Hello! How can I help?"]).toBe( initialRenderCount, ); }); it("should re-render markdown when its content changes", async () => { const markdownRenderCounts: Record = {}; const capturedContent: string[] = []; const TrackedMarkdownRenderer: React.FC<{ content: string; }> = ({ content }) => { markdownRenderCounts[content] = (markdownRenderCounts[content] || 0) + 1; capturedContent.push(content); return (
{content}
); }; const TrackedAssistantMessage: React.FC<{ message: AssistantMessage; messages?: Message[]; isRunning?: boolean; }> = ({ message, messages, isRunning }) => { return ( ); }; const initialMessages: Message[] = [ { id: "msg-1", role: "assistant", content: "Hello", } as AssistantMessage, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("markdown-content").textContent).toBe("Hello"); }); // Stream more content const messagesWithMoreContent: Message[] = [ { id: "msg-1", role: "assistant", content: "Hello! How are you today?", } as AssistantMessage, ]; rerender( , ); await waitFor(() => { expect(screen.getByTestId("markdown-content").textContent).toBe( "Hello! How are you today?", ); }); // THE KEY ASSERTION: Markdown SHOULD re-render when content changes expect(capturedContent).toContain("Hello"); expect(capturedContent).toContain("Hello! How are you today?"); }); }); describe("Copy Button Re-render Prevention", () => { it("should not re-render copy button when a new message is added", async () => { let copyButtonRenderCount = 0; const TrackedCopyButton: React.FC<{ onClick?: () => void; }> = ({ onClick }) => { copyButtonRenderCount++; return ( ); }; const TrackedAssistantMessage: React.FC<{ message: AssistantMessage; messages?: Message[]; isRunning?: boolean; }> = ({ message, messages, isRunning }) => { return ( ); }; // Start with a completed message (isRunning=false so toolbar shows) const initialMessages: Message[] = [ { id: "msg-1", role: "assistant", content: "Hello! First message here.", } as AssistantMessage, ]; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("copy-button")).toBeDefined(); }); const initialRenderCount = copyButtonRenderCount; // Add a second message - the first message's copy button should NOT re-render const messagesWithSecond: Message[] = [ ...initialMessages, { id: "msg-2", role: "assistant", content: "Second message here.", } as AssistantMessage, ]; rerender( , ); // Wait for second message to render await waitFor(() => { expect(screen.getAllByTestId("copy-button").length).toBe(2); }); // THE KEY ASSERTION: First message's copy button should NOT re-render when second message is added // We check that the total render count is 2 (one for each message), not 3 (which would mean first re-rendered) expect(copyButtonRenderCount).toBe(2); }); it("should re-render copy button when its onClick handler changes", async () => { let copyButtonRenderCount = 0; const TrackedCopyButton: React.FC<{ onClick?: () => void; }> = ({ onClick }) => { copyButtonRenderCount++; return ( ); }; // First render with one message (isRunning=false so toolbar shows) const message1: AssistantMessage = { id: "msg-1", role: "assistant", content: "First message", }; const { rerender } = render( , ); await waitFor(() => { expect(screen.getByTestId("copy-button")).toBeDefined(); }); const initialRenderCount = copyButtonRenderCount; // Re-render with a completely different message (different ID = different onClick) const message2: AssistantMessage = { id: "msg-2", role: "assistant", content: "Second message", }; rerender( , ); await waitFor(() => { expect(screen.getByTestId("copy-button")).toBeDefined(); }); // THE KEY ASSERTION: Copy button SHOULD re-render when the message changes // because the onClick handler needs to reference the new message content expect(copyButtonRenderCount).toBeGreaterThan(initialRenderCount); }); });