@tanstack/ai
Version:
Core TanStack AI library - Open source AI SDK
386 lines (385 loc) • 14.3 kB
TypeScript
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>;