UNPKG

@tanstack/ai

Version:

Core TanStack AI library - Open source AI SDK

386 lines (385 loc) 14.3 kB
import { ChunkRecording, ChunkStrategy, ProcessorResult, ProcessorState, ToolCallState } from './types.js'; import { ContentPart, ModelMessage, StreamChunk, UIMessage } from '../../../types.js'; /** * Events emitted by the StreamProcessor */ export interface StreamProcessorEvents { onMessagesChange?: (messages: Array<UIMessage>) => void; onStreamStart?: () => void; onStreamEnd?: (message: UIMessage) => void; onError?: (error: Error) => void; onToolCall?: (args: { toolCallId: string; toolName: string; input: any; }) => void; onApprovalRequest?: (args: { toolCallId: string; toolName: string; input: any; approvalId: string; }) => void; onCustomEvent?: (eventType: string, data: unknown, context: { toolCallId?: string; }) => void; onTextUpdate?: (messageId: string, content: string) => void; onToolCallStateChange?: (messageId: string, toolCallId: string, state: ToolCallState, args: string) => void; onThinkingUpdate?: (messageId: string, content: string) => void; } /** * Options for StreamProcessor */ export interface StreamProcessorOptions { chunkStrategy?: ChunkStrategy; /** Event-driven handlers */ events?: StreamProcessorEvents; jsonParser?: { parse: (jsonString: string) => any; }; /** Enable recording for replay testing */ recording?: boolean; /** Initial messages to populate the processor */ initialMessages?: Array<UIMessage>; } /** * StreamProcessor - State machine for processing AI response streams * * Manages the full UIMessage[] conversation and emits events on changes. * Trusts the adapter contract: adapters emit clean AG-UI events in the * correct order. * * State tracking: * - Full message array * - Per-message stream state (text, tool calls, thinking) * - Multiple concurrent message streams * - Tool call completion via TOOL_CALL_END events * * @see docs/chat-architecture.md#streamprocessor-internal-state — State field reference * @see docs/chat-architecture.md#adapter-contract — What this class expects from adapters */ export declare class StreamProcessor { private chunkStrategy; private events; private jsonParser; private recordingEnabled; private messages; private messageStates; private activeMessageIds; private toolCallToMessage; private pendingManualMessageId; private activeRuns; private finishReason; private hasError; private isDone; private recording; private recordingStartTime; constructor(options?: StreamProcessorOptions); /** * Set the messages array (e.g., from persisted state) */ setMessages(messages: Array<UIMessage>): void; /** * Add a user message to the conversation. * Supports both simple string content and multimodal content arrays. * * @param content - The message content (string or array of content parts) * @param id - Optional custom message ID (generated if not provided) * @returns The created UIMessage * * @example * ```ts * // Simple text message * processor.addUserMessage('Hello!') * * // Multimodal message with image * processor.addUserMessage([ * { type: 'text', content: 'What is in this image?' }, * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } } * ]) * * // With custom ID * processor.addUserMessage('Hello!', 'custom-id-123') * ``` */ addUserMessage(content: string | Array<ContentPart>, id?: string): UIMessage; /** * Prepare for a new assistant message stream. * Does NOT create the message immediately -- the message is created lazily * when the first content-bearing chunk arrives via ensureAssistantMessage(). * This prevents empty assistant messages from flickering in the UI when * auto-continuation produces no content. */ prepareAssistantMessage(): void; /** * @deprecated Use prepareAssistantMessage() instead. This eagerly creates * an assistant message which can cause empty message flicker. */ startAssistantMessage(messageId?: string): string; /** * Get the current assistant message ID (if one has been created). * Returns null if prepareAssistantMessage() was called but no content * has arrived yet. */ getCurrentAssistantMessageId(): string | null; /** * Add a tool result (called by client after handling onToolCall) */ addToolResult(toolCallId: string, output: any, error?: string): void; /** * Add an approval response (called by client after handling onApprovalRequest) */ addToolApprovalResponse(approvalId: string, approved: boolean): void; /** * Get the conversation as ModelMessages (for sending to LLM) */ toModelMessages(): Array<ModelMessage>; /** * Get current messages */ getMessages(): Array<UIMessage>; /** * Check if all tool calls in the last assistant message are complete * Useful for auto-continue logic */ areAllToolsComplete(): boolean; /** * Remove messages after a certain index (for reload/retry) */ removeMessagesAfter(index: number): void; /** * Clear all messages */ clearMessages(): void; /** * Process a stream and emit events through handlers */ process(stream: AsyncIterable<any>): Promise<ProcessorResult>; /** * Process a single chunk from the stream. * * Central dispatch for all AG-UI events. Each event type maps to a specific * handler. Events not listed in the switch are intentionally ignored * (RUN_STARTED, STEP_STARTED, STATE_DELTA). * * @see docs/chat-architecture.md#adapter-contract — Expected event types and ordering */ processChunk(chunk: StreamChunk): void; /** * Create a new MessageStreamState for a message */ private createMessageState; /** * Get the MessageStreamState for a message */ private getMessageState; /** * Get the most recent active assistant message ID. * Used as fallback for events that don't include a messageId. */ private getActiveAssistantMessageId; /** * Ensure an active assistant message exists, creating one if needed. * Used for backward compat when events arrive without prior TEXT_MESSAGE_START. * * On reconnect/resume, a TEXT_MESSAGE_CONTENT may arrive for a message that * already exists in this.messages (e.g. from initialMessages or a prior * MESSAGES_SNAPSHOT) but whose transient state was cleared. In that case we * hydrate state from the existing message rather than creating a duplicate. */ private ensureAssistantMessage; /** * Handle TEXT_MESSAGE_START event */ private handleTextMessageStartEvent; /** * Handle TEXT_MESSAGE_END event */ private handleTextMessageEndEvent; /** * Handle MESSAGES_SNAPSHOT event */ private handleMessagesSnapshotEvent; /** * Handle TEXT_MESSAGE_CONTENT event. * * Accumulates delta into both currentSegmentText (for UI emission) and * totalTextContent (for ProcessorResult). Lazily creates the assistant * UIMessage on first content. Uses updateTextPart() which replaces the * last TextPart or creates a new one depending on part ordering. * * @see docs/chat-architecture.md#single-shot-text-response — Text accumulation step-by-step * @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic */ private handleTextMessageContentEvent; /** * Handle TOOL_CALL_START event. * * Creates a new InternalToolCallState entry in the toolCalls Map and appends * a ToolCallPart to the UIMessage. Duplicate toolCallId is a no-op. * * CRITICAL: This MUST be received before any TOOL_CALL_ARGS for the same * toolCallId. Args for unknown IDs are silently dropped. * * @see docs/chat-architecture.md#single-shot-tool-call-response — Tool call state transitions * @see docs/chat-architecture.md#parallel-tool-calls-single-shot — Parallel tracking by ID * @see docs/chat-architecture.md#adapter-contract — Ordering requirements */ private handleToolCallStartEvent; /** * Handle TOOL_CALL_ARGS event. * * Appends the delta to the tool call's accumulated arguments string. * Transitions state from awaiting-input → input-streaming on first non-empty delta. * Attempts partial JSON parse on each update for UI preview. * * If toolCallId is not found in the Map (no preceding TOOL_CALL_START), * this event is silently dropped. * * @see docs/chat-architecture.md#single-shot-tool-call-response — Step-by-step tool call processing */ private handleToolCallArgsEvent; /** * Handle TOOL_CALL_END event — authoritative signal that a tool call's input is finalized. * * This event has a DUAL ROLE: * - Without `result`: Signals arguments are done (from adapter). Transitions to input-complete. * - With `result`: Signals tool was executed and result is available (from TextEngine). * Creates both output on the tool-call part AND a tool-result part. * * If `input` is provided, it overrides the accumulated string parse as the * canonical parsed arguments. * * @see docs/chat-architecture.md#tool-results-and-the-tool_call_end-dual-role — Full explanation * @see docs/chat-architecture.md#single-shot-tool-call-response — End-to-end flow */ private handleToolCallEndEvent; /** * Handle RUN_STARTED event. * * Registers the run so that RUN_FINISHED can determine whether other * runs are still active before finalizing. */ private handleRunStartedEvent; /** * Handle RUN_FINISHED event. * * Records the finishReason and removes the run from activeRuns. * Only finalizes when no more runs are active, so that concurrent * runs don't interfere with each other. * * @see docs/chat-architecture.md#single-shot-tool-call-response — finishReason semantics * @see docs/chat-architecture.md#adapter-contract — Why RUN_FINISHED is mandatory */ private handleRunFinishedEvent; /** * Handle RUN_ERROR event */ private handleRunErrorEvent; /** * Handle STEP_FINISHED event (for thinking/reasoning content). * * Accumulates delta into thinkingContent and updates a single ThinkingPart * in the UIMessage (replaced in-place, not appended). * * @see docs/chat-architecture.md#thinkingreasoning-content — Thinking flow */ private handleStepFinishedEvent; /** * Handle CUSTOM event. * * Handles special custom events emitted by the TextEngine (not adapters): * - 'tool-input-available': Client tool needs execution. Fires onToolCall. * - 'approval-requested': Tool needs user approval. Updates tool-call part * state and fires onApprovalRequest. * * @see docs/chat-architecture.md#client-tools-and-approval-flows — Full flow details */ private handleCustomEvent; /** * Detect if an incoming content chunk represents a NEW text segment */ private isNewTextSegment; /** * Complete all tool calls across all active messages — safety net for stream termination. * * Called by RUN_FINISHED and finalizeStream(). Force-transitions any tool call * not yet in input-complete state. Handles cases where TOOL_CALL_END was * missed (adapter bug, network error, aborted stream). * * @see docs/chat-architecture.md#single-shot-tool-call-response — Safety net behavior */ private completeAllToolCalls; /** * Complete all tool calls for a specific message */ private completeAllToolCallsForMessage; /** * Mark a tool call as complete and emit event */ private completeToolCall; /** * Emit pending text update for a specific message. * * Calls updateTextPart() which has critical append-vs-replace logic: * - If last UIMessage part is TextPart → replaces its content (same segment). * - If last part is anything else → pushes new TextPart (new segment after tools). * * @see docs/chat-architecture.md#uimessage-part-ordering-invariants — Replace vs. push logic */ private emitTextUpdateForMessage; /** * Emit messages change event */ private emitMessagesChange; /** * Finalize the stream — complete all pending operations. * * Called when the async iterable ends (stream closed). Acts as the final * safety net: completes any remaining tool calls, flushes un-emitted text, * and fires onStreamEnd. * * @see docs/chat-architecture.md#single-shot-text-response — Finalization step */ finalizeStream(): void; /** * Get completed tool calls in API format (aggregated across all messages) */ private getCompletedToolCalls; /** * Get current result (aggregated across all messages) */ private getResult; /** * Get current processor state (aggregated across all messages) */ getState(): ProcessorState; /** * Start recording chunks */ startRecording(): void; /** * Get the current recording */ getRecording(): ChunkRecording | null; /** * Reset stream state (but keep messages) */ private resetStreamState; /** * Full reset (including messages) */ reset(): void; /** * Check if a message contains only whitespace text and no other meaningful parts * (no tool calls, tool results, thinking, etc.) */ private isWhitespaceOnlyMessage; /** * Replay a recording through the processor */ static replay(recording: ChunkRecording, options?: StreamProcessorOptions): Promise<ProcessorResult>; } /** * Create an async iterable from a recording */ export declare function createReplayStream(recording: ChunkRecording): AsyncIterable<StreamChunk>;