UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

1,267 lines 51.9 kB
import { normalizeToolResult } from "../../../utilities/tool-result.js"; import { isProviderExecutedToolCall } from "../../../utilities/provider-executed.js"; import { aguiSnapshotMessageToUIMessage, generateMessageId, uiMessageToModelMessages } from "../messages.js"; import { defaultJSONParser } from "./json-parser.js"; import { appendStructuredOutputDelta, completeStructuredOutputPart, errorStructuredOutputPart, updateTextPart, updateThinkingPart, updateToolCallApproval, updateToolCallApprovalResponse, updateToolCallPart, updateToolCallWithOutput, updateToolResultPart } from "./message-updaters.js"; import { ImmediateStrategy } from "./strategies.js"; //#region src/activities/chat/stream/processor.ts /** * Unified Stream Processor * * Core stream processing engine that manages the full UIMessage[] conversation. * Single source of truth for message state. * * Handles: * - Full conversation management (UIMessage[]) * - Text content accumulation with configurable chunking strategies * - Parallel tool calls with lifecycle state tracking * - Tool results and approval flows * - Thinking/reasoning content * - Recording/replay for testing * - Event-driven architecture for UI updates * - Per-message stream state tracking for multi-message sessions * * @see docs/chat-architecture.md — Canonical reference for AG-UI chunk ordering, * adapter contract, single-shot flows, and expected UIMessage output. */ var STRUCTURED_OUTPUT_UPDATE_BATCH_SIZE = 12; /** * 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 */ var StreamProcessor = class StreamProcessor { chunkStrategy; events; jsonParser; recordingEnabled; messages = []; messageStates = /* @__PURE__ */ new Map(); activeMessageIds = /* @__PURE__ */ new Set(); toolCallToMessage = /* @__PURE__ */ new Map(); pendingManualMessageId = null; pendingThinkingStepId = null; structuredMessageIds = /* @__PURE__ */ new Set(); structuredOutputUpdateBatches = /* @__PURE__ */ new Map(); activeRuns = /* @__PURE__ */ new Set(); finishReason = null; hasError = false; isDone = false; recording = null; recordingStartTime = 0; constructor(options = {}) { this.chunkStrategy = options.chunkStrategy || new ImmediateStrategy(); this.events = options.events || {}; this.jsonParser = options.jsonParser || defaultJSONParser; this.recordingEnabled = options.recording ?? false; if (options.initialMessages) this.messages = [...options.initialMessages]; } /** * Set the messages array (e.g., from persisted state) */ setMessages(messages) { this.messages = [...messages]; this.emitMessagesChange(); } /** * 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, id) { const parts = typeof content === "string" ? [{ type: "text", content }] : content.map((part) => { return part; }); const userMessage = { id: id ?? generateMessageId(), role: "user", parts, createdAt: /* @__PURE__ */ new Date() }; this.messages = [...this.messages, userMessage]; this.emitMessagesChange(); return userMessage; } /** * 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() { this.resetStreamState(); } /** * @deprecated Use prepareAssistantMessage() instead. This eagerly creates * an assistant message which can cause empty message flicker. */ startAssistantMessage(messageId) { this.prepareAssistantMessage(); const { messageId: id } = this.ensureAssistantMessage(messageId); this.pendingManualMessageId = id; return id; } /** * Get the current assistant message ID (if one has been created). * Returns null if prepareAssistantMessage() was called but no content * has arrived yet. */ getCurrentAssistantMessageId() { let lastId = null; for (const [id, state] of this.messageStates) if (state.role === "assistant") lastId = id; return lastId; } /** * Add a tool result (called by client after handling onToolCall) */ addToolResult(toolCallId, output, error) { const messageWithToolCall = this.messages.find((msg) => msg.parts.some((p) => p.type === "tool-call" && p.id === toolCallId)); if (!messageWithToolCall) { console.warn(`[StreamProcessor] Could not find message with tool call ${toolCallId}`); return; } let updatedMessages = updateToolCallWithOutput(this.messages, toolCallId, output, error ? "error" : void 0, error); const content = normalizeToolResult(output); const toolResultState = error ? "error" : "complete"; updatedMessages = updateToolResultPart(updatedMessages, messageWithToolCall.id, toolCallId, content, toolResultState, error); this.messages = updatedMessages; this.emitMessagesChange(); } /** * Add an approval response (called by client after handling onApprovalRequest) */ addToolApprovalResponse(approvalId, approved) { this.messages = updateToolCallApprovalResponse(this.messages, approvalId, approved); this.emitMessagesChange(); } /** * Get the conversation as ModelMessages (for sending to LLM) */ toModelMessages() { const modelMessages = []; for (const msg of this.messages) modelMessages.push(...uiMessageToModelMessages(msg)); return modelMessages; } /** * Get current messages */ getMessages() { return this.messages; } /** * Check if all tool calls in the last assistant message are complete * Useful for auto-continue logic */ areAllToolsComplete() { const lastAssistant = this.messages.findLast((m) => m.role === "assistant"); if (!lastAssistant) return true; const toolParts = lastAssistant.parts.filter((p) => p.type === "tool-call"); if (toolParts.length === 0) return true; const toolResultIds = new Set(lastAssistant.parts.filter((p) => p.type === "tool-result").map((p) => p.toolCallId)); return toolParts.every((part) => part.state === "complete" || part.state === "approval-responded" || part.output !== void 0 && !part.approval || toolResultIds.has(part.id) || isProviderExecutedToolCall(part)); } /** * Remove messages after a certain index (for reload/retry) */ removeMessagesAfter(index) { const keptIds = new Set(this.messages.slice(0, index + 1).map((m) => m.id)); for (const id of this.structuredMessageIds) if (!keptIds.has(id)) this.structuredMessageIds.delete(id); for (const id of this.structuredOutputUpdateBatches.keys()) if (!keptIds.has(id)) this.structuredOutputUpdateBatches.delete(id); for (const id of this.messageStates.keys()) if (!keptIds.has(id)) this.messageStates.delete(id); for (const [toolCallId, msgId] of this.toolCallToMessage) if (!keptIds.has(msgId)) this.toolCallToMessage.delete(toolCallId); for (const id of this.activeMessageIds) if (!keptIds.has(id)) this.activeMessageIds.delete(id); this.messages = this.messages.slice(0, index + 1); this.emitMessagesChange(); } /** * Clear all messages */ clearMessages() { this.messages = []; this.messageStates.clear(); this.activeMessageIds.clear(); this.toolCallToMessage.clear(); this.structuredMessageIds.clear(); this.structuredOutputUpdateBatches.clear(); this.pendingManualMessageId = null; this.emitMessagesChange(); } /** * Process a stream and emit events through handlers */ async process(stream) { this.resetStreamState(); if (this.recordingEnabled) this.startRecording(); for await (const chunk of stream) this.processChunk(chunk); this.finalizeStream(); if (this.recording) this.recording.result = this.getResult(); return this.getResult(); } /** * 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 * (STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA). * * @see docs/chat-architecture.md#adapter-contract — Expected event types and ordering */ processChunk(chunk) { if (this.recording) this.recording.chunks.push({ chunk, timestamp: Date.now(), index: this.recording.chunks.length }); switch (chunk.type) { case "TEXT_MESSAGE_START": this.handleTextMessageStartEvent(chunk); break; case "TEXT_MESSAGE_CONTENT": this.handleTextMessageContentEvent(chunk); break; case "TEXT_MESSAGE_END": this.handleTextMessageEndEvent(chunk); break; case "TOOL_CALL_START": this.handleToolCallStartEvent(chunk); break; case "TOOL_CALL_ARGS": this.handleToolCallArgsEvent(chunk); break; case "TOOL_CALL_END": this.handleToolCallEndEvent(chunk); break; case "RUN_FINISHED": this.handleRunFinishedEvent(chunk); break; case "RUN_ERROR": this.handleRunErrorEvent(chunk); break; case "STEP_FINISHED": this.handleStepFinishedEvent(chunk); break; case "MESSAGES_SNAPSHOT": this.handleMessagesSnapshotEvent(chunk); break; case "CUSTOM": this.handleCustomEvent(chunk); break; case "RUN_STARTED": this.handleRunStartedEvent(chunk); break; case "REASONING_START": case "REASONING_MESSAGE_START": case "REASONING_MESSAGE_END": case "REASONING_END": break; case "REASONING_MESSAGE_CONTENT": this.handleReasoningMessageContentEvent(chunk); break; case "TOOL_CALL_RESULT": this.handleToolCallResultEvent(chunk); break; case "STEP_STARTED": this.handleStepStartedEvent(chunk); break; default: break; } } /** * Create a new MessageStreamState for a message */ createMessageState(messageId, role) { const state = { id: messageId, role, totalTextContent: "", currentSegmentText: "", lastEmittedText: "", hasSeenReasoningEvents: false, thinkingSteps: /* @__PURE__ */ new Map(), thinkingStepSignatures: /* @__PURE__ */ new Map(), thinkingStepOrder: [], currentThinkingStepId: null, toolCalls: /* @__PURE__ */ new Map(), toolCallOrder: [], hasToolCallsSinceTextStart: false, isComplete: false }; this.messageStates.set(messageId, state); return state; } /** * Get the MessageStreamState for a message */ getMessageState(messageId) { return this.messageStates.get(messageId); } /** * Promote a pending stepId from a STEP_STARTED that fired before the * assistant message existed onto the given message state, so the next * thinking event (STEP_FINISHED or REASONING_MESSAGE_CONTENT) attributes * to the correct step. */ consumePendingThinkingStep(state) { if (!this.pendingThinkingStepId) return; const stepId = this.pendingThinkingStepId; state.currentThinkingStepId = stepId; if (!state.thinkingSteps.has(stepId)) { state.thinkingSteps.set(stepId, ""); state.thinkingStepOrder.push(stepId); } this.pendingThinkingStepId = null; } /** * Get the most recent active assistant message ID. * Used as fallback for events that don't include a messageId. */ getActiveAssistantMessageId() { const ids = Array.from(this.activeMessageIds).reverse(); for (const id of ids) { const state = this.messageStates.get(id); if (state && state.role === "assistant") return id; } return null; } /** * 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. */ ensureAssistantMessage(preferredId) { if (preferredId) { const state = this.getMessageState(preferredId); if (state) return { messageId: preferredId, state }; } const activeId = this.getActiveAssistantMessageId(); if (activeId) { const state = this.getMessageState(activeId); if (state) return { messageId: activeId, state }; } if (preferredId) { const existingMsg = this.messages.find((m) => m.id === preferredId); if (existingMsg) { const state = this.createMessageState(preferredId, existingMsg.role); this.activeMessageIds.add(preferredId); const lastPart = existingMsg.parts.length > 0 ? existingMsg.parts[existingMsg.parts.length - 1] : null; if (lastPart && lastPart.type === "text") { state.currentSegmentText = lastPart.content; state.lastEmittedText = lastPart.content; state.totalTextContent = lastPart.content; } return { messageId: preferredId, state }; } } const id = preferredId || generateMessageId(); const assistantMessage = { id, role: "assistant", parts: [], createdAt: /* @__PURE__ */ new Date() }; this.messages = [...this.messages, assistantMessage]; const state = this.createMessageState(id, "assistant"); this.activeMessageIds.add(id); this.pendingManualMessageId = id; this.events.onStreamStart?.(); this.emitMessagesChange(); return { messageId: id, state }; } /** * Handle TEXT_MESSAGE_START event */ handleTextMessageStartEvent(chunk) { const { messageId, role } = chunk; const uiRole = role === "user" || role === "system" ? role : "assistant"; if (this.pendingManualMessageId) { const pendingId = this.pendingManualMessageId; this.pendingManualMessageId = null; if (pendingId !== messageId) { this.messages = this.messages.map((msg) => msg.id === pendingId ? { ...msg, id: messageId } : msg); const existingState = this.messageStates.get(pendingId); if (existingState) { existingState.id = messageId; this.messageStates.delete(pendingId); this.messageStates.set(messageId, existingState); } this.activeMessageIds.delete(pendingId); this.activeMessageIds.add(messageId); } if (!this.messageStates.has(messageId)) { this.createMessageState(messageId, uiRole); this.activeMessageIds.add(messageId); } this.emitMessagesChange(); return; } if (this.messages.find((m) => m.id === messageId)) { this.activeMessageIds.add(messageId); const existingState = this.messageStates.get(messageId); if (!existingState) this.createMessageState(messageId, uiRole); else if (existingState.hasToolCallsSinceTextStart) { if (existingState.currentSegmentText !== existingState.lastEmittedText) this.emitTextUpdateForMessage(messageId); existingState.currentSegmentText = ""; existingState.lastEmittedText = ""; existingState.hasToolCallsSinceTextStart = false; } return; } const newMessage = { id: messageId, role: uiRole, parts: [], createdAt: /* @__PURE__ */ new Date() }; this.messages = [...this.messages, newMessage]; this.createMessageState(messageId, uiRole); this.activeMessageIds.add(messageId); this.events.onStreamStart?.(); this.emitMessagesChange(); } /** * Handle TEXT_MESSAGE_END event */ handleTextMessageEndEvent(chunk) { const { messageId } = chunk; const state = this.getMessageState(messageId); if (!state) return; if (state.isComplete) return; if (state.currentSegmentText !== state.lastEmittedText) this.emitTextUpdateForMessage(messageId); this.completeAllToolCallsForMessage(messageId); } /** * Handle MESSAGES_SNAPSHOT event */ handleMessagesSnapshotEvent(chunk) { this.resetStreamState(); const prevMessages = this.messages; const normalized = chunk.messages.map(aguiSnapshotMessageToUIMessage); this.messages = this.reconcileSnapshotToolCalls(normalized, prevMessages); this.emitMessagesChange(); } /** * Reconcile a freshly normalized snapshot with the pre-snapshot message * state so unreconstructable tool-call metadata is preserved. * * Post-pass (a): anchor `tool-result`-only assistant messages (the shape * `aguiSnapshotMessageToUIMessage` emits for AG-UI `role: 'tool'` wire * messages) into the message containing the matching `tool-call` part, or — * when the snapshot supplies no such part — the nearest earlier anchorable * assistant message, matching the in-stream fan-out shape * `assistant: [text, tool-call, tool-result, ...]`. Detached messages with * no earlier anchorable assistant are kept verbatim. * * Post-pass (b): when a `tool-result` part references a `toolCallId` whose * `tool-call` part is absent from the snapshot, carry the `tool-call` part * forward from the pre-snapshot state (state and output untouched) so a * subsequent `addToolResult(toolCallId)` can still locate the call. * * Post-pass (c): AG-UI wire snapshots rebuild `tool-call` parts as * `input-complete` without `output` (ModelMessage has no result field on * the call). After anchoring results, copy each `tool-result` onto its * matching `tool-call` (and prefer pre-snapshot complete/output when the * snapshot is poorer) so server tools keep the same UI shape as client tools. */ reconcileSnapshotToolCalls(snapshot, prevMessages) { const prevToolCalls = /* @__PURE__ */ new Map(); for (const msg of prevMessages) for (const part of msg.parts) if (part.type === "tool-call") prevToolCalls.set(part.id, part); const snapshotToolCallIds = /* @__PURE__ */ new Set(); for (const msg of snapshot) for (const part of msg.parts) if (part.type === "tool-call") snapshotToolCallIds.add(part.id); const reconciled = []; for (const msg of snapshot) { const toolResultPart = msg.role === "assistant" && msg.parts.length === 1 ? msg.parts.find((p) => p.type === "tool-result") : void 0; if (!toolResultPart) { reconciled.push(msg); continue; } const target = reconciled.findLast((m) => m.parts.some((p) => p.type === "tool-call" && p.id === toolResultPart.toolCallId)) ?? reconciled.findLast((m) => m.role === "assistant" && !(m.parts.length === 1 && m.parts[0]?.type === "tool-result")); if (!target) { if (!snapshotToolCallIds.has(toolResultPart.toolCallId)) console.warn(`[StreamProcessor] MESSAGES_SNAPSHOT contains a tool-result for "${toolResultPart.toolCallId}" but no matching tool-call exists in the snapshot, and there is no assistant message to anchor into; addToolResult("${toolResultPart.toolCallId}") will not be able to locate this call`); reconciled.push(msg); continue; } const parts = [...target.parts]; if (!snapshotToolCallIds.has(toolResultPart.toolCallId) && !parts.some((p) => p.type === "tool-call" && p.id === toolResultPart.toolCallId)) { const prev = prevToolCalls.get(toolResultPart.toolCallId); if (prev) { parts.push({ ...prev }); snapshotToolCallIds.add(prev.id); } else console.warn(`[StreamProcessor] MESSAGES_SNAPSHOT contains a tool-result for "${toolResultPart.toolCallId}" but no matching tool-call exists in the snapshot or the pre-snapshot state; addToolResult("${toolResultPart.toolCallId}") will not be able to locate this call`); } parts.push(toolResultPart); target.parts = parts; } return this.enrichSnapshotToolCallsFromResults(reconciled, prevToolCalls); } /** * Post-pass (c): fold `tool-result` content into sibling `tool-call` parts * and prefer pre-snapshot complete/output when the snapshot rebuilt a * poorer `input-complete` call (AG-UI ModelMessage has no result on calls). */ enrichSnapshotToolCallsFromResults(messages, prevToolCalls) { const resultsByCallId = /* @__PURE__ */ new Map(); for (const msg of messages) for (const part of msg.parts) if (part.type === "tool-result") resultsByCallId.set(part.toolCallId, part); return messages.map((msg) => { const parts = msg.parts.map((part) => { if (part.type !== "tool-call") return part; const prev = prevToolCalls.get(part.id); const result = resultsByCallId.get(part.id); let next = part; if (prev && (prev.output !== void 0 || prev.state === "complete" || prev.state === "error") && (part.output === void 0 || part.state === "input-complete" || part.state === "input-streaming" || part.state === "awaiting-input")) next = { ...part, ...prev.output !== void 0 ? { output: prev.output } : {}, state: prev.state, ...prev.approval !== void 0 ? { approval: prev.approval } : {}, ...prev.metadata !== void 0 ? { metadata: prev.metadata } : {} }; if (result && next.output === void 0) { let output; if (Array.isArray(result.content)) output = result.content; else try { output = JSON.parse(result.content); } catch { output = result.content; } const errorText = result.state === "error" ? this.extractToolResultError(output) : void 0; next = { ...next, output: errorText ? { error: errorText } : output, state: result.state === "error" ? "error" : "complete" }; } return next; }); return parts.some((part, index) => part !== msg.parts[index]) ? { ...msg, parts } : msg; }); } /** * 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 */ handleTextMessageContentEvent(chunk) { const { messageId, state } = this.ensureAssistantMessage(chunk.messageId); this.completeAllToolCallsForMessage(messageId); if (this.structuredMessageIds.has(messageId)) { let delta = chunk.delta || ""; if (delta === "" && chunk.content !== void 0 && chunk.content !== "") { const existingRaw = (this.messages.find((m) => m.id === messageId)?.parts.find((p) => p.type === "structured-output") ?? { raw: "" }).raw; if (chunk.content.startsWith(existingRaw)) delta = chunk.content.slice(existingRaw.length); else if (existingRaw.startsWith(chunk.content)) delta = ""; else delta = chunk.content; } if (delta !== "") { this.messages = appendStructuredOutputDelta(this.messages, messageId, delta); state.totalTextContent += delta; this.queueStructuredOutputUpdate(messageId, delta); this.emitMessagesChange(); } return; } const previousSegment = state.currentSegmentText; if (state.hasToolCallsSinceTextStart && previousSegment.length > 0 && this.isNewTextSegment(chunk, previousSegment)) { if (previousSegment !== state.lastEmittedText) this.emitTextUpdateForMessage(messageId); state.currentSegmentText = ""; state.lastEmittedText = ""; state.hasToolCallsSinceTextStart = false; } const currentText = state.currentSegmentText; let nextText = currentText; const delta = chunk.delta || ""; if (delta !== "") nextText = currentText + delta; else if (chunk.content !== void 0 && chunk.content !== "") if (chunk.content.startsWith(currentText)) nextText = chunk.content; else if (currentText.startsWith(chunk.content)) nextText = currentText; else nextText = currentText + chunk.content; const textDelta = nextText.slice(currentText.length); state.currentSegmentText = nextText; state.totalTextContent += textDelta; const chunkPortion = chunk.delta || chunk.content || ""; if (this.chunkStrategy.shouldEmit(chunkPortion, state.currentSegmentText) && state.currentSegmentText !== state.lastEmittedText) this.emitTextUpdateForMessage(messageId); } /** * 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 */ handleToolCallStartEvent(chunk) { const targetMessageId = chunk.parentMessageId ?? this.getActiveAssistantMessageId(); const { messageId, state } = this.ensureAssistantMessage(targetMessageId ?? void 0); state.hasToolCallsSinceTextStart = true; const toolCallId = chunk.toolCallId; if (!state.toolCalls.get(toolCallId)) { const initialState = "awaiting-input"; const toolName = chunk.toolCallName ?? chunk.toolName; const chunkMetadata = chunk.metadata; const newToolCall = { id: chunk.toolCallId, name: toolName, arguments: "", state: initialState, parsedArguments: void 0, index: chunk.index ?? state.toolCalls.size, ...chunkMetadata !== void 0 && { metadata: chunkMetadata } }; state.toolCalls.set(toolCallId, newToolCall); state.toolCallOrder.push(toolCallId); this.toolCallToMessage.set(toolCallId, messageId); this.messages = updateToolCallPart(this.messages, messageId, { id: chunk.toolCallId, name: toolName, arguments: "", state: initialState, ...chunkMetadata !== void 0 && { metadata: chunkMetadata } }); this.emitMessagesChange(); this.events.onToolCallStateChange?.(messageId, chunk.toolCallId, initialState, ""); } } /** * 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 */ handleToolCallArgsEvent(chunk) { const toolCallId = chunk.toolCallId; const messageId = this.toolCallToMessage.get(toolCallId); if (!messageId) return; const state = this.getMessageState(messageId); if (!state) return; const existingToolCall = state.toolCalls.get(toolCallId); if (!existingToolCall) return; const wasAwaitingInput = existingToolCall.state === "awaiting-input"; existingToolCall.arguments += chunk.delta || ""; if (wasAwaitingInput && chunk.delta) existingToolCall.state = "input-streaming"; existingToolCall.parsedArguments = this.jsonParser.parse(existingToolCall.arguments); this.messages = updateToolCallPart(this.messages, messageId, { id: existingToolCall.id, name: existingToolCall.name, arguments: existingToolCall.arguments, state: existingToolCall.state }); this.emitMessagesChange(); this.events.onToolCallStateChange?.(messageId, existingToolCall.id, existingToolCall.state, existingToolCall.arguments); } /** * 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 */ handleToolCallEndEvent(chunk) { const messageId = this.toolCallToMessage.get(chunk.toolCallId); if (!messageId) return; const msgState = this.getMessageState(messageId); if (!msgState) return; const existingToolCall = msgState.toolCalls.get(chunk.toolCallId); if (existingToolCall && existingToolCall.state !== "input-complete") { if (chunk.input !== void 0 && !existingToolCall.arguments) try { existingToolCall.arguments = JSON.stringify(chunk.input); } catch {} const index = msgState.toolCallOrder.indexOf(chunk.toolCallId); this.completeToolCall(messageId, index, existingToolCall); if (chunk.input !== void 0) { existingToolCall.parsedArguments = chunk.input; this.messages = updateToolCallPart(this.messages, messageId, { id: existingToolCall.id, name: existingToolCall.name, arguments: existingToolCall.arguments, state: "input-complete", input: chunk.input, ...existingToolCall.metadata !== void 0 && { metadata: existingToolCall.metadata } }); this.emitMessagesChange(); } } if (chunk.result) { let output; if (Array.isArray(chunk.result)) output = chunk.result; else try { output = JSON.parse(chunk.result); } catch { output = chunk.result; } this.messages = updateToolCallWithOutput(this.messages, chunk.toolCallId, output, chunk.state === "output-error" ? "error" : void 0); const resultState = chunk.state === "output-error" ? "error" : "complete"; this.messages = updateToolResultPart(this.messages, messageId, chunk.toolCallId, chunk.result, resultState, resultState === "error" ? this.extractToolResultError(output) : void 0); this.emitMessagesChange(); } } extractToolResultError(output) { if (output && typeof output === "object" && "error" in output && typeof output.error === "string") return output.error; return typeof output === "string" ? output : "Tool execution failed"; } /** * Handle TOOL_CALL_RESULT event (AG-UI spec). * * Creates a tool-result part and updates the tool-call output field, * mirroring the logic from TOOL_CALL_END when it carries a result. * This is the spec-compliant path for delivering tool results to the client. */ handleToolCallResultEvent(chunk) { const messageId = this.toolCallToMessage.get(chunk.toolCallId); if (!messageId) return; let output; try { output = JSON.parse(chunk.content); } catch { output = chunk.content; } this.messages = updateToolCallWithOutput(this.messages, chunk.toolCallId, output, chunk.state === "output-error" ? "error" : void 0); const resultState = chunk.state === "output-error" ? "error" : "complete"; this.messages = updateToolResultPart(this.messages, messageId, chunk.toolCallId, chunk.content, resultState, resultState === "error" ? this.extractToolResultError(output) : void 0); this.emitMessagesChange(); } /** * Handle RUN_STARTED event. * * Registers the run so that RUN_FINISHED can determine whether other * runs are still active before finalizing. */ handleRunStartedEvent(chunk) { this.activeRuns.add(chunk.runId); } /** * 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 */ handleRunFinishedEvent(chunk) { this.finishReason = chunk.finishReason ?? null; this.activeRuns.delete(chunk.runId); if (chunk.outcome?.type === "interrupt") this.handleInterrupts(chunk.outcome.interrupts); if (this.activeRuns.size === 0) { this.isDone = true; this.completeAllToolCalls(); this.finalizeStream(); } } handleInterrupts(interrupts) { for (const interrupt of interrupts) { const metadata = interrupt.metadata && typeof interrupt.metadata === "object" ? interrupt.metadata : {}; const kind = typeof metadata.kind === "string" ? metadata.kind : void 0; const toolCallId = interrupt.toolCallId; if (!toolCallId) continue; const toolName = typeof metadata.toolName === "string" ? metadata.toolName : this.findToolCallName(toolCallId); const input = Object.hasOwn(metadata, "input") ? metadata.input : {}; if (kind === "approval" || interrupt.reason === "approval_required") { const resolvedMessageId = this.getActiveAssistantMessageId() ?? this.toolCallToMessage.get(toolCallId) ?? this.messages.find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "tool-call" && p.id === toolCallId))?.id; if (resolvedMessageId) { this.messages = updateToolCallApproval(this.messages, resolvedMessageId, toolCallId, interrupt.id); this.emitMessagesChange(); } this.events.onApprovalRequest?.({ toolCallId, toolName, input, approvalId: interrupt.id }); continue; } if (kind === "client_tool" || interrupt.reason === "client_tool_input") this.events.onToolCall?.({ toolCallId, toolName, input }); } } findToolCallName(toolCallId) { for (const state of this.messageStates.values()) { const toolCall = state.toolCalls.get(toolCallId); if (toolCall) return toolCall.name; } return ""; } /** * Handle RUN_ERROR event */ handleRunErrorEvent(chunk) { this.hasError = true; const runId = "runId" in chunk && typeof chunk.runId === "string" ? chunk.runId : void 0; if (runId) this.activeRuns.delete(runId); else this.activeRuns.clear(); const { messageId } = this.ensureAssistantMessage(); const errorMessage = chunk.message || chunk.error?.message || "An error occurred"; if (!chunk.message && !chunk.error?.message) console.error("[StreamProcessor] RUN_ERROR with no message; original chunk:", chunk); if (this.structuredMessageIds.has(messageId)) { this.flushStructuredOutputUpdate(messageId); this.messages = errorStructuredOutputPart(this.messages, messageId, errorMessage); this.structuredMessageIds.delete(messageId); this.emitStructuredOutputChange(messageId, "error"); this.emitMessagesChange(); } const error = new Error(errorMessage); const code = chunk.code ?? chunk.error?.code; if (code !== void 0) Object.assign(error, { code }); if (chunk.rawEvent !== void 0) Object.assign(error, { rawEvent: chunk.rawEvent }); this.events.onError?.(error); } /** * Handle STEP_STARTED event (for thinking/reasoning content). * * Records the stepId so that subsequent STEP_FINISHED deltas accumulate * into their own ThinkingPart. Does not create a message — the message * is lazily created when the first STEP_FINISHED content arrives. */ handleStepStartedEvent(chunk) { const stepId = chunk.stepId ?? generateMessageId(); const activeId = this.getActiveAssistantMessageId(); if (activeId) { const state = this.getMessageState(activeId); if (state) { state.currentThinkingStepId = stepId; if (!state.thinkingSteps.has(stepId)) { state.thinkingSteps.set(stepId, ""); state.thinkingStepOrder.push(stepId); } this.pendingThinkingStepId = null; return; } } this.pendingThinkingStepId = stepId; } /** * Handle STEP_FINISHED event (for thinking/reasoning content). * * Accumulates delta into the current thinking step's content and updates * the corresponding ThinkingPart in the UIMessage. * * @see docs/chat-architecture.md#thinkingreasoning-content — Thinking flow */ handleStepFinishedEvent(chunk) { const { messageId, state } = this.ensureAssistantMessage(this.getActiveAssistantMessageId() ?? void 0); if (state.hasSeenReasoningEvents) { if (chunk.signature) { const stepId = state.currentThinkingStepId ?? chunk.stepId; if (!stepId) return; const thinking = state.thinkingSteps.get(stepId); if (thinking !== void 0) { state.thinkingStepSignatures.set(stepId, chunk.signature); this.messages = updateThinkingPart(this.messages, messageId, stepId, thinking, chunk.signature); this.emitMessagesChange(); } } return; } this.consumePendingThinkingStep(state); const stepId = state.currentThinkingStepId ?? chunk.stepId ?? generateMessageId(); if (!state.thinkingSteps.has(stepId)) { state.thinkingSteps.set(stepId, ""); state.thinkingStepOrder.push(stepId); state.currentThinkingStepId = stepId; } const previous = state.thinkingSteps.get(stepId) ?? ""; let nextThinking = previous; if (chunk.delta && chunk.delta !== "") nextThinking = previous + chunk.delta; else if (chunk.content && chunk.content !== "") if (chunk.content.startsWith(previous)) nextThinking = chunk.content; else if (previous.startsWith(chunk.content)) nextThinking = previous; else nextThinking = previous + chunk.content; state.thinkingSteps.set(stepId, nextThinking); if (chunk.signature) state.thinkingStepSignatures.set(stepId, chunk.signature); this.messages = updateThinkingPart(this.messages, messageId, stepId, nextThinking, state.thinkingStepSignatures.get(stepId)); this.emitMessagesChange(); this.events.onThinkingUpdate?.(messageId, stepId, nextThinking); } /** * Handle REASONING_MESSAGE_CONTENT event (AG-UI reasoning protocol). * * Accumulates reasoning delta into thinking content and updates the * corresponding ThinkingPart in the UIMessage. */ handleReasoningMessageContentEvent(chunk) { const { messageId, state } = this.ensureAssistantMessage(this.getActiveAssistantMessageId() ?? void 0); state.hasSeenReasoningEvents = true; const delta = chunk.delta || ""; this.consumePendingThinkingStep(state); const stepId = state.currentThinkingStepId ?? chunk.messageId; if (!state.thinkingSteps.has(stepId)) { state.thinkingSteps.set(stepId, ""); state.thinkingStepOrder.push(stepId); state.currentThinkingStepId = stepId; } const nextThinking = (state.thinkingSteps.get(stepId) ?? "") + delta; state.thinkingSteps.set(stepId, nextThinking); this.messages = updateThinkingPart(this.messages, messageId, stepId, nextThinking, state.thinkingStepSignatures.get(stepId)); this.emitMessagesChange(); this.events.onThinkingUpdate?.(messageId, stepId, nextThinking); } /** * Handle CUSTOM event. * * Handles custom events consumed by the processor: * - 'tool-input-available': Legacy/replay-compatible input for client tool * execution. Fires onToolCall. * - 'approval-requested': Legacy/replay-compatible input for tool approval. * Updates tool-call part state and fires onApprovalRequest. * * Current core streams represent user-actionable waits through * RUN_FINISHED.outcome.type === 'interrupt'; these custom events are not the * source of truth for new emissions. * * @see docs/chat-architecture.md#client-tools-and-approval-flows — Full flow details */ handleCustomEvent(chunk) { const messageId = this.getActiveAssistantMessageId(); if (chunk.name === "structured-output.start" && chunk.value) { const targetId = chunk.value.messageId ?? messageId; if (targetId) { this.ensureAssistantMessage(targetId); this.structuredMessageIds.add(targetId); this.structuredOutputUpdateBatches.delete(targetId); this.events.onStructuredOutputChange?.({ phase: "start", messageId: targetId, status: "streaming", raw: "" }); } return; } if (chunk.name === "structured-output.complete" && chunk.value) { const v = chunk.value; const targetId = v.messageId ?? messageId; if (targetId) { this.flushStructuredOutputUpdate(targetId); this.messages = completeStructuredOutputPart(this.messages, targetId, v.object, v.raw ?? "", v.reasoning); this.structuredMessageIds.delete(targetId); this.emitStructuredOutputChange(targetId, "complete"); this.emitMessagesChange(); } } if (chunk.name === "tool-input-available" && chunk.value) { const { toolCallId, toolName, input } = chunk.value; this.events.onToolCall?.({ toolCallId, toolName, input }); return; } if (chunk.name === "approval-requested" && chunk.value) { const { toolCallId, toolName, input, approval } = chunk.value; const resolvedMessageId = messageId ?? this.toolCallToMessage.get(toolCallId); if (resolvedMessageId) { this.messages = updateToolCallApproval(this.messages, resolvedMessageId, toolCallId, approval.id); this.emitMessagesChange(); } this.events.onApprovalRequest?.({ toolCallId, toolName, input, approvalId: approval.id }); return; } if (chunk.name === "ui-resource" && chunk.value) { const v = chunk.value; const resolvedMessageId = this.toolCallToMessage.get(v.toolCallId) ?? messageId; if (resolvedMessageId) { const part = { type: "ui-resource", resource: v.resource, toolCallId: v.toolCallId, toolName: v.toolName, ...v.serverId !== void 0 && { serverId: v.serverId }, ...v.meta !== void 0 && { meta: v.meta } }; this.messages = this.messages.map((msg) => msg.id === resolvedMessageId ? { ...msg, parts: [...msg.parts, part] } : msg); this.emitMessagesChange(); } else console.warn(`[mcp-apps] dropped ui-resource: no target message for toolCallId "${v.toolCallId}" (toolName "${v.toolName}")`); return; } if (this.events.onCustomEvent) { const toolCallId = chunk.value && typeof chunk.value === "object" ? chunk.value.toolCallId : void 0; this.events.onCustomEvent(chunk.name, chunk.value, { toolCallId }); } } /** * Detect if an incoming content chunk represents a NEW text segment */ isNewTextSegment(chunk, previous) { if (chunk.content !== void 0) { if (chunk.content.length < previous.length) return true; if (!chunk.content.startsWith(previous) && !previous.startsWith(chunk.content)) return true; } return false; } /** * 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 */ completeAllToolCalls() { for (const messageId of this.activeMessageIds) this.completeAllToolCallsForMessage(messageId); } /** * Complete all tool calls for a specific message */ completeAllToolCallsForMessage(messageId) { const state = this.getMessageState(messageId); if (!state) return; state.toolCalls.forEach((toolCall, id) => { if (toolCall.state !== "input-complete") { const index = state.toolCallOrder.indexOf(id); this.completeToolCall(messageId, index, toolCall); } }); } /** * Mark a tool call as complete and emit event */ completeToolCall(messageId, _index, toolCall) { toolCall.state = "input-complete"; toolCall.parsedArguments = this.jsonParser.parse(toolCall.arguments); if (this.isToolCallPartErrored(toolCall.id)) return; if (this.isToolCallPartAwaitingUserAction(toolCall.id)) return; this.messages = updateToolCallPart(this.messages, messageId, { id: toolCall.id, name: toolCall.name, arguments: toolCall.arguments, state: "input-complete", ...toolCall.parsedArguments !== void 0 && { input: toolCall.parsedArguments }, ...toolCall.metadata !== void 0 && { metadata: toolCall.metadata } }); this.emitMessagesChange(); this.events.onToolCallStateChange?.(messageId, toolCall.id, "input-complete", toolCall.arguments); } isToolCallPartAwaitingUserAction(toolCallId) { return this.messages.some((msg) => msg.parts?.some((part) => part.type === "tool-call" && part.id === toolCallId && (part.state === "approval-requested" || part.state === "approval-responded"))); } /** * Whether the rendered tool-call part for the given id has reached the * terminal 'error' state. Used to prevent the completion safety net from * downgrading a failed call back to 'input-complete'. */ isToolCallPartErrored(toolCallId) { return this.messages.some((msg) => msg.parts?.some((part) => part.type === "tool-call" && part.id === toolCallId && part.state === "error")); } /** * 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 */ emitTextUpdateForMessage(messageId) { const state = this.getMessageState(messageId); if (!state) return; state.lastEmittedText = state.currentSegmentText; this.messages = updateTextPart(this.messages, messageId, state.currentSegmentText); this.emitMessagesChange(); this.events.onTextUpdate?.(messageId, state.currentSegmentText); } queueStructuredOutputUpdate(messageId, delta) { const existing = this.structuredOutputUpdateBatches.get(messageId); const next = { delta: `${existing?.delta ?? ""}${delta}`, chunkCount: (existing?.chunkCount ?? 0) + 1 }; this.structuredOutputUpdateBatches.set(messageId, next); if (next.chunkCount >= STRUCTURED_OUTPUT_UPDATE_BATCH_SIZE) this.flushStructuredOutputUpdate(messageId); } flushStructuredOutputUpdate(messageId) { const batch = this.structuredOutputUpdateBatches.get(messageId); if (!batch || batch.chunkCount === 0) return; this.structuredOutputUpdateBatches.delete(messageId); this.emitStructuredOutputChange(messageId, "update", batch.delta); } emitStructuredOutputChange(messageId, phase, delta) { const part = this.messages.find((message) => message.id === messageId)?.parts.find((messagePart) => messagePart.type === "structured-output"); if (!part) return; this.events.onStructuredOutputChange?.({ phase, messageId, status: part.status, raw: part.raw, ...part.partial !== void 0 ? { partial: part.partial } : {}, ...part.data !== void 0 ? { data: part.data } : {}, ...part.reasoning !== void 0 ? { reasoning: part.reasoning } : {}, ...part.errorMessage !== void 0 ? { errorMessage: part.errorMessage } : {}, ...delta !== void 0 ? { delta } : {} }); } /** * Emit messages change event */ emitMessagesChange() { this.events.onMessagesChange?.([...this.messages]); } /** * 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() { let lastAssistantMessage; for (const messageId of this.activeMessageIds) { const state = this.getMessageState(messageId); if (!state) continue; this.completeAllToolCallsForMessage(messageId); if (state.currentSegmentText !== state.lastEmittedText) this.emitTextUpdateForMessage(messageId); state.isComplete = true; const msg = this.messages.find((m) => m.id === messageId); if (msg && msg.role === "assistant") lastAssistantMessage = msg; } for (const messageId of this.structuredMessageIds) { this.flushStructuredOutputUpdate(messageId); this.messages = errorStructuredOutputPart(this.messages, messageId, "Stream ended without structured-output.complete"); this.emitStructuredOutputChange(messageId, "error"); } this.structuredMessageIds.clear(); this.structuredOutputUpdateBatches.clear(); this.activeMessageIds.clear(); if (lastAssistantMessage && !this.hasError) { if (this.isWhitespaceOnlyMessage(lastAssistantMessage)) { this.messages = this.messages.filter((m) => m.id !== lastAssistantMessage.id); this.emitMessagesChange(); return; } } if (lastAssistantMessage) this.events.onStreamEnd?.(lastAssistantMessage); } /** * Get completed tool calls in API format (aggregated across all messages) */ getCompletedToolCalls() { const result = []; for (const state of this.messageStates.values()) for (const tc of state.toolCalls.values()) if (tc.state === "input-complete") result.push({ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments }, ...tc.metadata !== void 0 && { metadata: tc.metadata } }); return result; } /** * Get current result (aggregated across all messages) */ getResult() { const toolCalls = this.getCompletedToolCalls(); let content = ""; let thinking = ""; for (const state of this.messageStates.values()) { content += state.totalTextContent; for (const stepId of state.thinkingStepOrder) thinking += state.thinkingSteps.get(stepId) ?? ""; } return { content, thinking: thinking || void 0, toolCalls: toolCalls.length > 0 ? toolCalls : void 0, finishReason: this.finishReason }; } /** * Get current processor state (aggregated across all messages) */ getState() { let content = ""; let thinking = ""; const toolCalls = /* @__PURE__ */ new Map(); const toolCallOrder = []; for (const state of this.messageStates.values()) { content += state.totalTextContent; for (const stepId of state.thinkingStepOrder) thinking += state.thinkingSteps.get(stepId) ?? ""; for (const [id, tc] of state.toolCalls) to