import React from "react"; import { render, act } from "@testing-library/react"; import { CopilotKitProvider } from "../../providers/CopilotKitProvider"; import { CopilotChat } from "../../components/chat/CopilotChat"; import { CopilotChatConfigurationProvider } from "../../providers/CopilotChatConfigurationProvider"; import { DEFAULT_AGENT_ID } from "@copilotkit/shared"; import { AbstractAgent, EventType, type BaseEvent, type RunAgentInput, } from "@ag-ui/client"; import { Observable, Subject, from, delay } from "rxjs"; import { ReactActivityMessageRenderer, ReactToolCallRenderer, } from "../../types"; import { ReactCustomMessageRenderer } from "../../types/react-custom-message-renderer"; /** * A controllable mock agent for deterministic E2E testing. * Exposes emit() and complete() methods to drive agent events step-by-step. */ export class MockStepwiseAgent extends AbstractAgent { private subject = new Subject(); /** * Emit a single agent event */ 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; } act(() => { this.subject.next(event); }); } /** * Complete the agent stream */ complete() { this.isRunning = false; act(() => { this.subject.complete(); }); } clone(): this { // Return a new instance that shares the same subject so tests can keep // controlling events via the original reference while satisfying the // clone() contract (must return a distinct object). // Use the concrete constructor so subclasses (e.g. FailingConnectAgent) // preserve their overridden methods. const cloned = new (this .constructor as new () => MockStepwiseAgent)() as this; cloned.agentId = this.agentId; (cloned as unknown as { subject: Subject }).subject = this.subject; return cloned; } // No-op: prevent the base class from tearing down the Subject // before tests have finished emitting events. async detachActiveRun(): Promise {} run(_input: RunAgentInput): Observable { return this.subject.asObservable(); } } /** * A mock agent that supports both run() and connect() for testing reconnection scenarios. * On run(), emits events and stores them. * On connect(), replays stored events (simulating thread history replay). */ export class MockReconnectableAgent extends AbstractAgent { private subject = new Subject(); private storedEvents: BaseEvent[] = []; /** * Emit a single agent event during run */ 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.storedEvents.push(event); act(() => { this.subject.next(event); }); } /** * Complete the agent stream */ complete() { this.isRunning = false; act(() => { this.subject.complete(); }); } /** * Reset for reconnection test - creates new subject for connect */ reset() { this.subject = new Subject(); } clone(): MockReconnectableAgent { const cloned = new MockReconnectableAgent(); cloned.agentId = this.agentId; ( cloned as unknown as { subject: Subject; storedEvents: BaseEvent[]; } ).subject = this.subject; ( cloned as unknown as { subject: Subject; storedEvents: BaseEvent[]; } ).storedEvents = this.storedEvents; return cloned; } // No-op: prevent the base class from tearing down the Subject // before tests have finished emitting events. async detachActiveRun(): Promise {} run(_input: RunAgentInput): Observable { return this.subject.asObservable(); } connect(_input: RunAgentInput): Observable { // Replay stored events with async delay to simulate HTTP transport // This is critical for reproducing timing bugs that occur in real scenarios return from(this.storedEvents).pipe(delay(10)); } } /** * Helper to render components with CopilotKitProvider for E2E tests */ export function renderWithCopilotKit({ agent, agents, renderToolCalls, renderCustomMessages, renderActivityMessages, frontendTools, humanInTheLoop, agentId, threadId, defaultThrottleMs, children, }: { agent?: AbstractAgent; agents?: Record; renderToolCalls?: ReactToolCallRenderer[]; renderCustomMessages?: ReactCustomMessageRenderer[]; renderActivityMessages?: ReactActivityMessageRenderer[]; frontendTools?: any[]; humanInTheLoop?: any[]; agentId?: string; threadId?: string; defaultThrottleMs?: number; children?: React.ReactNode; }): ReturnType { const resolvedAgents = agents || (agent ? { default: agent } : undefined); const resolvedAgentId = agentId ?? DEFAULT_AGENT_ID; const resolvedThreadId = threadId ?? "test-thread"; return render( {children || (
)}
, ); } /** * Helper to create a RUN_STARTED event */ export function runStartedEvent(): BaseEvent { return { type: EventType.RUN_STARTED } as BaseEvent; } /** * Helper to create a RUN_FINISHED event */ export function runFinishedEvent(): BaseEvent { return { type: EventType.RUN_FINISHED } as BaseEvent; } /** * Helper to create a STATE_SNAPSHOT event */ export function stateSnapshotEvent(snapshot: unknown): BaseEvent { return { type: EventType.STATE_SNAPSHOT, snapshot, } as BaseEvent; } /** * Helper to create an ACTIVITY_SNAPSHOT event */ export function activitySnapshotEvent({ messageId, activityType, content, }: { messageId: string; activityType: string; content: Record; }): BaseEvent { return { type: EventType.ACTIVITY_SNAPSHOT, messageId, activityType, content, } as BaseEvent; } /** * Helper to start an assistant text message */ export function textMessageStartEvent( messageId: string, role: "assistant" | "developer" | "system" | "user" = "assistant", ): BaseEvent { return { type: EventType.TEXT_MESSAGE_START, messageId, role, } as BaseEvent; } /** * Helper to stream text message content */ export function textMessageContentEvent( messageId: string, delta: string, ): BaseEvent { return { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta, } as BaseEvent; } /** * Helper to end a text message */ export function textMessageEndEvent(messageId: string): BaseEvent { return { type: EventType.TEXT_MESSAGE_END, messageId, } as BaseEvent; } /** * Helper to create a TEXT_MESSAGE_CHUNK event */ export function textChunkEvent(messageId: string, delta: string): BaseEvent { return { type: EventType.TEXT_MESSAGE_CHUNK, messageId, delta, } as BaseEvent; } /** * Helper to create a TOOL_CALL_CHUNK event */ export function toolCallChunkEvent({ toolCallId, toolCallName, parentMessageId, delta, }: { toolCallId: string; toolCallName?: string; parentMessageId: string; delta: string; }): BaseEvent { return { type: EventType.TOOL_CALL_CHUNK, toolCallId, toolCallName, parentMessageId, delta, } as BaseEvent; } /** * Helper to create a TOOL_CALL_RESULT event */ export function toolCallResultEvent({ toolCallId, messageId, content, }: { toolCallId: string; messageId: string; content: string; }): BaseEvent { return { type: EventType.TOOL_CALL_RESULT, toolCallId, messageId, content, } as BaseEvent; } /** * Helper to create a REASONING_START event */ export function reasoningStartEvent(messageId: string): BaseEvent { return { type: EventType.REASONING_START, messageId, } as BaseEvent; } /** * Helper to create a REASONING_MESSAGE_START event */ export function reasoningMessageStartEvent(messageId: string): BaseEvent { return { type: EventType.REASONING_MESSAGE_START, messageId, role: "reasoning", } as BaseEvent; } /** * Helper to create a REASONING_MESSAGE_CONTENT event */ export function reasoningMessageContentEvent( messageId: string, delta: string, ): BaseEvent { return { type: EventType.REASONING_MESSAGE_CONTENT, messageId, delta, } as BaseEvent; } /** * Helper to create a REASONING_MESSAGE_END event */ export function reasoningMessageEndEvent(messageId: string): BaseEvent { return { type: EventType.REASONING_MESSAGE_END, messageId, } as BaseEvent; } /** * Helper to create a REASONING_END event */ export function reasoningEndEvent(messageId: string): BaseEvent { return { type: EventType.REASONING_END, messageId, } as BaseEvent; } /** * Helper to emit a complete reasoning sequence (all 5 events) */ export function emitReasoningSequence( agent: MockStepwiseAgent, messageId: string, content: string, ) { agent.emit(reasoningStartEvent(messageId)); agent.emit(reasoningMessageStartEvent(messageId)); agent.emit(reasoningMessageContentEvent(messageId, content)); agent.emit(reasoningMessageEndEvent(messageId)); agent.emit(reasoningEndEvent(messageId)); } /** * Helper to generate unique IDs for tests */ export function testId(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } // Varied content lengths for realistic message sizes in perf tests. const SAMPLE_ASSISTANT_TEXTS = [ "Sure! I'd be happy to help you with that.", "The weather in San Francisco today is 65°F with partly cloudy skies.", "Here are the main points from the meeting: 1) Roadmap review, 2) Bug triage, 3) Release planning.", "To configure a custom agent, extend AbstractAgent and implement the run() method. Register it with CopilotKitProvider via the agents__unsafe_dev_only prop.", "Here is a React component that fetches data from an API endpoint using useEffect and useState.", ]; /** * Generate a realistic sequence of BaseEvents for N assistant messages. * Uses TEXT_MESSAGE_CHUNK (the only event type proven to create rendered messages * in jsdom tests). Every 5th message includes a tool call. * * Wrap in RUN_STARTED / RUN_FINISHED yourself if you need a full run sequence: * @example * agent.emit(runStartedEvent()); * for (const event of generateMessages(100)) agent.emit(event); * agent.emit(runFinishedEvent()); */ export function generateMessages(n: number): BaseEvent[] { const events: BaseEvent[] = []; for (let i = 0; i < n; i++) { const msgId = `gen-msg-${i}`; const text = SAMPLE_ASSISTANT_TEXTS[i % SAMPLE_ASSISTANT_TEXTS.length]; // Stream content in ~20-char chunks to simulate real streaming for (let offset = 0; offset < text.length; offset += 20) { events.push(textChunkEvent(msgId, text.slice(offset, offset + 20))); } // Every 5th message has a tool call for realistic variety if (i % 5 === 4) { const tcId = `gen-tc-${i}`; const tcResult = `{"result":"tool output for message ${i}"}`; events.push( toolCallChunkEvent({ toolCallId: tcId, toolCallName: "exampleTool", parentMessageId: msgId, delta: "", }), ); events.push( toolCallChunkEvent({ toolCallId: tcId, parentMessageId: msgId, delta: tcResult, }), ); events.push( toolCallResultEvent({ toolCallId: tcId, messageId: `${msgId}-result`, content: tcResult, }), ); } } return events; } /** * Helper to emit a complete suggestion tool call with streaming chunks */ export function emitSuggestionToolCall( agent: MockStepwiseAgent, { toolCallId, parentMessageId, suggestions, }: { toolCallId: string; parentMessageId: string; suggestions: Array<{ title: string; message: string }>; }, ) { // Convert suggestions to JSON string const suggestionsJson = JSON.stringify({ suggestions }); // Emit the tool call name first agent.emit( toolCallChunkEvent({ toolCallId, toolCallName: "copilotkitSuggest", parentMessageId, delta: "", }), ); // Stream the JSON in chunks to simulate streaming const chunkSize = 10; // Characters per chunk for (let i = 0; i < suggestionsJson.length; i += chunkSize) { const chunk = suggestionsJson.substring(i, i + chunkSize); agent.emit( toolCallChunkEvent({ toolCallId, parentMessageId, delta: chunk, }), ); } } /** * A MockStepwiseAgent that emits suggestion events when run() is called */ export class SuggestionsProviderAgent extends MockStepwiseAgent { // Shared via a container so clone() and original see the same value even // when setSuggestions() is called after the clone is created. private _shared: { suggestions: Array<{ title: string; message: string }> } = { suggestions: [] }; setSuggestions(suggestions: Array<{ title: string; message: string }>) { this._shared.suggestions = suggestions; } clone(): this { const cloned = super.clone(); (cloned as unknown as { _shared: typeof this._shared })._shared = this._shared; return cloned; } run(_input: RunAgentInput): Observable { // Call the parent's run() to get the Subject that's already set up const parentObservable = super.run(_input); // Use setTimeout to emit events asynchronously through the existing subject setTimeout(() => { const messageId = testId("suggest-msg"); this.emit({ type: EventType.RUN_STARTED } as BaseEvent); emitSuggestionToolCall(this, { toolCallId: testId("tc"), parentMessageId: messageId, suggestions: this._shared.suggestions, }); this.emit({ type: EventType.RUN_FINISHED } as BaseEvent); this.complete(); }, 0); return parentObservable; } }