@tanstack/ai 0.47.1 → 0.47.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"chat-stream-summarize.js","names":[],"sources":["../../../../src/activities/summarize/chat-stream-summarize.ts"],"sourcesContent":["import { EventType } from '@ag-ui/core'\nimport { toRunErrorPayload } from '../error-payload'\nimport { MAX_TOKENS_KEYS } from '../../utilities/sampling-keys'\nimport { BaseSummarizeAdapter } from './adapter'\nimport type {\n StreamChunk,\n SummarizationOptions,\n SummarizationResult,\n TextOptions,\n} from '../../types'\n\n/**\n * Minimal contract for a text adapter that supports `chatStream`. Lets\n * `ChatStreamSummarizeAdapter` work with any text adapter without coupling\n * to a specific implementation.\n *\n * The provider-options shape is intentionally `any` here — the wrapper only\n * forwards `modelOptions` straight through, so a text adapter with a richer\n * per-model options type (e.g. `ResolveProviderOptions<TModel>`) is still\n * acceptable. Summarize-level type safety is enforced via\n * `SummarizationOptions<TProviderOptions>` on the wrapper itself.\n */\nexport interface ChatStreamCapable {\n chatStream: (options: TextOptions<any>) => AsyncIterable<StreamChunk>\n}\n\n/**\n * Provider-native max-output-tokens key per summarize-adapter `name`. summarize\n * is provider-agnostic and forwards `modelOptions` opaquely to the wrapped text\n * adapter, so `maxLength` must be written under the exact key the underlying\n * provider reads — no adapter reads a generic `maxTokens`. Ollama is the one\n * exception: it nests sampling under `options`, so it has no entry here and is\n * handled as a special nested case in `applyMaxLength`/`applyDefaultTemperature`.\n *\n * Keep in sync with each adapter's wire mapping:\n * - OpenAI (Responses): `max_output_tokens`\n * - Anthropic / Grok: `max_tokens`\n * - Groq: `max_completion_tokens`\n * - Gemini: `maxOutputTokens`\n * - OpenRouter: `maxCompletionTokens`\n * - Ollama: nested `options.num_predict` (no entry — see `applyMaxLength`)\n */\nconst MAX_TOKENS_KEY_BY_ADAPTER: Record<string, string> = {\n openai: 'max_output_tokens',\n anthropic: 'max_tokens',\n grok: 'max_tokens',\n groq: 'max_completion_tokens',\n gemini: 'maxOutputTokens',\n openrouter: 'maxCompletionTokens',\n}\n\n/**\n * Every flat key any supported provider uses to cap output tokens (plus the\n * generic `maxTokens` spelling no adapter reads). Used to detect a\n * caller-supplied token limit so the summarize default never overrides an\n * explicit caller value. Shared with the OTel middleware via\n * `MAX_TOKENS_KEYS` so the two spelling sets cannot drift.\n */\nconst KNOWN_MAX_TOKENS_KEYS = MAX_TOKENS_KEYS\n\n/**\n * Whether `applyMaxLength` knows how to place a token limit for this adapter\n * `name` (either the nested Ollama shape or a flat provider-native key).\n * Used to surface a warning when `maxLength` would otherwise be silently\n * dropped for an unrecognised adapter name.\n */\nfunction isKnownMaxTokensAdapter(adapterName: string): boolean {\n return (\n adapterName === 'ollama' ||\n MAX_TOKENS_KEY_BY_ADAPTER[adapterName] !== undefined\n )\n}\n\n/**\n * Apply the low-temperature summarize default to a working copy of the\n * caller's `modelOptions`, placed where the wrapped provider actually reads\n * it (nested under `options` for Ollama, flat otherwise). The caller always\n * wins: if they already set `temperature` in that location, it is untouched.\n */\nfunction applyDefaultTemperature(\n adapterName: string,\n temperature: number,\n modelOptions: Record<string, unknown>,\n): Record<string, unknown> {\n const merged: Record<string, unknown> = { ...modelOptions }\n\n if (adapterName === 'ollama') {\n const existing =\n merged.options && typeof merged.options === 'object'\n ? (merged.options as Record<string, unknown>)\n : undefined\n if (existing && 'temperature' in existing) return merged\n merged.options = { temperature, ...existing }\n return merged\n }\n\n if ('temperature' in merged) return merged\n merged.temperature = temperature\n return merged\n}\n\n/**\n * Resolve `maxLength` to the provider-native max-output-tokens key for the\n * given summarize-adapter `name` (this wrapper's OWN `name`, not the wrapped\n * text adapter's) and merge it into a working copy of the caller's\n * `modelOptions`. The caller always wins: if they already set any recognised\n * token-limit key (flat or, for Ollama, nested `options.num_predict`), the\n * default is left untouched. Unknown/unrecognised adapter names fall back to\n * NOT setting a token key (the prompt hint still asks the model to stay under\n * `maxLength`) rather than writing a dead key no provider reads.\n *\n * Caveat (intentional): \"caller wins\" keys off ANY recognised spelling in\n * `KNOWN_MAX_TOKENS_KEYS`, but only the adapter's native key is read on the\n * wire. So a caller who sets a NON-native spelling for this provider — e.g.\n * `maxTokens`, or Anthropic's `max_tokens` against an OpenAI adapter — suppresses\n * the summarize default WITHOUT getting their own value applied either: neither\n * cap reaches the wire. This favours never clobbering a migration leftover over\n * guaranteeing a cap; the prompt-level hint still asks the model to stay under\n * `maxLength`. Rename the key to the provider-native spelling to forward it.\n */\nfunction applyMaxLength(\n adapterName: string,\n maxLength: number,\n modelOptions: Record<string, unknown>,\n): Record<string, unknown> {\n const merged: Record<string, unknown> = { ...modelOptions }\n\n if (adapterName === 'ollama') {\n // Honor a caller-set limit in either shape: a recognised flat key (e.g.\n // left over from a migration) or the nested `options.num_predict`.\n const callerSetFlatLimit = KNOWN_MAX_TOKENS_KEYS.some(\n (k) => typeof merged[k] === 'number',\n )\n const existing =\n merged.options && typeof merged.options === 'object'\n ? (merged.options as Record<string, unknown>)\n : undefined\n if (\n callerSetFlatLimit ||\n (existing && typeof existing.num_predict === 'number')\n ) {\n return merged\n }\n merged.options = { num_predict: maxLength, ...existing }\n return merged\n }\n\n const key = MAX_TOKENS_KEY_BY_ADAPTER[adapterName]\n if (key === undefined) return merged\n\n const callerSetLimit = KNOWN_MAX_TOKENS_KEYS.some(\n (k) => typeof merged[k] === 'number',\n )\n if (callerSetLimit) return merged\n\n merged[key] = maxLength\n return merged\n}\n\n/**\n * Extract the per-model `modelOptions` type a text adapter accepts. Used by\n * provider summarize factories so their `modelOptions` IntelliSense matches\n * what the underlying text adapter actually understands.\n */\nexport type InferTextProviderOptions<TAdapter> = TAdapter extends {\n '~types': { providerOptions: infer P }\n}\n ? P extends object\n ? P\n : object\n : object\n\n/**\n * Summarize adapter that wraps any `ChatStreamCapable` text adapter and\n * prompts it for summarization. Not tied to any wire format.\n */\nexport class ChatStreamSummarizeAdapter<\n TModel extends string,\n TProviderOptions extends object = Record<string, unknown>,\n> extends BaseSummarizeAdapter<TModel, TProviderOptions> {\n readonly name: string\n\n private readonly textAdapter: ChatStreamCapable\n\n constructor(\n textAdapter: ChatStreamCapable,\n model: TModel,\n name: string = 'chat-stream-summarize',\n ) {\n super({}, model)\n this.name = name\n this.textAdapter = textAdapter\n }\n\n async summarize(\n options: SummarizationOptions<TProviderOptions>,\n ): Promise<SummarizationResult> {\n const systemPrompt = this.buildSummarizationPrompt(options)\n\n let summary = ''\n const id = this.generateId()\n let model = options.model\n let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }\n\n options.logger.request(\n `activity=summarize provider=${this.name} model=${options.model} text-length=${options.text.length} maxLength=${options.maxLength ?? 'unset'}`,\n { provider: this.name, model: options.model },\n )\n\n try {\n for await (const chunk of this.textAdapter.chatStream(\n this.buildTextOptions(options, systemPrompt),\n )) {\n if (chunk.type === 'TEXT_MESSAGE_CONTENT') {\n if (chunk.content) {\n summary = chunk.content\n } else if (chunk.delta) {\n // Append delta only when present — a content-less chunk with no\n // delta would otherwise concat literal `'undefined'`.\n summary += chunk.delta\n }\n model = chunk.model || model\n }\n if (chunk.type === 'RUN_FINISHED') {\n if (chunk.usage) {\n usage = chunk.usage\n }\n }\n // Surface failures: the underlying chatStream emits RUN_ERROR instead\n // of throwing, so without this branch summarize() would return an\n // empty summary and pretend a failed run succeeded.\n if (chunk.type === 'RUN_ERROR') {\n const message =\n (chunk.error && typeof chunk.error.message === 'string'\n ? chunk.error.message\n : null) ?? 'Summarization failed'\n const code =\n chunk.error && typeof chunk.error.code === 'string'\n ? chunk.error.code\n : undefined\n const err = new Error(message)\n if (code) {\n ;(err as Error & { code?: string }).code = code\n }\n throw err\n }\n }\n } catch (error: unknown) {\n // Narrow before logging: raw SDK errors can carry request metadata\n // (including auth headers) which we must never surface to user loggers.\n options.logger.errors(`${this.name}.summarize fatal`, {\n error: toRunErrorPayload(error, `${this.name}.summarize failed`),\n source: `${this.name}.summarize`,\n })\n throw error\n }\n\n return { id, model, summary, usage }\n }\n\n override async *summarizeStream(\n options: SummarizationOptions<TProviderOptions>,\n ): AsyncIterable<StreamChunk> {\n const systemPrompt = this.buildSummarizationPrompt(options)\n\n options.logger.request(\n `activity=summarizeStream provider=${this.name} model=${options.model} text-length=${options.text.length} maxLength=${options.maxLength ?? 'unset'}`,\n { provider: this.name, model: options.model },\n )\n\n const id = this.generateId()\n let summary = ''\n let model = options.model\n let usage: SummarizationResult['usage'] = {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n }\n\n try {\n for await (const chunk of this.textAdapter.chatStream(\n this.buildTextOptions(options, systemPrompt),\n )) {\n // Accumulate the same way `summarize()` does so consumers see deltas\n // AND the terminal `generation:result` event below carries the same\n // final summary that non-streaming returns.\n if (chunk.type === 'TEXT_MESSAGE_CONTENT') {\n if (chunk.content) {\n summary = chunk.content\n } else if (chunk.delta) {\n summary += chunk.delta\n }\n if (chunk.model) model = chunk.model\n }\n\n // Emit the GenerationClient-shaped result event just before the\n // terminal RUN_FINISHED so subscribers (useSummarize) populate\n // `result` before flipping `status` to success.\n if (chunk.type === 'RUN_FINISHED') {\n if (chunk.usage) usage = chunk.usage\n if (chunk.model) model = chunk.model\n yield {\n type: EventType.CUSTOM,\n name: 'generation:result',\n value: { id, model, summary, usage } satisfies SummarizationResult,\n model,\n timestamp: Date.now(),\n }\n }\n\n yield chunk\n }\n } catch (error: unknown) {\n options.logger.errors(`${this.name}.summarizeStream fatal`, {\n error: toRunErrorPayload(error, `${this.name}.summarizeStream failed`),\n source: `${this.name}.summarizeStream`,\n })\n throw error\n }\n }\n\n /**\n * Build the TextOptions passed to the underlying chatStream. Provider\n * `modelOptions` from the summarize call are forwarded as-is so knobs like\n * Anthropic cache headers, Gemini safety settings, or Ollama tuning params\n * still reach the wire layer.\n */\n protected buildTextOptions(\n options: SummarizationOptions<TProviderOptions>,\n systemPrompt: string,\n ): TextOptions<TProviderOptions> {\n // Sampling knobs now live in provider-native `modelOptions`. Apply the\n // low-temperature default where the wrapped provider actually reads it\n // (nested under `options` for Ollama, flat otherwise) so callers can still\n // override it. Resolving the placement from this summarize adapter's OWN\n // `name` keeps the default off the wire correctly per provider — a flat\n // `temperature` would be silently dropped by Ollama while still showing up\n // in OTel.\n let working: Record<string, unknown> = {\n ...(options.modelOptions as Record<string, unknown> | undefined),\n }\n working = applyDefaultTemperature(this.name, 0.3, working)\n // `maxLength` must reach the wire under the provider-native token key (it\n // differs per provider, and no adapter reads a generic `maxTokens`).\n // Resolve it from this summarize adapter's `name` (the constructor arg,\n // not the wrapped text adapter's name), never overriding a caller-supplied\n // token limit.\n if (options.maxLength !== undefined) {\n if (!isKnownMaxTokensAdapter(this.name)) {\n options.logger.warn(\n `summarize: maxLength=${options.maxLength} could not be mapped to a provider token key for adapter name \"${this.name}\" — it was dropped from modelOptions (the prompt still asks the model to stay under it). Construct ChatStreamSummarizeAdapter with a recognised provider name to forward the cap.`,\n { provider: this.name },\n )\n }\n working = applyMaxLength(this.name, options.maxLength, working)\n }\n const modelOptions = working as TProviderOptions\n\n return {\n model: options.model,\n messages: [{ role: 'user', content: options.text }],\n systemPrompts: [systemPrompt],\n modelOptions,\n logger: options.logger,\n // Forward the run identity so the wrapped chat stamps it onto RUN_STARTED\n // (chat uses `runId` as its `runIdOverride`). Conditional spreads keep the\n // fields absent when unset, under `exactOptionalPropertyTypes`.\n ...(options.runId !== undefined ? { runId: options.runId } : {}),\n ...(options.threadId !== undefined ? { threadId: options.threadId } : {}),\n }\n }\n\n protected buildSummarizationPrompt(\n options: SummarizationOptions<TProviderOptions>,\n ): string {\n let prompt = 'You are a professional summarizer. '\n\n switch (options.style) {\n case 'bullet-points':\n prompt += 'Provide a summary in bullet point format. '\n break\n case 'paragraph':\n prompt += 'Provide a summary in paragraph format. '\n break\n case 'concise':\n prompt += 'Provide a very concise summary in 1-2 sentences. '\n break\n case undefined:\n prompt += 'Provide a clear and concise summary. '\n break\n default:\n prompt += 'Provide a clear and concise summary. '\n }\n\n if (options.focus && options.focus.length > 0) {\n prompt += `Focus on the following aspects: ${options.focus.join(', ')}. `\n }\n\n if (options.maxLength) {\n prompt += `Keep the summary under ${options.maxLength} tokens. `\n }\n\n return prompt\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"chat-stream-summarize.js","names":[],"sources":["../../../../src/activities/summarize/chat-stream-summarize.ts"],"sourcesContent":["import { EventType } from '@ag-ui/core'\nimport { toRunErrorPayload } from '../error-payload'\nimport { MAX_TOKENS_KEYS } from '../../utilities/sampling-keys'\nimport { BaseSummarizeAdapter } from './adapter'\nimport type {\n StreamChunk,\n SummarizationOptions,\n SummarizationResult,\n TextOptions,\n} from '../../types'\n\n/**\n * Minimal contract for a text adapter that supports `chatStream`. Lets\n * `ChatStreamSummarizeAdapter` work with any text adapter without coupling\n * to a specific implementation.\n *\n * The provider-options shape is intentionally `any` here — the wrapper only\n * forwards `modelOptions` straight through, so a text adapter with a richer\n * per-model options type (e.g. `ResolveProviderOptions<TModel>`) is still\n * acceptable. Summarize-level type safety is enforced via\n * `SummarizationOptions<TProviderOptions>` on the wrapper itself.\n */\nexport interface ChatStreamCapable {\n chatStream: (options: TextOptions<any>) => AsyncIterable<StreamChunk>\n}\n\n/**\n * Provider-native max-output-tokens key per summarize-adapter `name`. summarize\n * is provider-agnostic and forwards `modelOptions` opaquely to the wrapped text\n * adapter, so `maxLength` must be written under the exact key the underlying\n * provider reads — no adapter reads a generic `maxTokens`. Ollama is the one\n * exception: it nests sampling under `options`, so it has no entry here and is\n * handled as a special nested case in `applyMaxLength`/`applyDefaultTemperature`.\n *\n * Keep in sync with each adapter's wire mapping:\n * - OpenAI (Responses): `max_output_tokens`\n * - Anthropic / Grok: `max_tokens`\n * - Groq: `max_completion_tokens`\n * - Gemini: `maxOutputTokens`\n * - OpenRouter: `maxCompletionTokens`\n * - LLM Gateway: `max_tokens`\n * - Ollama: nested `options.num_predict` (no entry — see `applyMaxLength`)\n */\nconst MAX_TOKENS_KEY_BY_ADAPTER: Record<string, string> = {\n openai: 'max_output_tokens',\n anthropic: 'max_tokens',\n grok: 'max_tokens',\n groq: 'max_completion_tokens',\n gemini: 'maxOutputTokens',\n openrouter: 'maxCompletionTokens',\n // LLM Gateway exposes an OpenAI-compatible Chat Completions surface whose\n // only output cap is `max_tokens` — it does not read `max_completion_tokens`.\n llmgateway: 'max_tokens',\n}\n\n/**\n * Every flat key any supported provider uses to cap output tokens (plus the\n * generic `maxTokens` spelling no adapter reads). Used to detect a\n * caller-supplied token limit so the summarize default never overrides an\n * explicit caller value. Shared with the OTel middleware via\n * `MAX_TOKENS_KEYS` so the two spelling sets cannot drift.\n */\nconst KNOWN_MAX_TOKENS_KEYS = MAX_TOKENS_KEYS\n\n/**\n * Whether `applyMaxLength` knows how to place a token limit for this adapter\n * `name` (either the nested Ollama shape or a flat provider-native key).\n * Used to surface a warning when `maxLength` would otherwise be silently\n * dropped for an unrecognised adapter name.\n */\nfunction isKnownMaxTokensAdapter(adapterName: string): boolean {\n return (\n adapterName === 'ollama' ||\n MAX_TOKENS_KEY_BY_ADAPTER[adapterName] !== undefined\n )\n}\n\n/**\n * Apply the low-temperature summarize default to a working copy of the\n * caller's `modelOptions`, placed where the wrapped provider actually reads\n * it (nested under `options` for Ollama, flat otherwise). The caller always\n * wins: if they already set `temperature` in that location, it is untouched.\n */\nfunction applyDefaultTemperature(\n adapterName: string,\n temperature: number,\n modelOptions: Record<string, unknown>,\n): Record<string, unknown> {\n const merged: Record<string, unknown> = { ...modelOptions }\n\n if (adapterName === 'ollama') {\n const existing =\n merged.options && typeof merged.options === 'object'\n ? (merged.options as Record<string, unknown>)\n : undefined\n if (existing && 'temperature' in existing) return merged\n merged.options = { temperature, ...existing }\n return merged\n }\n\n if ('temperature' in merged) return merged\n merged.temperature = temperature\n return merged\n}\n\n/**\n * Resolve `maxLength` to the provider-native max-output-tokens key for the\n * given summarize-adapter `name` (this wrapper's OWN `name`, not the wrapped\n * text adapter's) and merge it into a working copy of the caller's\n * `modelOptions`. The caller always wins: if they already set any recognised\n * token-limit key (flat or, for Ollama, nested `options.num_predict`), the\n * default is left untouched. Unknown/unrecognised adapter names fall back to\n * NOT setting a token key (the prompt hint still asks the model to stay under\n * `maxLength`) rather than writing a dead key no provider reads.\n *\n * Caveat (intentional): \"caller wins\" keys off ANY recognised spelling in\n * `KNOWN_MAX_TOKENS_KEYS`, but only the adapter's native key is read on the\n * wire. So a caller who sets a NON-native spelling for this provider — e.g.\n * `maxTokens`, or Anthropic's `max_tokens` against an OpenAI adapter — suppresses\n * the summarize default WITHOUT getting their own value applied either: neither\n * cap reaches the wire. This favours never clobbering a migration leftover over\n * guaranteeing a cap; the prompt-level hint still asks the model to stay under\n * `maxLength`. Rename the key to the provider-native spelling to forward it.\n */\nfunction applyMaxLength(\n adapterName: string,\n maxLength: number,\n modelOptions: Record<string, unknown>,\n): Record<string, unknown> {\n const merged: Record<string, unknown> = { ...modelOptions }\n\n if (adapterName === 'ollama') {\n // Honor a caller-set limit in either shape: a recognised flat key (e.g.\n // left over from a migration) or the nested `options.num_predict`.\n const callerSetFlatLimit = KNOWN_MAX_TOKENS_KEYS.some(\n (k) => typeof merged[k] === 'number',\n )\n const existing =\n merged.options && typeof merged.options === 'object'\n ? (merged.options as Record<string, unknown>)\n : undefined\n if (\n callerSetFlatLimit ||\n (existing && typeof existing.num_predict === 'number')\n ) {\n return merged\n }\n merged.options = { num_predict: maxLength, ...existing }\n return merged\n }\n\n const key = MAX_TOKENS_KEY_BY_ADAPTER[adapterName]\n if (key === undefined) return merged\n\n const callerSetLimit = KNOWN_MAX_TOKENS_KEYS.some(\n (k) => typeof merged[k] === 'number',\n )\n if (callerSetLimit) return merged\n\n merged[key] = maxLength\n return merged\n}\n\n/**\n * Extract the per-model `modelOptions` type a text adapter accepts. Used by\n * provider summarize factories so their `modelOptions` IntelliSense matches\n * what the underlying text adapter actually understands.\n */\nexport type InferTextProviderOptions<TAdapter> = TAdapter extends {\n '~types': { providerOptions: infer P }\n}\n ? P extends object\n ? P\n : object\n : object\n\n/**\n * Summarize adapter that wraps any `ChatStreamCapable` text adapter and\n * prompts it for summarization. Not tied to any wire format.\n */\nexport class ChatStreamSummarizeAdapter<\n TModel extends string,\n TProviderOptions extends object = Record<string, unknown>,\n> extends BaseSummarizeAdapter<TModel, TProviderOptions> {\n readonly name: string\n\n private readonly textAdapter: ChatStreamCapable\n\n constructor(\n textAdapter: ChatStreamCapable,\n model: TModel,\n name: string = 'chat-stream-summarize',\n ) {\n super({}, model)\n this.name = name\n this.textAdapter = textAdapter\n }\n\n async summarize(\n options: SummarizationOptions<TProviderOptions>,\n ): Promise<SummarizationResult> {\n const systemPrompt = this.buildSummarizationPrompt(options)\n\n let summary = ''\n const id = this.generateId()\n let model = options.model\n let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }\n\n options.logger.request(\n `activity=summarize provider=${this.name} model=${options.model} text-length=${options.text.length} maxLength=${options.maxLength ?? 'unset'}`,\n { provider: this.name, model: options.model },\n )\n\n try {\n for await (const chunk of this.textAdapter.chatStream(\n this.buildTextOptions(options, systemPrompt),\n )) {\n if (chunk.type === 'TEXT_MESSAGE_CONTENT') {\n if (chunk.content) {\n summary = chunk.content\n } else if (chunk.delta) {\n // Append delta only when present — a content-less chunk with no\n // delta would otherwise concat literal `'undefined'`.\n summary += chunk.delta\n }\n model = chunk.model || model\n }\n if (chunk.type === 'RUN_FINISHED') {\n if (chunk.usage) {\n usage = chunk.usage\n }\n }\n // Surface failures: the underlying chatStream emits RUN_ERROR instead\n // of throwing, so without this branch summarize() would return an\n // empty summary and pretend a failed run succeeded.\n if (chunk.type === 'RUN_ERROR') {\n const message =\n (chunk.error && typeof chunk.error.message === 'string'\n ? chunk.error.message\n : null) ?? 'Summarization failed'\n const code =\n chunk.error && typeof chunk.error.code === 'string'\n ? chunk.error.code\n : undefined\n const err = new Error(message)\n if (code) {\n ;(err as Error & { code?: string }).code = code\n }\n throw err\n }\n }\n } catch (error: unknown) {\n // Narrow before logging: raw SDK errors can carry request metadata\n // (including auth headers) which we must never surface to user loggers.\n options.logger.errors(`${this.name}.summarize fatal`, {\n error: toRunErrorPayload(error, `${this.name}.summarize failed`),\n source: `${this.name}.summarize`,\n })\n throw error\n }\n\n return { id, model, summary, usage }\n }\n\n override async *summarizeStream(\n options: SummarizationOptions<TProviderOptions>,\n ): AsyncIterable<StreamChunk> {\n const systemPrompt = this.buildSummarizationPrompt(options)\n\n options.logger.request(\n `activity=summarizeStream provider=${this.name} model=${options.model} text-length=${options.text.length} maxLength=${options.maxLength ?? 'unset'}`,\n { provider: this.name, model: options.model },\n )\n\n const id = this.generateId()\n let summary = ''\n let model = options.model\n let usage: SummarizationResult['usage'] = {\n promptTokens: 0,\n completionTokens: 0,\n totalTokens: 0,\n }\n\n try {\n for await (const chunk of this.textAdapter.chatStream(\n this.buildTextOptions(options, systemPrompt),\n )) {\n // Accumulate the same way `summarize()` does so consumers see deltas\n // AND the terminal `generation:result` event below carries the same\n // final summary that non-streaming returns.\n if (chunk.type === 'TEXT_MESSAGE_CONTENT') {\n if (chunk.content) {\n summary = chunk.content\n } else if (chunk.delta) {\n summary += chunk.delta\n }\n if (chunk.model) model = chunk.model\n }\n\n // Emit the GenerationClient-shaped result event just before the\n // terminal RUN_FINISHED so subscribers (useSummarize) populate\n // `result` before flipping `status` to success.\n if (chunk.type === 'RUN_FINISHED') {\n if (chunk.usage) usage = chunk.usage\n if (chunk.model) model = chunk.model\n yield {\n type: EventType.CUSTOM,\n name: 'generation:result',\n value: { id, model, summary, usage } satisfies SummarizationResult,\n model,\n timestamp: Date.now(),\n }\n }\n\n yield chunk\n }\n } catch (error: unknown) {\n options.logger.errors(`${this.name}.summarizeStream fatal`, {\n error: toRunErrorPayload(error, `${this.name}.summarizeStream failed`),\n source: `${this.name}.summarizeStream`,\n })\n throw error\n }\n }\n\n /**\n * Build the TextOptions passed to the underlying chatStream. Provider\n * `modelOptions` from the summarize call are forwarded as-is so knobs like\n * Anthropic cache headers, Gemini safety settings, or Ollama tuning params\n * still reach the wire layer.\n */\n protected buildTextOptions(\n options: SummarizationOptions<TProviderOptions>,\n systemPrompt: string,\n ): TextOptions<TProviderOptions> {\n // Sampling knobs now live in provider-native `modelOptions`. Apply the\n // low-temperature default where the wrapped provider actually reads it\n // (nested under `options` for Ollama, flat otherwise) so callers can still\n // override it. Resolving the placement from this summarize adapter's OWN\n // `name` keeps the default off the wire correctly per provider — a flat\n // `temperature` would be silently dropped by Ollama while still showing up\n // in OTel.\n let working: Record<string, unknown> = {\n ...(options.modelOptions as Record<string, unknown> | undefined),\n }\n working = applyDefaultTemperature(this.name, 0.3, working)\n // `maxLength` must reach the wire under the provider-native token key (it\n // differs per provider, and no adapter reads a generic `maxTokens`).\n // Resolve it from this summarize adapter's `name` (the constructor arg,\n // not the wrapped text adapter's name), never overriding a caller-supplied\n // token limit.\n if (options.maxLength !== undefined) {\n if (!isKnownMaxTokensAdapter(this.name)) {\n options.logger.warn(\n `summarize: maxLength=${options.maxLength} could not be mapped to a provider token key for adapter name \"${this.name}\" — it was dropped from modelOptions (the prompt still asks the model to stay under it). Construct ChatStreamSummarizeAdapter with a recognised provider name to forward the cap.`,\n { provider: this.name },\n )\n }\n working = applyMaxLength(this.name, options.maxLength, working)\n }\n const modelOptions = working as TProviderOptions\n\n return {\n model: options.model,\n messages: [{ role: 'user', content: options.text }],\n systemPrompts: [systemPrompt],\n modelOptions,\n logger: options.logger,\n // Forward the run identity so the wrapped chat stamps it onto RUN_STARTED\n // (chat uses `runId` as its `runIdOverride`). Conditional spreads keep the\n // fields absent when unset, under `exactOptionalPropertyTypes`.\n ...(options.runId !== undefined ? { runId: options.runId } : {}),\n ...(options.threadId !== undefined ? { threadId: options.threadId } : {}),\n }\n }\n\n protected buildSummarizationPrompt(\n options: SummarizationOptions<TProviderOptions>,\n ): string {\n let prompt = 'You are a professional summarizer. '\n\n switch (options.style) {\n case 'bullet-points':\n prompt += 'Provide a summary in bullet point format. '\n break\n case 'paragraph':\n prompt += 'Provide a summary in paragraph format. '\n break\n case 'concise':\n prompt += 'Provide a very concise summary in 1-2 sentences. '\n break\n case undefined:\n prompt += 'Provide a clear and concise summary. '\n break\n default:\n prompt += 'Provide a clear and concise summary. '\n }\n\n if (options.focus && options.focus.length > 0) {\n prompt += `Focus on the following aspects: ${options.focus.join(', ')}. `\n }\n\n if (options.maxLength) {\n prompt += `Keep the summary under ${options.maxLength} tokens. `\n }\n\n return prompt\n }\n}\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
+ import { errorMessage, errorTypeName } from "../utilities/errors.js";
1
2
  import { MAX_TOKENS_KEYS, NESTED_MAX_TOKENS_KEY } from "../utilities/sampling-keys.js";
2
3
  import { firstNumber } from "../utilities/numbers.js";
3
- import { errorMessage, errorTypeName } from "../utilities/errors.js";
4
4
  import { usageAttributes } from "./usage-attributes.js";
5
5
  import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api";
6
6
  //#region src/middlewares/otel.ts
@@ -6,6 +6,7 @@ import { notifyRunDisconnected } from "./delivery-disconnect.js";
6
6
  import { resolveResumeRunId } from "./stream-durability.js";
7
7
  import { EventType } from "./types.js";
8
8
  import { resolveDebugOption } from "./logger/resolve.js";
9
+ import { runErrorEventToError } from "./utilities/errors.js";
9
10
  //#region src/stream-to-response.ts
10
11
  /**
11
12
  * Collect all text content from a StreamChunk async iterable and return as a string.
@@ -28,7 +29,10 @@ import { resolveDebugOption } from "./logger/resolve.js";
28
29
  */
29
30
  async function streamToText(stream) {
30
31
  let accumulatedContent = "";
31
- for await (const chunk of stream) if (chunk.type === "TEXT_MESSAGE_CONTENT" && chunk.delta) accumulatedContent += chunk.delta;
32
+ for await (const chunk of stream) {
33
+ if (chunk.type === "RUN_ERROR") throw runErrorEventToError(chunk);
34
+ if (chunk.type === "TEXT_MESSAGE_CONTENT" && chunk.delta) accumulatedContent += chunk.delta;
35
+ }
32
36
  return accumulatedContent;
33
37
  }
34
38
  function errorMessage(error) {
@@ -1 +1 @@
1
- {"version":3,"file":"stream-to-response.js","names":[],"sources":["../../src/stream-to-response.ts"],"sourcesContent":["import { toRunErrorPayload } from './activities/error-payload'\nimport { isCancelRequestedReason } from './activities/chat/cancel'\nimport {\n isRunStatus,\n isTerminalRunStatus,\n} from './activities/chat/middleware/run-store'\nimport { wasRunDetached } from './delivery-detach'\nimport { notifyRunDisconnected } from './delivery-disconnect'\nimport { resolveResumeRunId } from './stream-durability'\nimport { EventType } from './types'\nimport { resolveDebugOption } from './logger/resolve'\nimport type { LockStore } from './activities/chat/middleware/locks'\nimport type {\n RunRecord,\n RunStore,\n} from './activities/chat/middleware/run-store'\nimport type { InternalLogger } from './logger/internal-logger'\nimport type { DebugOption } from './logger/types'\nimport type { StreamDurability } from './stream-durability'\nimport type { StreamChunk } from './types'\n\nexport { resolveResumeRunId } from './stream-durability'\n\n/**\n * Collect all text content from a StreamChunk async iterable and return as a string.\n *\n * This function consumes the entire stream, accumulating content from TEXT_MESSAGE_CONTENT events,\n * and returns the final concatenated text.\n *\n * @param stream - AsyncIterable of StreamChunks from chat()\n * @returns Promise<string> - The accumulated text content\n *\n * @example\n * ```typescript\n * const stream = chat({\n * adapter: openaiText('gpt-5.5'),\n * messages: [{ role: 'user', content: 'Hello!' }]\n * });\n * const text = await streamToText(stream);\n * console.log(text); // \"Hello! How can I help you today?\"\n * ```\n */\nexport async function streamToText(\n stream: AsyncIterable<StreamChunk>,\n): Promise<string> {\n let accumulatedContent = ''\n\n for await (const chunk of stream) {\n if (chunk.type === 'TEXT_MESSAGE_CONTENT' && chunk.delta) {\n accumulatedContent += chunk.delta\n }\n }\n\n return accumulatedContent\n}\n\ninterface RecordedFailure {\n error: unknown\n}\n\nfunction errorMessage(error: unknown): string {\n return toRunErrorPayload(error).message\n}\n\nfunction combineFailures(\n primary: unknown,\n secondary: unknown,\n phase: string,\n): unknown {\n if (primary === secondary) return primary\n const errors =\n primary instanceof AggregateError\n ? [...primary.errors, secondary]\n : [primary, secondary]\n return new AggregateError(\n errors,\n `${errorMessage(primary)}; ${phase}: ${errorMessage(secondary)}`,\n )\n}\n\nexport function runErrorChunk(\n error: unknown,\n): Extract<StreamChunk, { type: 'RUN_ERROR' }> {\n const payload = toRunErrorPayload(error)\n return {\n type: EventType.RUN_ERROR,\n timestamp: Date.now(),\n message: payload.message,\n ...(payload.code === undefined ? {} : { code: payload.code }),\n error: payload,\n }\n}\n\nfunction isAborted(signal: AbortSignal): boolean {\n return signal.aborted\n}\n\n/**\n * Whether this abort is an EXPLICIT in-process cancel — the caller aborted with\n * {@link RUN_CANCEL_REASON} rather than the socket going away.\n *\n * Core's own guard, independent of any middleware verdict: a user pressing Stop\n * must always get a closed, terminal log, so the sink refuses to treat that abort\n * as a detach even if the run's middleware published one. A reason-less abort\n * carries a `DOMException`, never a string, so a non-string reason is \"no\n * explicit intent\" — exactly how `resolveAbortReason` reads it in `chat()`.\n */\nfunction isExplicitCancel(signal: AbortSignal): boolean {\n const reason: unknown = signal.reason\n return typeof reason === 'string' && isCancelRequestedReason(reason)\n}\n\nfunction needsTerminalPersistence(\n terminalPersisted: boolean,\n cancelled: boolean,\n failed: boolean,\n): boolean {\n return !terminalPersisted && (cancelled || failed)\n}\n\nfunction toEncodedStream(\n stream: AsyncIterable<StreamChunk>,\n abortController: AbortController | undefined,\n encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array,\n encodeError: (error: unknown) => Uint8Array,\n detachOnCancel = false,\n /**\n * Called once when the response body is cancelled on the detach path, BEFORE\n * returning. The durability branch uses it to tell the run its viewer is gone\n * (see `./delivery-disconnect`) without aborting it.\n */\n onDetachedCancel?: () => void,\n): ReadableStream<Uint8Array> {\n const cancellation = abortController ?? new AbortController()\n let iterator: AsyncIterator<StreamChunk> | undefined\n let iteratorCleanup: Promise<void> | undefined\n let pumpPromise: Promise<void> = Promise.resolve()\n let pumpFailure: RecordedFailure | undefined\n let cancelled = false\n\n const recordPumpFailure = (error: unknown, phase: string): void => {\n pumpFailure = {\n error:\n pumpFailure === undefined\n ? error\n : combineFailures(pumpFailure.error, error, phase),\n }\n }\n\n const closeIterator = (): Promise<void> => {\n iteratorCleanup ??= (async () => {\n if (iterator?.return) await iterator.return()\n })()\n return iteratorCleanup\n }\n\n return new ReadableStream({\n start(controller) {\n iterator = stream[Symbol.asyncIterator]()\n pumpPromise = (async () => {\n let index = 0\n let iteratorDone = false\n\n try {\n while (!isAborted(cancellation.signal)) {\n const result = await iterator.next()\n if (result.done) {\n iteratorDone = true\n break\n }\n if (isAborted(cancellation.signal)) break\n // After a detached cancel the reader is gone but we keep pulling to\n // drain the producer into the durable log; skip enqueuing to the\n // closed controller.\n if (!cancelled) controller.enqueue(encodeChunk(result.value, index))\n index += 1\n }\n } catch (error) {\n recordPumpFailure(error, 'stream iteration failed')\n } finally {\n if (!iteratorDone) {\n try {\n await closeIterator()\n } catch (error) {\n recordPumpFailure(error, 'iterator cleanup failed')\n }\n }\n\n if (\n !cancelled &&\n !isAborted(cancellation.signal) &&\n pumpFailure !== undefined\n ) {\n controller.enqueue(encodeError(pumpFailure.error))\n }\n if (!cancelled) controller.close()\n }\n })().catch((error: unknown) => {\n recordPumpFailure(error, 'stream pump failed')\n })\n },\n async cancel(reason) {\n cancelled = true\n // Detached durable delivery: the client is gone (e.g. a page reload), but\n // the run must finish into the durable log so a rejoining client can tail\n // it to the real terminal. Do NOT abort the producer (that would kill the\n // run and seal the log with RUN_ERROR) and do NOT await the pump — it\n // keeps draining `stream` → the log in the background and terminates\n // normally on its own. A genuine caller-driven stop aborts the producer's\n // own AbortController instead, which this path never touches.\n //\n // Notify the run FIRST, and synchronously. This is the only moment the\n // socket-closed fact exists anywhere, and the run cannot observe it on its\n // own: it holds no handle on this response. That notification is what lets a\n // durable run record itself as detached while it KEEPS RUNNING — the\n // alternative applications were driven to (mirroring `request.signal` into\n // `chat()`'s abortController) reaches the middleware only by killing the run,\n // which for a sandboxed run means the agent is never even launched.\n if (detachOnCancel) {\n onDetachedCancel?.()\n return\n }\n\n if (!isAborted(cancellation.signal)) cancellation.abort(reason)\n\n let cancellationFailure: RecordedFailure | undefined\n try {\n await closeIterator()\n } catch (error) {\n cancellationFailure = { error }\n }\n await pumpPromise\n\n if (pumpFailure !== undefined && cancellationFailure !== undefined) {\n throw combineFailures(\n pumpFailure.error,\n cancellationFailure.error,\n 'iterator cancellation failed',\n )\n }\n if (pumpFailure !== undefined) throw pumpFailure.error\n if (cancellationFailure !== undefined) throw cancellationFailure.error\n },\n })\n}\n\n/**\n * Convert a StreamChunk async iterable to a ReadableStream in Server-Sent Events format\n *\n * This creates a ReadableStream that emits chunks in SSE format:\n * - Each chunk is prefixed with \"data: \"\n * - Each chunk is followed by \"\\n\\n\"\n * - Stream ends when the underlying iterable is exhausted (RUN_FINISHED is the terminal event)\n *\n * @param stream - AsyncIterable of StreamChunks from chat()\n * @param abortController - Optional AbortController to abort when stream is cancelled\n * @param getId - Optional per-chunk durability offset; when present, each event gets an `id:` line\n * @returns ReadableStream in Server-Sent Events format\n */\nexport function toServerSentEventsStream(\n stream: AsyncIterable<StreamChunk>,\n abortController?: AbortController,\n getId?: (chunk: StreamChunk, index: number) => string | undefined,\n): ReadableStream<Uint8Array> {\n const { encodeChunk, encodeError } = sseEncoders(getId)\n return toEncodedStream(stream, abortController, encodeChunk, encodeError)\n}\n\n/**\n * SSE chunk/error encoders. Shared by the public {@link toServerSentEventsStream}\n * and the internal durability branch (which additionally needs `toEncodedStream`'s\n * private `detachOnCancel`), so the wire format stays identical for both.\n */\nfunction sseEncoders(\n getId?: (chunk: StreamChunk, index: number) => string | undefined,\n): {\n encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array\n encodeError: (error: unknown) => Uint8Array\n} {\n const encoder = new TextEncoder()\n return {\n encodeChunk: (chunk, index) => {\n const id = getId?.(chunk, index)\n const idLine = id === undefined ? '' : `id: ${id}\\n`\n return encoder.encode(`${idLine}data: ${JSON.stringify(chunk)}\\n\\n`)\n },\n encodeError: (error) =>\n encoder.encode(`data: ${JSON.stringify(runErrorChunk(error))}\\n\\n`),\n }\n}\n\n/** Default number of chunks buffered before a durability `append`. */\nconst DEFAULT_DURABILITY_BATCH = 32\n\n/**\n * Resolve and validate the durability batch size. A non-positive-integer (0,\n * negative, fractional, or `NaN`) is rejected rather than clamped: silently\n * `Math.max(1, …)`-ing a `NaN` used to disable size-based flushing entirely\n * (`length >= NaN` is always false), which is a subtle footgun.\n */\nfunction resolveBatchSize(batch: number | undefined): number {\n if (batch === undefined) return DEFAULT_DURABILITY_BATCH\n if (!Number.isInteger(batch) || batch <= 0) {\n throw new Error(\n `Invalid durability batch size: ${batch}. Must be a positive integer.`,\n )\n }\n return batch\n}\n\n/**\n * Boundaries at which the batching producer flushes early, regardless of the\n * batch size — the run-start marker, terminal events, and tool-call ends.\n * Flushing here keeps the durability log promptly consistent at semantically\n * meaningful points.\n *\n * `RUN_STARTED` matters especially for one-shot activities (image, speech,\n * transcription, summarize): they emit `RUN_STARTED`, then await the provider\n * for seconds, then a terminal. Without flushing `RUN_STARTED` the log stays\n * empty for the whole run, so a mount-time `joinRun` finds nothing and its\n * empty-log deadline fast-fails as \"run gone\" — even though the run is alive.\n * Flushing it immediately makes the run resumable from the instant it starts.\n */\nfunction isDurabilityFlushBoundary(chunk: StreamChunk): boolean {\n return (\n chunk.type === 'RUN_STARTED' ||\n chunk.type === 'RUN_FINISHED' ||\n chunk.type === 'RUN_ERROR' ||\n chunk.type === 'TOOL_CALL_END'\n )\n}\n\n/**\n * Name of the synthetic `CUSTOM` chunk a fresh durable producer appends to its\n * log before pulling the first real chunk.\n *\n * Flushing `RUN_STARTED` (above) makes a run joinable from the instant the\n * stream EMITS something — but a `chat()` whose middleware boots a sandbox\n * (create a container, install a CLI) legitimately emits nothing for minutes,\n * and during that window the log is empty. Every joiner's empty-log fail-fast\n * (`memoryStream`'s first-chunk deadline, the client's rejoin connect deadline)\n * then reads the run as gone — and the client clears its resume pointer, so a\n * reload during the boot window permanently orphans a run that is still going.\n *\n * This marker closes the window: it is appended (and flushed) before the\n * producer stream is first pulled, so a join always finds a first chunk within\n * milliseconds of the run being accepted. Takeover alignment is unaffected — a\n * journal replay cannot reproduce the marker, and alignment already skips\n * stored `CUSTOM` chunks as out-of-band for exactly that reason (see\n * `isBridgeCustomChunk` in `@tanstack/ai-sandbox`).\n */\nexport const RUN_ACCEPTED_EVENT = 'run.accepted'\n\n/**\n * Build the delivery-durable source iterable for a transport helper.\n *\n * - **Resume** (`resumeFrom()` non-null): replay strictly after the offset,\n * reading only from the durability log. The input `stream` is NEVER iterated,\n * so `chat()`'s lazy iterator never fires the provider — the untouched\n * generator is simply GC'd. This is what makes resume free of re-invocation.\n * - **Fresh** (`resumeFrom()` null): iterate `stream`, buffering up to `batch`\n * chunks (flushing early at terminal / tool-call boundaries), `append` each\n * batch to the log, then forward. Appending BEFORE forwarding guarantees a\n * reconnecting client can always replay exactly what it already saw.\n *\n * The returned `getId` maps each forwarded chunk to the exact opaque offset\n * returned by the durability adapter for the SSE `id:` line.\n */\nexport function durableStreamSource<TOffset extends string>(\n stream: AsyncIterable<StreamChunk>,\n durability: StreamDurability<TOffset>,\n options: {\n abortController: AbortController\n batch?: number\n logger?: InternalLogger\n },\n): {\n source: AsyncIterable<StreamChunk>\n getId: (chunk: StreamChunk) => string | undefined\n} {\n const resumeOffset = durability.resumeFrom()\n const batchSize = resolveBatchSize(options.batch)\n const abortController = options.abortController\n const logger = options.logger\n const idByChunk = new WeakMap<object, string>()\n const seenOffsets = new Set<string>()\n const getId = (chunk: StreamChunk): string | undefined => idByChunk.get(chunk)\n\n const validateOffset = (offset: TOffset): void => {\n // Reject NUL/CR/LF (would corrupt the SSE `id:` line) and any offset that\n // is not invariant under the wire round-trip. The SSE client reads the id\n // with `.trim()`, so an offset with leading/trailing whitespace would come\n // back changed and no longer match on reconnect — fail loud here rather\n // than silently mis-resuming. (NDJSON carries the offset inside the JSON\n // envelope and is unaffected, but the contract must hold for both wires.)\n if (\n offset.length === 0 ||\n offset.includes('\\0') ||\n offset.includes('\\r') ||\n offset.includes('\\n') ||\n offset !== offset.trim()\n ) {\n throw new Error(\n `Invalid durability offset for SSE id: ${JSON.stringify(offset)}`,\n )\n }\n if (seenOffsets.has(offset)) {\n throw new Error(\n `Durability adapter must return a unique offset per chunk: ${JSON.stringify(offset)}`,\n )\n }\n seenOffsets.add(offset)\n }\n\n async function* produce(): AsyncIterable<StreamChunk> {\n let batch: Array<StreamChunk> = []\n let terminalPersisted = false\n // Whether a terminal event was actually delivered LIVE to the consumer (as\n // opposed to only appended to the log). Distinguishes \"the run already ended\n // on the wire\" from \"the log has a terminal but the consumer never saw one\",\n // which governs whether a late durability-cleanup failure may be rethrown.\n // Only ever assigned inside the nested flush() closure, which TS's\n // control-flow analysis can't observe (see the disable at the read site).\n let terminalForwarded = false\n let failure: RecordedFailure | undefined\n let terminalCause: unknown\n let hasTerminalCause = false\n\n const recordFailure = (error: unknown, phase: string): void => {\n failure = {\n error:\n failure === undefined\n ? error\n : combineFailures(failure.error, error, phase),\n }\n }\n\n async function* flush(): AsyncIterable<StreamChunk> {\n if (batch.length === 0) return\n const toForward = batch\n batch = []\n // Tag each chunk with the exact backend offset. Requiring one opaque\n // token per chunk preserves exact-once resume at any batch size.\n const offsets = await durability.append(toForward)\n if (offsets.length !== toForward.length) {\n throw new Error(\n `Durability append returned ${offsets.length} offsets for ${toForward.length} chunks`,\n )\n }\n toForward.forEach((chunk, i) => {\n const offset = offsets[i]\n if (offset === undefined) {\n throw new Error(`Durability append omitted offset at index ${i}`)\n }\n validateOffset(offset)\n idByChunk.set(chunk, offset)\n })\n if (\n toForward.some(\n (chunk) =>\n chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR',\n )\n ) {\n terminalPersisted = true\n }\n for (const chunk of toForward) {\n if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {\n terminalForwarded = true\n }\n yield chunk\n }\n }\n\n try {\n if (isAborted(abortController.signal)) return\n // Make the run joinable BEFORE the producer is first pulled — the pull\n // is what runs the middleware chain, and middleware may take minutes to\n // yield a first chunk. See {@link RUN_ACCEPTED_EVENT}.\n batch.push({\n type: 'CUSTOM',\n name: RUN_ACCEPTED_EVENT,\n value: {},\n timestamp: Date.now(),\n })\n yield* flush()\n for await (const chunk of stream) {\n if (isAborted(abortController.signal)) break\n batch.push(chunk)\n if (batch.length >= batchSize || isDurabilityFlushBoundary(chunk)) {\n yield* flush()\n }\n }\n if (!isAborted(abortController.signal)) yield* flush()\n } catch (error) {\n terminalCause = error\n hasTerminalCause = true\n recordFailure(error, 'producer failed')\n // The provider stream threw. Persist a terminal RUN_ERROR to the\n // durability log so a resumer / joiner learns the run failed (otherwise\n // the log ends with no terminal and they wait forever). Flush any\n // buffered chunks first, then append the terminal WITHOUT forwarding it\n // live — the transport layer synthesizes the live RUN_ERROR on rethrow,\n // so forwarding here too would double-emit.\n if (!isAborted(abortController.signal)) {\n try {\n yield* flush()\n } catch (flushError) {\n recordFailure(flushError, 'flushing buffered chunks failed')\n }\n }\n } finally {\n // The PRODUCER was stopped, which is deliberately not the same question as\n // \"did the delivery socket go away\". A disconnect alone must leave this\n // false: the run survives it and terminalizes this log itself on its way\n // out, and treating the disconnect as a cancel here would make `detached`\n // true for a run that had already finished — skipping `close()` and parking\n // every later tailer forever on a log nobody will ever continue.\n const cancelled = isAborted(abortController.signal)\n\n // Persist any buffered-but-unflushed chunks before terminalizing, so a\n // joiner replaying the log sees everything produced up to a disconnect\n // rather than a truncated prefix. On the abort path the streaming loop\n // broke before its trailing flush; drain flush() here for its persistence\n // side effect only (the delivery socket is gone, so the yielded chunks are\n // discarded). The normal and provider-throw paths already flushed, so\n // `batch` is empty for them and this is a no-op.\n if (batch.length > 0) {\n try {\n for await (const _chunk of flush()) {\n // persist-only: nothing consumes these\n }\n } catch (flushError) {\n recordFailure(flushError, 'flushing buffered chunks on exit failed')\n }\n }\n\n // Was this abort a DETACH? Only the run's own middleware can say — it is\n // the only actor that has resolved both out-of-band cancel bands and\n // `detachOnDisconnect` — and it says so on the stream itself (see\n // `./delivery-detach`). Read only AFTER the try block above has exited,\n // which is what awaits the chat generator's `return()` and therefore the\n // whole `onAbort` chain that publishes the verdict.\n //\n // Every conjunct is load bearing. `cancelled` keeps a normal finish on\n // today's path. `!isExplicitCancel` is core's own belt-and-braces refusal to\n // spare a run the user deliberately stopped, whatever a middleware claims.\n // `!hasTerminalCause` keeps a GENUINE provider failure\n // terminal even if the socket died too, so a real error is never mistaken\n // for a detach. And `wasRunDetached` is false for an\n // explicit cancel (either band), for a non-detachable disconnect, and for\n // every app that has not wired durability — all of which keep terminalizing\n // and closing exactly as before.\n //\n // What is ALREADY IN THE LOG is deliberately NOT a conjunct. An agent-loop\n // run emits one `RUN_FINISHED` PER ITERATION — the intermediate\n // `finishReason: 'tool_calls'` terminal is flushed at its boundary\n // mid-run — so `terminalPersisted` means \"some terminal is in the log\",\n // never \"the run ended\". Gating on it terminalized the log of a healthy,\n // still-running agent for every tool-calling run. Nor can the sink\n // distinguish a final terminal from an intermediate one by its\n // `finishReason`: only the run's middleware knows, and that is exactly\n // what the verdict reports. So a published detach verdict WINS — it\n // already means \"the agent is alive and a successor will terminalize this\n // log\".\n const detached =\n cancelled &&\n !isExplicitCancel(abortController.signal) &&\n !hasTerminalCause &&\n wasRunDetached(stream)\n\n if (\n !detached &&\n needsTerminalPersistence(terminalPersisted, cancelled, hasTerminalCause)\n ) {\n // Prefer the real provider error even when the delivery socket was also\n // aborted: if the run genuinely failed, a joiner should see that cause,\n // not a generic AbortError that masks it. AbortError is only used for a\n // pure cancellation with no underlying failure.\n const cause = hasTerminalCause ? terminalCause : { name: 'AbortError' }\n try {\n await durability.append([runErrorChunk(cause)])\n terminalPersisted = true\n } catch (terminalError) {\n // Rethrown to the live consumer below, but a joiner replaying the log\n // only ever sees a generic incomplete error — so record the real\n // cause server-side where an operator can act on it.\n logger?.errors('persisting terminal RUN_ERROR failed', {\n error: terminalError,\n })\n recordFailure(terminalError, 'persisting terminal RUN_ERROR failed')\n }\n }\n\n // A detached run's log is deliberately left OPEN: the run is still going,\n // and `close()` would terminalize the log the takeover has to continue —\n // a tailing attach would stop at the prefix, and a stored synthetic\n // `RUN_ERROR` would additionally diverge the takeover's journal replay and\n // record a healthy run as failed.\n //\n // This is NOT the general \"fence the close\" that `ai-sandbox`'s claim.ts\n // rules out. That fence would suppress `close()` for a run nobody will ever\n // drive again, wedging the record at `'running'` with every tailer parked\n // forever. The skip here is conditional on a verdict that means the exact\n // opposite: the agent is alive and a successor WILL terminalize this log\n // (its own producer exit runs this same `finally`). Keep that distinction —\n // widening this condition to \"any abort\" re-introduces the wedge.\n if (!detached) {\n try {\n await durability.close()\n } catch (closeError) {\n // A failed close leaves the durable log unterminated for joiners; the\n // live consumer gets the rethrow, but log it for the joiner's sake.\n logger?.errors('closing durability stream failed', {\n error: closeError,\n })\n recordFailure(closeError, 'closing durability stream failed')\n }\n }\n\n // Rethrow a terminalization/close failure to the live consumer ONLY when\n // no terminal reached it yet — the transport then synthesizes a live\n // RUN_ERROR so the consumer isn't left without a terminal. If a terminal\n // was already forwarded (the run ended on the wire), a late failure is a\n // server-side cleanup issue; rethrowing it would append a contradictory\n // second terminal (RUN_ERROR after RUN_FINISHED) on the wire. Suppress the\n // rethrow, but never let the cause vanish — record it server-side, the\n // same as the close / terminal-append failures above. (This also covers a\n // provider that throws AFTER emitting its own terminal, whose error is\n // otherwise neither delivered nor logged.)\n if (failure !== undefined) {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- terminalForwarded is set only inside the flush() closure, which TS CFA narrows away here\n if (!terminalForwarded) {\n // eslint-disable-next-line no-unsafe-finally\n throw failure.error\n }\n logger?.errors(\n 'durability failure after a terminal event was forwarded',\n {\n error: failure.error,\n },\n )\n }\n }\n }\n\n async function* replay(offset: TOffset): AsyncIterable<StreamChunk> {\n // Thread the consumer's abort signal into the read so a live-tailing join\n // (a mid-stream reconnect) that is aborted — or that hit a runId with no\n // in-process producer — stops parking and ends instead of hanging forever.\n for await (const { offset: eventOffset, chunk } of durability.read(\n offset,\n abortController.signal,\n )) {\n if (isAborted(abortController.signal)) break\n validateOffset(eventOffset)\n idByChunk.set(chunk, eventOffset)\n yield chunk\n }\n }\n\n return {\n source: resumeOffset !== null ? replay(resumeOffset) : produce(),\n getId,\n }\n}\n\n/**\n * Convert a StreamChunk async iterable to a Response in Server-Sent Events format\n *\n * This creates a Response that emits chunks in SSE format:\n * - Each chunk is prefixed with \"data: \"\n * - Each chunk is followed by \"\\n\\n\"\n * - Stream ends when the underlying iterable is exhausted (RUN_FINISHED is the terminal event)\n *\n * Pass a `durability` sink (`memoryStream(request)` / `durableStream(request)`)\n * to make the stream resumable: fresh runs are appended to the log and each SSE\n * event is tagged with an `id:` offset; a reconnect (native `Last-Event-ID`) or\n * a `?offset` join replays from the log without re-running the producer. `batch`\n * controls how many chunks are buffered per `append` (default 32).\n *\n * @param stream - AsyncIterable of StreamChunks from chat()\n * @param init - Optional Response initialization options (including `abortController`, `durability` with its optional `batch`, and `debug`)\n * @returns Response in Server-Sent Events format\n *\n * @example\n * ```typescript\n * export async function POST(request: Request) {\n * const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });\n * return toServerSentEventsResponse(stream, { durability: { adapter: memoryStream(request) } });\n * }\n * ```\n */\nexport function toServerSentEventsResponse<TOffset extends string = string>(\n stream: AsyncIterable<StreamChunk>,\n init?: ResponseInit & {\n abortController?: AbortController\n durability?: { adapter: StreamDurability<TOffset>; batch?: number }\n /**\n * Customize logging for durability failure paths (terminal-append and\n * close). These failures are always logged server-side by default (the\n * `errors` category is on even without `debug`, via a `ConsoleLogger`);\n * pass `debug` to route them to a custom `Logger` or raise verbosity. A\n * joiner replaying the log only ever sees a generic incomplete error, so\n * server-side logging is where the real cause is recoverable.\n */\n debug?: DebugOption\n },\n): Response {\n const { headers, abortController, durability, debug, ...responseInit } =\n init ?? {}\n\n // Start with default SSE headers\n const mergedHeaders = new Headers({\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n })\n\n // Override with user headers if provided, handling all HeadersInit forms:\n // Headers instance, string[][], or plain object\n if (headers) {\n const userHeaders = new Headers(headers)\n userHeaders.forEach((value, key) => {\n mergedHeaders.set(key, value)\n })\n }\n\n let body: ReadableStream<Uint8Array>\n if (durability) {\n // A fresh run (not a resume/replay) drains into the durable log under its\n // OWN producer controller, decoupled from the HTTP response: a response\n // cancel (page reload) detaches and keeps draining in the background so a\n // rejoining client tails the log to the real terminal, rather than killing\n // the run and sealing the log with RUN_ERROR. The producer is aborted only\n // by a caller-supplied `abortController` (a genuine stop()). On the resume\n // path the response IS a reader, so a cancel should stop the read normally.\n const isFresh = durability.adapter.resumeFrom() === null\n const producerAbortController = abortController ?? new AbortController()\n const deliveryAbortController = isFresh\n ? new AbortController()\n : producerAbortController\n const { source, getId } = durableStreamSource(stream, durability.adapter, {\n abortController: producerAbortController,\n batch: durability.batch,\n // `errors` category is on by default even when `debug` is undefined, so\n // durability terminal-append / close failures always surface server-side —\n // including on the client-disconnect path where there is no live consumer.\n logger: resolveDebugOption(debug),\n })\n const { encodeChunk, encodeError } = sseEncoders(getId)\n body = toEncodedStream(\n source,\n deliveryAbortController,\n encodeChunk,\n encodeError,\n isFresh,\n // Fresh runs only: a resume response IS a reader, so its cancel is an\n // ordinary read being stopped, not a producer losing its viewer.\n isFresh ? () => notifyRunDisconnected(stream) : undefined,\n )\n } else {\n body = toServerSentEventsStream(stream, abortController)\n }\n\n return new Response(body, {\n ...responseInit,\n headers: mergedHeaders,\n })\n}\n\n/**\n * A resume is served entirely from the durability log, so there is no producer\n * to iterate. This empty source satisfies the response helpers' signature; on a\n * resume `durableStreamSource` replays from the log and never touches it.\n */\nfunction emptyDurableSource(): AsyncIterable<StreamChunk> {\n return (async function* () {})()\n}\n\n/**\n * Everything the resume helpers need to take a run over as a side effect of\n * serving its log.\n *\n * `claim` and `pipe` are **injected**, not imported. The two mechanisms a\n * takeover needs (`withRunClaim` and `pipeToRunLog`) live in\n * `@tanstack/ai-sandbox`, and `@tanstack/ai` must not depend on that package —\n * that layering inversion is exactly what moving `LockStore` into core was meant\n * to prevent, and it would make core depend on the sandbox package to serve a\n * plain chat run. Injecting them keeps only the *shape* of a takeover in core\n * (parse the run id, read the record, skip if terminal, claim, drive) and lets a\n * background-worker-driven run supply its own pair.\n * `@tanstack/ai-sandbox`'s `sandboxRunDriver` fills both in.\n */\nexport interface RunDriverOptions {\n /** The attach request; its run id is read with {@link resolveResumeRunId}. */\n request: Request\n runs: RunStore\n locks: LockStore\n /** Produce the run's remaining events. Called only once the claim is held. */\n drive: (input: {\n runId: string\n threadId: string\n signal: AbortSignal\n }) => AsyncIterable<StreamChunk>\n /** Run `fn` under exclusive ownership of the run, or reject if refused. */\n claim: <T>(\n input: { runs: RunStore; locks: LockStore; runId: string },\n fn: (claim: {\n runId: string\n epoch: number\n signal: AbortSignal\n }) => Promise<T>,\n ) => Promise<T>\n /** Persist the driven stream to the run's producer-side durability log. */\n pipe: (\n stream: AsyncIterable<StreamChunk>,\n input: { runId: string; threadId: string; signal: AbortSignal },\n ) => Promise<unknown>\n /** Platform keep-alive (e.g. `ctx.waitUntil`) for the background drive. */\n waitUntil?: (promise: Promise<unknown>) => void\n logger?: InternalLogger\n}\n\n/** Shared options for the resume-only response helpers. */\ntype ResumeResponseOptions<TOffset extends string> = ResponseInit & {\n adapter: StreamDurability<TOffset>\n batch?: number\n debug?: DebugOption\n /**\n * Take the run over while serving its log. Omit to serve the log only —\n * the response is byte-identical either way.\n */\n driver?: RunDriverOptions\n}\n\n/**\n * Take over an in-flight run as a side effect of serving its log.\n *\n * The response itself is unchanged: it still replays from the durability log via\n * `emptyDurableSource()`. The drive runs BESIDE it, appending to the run's own\n * producer-side log through the injected `pipe`, and the response tails what\n * lands. That separation is what lets a taken-over run keep `chat()`'s normal\n * middleware path — `withPersistence.onFinish` is what saves the transcript, so a\n * parallel translation path would lose the history of any run that completed\n * while detached.\n *\n * TOTAL BY CONSTRUCTION. Every failure is logged and swallowed:\n *\n * - No run id, no record, or a terminal record → serve the log, drive nothing.\n * A second tab attaching to a finished run must still see the transcript.\n * - The claim is refused (another host is already driving) → serve the log,\n * drive nothing. That is the documented \"two hosts attach at once: one wins\n * the lease and drives, the other tails the log\" behavior.\n * - The drive throws → logged. It cannot be reported to this response, which is\n * already streaming the log; the run's own `RUN_ERROR` event is the channel.\n *\n * A rejection escaping here would be an unhandled rejection with nobody to\n * report it to — process-fatal on modern Node and instance-fatal inside a\n * Durable Object.\n */\nfunction startRunDriver(driver: RunDriverOptions): void {\n const logger = driver.logger\n const promise = (async () => {\n const runId = resolveResumeRunId(driver.request)\n if (runId === null) return\n let record: RunRecord | null = null\n try {\n record = await driver.runs.get(runId)\n } catch (error) {\n logger?.errors('resume driver: reading the run record failed', {\n runId,\n error,\n })\n return\n }\n // Validated, not trusted: `record.status` is typed `RunStatus` but comes off\n // a user-implemented `RunStore`, so the type is a claim about a storage\n // column and nothing checked it. An unrecognized value means the run cannot\n // be reasoned about at all — the record says nothing trustworthy about\n // whether an agent is already driving it — so refuse the drive the same way\n // a terminal record does, and still serve the log so a corrupt row does not\n // also blank the transcript.\n if (record !== null && !isRunStatus(record.status)) {\n logger?.errors(\n 'resume driver: the run record has an unrecognized status',\n {\n runId,\n status: record.status,\n },\n )\n return\n }\n if (record === null || isTerminalRunStatus(record.status)) return\n // A recorded cancel is NOT a status. `requestRunCancel` deliberately writes\n // only `cancelRequested`, so a run cancelled out of band while its driving\n // host had already died stays `'running'` — and the status gate above waves\n // it straight through. Driving it resurrects a run the user explicitly\n // stopped and burns tokens until the TTL expires. The log is still served, so\n // an attaching tab sees the transcript; only the drive is refused.\n //\n // This is the \"don't START one\" half. Aborting a drive that is ALREADY live\n // when a cancel lands afterwards is a separate, still-open concern.\n if (record.cancelRequested === true) return\n // Captured after narrowing so the closure below sees a definite record\n // rather than the re-widened `let`.\n const active = record\n\n try {\n await driver.claim(\n { runs: driver.runs, locks: driver.locks, runId },\n async (claim) => {\n // A viewer is attached again, so the detached clock stops. Cleared\n // under the claim so it cannot race the reaper's read.\n //\n // THE REAPER: do NOT reuse `startRunDriver` for reclaiming detached\n // runs. `@tanstack/ai-sandbox`'s `reapDetachedRuns` deliberately does\n // the opposite of this line — it ACTS ON `detachedSince` and must\n // leave the marker intact for its own TTL accounting — so borrowing\n // this path would erase the very evidence the reaper selected the run\n // on, resetting the TTL on every sweep so a detached run could never\n // expire. That is why the reaper has its own drive path.\n //\n // LOG AND CONTINUE. This write is BOOKKEEPING for the reaper's TTL\n // accounting; the claim is already held and the takeover is the\n // valuable part. Letting a rejection propagate would land in the catch\n // below — the channel reserved for the normal \"someone else won the\n // lease\" case — so one transient store error would silently cost the\n // whole drive, logged only on the `provider` debug channel and\n // therefore invisible at default log levels. The worst case of\n // continuing is a stale `detachedSince` the reaper may act on later;\n // the worst case of vetoing is a run nobody drives at all.\n try {\n await driver.runs.update(runId, { detachedSince: undefined })\n } catch (error) {\n logger?.errors('resume driver: clearing detachedSince failed', {\n runId,\n error,\n })\n }\n await driver.pipe(\n driver.drive({\n runId,\n threadId: active.threadId,\n signal: claim.signal,\n }),\n { runId, threadId: active.threadId, signal: claim.signal },\n )\n },\n )\n } catch (error) {\n // Includes RunClaimNotAcquiredError (someone else is driving) and\n // RunClaimLostError (we were superseded mid-drive). Both are normal.\n logger?.provider('resume driver: not driving this run', { runId, error })\n }\n })()\n\n if (driver.waitUntil) {\n driver.waitUntil(promise)\n } else {\n // No platform keep-alive: at least ensure the rejection is handled. The\n // async body above already catches everything, so this is belt-and-braces.\n void promise.catch(() => {})\n }\n}\n\n/**\n * The single wiring point both resume helpers call, so the SSE and NDJSON\n * halves cannot drift: a fix here applies to both. Called AFTER each helper's\n * `resumeFrom() === null` 400 check — an attach with no offset has nothing to\n * replay, and driving a run whose response will 400 would start an agent\n * nobody is watching.\n */\nfunction maybeStartRunDriver(driver: RunDriverOptions | undefined): void {\n if (driver) startRunDriver(driver)\n}\n\nconst NO_RESUME_OFFSET =\n 'No resume offset provided (expected a Last-Event-ID header or an ?offset query parameter).'\n\n/**\n * Serve a resumable run from its durability log over Server-Sent Events, without\n * re-running the model. Use this in a `GET` handler so a reload or a second tab\n * can re-attach to an in-flight or finished run.\n *\n * The adapter (`memoryStream(request)` / `durableStream(request)`) captures the\n * resume offset from the request. If there is none (no `Last-Event-ID` header\n * and no `?offset`), there is nothing to replay and this returns a 400.\n *\n * @example\n * ```typescript\n * export async function GET(request: Request) {\n * return resumeServerSentEventsResponse({ adapter: memoryStream(request) });\n * }\n * ```\n */\nexport function resumeServerSentEventsResponse<TOffset extends string = string>(\n options: ResumeResponseOptions<TOffset>,\n): Response {\n // `driver` MUST be destructured out: `responseInit` is spread into\n // `new Response(body, init)`, so leaving it in would leak the driver object\n // (and its Request) into the response init.\n const { adapter, batch, debug, driver, ...responseInit } = options\n if (adapter.resumeFrom() === null) {\n return new Response(NO_RESUME_OFFSET, { status: 400 })\n }\n maybeStartRunDriver(driver)\n return toServerSentEventsResponse(emptyDurableSource(), {\n ...responseInit,\n durability: { adapter, batch },\n debug,\n })\n}\n\n/**\n * Convert a StreamChunk async iterable to a ReadableStream in HTTP stream format (newline-delimited JSON)\n *\n * This creates a ReadableStream that emits chunks as newline-delimited JSON:\n * - Each chunk is JSON.stringify'd and followed by \"\\n\"\n * - No SSE formatting (no \"data: \" prefix)\n *\n * This format is compatible with `fetchHttpStream` connection adapter.\n *\n * When `getId` is supplied (delivery durability), each chunk is emitted as an\n * envelope `{\"id\":\"<offset>\",\"chunk\":{…}}` instead of a bare chunk. NDJSON has\n * no native event-id field like SSE's `id:` line, so the resumable offset rides\n * inside the payload. Untagged chunks (no id) stay bare, so a non-durable\n * stream is byte-identical to before and the client auto-detects either form.\n *\n * @param stream - AsyncIterable of StreamChunks from chat()\n * @param abortController - Optional AbortController to abort when stream is cancelled\n * @param getId - Optional per-chunk durability offset; when present, chunks are envelope-encoded\n * @returns ReadableStream in HTTP stream format (newline-delimited JSON)\n *\n * @example\n * ```typescript\n * const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });\n * const readableStream = toHttpStream(stream);\n * // Use with Response for HTTP streaming (not SSE)\n * return new Response(readableStream, {\n * headers: { 'Content-Type': 'application/x-ndjson' }\n * });\n * ```\n */\nexport function toHttpStream(\n stream: AsyncIterable<StreamChunk>,\n abortController?: AbortController,\n getId?: (chunk: StreamChunk, index: number) => string | undefined,\n): ReadableStream<Uint8Array> {\n const { encodeChunk, encodeError } = ndjsonEncoders(getId)\n return toEncodedStream(stream, abortController, encodeChunk, encodeError)\n}\n\n/**\n * NDJSON chunk/error encoders. Shared by {@link toHttpStream} and the internal\n * durability branch (see {@link sseEncoders}).\n */\nfunction ndjsonEncoders(\n getId?: (chunk: StreamChunk, index: number) => string | undefined,\n): {\n encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array\n encodeError: (error: unknown) => Uint8Array\n} {\n const encoder = new TextEncoder()\n return {\n encodeChunk: (chunk, index) => {\n const id = getId?.(chunk, index)\n const line =\n id === undefined ? JSON.stringify(chunk) : JSON.stringify({ id, chunk })\n return encoder.encode(`${line}\\n`)\n },\n encodeError: (error) =>\n encoder.encode(`${JSON.stringify(runErrorChunk(error))}\\n`),\n }\n}\n\n/**\n * Convert a StreamChunk async iterable to a Response in HTTP stream format (newline-delimited JSON)\n *\n * This creates a Response that emits chunks in HTTP stream format:\n * - Each chunk is JSON.stringify'd and followed by \"\\n\"\n * - No SSE formatting (no \"data: \" prefix)\n *\n * This format is compatible with `fetchHttpStream` connection adapter.\n *\n * Pass a `durability` sink (`memoryStream(request)` / `durableStream(request)`)\n * to make the stream resumable: fresh runs are appended to the log and each\n * NDJSON line is emitted as an `{ id, chunk }` envelope carrying an opaque\n * offset; a reconnect (native `Last-Event-ID` header) or a `?offset` join\n * replays from the log without re-running the producer. `batch` controls how\n * many chunks are buffered per `append` (default 32). This shares the exact\n * `durableStreamSource` used by `toServerSentEventsResponse` — only the wire\n * encoding differs.\n *\n * @param stream - AsyncIterable of StreamChunks from chat()\n * @param init - Optional Response initialization options (including `abortController`, `durability` with its optional `batch`, and `debug`)\n * @returns Response in HTTP stream format (newline-delimited JSON)\n *\n * @example\n * ```typescript\n * export async function POST(request: Request) {\n * const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });\n * return toHttpResponse(stream, { durability: { adapter: memoryStream(request) } });\n * }\n * ```\n */\nexport function toHttpResponse<TOffset extends string = string>(\n stream: AsyncIterable<StreamChunk>,\n init?: ResponseInit & {\n abortController?: AbortController\n durability?: { adapter: StreamDurability<TOffset>; batch?: number }\n /**\n * Customize logging for durability failure paths (terminal-append and\n * close). These failures are always logged server-side by default (the\n * `errors` category is on even without `debug`, via a `ConsoleLogger`);\n * pass `debug` to route them to a custom `Logger` or raise verbosity. A\n * joiner replaying the log only ever sees a generic incomplete error, so\n * server-side logging is where the real cause is recoverable.\n */\n debug?: DebugOption\n },\n): Response {\n const { abortController, durability, debug, headers, ...responseInit } =\n init ?? {}\n\n // Default to a streaming NDJSON content type (with no-cache), overridable by\n // user headers. Without an explicit streaming type some intermediaries buffer\n // the response, defeating incremental delivery. Mirrors the SSE helper.\n const mergedHeaders = new Headers({\n 'Content-Type': 'application/x-ndjson',\n 'Cache-Control': 'no-cache',\n })\n if (headers) {\n const userHeaders = new Headers(headers)\n userHeaders.forEach((value, key) => {\n mergedHeaders.set(key, value)\n })\n }\n\n let body: ReadableStream<Uint8Array>\n if (durability) {\n // See toServerSentEventsResponse: a fresh run drains into the durable log\n // under its own producer controller, so a response cancel (reload) detaches\n // and keeps draining in the background instead of killing the run; a resume\n // response is a reader whose cancel stops the read normally.\n const isFresh = durability.adapter.resumeFrom() === null\n const producerAbortController = abortController ?? new AbortController()\n const deliveryAbortController = isFresh\n ? new AbortController()\n : producerAbortController\n const { source, getId } = durableStreamSource(stream, durability.adapter, {\n abortController: producerAbortController,\n batch: durability.batch,\n // Errors-on-by-default logger (see toServerSentEventsResponse).\n logger: resolveDebugOption(debug),\n })\n const { encodeChunk, encodeError } = ndjsonEncoders(getId)\n body = toEncodedStream(\n source,\n deliveryAbortController,\n encodeChunk,\n encodeError,\n isFresh,\n // See the SSE helper: fresh runs only.\n isFresh ? () => notifyRunDisconnected(stream) : undefined,\n )\n } else {\n body = toHttpStream(stream, abortController)\n }\n\n return new Response(body, {\n ...responseInit,\n headers: mergedHeaders,\n })\n}\n\n/**\n * Serve a resumable run from its durability log over NDJSON, without re-running\n * the model. The NDJSON counterpart of {@link resumeServerSentEventsResponse};\n * pair it with a `toHttpResponse` producer. Returns a 400 when the request\n * carries no resume offset (no `Last-Event-ID` header and no `?offset`).\n *\n * @example\n * ```typescript\n * export async function GET(request: Request) {\n * return resumeHttpResponse({ adapter: memoryStream(request) });\n * }\n * ```\n */\nexport function resumeHttpResponse<TOffset extends string = string>(\n options: ResumeResponseOptions<TOffset>,\n): Response {\n // See `resumeServerSentEventsResponse`: `driver` must not reach `responseInit`.\n const { adapter, batch, debug, driver, ...responseInit } = options\n if (adapter.resumeFrom() === null) {\n return new Response(NO_RESUME_OFFSET, { status: 400 })\n }\n maybeStartRunDriver(driver)\n return toHttpResponse(emptyDurableSource(), {\n ...responseInit,\n durability: { adapter, batch },\n debug,\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"stream-to-response.js","names":[],"sources":["../../src/stream-to-response.ts"],"sourcesContent":["import { toRunErrorPayload } from './activities/error-payload'\nimport { isCancelRequestedReason } from './activities/chat/cancel'\nimport {\n isRunStatus,\n isTerminalRunStatus,\n} from './activities/chat/middleware/run-store'\nimport { wasRunDetached } from './delivery-detach'\nimport { notifyRunDisconnected } from './delivery-disconnect'\nimport { resolveResumeRunId } from './stream-durability'\nimport { EventType } from './types'\nimport { resolveDebugOption } from './logger/resolve'\nimport { runErrorEventToError } from './utilities/errors'\nimport type { LockStore } from './activities/chat/middleware/locks'\nimport type {\n RunRecord,\n RunStore,\n} from './activities/chat/middleware/run-store'\nimport type { InternalLogger } from './logger/internal-logger'\nimport type { DebugOption } from './logger/types'\nimport type { StreamDurability } from './stream-durability'\nimport type { StreamChunk } from './types'\n\nexport { resolveResumeRunId } from './stream-durability'\n\n/**\n * Collect all text content from a StreamChunk async iterable and return as a string.\n *\n * This function consumes the entire stream, accumulating content from TEXT_MESSAGE_CONTENT events,\n * and returns the final concatenated text.\n *\n * @param stream - AsyncIterable of StreamChunks from chat()\n * @returns Promise<string> - The accumulated text content\n *\n * @example\n * ```typescript\n * const stream = chat({\n * adapter: openaiText('gpt-5.5'),\n * messages: [{ role: 'user', content: 'Hello!' }]\n * });\n * const text = await streamToText(stream);\n * console.log(text); // \"Hello! How can I help you today?\"\n * ```\n */\nexport async function streamToText(\n stream: AsyncIterable<StreamChunk>,\n): Promise<string> {\n let accumulatedContent = ''\n\n for await (const chunk of stream) {\n if (chunk.type === 'RUN_ERROR') {\n throw runErrorEventToError(chunk)\n }\n\n if (chunk.type === 'TEXT_MESSAGE_CONTENT' && chunk.delta) {\n accumulatedContent += chunk.delta\n }\n }\n\n return accumulatedContent\n}\n\ninterface RecordedFailure {\n error: unknown\n}\n\nfunction errorMessage(error: unknown): string {\n return toRunErrorPayload(error).message\n}\n\nfunction combineFailures(\n primary: unknown,\n secondary: unknown,\n phase: string,\n): unknown {\n if (primary === secondary) return primary\n const errors =\n primary instanceof AggregateError\n ? [...primary.errors, secondary]\n : [primary, secondary]\n return new AggregateError(\n errors,\n `${errorMessage(primary)}; ${phase}: ${errorMessage(secondary)}`,\n )\n}\n\nexport function runErrorChunk(\n error: unknown,\n): Extract<StreamChunk, { type: 'RUN_ERROR' }> {\n const payload = toRunErrorPayload(error)\n return {\n type: EventType.RUN_ERROR,\n timestamp: Date.now(),\n message: payload.message,\n ...(payload.code === undefined ? {} : { code: payload.code }),\n error: payload,\n }\n}\n\nfunction isAborted(signal: AbortSignal): boolean {\n return signal.aborted\n}\n\n/**\n * Whether this abort is an EXPLICIT in-process cancel — the caller aborted with\n * {@link RUN_CANCEL_REASON} rather than the socket going away.\n *\n * Core's own guard, independent of any middleware verdict: a user pressing Stop\n * must always get a closed, terminal log, so the sink refuses to treat that abort\n * as a detach even if the run's middleware published one. A reason-less abort\n * carries a `DOMException`, never a string, so a non-string reason is \"no\n * explicit intent\" — exactly how `resolveAbortReason` reads it in `chat()`.\n */\nfunction isExplicitCancel(signal: AbortSignal): boolean {\n const reason: unknown = signal.reason\n return typeof reason === 'string' && isCancelRequestedReason(reason)\n}\n\nfunction needsTerminalPersistence(\n terminalPersisted: boolean,\n cancelled: boolean,\n failed: boolean,\n): boolean {\n return !terminalPersisted && (cancelled || failed)\n}\n\nfunction toEncodedStream(\n stream: AsyncIterable<StreamChunk>,\n abortController: AbortController | undefined,\n encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array,\n encodeError: (error: unknown) => Uint8Array,\n detachOnCancel = false,\n /**\n * Called once when the response body is cancelled on the detach path, BEFORE\n * returning. The durability branch uses it to tell the run its viewer is gone\n * (see `./delivery-disconnect`) without aborting it.\n */\n onDetachedCancel?: () => void,\n): ReadableStream<Uint8Array> {\n const cancellation = abortController ?? new AbortController()\n let iterator: AsyncIterator<StreamChunk> | undefined\n let iteratorCleanup: Promise<void> | undefined\n let pumpPromise: Promise<void> = Promise.resolve()\n let pumpFailure: RecordedFailure | undefined\n let cancelled = false\n\n const recordPumpFailure = (error: unknown, phase: string): void => {\n pumpFailure = {\n error:\n pumpFailure === undefined\n ? error\n : combineFailures(pumpFailure.error, error, phase),\n }\n }\n\n const closeIterator = (): Promise<void> => {\n iteratorCleanup ??= (async () => {\n if (iterator?.return) await iterator.return()\n })()\n return iteratorCleanup\n }\n\n return new ReadableStream({\n start(controller) {\n iterator = stream[Symbol.asyncIterator]()\n pumpPromise = (async () => {\n let index = 0\n let iteratorDone = false\n\n try {\n while (!isAborted(cancellation.signal)) {\n const result = await iterator.next()\n if (result.done) {\n iteratorDone = true\n break\n }\n if (isAborted(cancellation.signal)) break\n // After a detached cancel the reader is gone but we keep pulling to\n // drain the producer into the durable log; skip enqueuing to the\n // closed controller.\n if (!cancelled) controller.enqueue(encodeChunk(result.value, index))\n index += 1\n }\n } catch (error) {\n recordPumpFailure(error, 'stream iteration failed')\n } finally {\n if (!iteratorDone) {\n try {\n await closeIterator()\n } catch (error) {\n recordPumpFailure(error, 'iterator cleanup failed')\n }\n }\n\n if (\n !cancelled &&\n !isAborted(cancellation.signal) &&\n pumpFailure !== undefined\n ) {\n controller.enqueue(encodeError(pumpFailure.error))\n }\n if (!cancelled) controller.close()\n }\n })().catch((error: unknown) => {\n recordPumpFailure(error, 'stream pump failed')\n })\n },\n async cancel(reason) {\n cancelled = true\n // Detached durable delivery: the client is gone (e.g. a page reload), but\n // the run must finish into the durable log so a rejoining client can tail\n // it to the real terminal. Do NOT abort the producer (that would kill the\n // run and seal the log with RUN_ERROR) and do NOT await the pump — it\n // keeps draining `stream` → the log in the background and terminates\n // normally on its own. A genuine caller-driven stop aborts the producer's\n // own AbortController instead, which this path never touches.\n //\n // Notify the run FIRST, and synchronously. This is the only moment the\n // socket-closed fact exists anywhere, and the run cannot observe it on its\n // own: it holds no handle on this response. That notification is what lets a\n // durable run record itself as detached while it KEEPS RUNNING — the\n // alternative applications were driven to (mirroring `request.signal` into\n // `chat()`'s abortController) reaches the middleware only by killing the run,\n // which for a sandboxed run means the agent is never even launched.\n if (detachOnCancel) {\n onDetachedCancel?.()\n return\n }\n\n if (!isAborted(cancellation.signal)) cancellation.abort(reason)\n\n let cancellationFailure: RecordedFailure | undefined\n try {\n await closeIterator()\n } catch (error) {\n cancellationFailure = { error }\n }\n await pumpPromise\n\n if (pumpFailure !== undefined && cancellationFailure !== undefined) {\n throw combineFailures(\n pumpFailure.error,\n cancellationFailure.error,\n 'iterator cancellation failed',\n )\n }\n if (pumpFailure !== undefined) throw pumpFailure.error\n if (cancellationFailure !== undefined) throw cancellationFailure.error\n },\n })\n}\n\n/**\n * Convert a StreamChunk async iterable to a ReadableStream in Server-Sent Events format\n *\n * This creates a ReadableStream that emits chunks in SSE format:\n * - Each chunk is prefixed with \"data: \"\n * - Each chunk is followed by \"\\n\\n\"\n * - Stream ends when the underlying iterable is exhausted (RUN_FINISHED is the terminal event)\n *\n * @param stream - AsyncIterable of StreamChunks from chat()\n * @param abortController - Optional AbortController to abort when stream is cancelled\n * @param getId - Optional per-chunk durability offset; when present, each event gets an `id:` line\n * @returns ReadableStream in Server-Sent Events format\n */\nexport function toServerSentEventsStream(\n stream: AsyncIterable<StreamChunk>,\n abortController?: AbortController,\n getId?: (chunk: StreamChunk, index: number) => string | undefined,\n): ReadableStream<Uint8Array> {\n const { encodeChunk, encodeError } = sseEncoders(getId)\n return toEncodedStream(stream, abortController, encodeChunk, encodeError)\n}\n\n/**\n * SSE chunk/error encoders. Shared by the public {@link toServerSentEventsStream}\n * and the internal durability branch (which additionally needs `toEncodedStream`'s\n * private `detachOnCancel`), so the wire format stays identical for both.\n */\nfunction sseEncoders(\n getId?: (chunk: StreamChunk, index: number) => string | undefined,\n): {\n encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array\n encodeError: (error: unknown) => Uint8Array\n} {\n const encoder = new TextEncoder()\n return {\n encodeChunk: (chunk, index) => {\n const id = getId?.(chunk, index)\n const idLine = id === undefined ? '' : `id: ${id}\\n`\n return encoder.encode(`${idLine}data: ${JSON.stringify(chunk)}\\n\\n`)\n },\n encodeError: (error) =>\n encoder.encode(`data: ${JSON.stringify(runErrorChunk(error))}\\n\\n`),\n }\n}\n\n/** Default number of chunks buffered before a durability `append`. */\nconst DEFAULT_DURABILITY_BATCH = 32\n\n/**\n * Resolve and validate the durability batch size. A non-positive-integer (0,\n * negative, fractional, or `NaN`) is rejected rather than clamped: silently\n * `Math.max(1, …)`-ing a `NaN` used to disable size-based flushing entirely\n * (`length >= NaN` is always false), which is a subtle footgun.\n */\nfunction resolveBatchSize(batch: number | undefined): number {\n if (batch === undefined) return DEFAULT_DURABILITY_BATCH\n if (!Number.isInteger(batch) || batch <= 0) {\n throw new Error(\n `Invalid durability batch size: ${batch}. Must be a positive integer.`,\n )\n }\n return batch\n}\n\n/**\n * Boundaries at which the batching producer flushes early, regardless of the\n * batch size — the run-start marker, terminal events, and tool-call ends.\n * Flushing here keeps the durability log promptly consistent at semantically\n * meaningful points.\n *\n * `RUN_STARTED` matters especially for one-shot activities (image, speech,\n * transcription, summarize): they emit `RUN_STARTED`, then await the provider\n * for seconds, then a terminal. Without flushing `RUN_STARTED` the log stays\n * empty for the whole run, so a mount-time `joinRun` finds nothing and its\n * empty-log deadline fast-fails as \"run gone\" — even though the run is alive.\n * Flushing it immediately makes the run resumable from the instant it starts.\n */\nfunction isDurabilityFlushBoundary(chunk: StreamChunk): boolean {\n return (\n chunk.type === 'RUN_STARTED' ||\n chunk.type === 'RUN_FINISHED' ||\n chunk.type === 'RUN_ERROR' ||\n chunk.type === 'TOOL_CALL_END'\n )\n}\n\n/**\n * Name of the synthetic `CUSTOM` chunk a fresh durable producer appends to its\n * log before pulling the first real chunk.\n *\n * Flushing `RUN_STARTED` (above) makes a run joinable from the instant the\n * stream EMITS something — but a `chat()` whose middleware boots a sandbox\n * (create a container, install a CLI) legitimately emits nothing for minutes,\n * and during that window the log is empty. Every joiner's empty-log fail-fast\n * (`memoryStream`'s first-chunk deadline, the client's rejoin connect deadline)\n * then reads the run as gone — and the client clears its resume pointer, so a\n * reload during the boot window permanently orphans a run that is still going.\n *\n * This marker closes the window: it is appended (and flushed) before the\n * producer stream is first pulled, so a join always finds a first chunk within\n * milliseconds of the run being accepted. Takeover alignment is unaffected — a\n * journal replay cannot reproduce the marker, and alignment already skips\n * stored `CUSTOM` chunks as out-of-band for exactly that reason (see\n * `isBridgeCustomChunk` in `@tanstack/ai-sandbox`).\n */\nexport const RUN_ACCEPTED_EVENT = 'run.accepted'\n\n/**\n * Build the delivery-durable source iterable for a transport helper.\n *\n * - **Resume** (`resumeFrom()` non-null): replay strictly after the offset,\n * reading only from the durability log. The input `stream` is NEVER iterated,\n * so `chat()`'s lazy iterator never fires the provider — the untouched\n * generator is simply GC'd. This is what makes resume free of re-invocation.\n * - **Fresh** (`resumeFrom()` null): iterate `stream`, buffering up to `batch`\n * chunks (flushing early at terminal / tool-call boundaries), `append` each\n * batch to the log, then forward. Appending BEFORE forwarding guarantees a\n * reconnecting client can always replay exactly what it already saw.\n *\n * The returned `getId` maps each forwarded chunk to the exact opaque offset\n * returned by the durability adapter for the SSE `id:` line.\n */\nexport function durableStreamSource<TOffset extends string>(\n stream: AsyncIterable<StreamChunk>,\n durability: StreamDurability<TOffset>,\n options: {\n abortController: AbortController\n batch?: number\n logger?: InternalLogger\n },\n): {\n source: AsyncIterable<StreamChunk>\n getId: (chunk: StreamChunk) => string | undefined\n} {\n const resumeOffset = durability.resumeFrom()\n const batchSize = resolveBatchSize(options.batch)\n const abortController = options.abortController\n const logger = options.logger\n const idByChunk = new WeakMap<object, string>()\n const seenOffsets = new Set<string>()\n const getId = (chunk: StreamChunk): string | undefined => idByChunk.get(chunk)\n\n const validateOffset = (offset: TOffset): void => {\n // Reject NUL/CR/LF (would corrupt the SSE `id:` line) and any offset that\n // is not invariant under the wire round-trip. The SSE client reads the id\n // with `.trim()`, so an offset with leading/trailing whitespace would come\n // back changed and no longer match on reconnect — fail loud here rather\n // than silently mis-resuming. (NDJSON carries the offset inside the JSON\n // envelope and is unaffected, but the contract must hold for both wires.)\n if (\n offset.length === 0 ||\n offset.includes('\\0') ||\n offset.includes('\\r') ||\n offset.includes('\\n') ||\n offset !== offset.trim()\n ) {\n throw new Error(\n `Invalid durability offset for SSE id: ${JSON.stringify(offset)}`,\n )\n }\n if (seenOffsets.has(offset)) {\n throw new Error(\n `Durability adapter must return a unique offset per chunk: ${JSON.stringify(offset)}`,\n )\n }\n seenOffsets.add(offset)\n }\n\n async function* produce(): AsyncIterable<StreamChunk> {\n let batch: Array<StreamChunk> = []\n let terminalPersisted = false\n // Whether a terminal event was actually delivered LIVE to the consumer (as\n // opposed to only appended to the log). Distinguishes \"the run already ended\n // on the wire\" from \"the log has a terminal but the consumer never saw one\",\n // which governs whether a late durability-cleanup failure may be rethrown.\n // Only ever assigned inside the nested flush() closure, which TS's\n // control-flow analysis can't observe (see the disable at the read site).\n let terminalForwarded = false\n let failure: RecordedFailure | undefined\n let terminalCause: unknown\n let hasTerminalCause = false\n\n const recordFailure = (error: unknown, phase: string): void => {\n failure = {\n error:\n failure === undefined\n ? error\n : combineFailures(failure.error, error, phase),\n }\n }\n\n async function* flush(): AsyncIterable<StreamChunk> {\n if (batch.length === 0) return\n const toForward = batch\n batch = []\n // Tag each chunk with the exact backend offset. Requiring one opaque\n // token per chunk preserves exact-once resume at any batch size.\n const offsets = await durability.append(toForward)\n if (offsets.length !== toForward.length) {\n throw new Error(\n `Durability append returned ${offsets.length} offsets for ${toForward.length} chunks`,\n )\n }\n toForward.forEach((chunk, i) => {\n const offset = offsets[i]\n if (offset === undefined) {\n throw new Error(`Durability append omitted offset at index ${i}`)\n }\n validateOffset(offset)\n idByChunk.set(chunk, offset)\n })\n if (\n toForward.some(\n (chunk) =>\n chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR',\n )\n ) {\n terminalPersisted = true\n }\n for (const chunk of toForward) {\n if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {\n terminalForwarded = true\n }\n yield chunk\n }\n }\n\n try {\n if (isAborted(abortController.signal)) return\n // Make the run joinable BEFORE the producer is first pulled — the pull\n // is what runs the middleware chain, and middleware may take minutes to\n // yield a first chunk. See {@link RUN_ACCEPTED_EVENT}.\n batch.push({\n type: 'CUSTOM',\n name: RUN_ACCEPTED_EVENT,\n value: {},\n timestamp: Date.now(),\n })\n yield* flush()\n for await (const chunk of stream) {\n if (isAborted(abortController.signal)) break\n batch.push(chunk)\n if (batch.length >= batchSize || isDurabilityFlushBoundary(chunk)) {\n yield* flush()\n }\n }\n if (!isAborted(abortController.signal)) yield* flush()\n } catch (error) {\n terminalCause = error\n hasTerminalCause = true\n recordFailure(error, 'producer failed')\n // The provider stream threw. Persist a terminal RUN_ERROR to the\n // durability log so a resumer / joiner learns the run failed (otherwise\n // the log ends with no terminal and they wait forever). Flush any\n // buffered chunks first, then append the terminal WITHOUT forwarding it\n // live — the transport layer synthesizes the live RUN_ERROR on rethrow,\n // so forwarding here too would double-emit.\n if (!isAborted(abortController.signal)) {\n try {\n yield* flush()\n } catch (flushError) {\n recordFailure(flushError, 'flushing buffered chunks failed')\n }\n }\n } finally {\n // The PRODUCER was stopped, which is deliberately not the same question as\n // \"did the delivery socket go away\". A disconnect alone must leave this\n // false: the run survives it and terminalizes this log itself on its way\n // out, and treating the disconnect as a cancel here would make `detached`\n // true for a run that had already finished — skipping `close()` and parking\n // every later tailer forever on a log nobody will ever continue.\n const cancelled = isAborted(abortController.signal)\n\n // Persist any buffered-but-unflushed chunks before terminalizing, so a\n // joiner replaying the log sees everything produced up to a disconnect\n // rather than a truncated prefix. On the abort path the streaming loop\n // broke before its trailing flush; drain flush() here for its persistence\n // side effect only (the delivery socket is gone, so the yielded chunks are\n // discarded). The normal and provider-throw paths already flushed, so\n // `batch` is empty for them and this is a no-op.\n if (batch.length > 0) {\n try {\n for await (const _chunk of flush()) {\n // persist-only: nothing consumes these\n }\n } catch (flushError) {\n recordFailure(flushError, 'flushing buffered chunks on exit failed')\n }\n }\n\n // Was this abort a DETACH? Only the run's own middleware can say — it is\n // the only actor that has resolved both out-of-band cancel bands and\n // `detachOnDisconnect` — and it says so on the stream itself (see\n // `./delivery-detach`). Read only AFTER the try block above has exited,\n // which is what awaits the chat generator's `return()` and therefore the\n // whole `onAbort` chain that publishes the verdict.\n //\n // Every conjunct is load bearing. `cancelled` keeps a normal finish on\n // today's path. `!isExplicitCancel` is core's own belt-and-braces refusal to\n // spare a run the user deliberately stopped, whatever a middleware claims.\n // `!hasTerminalCause` keeps a GENUINE provider failure\n // terminal even if the socket died too, so a real error is never mistaken\n // for a detach. And `wasRunDetached` is false for an\n // explicit cancel (either band), for a non-detachable disconnect, and for\n // every app that has not wired durability — all of which keep terminalizing\n // and closing exactly as before.\n //\n // What is ALREADY IN THE LOG is deliberately NOT a conjunct. An agent-loop\n // run emits one `RUN_FINISHED` PER ITERATION — the intermediate\n // `finishReason: 'tool_calls'` terminal is flushed at its boundary\n // mid-run — so `terminalPersisted` means \"some terminal is in the log\",\n // never \"the run ended\". Gating on it terminalized the log of a healthy,\n // still-running agent for every tool-calling run. Nor can the sink\n // distinguish a final terminal from an intermediate one by its\n // `finishReason`: only the run's middleware knows, and that is exactly\n // what the verdict reports. So a published detach verdict WINS — it\n // already means \"the agent is alive and a successor will terminalize this\n // log\".\n const detached =\n cancelled &&\n !isExplicitCancel(abortController.signal) &&\n !hasTerminalCause &&\n wasRunDetached(stream)\n\n if (\n !detached &&\n needsTerminalPersistence(terminalPersisted, cancelled, hasTerminalCause)\n ) {\n // Prefer the real provider error even when the delivery socket was also\n // aborted: if the run genuinely failed, a joiner should see that cause,\n // not a generic AbortError that masks it. AbortError is only used for a\n // pure cancellation with no underlying failure.\n const cause = hasTerminalCause ? terminalCause : { name: 'AbortError' }\n try {\n await durability.append([runErrorChunk(cause)])\n terminalPersisted = true\n } catch (terminalError) {\n // Rethrown to the live consumer below, but a joiner replaying the log\n // only ever sees a generic incomplete error — so record the real\n // cause server-side where an operator can act on it.\n logger?.errors('persisting terminal RUN_ERROR failed', {\n error: terminalError,\n })\n recordFailure(terminalError, 'persisting terminal RUN_ERROR failed')\n }\n }\n\n // A detached run's log is deliberately left OPEN: the run is still going,\n // and `close()` would terminalize the log the takeover has to continue —\n // a tailing attach would stop at the prefix, and a stored synthetic\n // `RUN_ERROR` would additionally diverge the takeover's journal replay and\n // record a healthy run as failed.\n //\n // This is NOT the general \"fence the close\" that `ai-sandbox`'s claim.ts\n // rules out. That fence would suppress `close()` for a run nobody will ever\n // drive again, wedging the record at `'running'` with every tailer parked\n // forever. The skip here is conditional on a verdict that means the exact\n // opposite: the agent is alive and a successor WILL terminalize this log\n // (its own producer exit runs this same `finally`). Keep that distinction —\n // widening this condition to \"any abort\" re-introduces the wedge.\n if (!detached) {\n try {\n await durability.close()\n } catch (closeError) {\n // A failed close leaves the durable log unterminated for joiners; the\n // live consumer gets the rethrow, but log it for the joiner's sake.\n logger?.errors('closing durability stream failed', {\n error: closeError,\n })\n recordFailure(closeError, 'closing durability stream failed')\n }\n }\n\n // Rethrow a terminalization/close failure to the live consumer ONLY when\n // no terminal reached it yet — the transport then synthesizes a live\n // RUN_ERROR so the consumer isn't left without a terminal. If a terminal\n // was already forwarded (the run ended on the wire), a late failure is a\n // server-side cleanup issue; rethrowing it would append a contradictory\n // second terminal (RUN_ERROR after RUN_FINISHED) on the wire. Suppress the\n // rethrow, but never let the cause vanish — record it server-side, the\n // same as the close / terminal-append failures above. (This also covers a\n // provider that throws AFTER emitting its own terminal, whose error is\n // otherwise neither delivered nor logged.)\n if (failure !== undefined) {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- terminalForwarded is set only inside the flush() closure, which TS CFA narrows away here\n if (!terminalForwarded) {\n // eslint-disable-next-line no-unsafe-finally\n throw failure.error\n }\n logger?.errors(\n 'durability failure after a terminal event was forwarded',\n {\n error: failure.error,\n },\n )\n }\n }\n }\n\n async function* replay(offset: TOffset): AsyncIterable<StreamChunk> {\n // Thread the consumer's abort signal into the read so a live-tailing join\n // (a mid-stream reconnect) that is aborted — or that hit a runId with no\n // in-process producer — stops parking and ends instead of hanging forever.\n for await (const { offset: eventOffset, chunk } of durability.read(\n offset,\n abortController.signal,\n )) {\n if (isAborted(abortController.signal)) break\n validateOffset(eventOffset)\n idByChunk.set(chunk, eventOffset)\n yield chunk\n }\n }\n\n return {\n source: resumeOffset !== null ? replay(resumeOffset) : produce(),\n getId,\n }\n}\n\n/**\n * Convert a StreamChunk async iterable to a Response in Server-Sent Events format\n *\n * This creates a Response that emits chunks in SSE format:\n * - Each chunk is prefixed with \"data: \"\n * - Each chunk is followed by \"\\n\\n\"\n * - Stream ends when the underlying iterable is exhausted (RUN_FINISHED is the terminal event)\n *\n * Pass a `durability` sink (`memoryStream(request)` / `durableStream(request)`)\n * to make the stream resumable: fresh runs are appended to the log and each SSE\n * event is tagged with an `id:` offset; a reconnect (native `Last-Event-ID`) or\n * a `?offset` join replays from the log without re-running the producer. `batch`\n * controls how many chunks are buffered per `append` (default 32).\n *\n * @param stream - AsyncIterable of StreamChunks from chat()\n * @param init - Optional Response initialization options (including `abortController`, `durability` with its optional `batch`, and `debug`)\n * @returns Response in Server-Sent Events format\n *\n * @example\n * ```typescript\n * export async function POST(request: Request) {\n * const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });\n * return toServerSentEventsResponse(stream, { durability: { adapter: memoryStream(request) } });\n * }\n * ```\n */\nexport function toServerSentEventsResponse<TOffset extends string = string>(\n stream: AsyncIterable<StreamChunk>,\n init?: ResponseInit & {\n abortController?: AbortController\n durability?: { adapter: StreamDurability<TOffset>; batch?: number }\n /**\n * Customize logging for durability failure paths (terminal-append and\n * close). These failures are always logged server-side by default (the\n * `errors` category is on even without `debug`, via a `ConsoleLogger`);\n * pass `debug` to route them to a custom `Logger` or raise verbosity. A\n * joiner replaying the log only ever sees a generic incomplete error, so\n * server-side logging is where the real cause is recoverable.\n */\n debug?: DebugOption\n },\n): Response {\n const { headers, abortController, durability, debug, ...responseInit } =\n init ?? {}\n\n // Start with default SSE headers\n const mergedHeaders = new Headers({\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n })\n\n // Override with user headers if provided, handling all HeadersInit forms:\n // Headers instance, string[][], or plain object\n if (headers) {\n const userHeaders = new Headers(headers)\n userHeaders.forEach((value, key) => {\n mergedHeaders.set(key, value)\n })\n }\n\n let body: ReadableStream<Uint8Array>\n if (durability) {\n // A fresh run (not a resume/replay) drains into the durable log under its\n // OWN producer controller, decoupled from the HTTP response: a response\n // cancel (page reload) detaches and keeps draining in the background so a\n // rejoining client tails the log to the real terminal, rather than killing\n // the run and sealing the log with RUN_ERROR. The producer is aborted only\n // by a caller-supplied `abortController` (a genuine stop()). On the resume\n // path the response IS a reader, so a cancel should stop the read normally.\n const isFresh = durability.adapter.resumeFrom() === null\n const producerAbortController = abortController ?? new AbortController()\n const deliveryAbortController = isFresh\n ? new AbortController()\n : producerAbortController\n const { source, getId } = durableStreamSource(stream, durability.adapter, {\n abortController: producerAbortController,\n batch: durability.batch,\n // `errors` category is on by default even when `debug` is undefined, so\n // durability terminal-append / close failures always surface server-side —\n // including on the client-disconnect path where there is no live consumer.\n logger: resolveDebugOption(debug),\n })\n const { encodeChunk, encodeError } = sseEncoders(getId)\n body = toEncodedStream(\n source,\n deliveryAbortController,\n encodeChunk,\n encodeError,\n isFresh,\n // Fresh runs only: a resume response IS a reader, so its cancel is an\n // ordinary read being stopped, not a producer losing its viewer.\n isFresh ? () => notifyRunDisconnected(stream) : undefined,\n )\n } else {\n body = toServerSentEventsStream(stream, abortController)\n }\n\n return new Response(body, {\n ...responseInit,\n headers: mergedHeaders,\n })\n}\n\n/**\n * A resume is served entirely from the durability log, so there is no producer\n * to iterate. This empty source satisfies the response helpers' signature; on a\n * resume `durableStreamSource` replays from the log and never touches it.\n */\nfunction emptyDurableSource(): AsyncIterable<StreamChunk> {\n return (async function* () {})()\n}\n\n/**\n * Everything the resume helpers need to take a run over as a side effect of\n * serving its log.\n *\n * `claim` and `pipe` are **injected**, not imported. The two mechanisms a\n * takeover needs (`withRunClaim` and `pipeToRunLog`) live in\n * `@tanstack/ai-sandbox`, and `@tanstack/ai` must not depend on that package —\n * that layering inversion is exactly what moving `LockStore` into core was meant\n * to prevent, and it would make core depend on the sandbox package to serve a\n * plain chat run. Injecting them keeps only the *shape* of a takeover in core\n * (parse the run id, read the record, skip if terminal, claim, drive) and lets a\n * background-worker-driven run supply its own pair.\n * `@tanstack/ai-sandbox`'s `sandboxRunDriver` fills both in.\n */\nexport interface RunDriverOptions {\n /** The attach request; its run id is read with {@link resolveResumeRunId}. */\n request: Request\n runs: RunStore\n locks: LockStore\n /** Produce the run's remaining events. Called only once the claim is held. */\n drive: (input: {\n runId: string\n threadId: string\n signal: AbortSignal\n }) => AsyncIterable<StreamChunk>\n /** Run `fn` under exclusive ownership of the run, or reject if refused. */\n claim: <T>(\n input: { runs: RunStore; locks: LockStore; runId: string },\n fn: (claim: {\n runId: string\n epoch: number\n signal: AbortSignal\n }) => Promise<T>,\n ) => Promise<T>\n /** Persist the driven stream to the run's producer-side durability log. */\n pipe: (\n stream: AsyncIterable<StreamChunk>,\n input: { runId: string; threadId: string; signal: AbortSignal },\n ) => Promise<unknown>\n /** Platform keep-alive (e.g. `ctx.waitUntil`) for the background drive. */\n waitUntil?: (promise: Promise<unknown>) => void\n logger?: InternalLogger\n}\n\n/** Shared options for the resume-only response helpers. */\ntype ResumeResponseOptions<TOffset extends string> = ResponseInit & {\n adapter: StreamDurability<TOffset>\n batch?: number\n debug?: DebugOption\n /**\n * Take the run over while serving its log. Omit to serve the log only —\n * the response is byte-identical either way.\n */\n driver?: RunDriverOptions\n}\n\n/**\n * Take over an in-flight run as a side effect of serving its log.\n *\n * The response itself is unchanged: it still replays from the durability log via\n * `emptyDurableSource()`. The drive runs BESIDE it, appending to the run's own\n * producer-side log through the injected `pipe`, and the response tails what\n * lands. That separation is what lets a taken-over run keep `chat()`'s normal\n * middleware path — `withPersistence.onFinish` is what saves the transcript, so a\n * parallel translation path would lose the history of any run that completed\n * while detached.\n *\n * TOTAL BY CONSTRUCTION. Every failure is logged and swallowed:\n *\n * - No run id, no record, or a terminal record → serve the log, drive nothing.\n * A second tab attaching to a finished run must still see the transcript.\n * - The claim is refused (another host is already driving) → serve the log,\n * drive nothing. That is the documented \"two hosts attach at once: one wins\n * the lease and drives, the other tails the log\" behavior.\n * - The drive throws → logged. It cannot be reported to this response, which is\n * already streaming the log; the run's own `RUN_ERROR` event is the channel.\n *\n * A rejection escaping here would be an unhandled rejection with nobody to\n * report it to — process-fatal on modern Node and instance-fatal inside a\n * Durable Object.\n */\nfunction startRunDriver(driver: RunDriverOptions): void {\n const logger = driver.logger\n const promise = (async () => {\n const runId = resolveResumeRunId(driver.request)\n if (runId === null) return\n let record: RunRecord | null = null\n try {\n record = await driver.runs.get(runId)\n } catch (error) {\n logger?.errors('resume driver: reading the run record failed', {\n runId,\n error,\n })\n return\n }\n // Validated, not trusted: `record.status` is typed `RunStatus` but comes off\n // a user-implemented `RunStore`, so the type is a claim about a storage\n // column and nothing checked it. An unrecognized value means the run cannot\n // be reasoned about at all — the record says nothing trustworthy about\n // whether an agent is already driving it — so refuse the drive the same way\n // a terminal record does, and still serve the log so a corrupt row does not\n // also blank the transcript.\n if (record !== null && !isRunStatus(record.status)) {\n logger?.errors(\n 'resume driver: the run record has an unrecognized status',\n {\n runId,\n status: record.status,\n },\n )\n return\n }\n if (record === null || isTerminalRunStatus(record.status)) return\n // A recorded cancel is NOT a status. `requestRunCancel` deliberately writes\n // only `cancelRequested`, so a run cancelled out of band while its driving\n // host had already died stays `'running'` — and the status gate above waves\n // it straight through. Driving it resurrects a run the user explicitly\n // stopped and burns tokens until the TTL expires. The log is still served, so\n // an attaching tab sees the transcript; only the drive is refused.\n //\n // This is the \"don't START one\" half. Aborting a drive that is ALREADY live\n // when a cancel lands afterwards is a separate, still-open concern.\n if (record.cancelRequested === true) return\n // Captured after narrowing so the closure below sees a definite record\n // rather than the re-widened `let`.\n const active = record\n\n try {\n await driver.claim(\n { runs: driver.runs, locks: driver.locks, runId },\n async (claim) => {\n // A viewer is attached again, so the detached clock stops. Cleared\n // under the claim so it cannot race the reaper's read.\n //\n // THE REAPER: do NOT reuse `startRunDriver` for reclaiming detached\n // runs. `@tanstack/ai-sandbox`'s `reapDetachedRuns` deliberately does\n // the opposite of this line — it ACTS ON `detachedSince` and must\n // leave the marker intact for its own TTL accounting — so borrowing\n // this path would erase the very evidence the reaper selected the run\n // on, resetting the TTL on every sweep so a detached run could never\n // expire. That is why the reaper has its own drive path.\n //\n // LOG AND CONTINUE. This write is BOOKKEEPING for the reaper's TTL\n // accounting; the claim is already held and the takeover is the\n // valuable part. Letting a rejection propagate would land in the catch\n // below — the channel reserved for the normal \"someone else won the\n // lease\" case — so one transient store error would silently cost the\n // whole drive, logged only on the `provider` debug channel and\n // therefore invisible at default log levels. The worst case of\n // continuing is a stale `detachedSince` the reaper may act on later;\n // the worst case of vetoing is a run nobody drives at all.\n try {\n await driver.runs.update(runId, { detachedSince: undefined })\n } catch (error) {\n logger?.errors('resume driver: clearing detachedSince failed', {\n runId,\n error,\n })\n }\n await driver.pipe(\n driver.drive({\n runId,\n threadId: active.threadId,\n signal: claim.signal,\n }),\n { runId, threadId: active.threadId, signal: claim.signal },\n )\n },\n )\n } catch (error) {\n // Includes RunClaimNotAcquiredError (someone else is driving) and\n // RunClaimLostError (we were superseded mid-drive). Both are normal.\n logger?.provider('resume driver: not driving this run', { runId, error })\n }\n })()\n\n if (driver.waitUntil) {\n driver.waitUntil(promise)\n } else {\n // No platform keep-alive: at least ensure the rejection is handled. The\n // async body above already catches everything, so this is belt-and-braces.\n void promise.catch(() => {})\n }\n}\n\n/**\n * The single wiring point both resume helpers call, so the SSE and NDJSON\n * halves cannot drift: a fix here applies to both. Called AFTER each helper's\n * `resumeFrom() === null` 400 check — an attach with no offset has nothing to\n * replay, and driving a run whose response will 400 would start an agent\n * nobody is watching.\n */\nfunction maybeStartRunDriver(driver: RunDriverOptions | undefined): void {\n if (driver) startRunDriver(driver)\n}\n\nconst NO_RESUME_OFFSET =\n 'No resume offset provided (expected a Last-Event-ID header or an ?offset query parameter).'\n\n/**\n * Serve a resumable run from its durability log over Server-Sent Events, without\n * re-running the model. Use this in a `GET` handler so a reload or a second tab\n * can re-attach to an in-flight or finished run.\n *\n * The adapter (`memoryStream(request)` / `durableStream(request)`) captures the\n * resume offset from the request. If there is none (no `Last-Event-ID` header\n * and no `?offset`), there is nothing to replay and this returns a 400.\n *\n * @example\n * ```typescript\n * export async function GET(request: Request) {\n * return resumeServerSentEventsResponse({ adapter: memoryStream(request) });\n * }\n * ```\n */\nexport function resumeServerSentEventsResponse<TOffset extends string = string>(\n options: ResumeResponseOptions<TOffset>,\n): Response {\n // `driver` MUST be destructured out: `responseInit` is spread into\n // `new Response(body, init)`, so leaving it in would leak the driver object\n // (and its Request) into the response init.\n const { adapter, batch, debug, driver, ...responseInit } = options\n if (adapter.resumeFrom() === null) {\n return new Response(NO_RESUME_OFFSET, { status: 400 })\n }\n maybeStartRunDriver(driver)\n return toServerSentEventsResponse(emptyDurableSource(), {\n ...responseInit,\n durability: { adapter, batch },\n debug,\n })\n}\n\n/**\n * Convert a StreamChunk async iterable to a ReadableStream in HTTP stream format (newline-delimited JSON)\n *\n * This creates a ReadableStream that emits chunks as newline-delimited JSON:\n * - Each chunk is JSON.stringify'd and followed by \"\\n\"\n * - No SSE formatting (no \"data: \" prefix)\n *\n * This format is compatible with `fetchHttpStream` connection adapter.\n *\n * When `getId` is supplied (delivery durability), each chunk is emitted as an\n * envelope `{\"id\":\"<offset>\",\"chunk\":{…}}` instead of a bare chunk. NDJSON has\n * no native event-id field like SSE's `id:` line, so the resumable offset rides\n * inside the payload. Untagged chunks (no id) stay bare, so a non-durable\n * stream is byte-identical to before and the client auto-detects either form.\n *\n * @param stream - AsyncIterable of StreamChunks from chat()\n * @param abortController - Optional AbortController to abort when stream is cancelled\n * @param getId - Optional per-chunk durability offset; when present, chunks are envelope-encoded\n * @returns ReadableStream in HTTP stream format (newline-delimited JSON)\n *\n * @example\n * ```typescript\n * const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });\n * const readableStream = toHttpStream(stream);\n * // Use with Response for HTTP streaming (not SSE)\n * return new Response(readableStream, {\n * headers: { 'Content-Type': 'application/x-ndjson' }\n * });\n * ```\n */\nexport function toHttpStream(\n stream: AsyncIterable<StreamChunk>,\n abortController?: AbortController,\n getId?: (chunk: StreamChunk, index: number) => string | undefined,\n): ReadableStream<Uint8Array> {\n const { encodeChunk, encodeError } = ndjsonEncoders(getId)\n return toEncodedStream(stream, abortController, encodeChunk, encodeError)\n}\n\n/**\n * NDJSON chunk/error encoders. Shared by {@link toHttpStream} and the internal\n * durability branch (see {@link sseEncoders}).\n */\nfunction ndjsonEncoders(\n getId?: (chunk: StreamChunk, index: number) => string | undefined,\n): {\n encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array\n encodeError: (error: unknown) => Uint8Array\n} {\n const encoder = new TextEncoder()\n return {\n encodeChunk: (chunk, index) => {\n const id = getId?.(chunk, index)\n const line =\n id === undefined ? JSON.stringify(chunk) : JSON.stringify({ id, chunk })\n return encoder.encode(`${line}\\n`)\n },\n encodeError: (error) =>\n encoder.encode(`${JSON.stringify(runErrorChunk(error))}\\n`),\n }\n}\n\n/**\n * Convert a StreamChunk async iterable to a Response in HTTP stream format (newline-delimited JSON)\n *\n * This creates a Response that emits chunks in HTTP stream format:\n * - Each chunk is JSON.stringify'd and followed by \"\\n\"\n * - No SSE formatting (no \"data: \" prefix)\n *\n * This format is compatible with `fetchHttpStream` connection adapter.\n *\n * Pass a `durability` sink (`memoryStream(request)` / `durableStream(request)`)\n * to make the stream resumable: fresh runs are appended to the log and each\n * NDJSON line is emitted as an `{ id, chunk }` envelope carrying an opaque\n * offset; a reconnect (native `Last-Event-ID` header) or a `?offset` join\n * replays from the log without re-running the producer. `batch` controls how\n * many chunks are buffered per `append` (default 32). This shares the exact\n * `durableStreamSource` used by `toServerSentEventsResponse` — only the wire\n * encoding differs.\n *\n * @param stream - AsyncIterable of StreamChunks from chat()\n * @param init - Optional Response initialization options (including `abortController`, `durability` with its optional `batch`, and `debug`)\n * @returns Response in HTTP stream format (newline-delimited JSON)\n *\n * @example\n * ```typescript\n * export async function POST(request: Request) {\n * const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });\n * return toHttpResponse(stream, { durability: { adapter: memoryStream(request) } });\n * }\n * ```\n */\nexport function toHttpResponse<TOffset extends string = string>(\n stream: AsyncIterable<StreamChunk>,\n init?: ResponseInit & {\n abortController?: AbortController\n durability?: { adapter: StreamDurability<TOffset>; batch?: number }\n /**\n * Customize logging for durability failure paths (terminal-append and\n * close). These failures are always logged server-side by default (the\n * `errors` category is on even without `debug`, via a `ConsoleLogger`);\n * pass `debug` to route them to a custom `Logger` or raise verbosity. A\n * joiner replaying the log only ever sees a generic incomplete error, so\n * server-side logging is where the real cause is recoverable.\n */\n debug?: DebugOption\n },\n): Response {\n const { abortController, durability, debug, headers, ...responseInit } =\n init ?? {}\n\n // Default to a streaming NDJSON content type (with no-cache), overridable by\n // user headers. Without an explicit streaming type some intermediaries buffer\n // the response, defeating incremental delivery. Mirrors the SSE helper.\n const mergedHeaders = new Headers({\n 'Content-Type': 'application/x-ndjson',\n 'Cache-Control': 'no-cache',\n })\n if (headers) {\n const userHeaders = new Headers(headers)\n userHeaders.forEach((value, key) => {\n mergedHeaders.set(key, value)\n })\n }\n\n let body: ReadableStream<Uint8Array>\n if (durability) {\n // See toServerSentEventsResponse: a fresh run drains into the durable log\n // under its own producer controller, so a response cancel (reload) detaches\n // and keeps draining in the background instead of killing the run; a resume\n // response is a reader whose cancel stops the read normally.\n const isFresh = durability.adapter.resumeFrom() === null\n const producerAbortController = abortController ?? new AbortController()\n const deliveryAbortController = isFresh\n ? new AbortController()\n : producerAbortController\n const { source, getId } = durableStreamSource(stream, durability.adapter, {\n abortController: producerAbortController,\n batch: durability.batch,\n // Errors-on-by-default logger (see toServerSentEventsResponse).\n logger: resolveDebugOption(debug),\n })\n const { encodeChunk, encodeError } = ndjsonEncoders(getId)\n body = toEncodedStream(\n source,\n deliveryAbortController,\n encodeChunk,\n encodeError,\n isFresh,\n // See the SSE helper: fresh runs only.\n isFresh ? () => notifyRunDisconnected(stream) : undefined,\n )\n } else {\n body = toHttpStream(stream, abortController)\n }\n\n return new Response(body, {\n ...responseInit,\n headers: mergedHeaders,\n })\n}\n\n/**\n * Serve a resumable run from its durability log over NDJSON, without re-running\n * the model. The NDJSON counterpart of {@link resumeServerSentEventsResponse};\n * pair it with a `toHttpResponse` producer. Returns a 400 when the request\n * carries no resume offset (no `Last-Event-ID` header and no `?offset`).\n *\n * @example\n * ```typescript\n * export async function GET(request: Request) {\n * return resumeHttpResponse({ adapter: memoryStream(request) });\n * }\n * ```\n */\nexport function resumeHttpResponse<TOffset extends string = string>(\n options: ResumeResponseOptions<TOffset>,\n): Response {\n // See `resumeServerSentEventsResponse`: `driver` must not reach `responseInit`.\n const { adapter, batch, debug, driver, ...responseInit } = options\n if (adapter.resumeFrom() === null) {\n return new Response(NO_RESUME_OFFSET, { status: 400 })\n }\n maybeStartRunDriver(driver)\n return toHttpResponse(emptyDurableSource(), {\n ...responseInit,\n durability: { adapter, batch },\n debug,\n })\n}\n"],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { StreamChunk } from '../types.js';
1
2
  /**
2
3
  * Best-effort extraction of a human-readable message from an unknown thrown
3
4
  * value, returning `undefined` when none can be found.
@@ -11,3 +12,11 @@ export declare function errorMessage(err: unknown): string | undefined;
11
12
  * metric attribute), falling back to `'Error'` when no name is available.
12
13
  */
13
14
  export declare function errorTypeName(err: unknown): string;
15
+ /**
16
+ * Convert an AG-UI RUN_ERROR event to the Error shape exposed to consumers.
17
+ * Preserves the provider code and sanitized raw event when available, while
18
+ * accepting the deprecated nested error payload for backward compatibility.
19
+ */
20
+ export declare function runErrorEventToError(chunk: Extract<StreamChunk, {
21
+ type: 'RUN_ERROR';
22
+ }>): Error;
@@ -26,7 +26,19 @@ function errorTypeName(err) {
26
26
  }
27
27
  return "Error";
28
28
  }
29
+ /**
30
+ * Convert an AG-UI RUN_ERROR event to the Error shape exposed to consumers.
31
+ * Preserves the provider code and sanitized raw event when available, while
32
+ * accepting the deprecated nested error payload for backward compatibility.
33
+ */
34
+ function runErrorEventToError(chunk) {
35
+ const error = new Error(chunk.message || chunk.error?.message || "An error occurred");
36
+ const code = chunk.code ?? chunk.error?.code;
37
+ if (code !== void 0) Object.assign(error, { code });
38
+ if (chunk.rawEvent !== void 0) Object.assign(error, { rawEvent: chunk.rawEvent });
39
+ return error;
40
+ }
29
41
  //#endregion
30
- export { errorMessage, errorTypeName };
42
+ export { errorMessage, errorTypeName, runErrorEventToError };
31
43
 
32
44
  //# sourceMappingURL=errors.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","names":[],"sources":["../../../src/utilities/errors.ts"],"sourcesContent":["/**\n * Best-effort extraction of a human-readable message from an unknown thrown\n * value, returning `undefined` when none can be found.\n *\n * Used by `otelMiddleware` so error reporting stays identical across chat and\n * media spans.\n */\nexport function errorMessage(err: unknown): string | undefined {\n if (err instanceof Error) return err.message\n if (typeof err === 'string') return err\n if (err && typeof err === 'object' && 'message' in err) {\n const m = (err as { message?: unknown }).message\n if (typeof m === 'string') return m\n }\n return undefined\n}\n\n/**\n * Best-effort extraction of an error's type name (used for the `error.type`\n * metric attribute), falling back to `'Error'` when no name is available.\n */\nexport function errorTypeName(err: unknown): string {\n if (err instanceof Error) return err.name || 'Error'\n if (err && typeof err === 'object' && 'name' in err) {\n const n = (err as { name?: unknown }).name\n if (typeof n === 'string') return n\n }\n return 'Error'\n}\n"],"mappings":";;;;;;;;AAOA,SAAgB,aAAa,KAAkC;CAC7D,IAAI,eAAe,OAAO,OAAO,IAAI;CACrC,IAAI,OAAO,QAAQ,UAAU,OAAO;CACpC,IAAI,OAAO,OAAO,QAAQ,YAAY,aAAa,KAAK;EACtD,MAAM,IAAK,IAA8B;EACzC,IAAI,OAAO,MAAM,UAAU,OAAO;CACpC;AAEF;;;;;AAMA,SAAgB,cAAc,KAAsB;CAClD,IAAI,eAAe,OAAO,OAAO,IAAI,QAAQ;CAC7C,IAAI,OAAO,OAAO,QAAQ,YAAY,UAAU,KAAK;EACnD,MAAM,IAAK,IAA2B;EACtC,IAAI,OAAO,MAAM,UAAU,OAAO;CACpC;CACA,OAAO;AACT"}
1
+ {"version":3,"file":"errors.js","names":[],"sources":["../../../src/utilities/errors.ts"],"sourcesContent":["import type { StreamChunk } from '../types'\n\n/**\n * Best-effort extraction of a human-readable message from an unknown thrown\n * value, returning `undefined` when none can be found.\n *\n * Used by `otelMiddleware` so error reporting stays identical across chat and\n * media spans.\n */\nexport function errorMessage(err: unknown): string | undefined {\n if (err instanceof Error) return err.message\n if (typeof err === 'string') return err\n if (err && typeof err === 'object' && 'message' in err) {\n const m = (err as { message?: unknown }).message\n if (typeof m === 'string') return m\n }\n return undefined\n}\n\n/**\n * Best-effort extraction of an error's type name (used for the `error.type`\n * metric attribute), falling back to `'Error'` when no name is available.\n */\nexport function errorTypeName(err: unknown): string {\n if (err instanceof Error) return err.name || 'Error'\n if (err && typeof err === 'object' && 'name' in err) {\n const n = (err as { name?: unknown }).name\n if (typeof n === 'string') return n\n }\n return 'Error'\n}\n\n/**\n * Convert an AG-UI RUN_ERROR event to the Error shape exposed to consumers.\n * Preserves the provider code and sanitized raw event when available, while\n * accepting the deprecated nested error payload for backward compatibility.\n */\nexport function runErrorEventToError(\n chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,\n): Error {\n const error = new Error(\n chunk.message || chunk.error?.message || 'An error occurred',\n )\n const code = chunk.code ?? chunk.error?.code\n if (code !== undefined) {\n Object.assign(error, { code })\n }\n if (chunk.rawEvent !== undefined) {\n Object.assign(error, { rawEvent: chunk.rawEvent })\n }\n return error\n}\n"],"mappings":";;;;;;;;AASA,SAAgB,aAAa,KAAkC;CAC7D,IAAI,eAAe,OAAO,OAAO,IAAI;CACrC,IAAI,OAAO,QAAQ,UAAU,OAAO;CACpC,IAAI,OAAO,OAAO,QAAQ,YAAY,aAAa,KAAK;EACtD,MAAM,IAAK,IAA8B;EACzC,IAAI,OAAO,MAAM,UAAU,OAAO;CACpC;AAEF;;;;;AAMA,SAAgB,cAAc,KAAsB;CAClD,IAAI,eAAe,OAAO,OAAO,IAAI,QAAQ;CAC7C,IAAI,OAAO,OAAO,QAAQ,YAAY,UAAU,KAAK;EACnD,MAAM,IAAK,IAA2B;EACtC,IAAI,OAAO,MAAM,UAAU,OAAO;CACpC;CACA,OAAO;AACT;;;;;;AAOA,SAAgB,qBACd,OACO;CACP,MAAM,QAAQ,IAAI,MAChB,MAAM,WAAW,MAAM,OAAO,WAAW,mBAC3C;CACA,MAAM,OAAO,MAAM,QAAQ,MAAM,OAAO;CACxC,IAAI,SAAS,KAAA,GACX,OAAO,OAAO,OAAO,EAAE,KAAK,CAAC;CAE/B,IAAI,MAAM,aAAa,KAAA,GACrB,OAAO,OAAO,OAAO,EAAE,UAAU,MAAM,SAAS,CAAC;CAEnD,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai",
3
- "version": "0.47.1",
3
+ "version": "0.47.3",
4
4
  "description": "Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -1183,9 +1183,8 @@ class TextEngine<
1183
1183
 
1184
1184
  if (!skipAgentLoop) {
1185
1185
  do {
1186
- if (this.earlyTermination || this.isCancelled()) {
1187
- return
1188
- }
1186
+ if (this.earlyTermination) break
1187
+ if (this.isCancelled()) return
1189
1188
 
1190
1189
  this.logger.agentLoop(`iteration=${this.middlewareCtx.iteration}`, {
1191
1190
  iteration: this.middlewareCtx.iteration,
@@ -1217,6 +1216,8 @@ class TextEngine<
1217
1216
 
1218
1217
  yield* this.streamModelResponse()
1219
1218
 
1219
+ if (this.earlyTermination) break
1220
+
1220
1221
  if (
1221
1222
  yield* this.emitBoundaryInterrupts(
1222
1223
  'afterModel',
@@ -1783,11 +1784,8 @@ class TextEngine<
1783
1784
  chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
1784
1785
  ): void {
1785
1786
  this.earlyTermination = true
1786
- if (this.finalStructuredOutput && this.finalizationError === null) {
1787
- const message =
1788
- chunk.message ||
1789
- chunk.error?.message ||
1790
- 'Run failed before structured output completed'
1787
+ if (this.finalizationError === null) {
1788
+ const message = chunk.message || chunk.error?.message || 'Run failed'
1791
1789
  this.finalizationError = {
1792
1790
  message,
1793
1791
  ...(chunk.code !== undefined
@@ -1,3 +1,4 @@
1
+ import { isProviderExecutedToolCall } from '../../utilities/provider-executed'
1
2
  import { normalizeToolResult } from '../../utilities/tool-result'
2
3
  import type { Message as AGUIMessage } from '@ag-ui/core'
3
4
  import type {
@@ -249,14 +250,15 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
249
250
  const content = collapseContentParts(current.contentParts)
250
251
  const hasContent = content !== null
251
252
  const hasToolCalls = current.toolCalls.length > 0
253
+ const hasThinking = pendingThinking.length > 0
252
254
 
253
- if (hasContent || hasToolCalls) {
255
+ if (hasContent || hasToolCalls || hasThinking) {
254
256
  messageList.push({
255
257
  id: uiMessage.id,
256
258
  role: 'assistant',
257
259
  content,
258
260
  ...(hasToolCalls && { toolCalls: current.toolCalls }),
259
- ...(pendingThinking.length > 0 && { thinking: pendingThinking }),
261
+ ...(hasThinking && { thinking: pendingThinking }),
260
262
  ...(current.structuredOutput && {
261
263
  structuredOutput: current.structuredOutput,
262
264
  }),
@@ -317,6 +319,11 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
317
319
 
318
320
  case 'thinking':
319
321
  if (part.content) {
322
+ // Provider-executed tools have no tool-result part, so thinking
323
+ // after them has to start the next segment or it replays first.
324
+ if (current.toolCalls.some(isProviderExecutedToolCall)) {
325
+ flushSegment()
326
+ }
320
327
  pendingThinking.push({
321
328
  content: part.content,
322
329
  ...(part.signature && { signature: part.signature }),