diff --git a/.changeset/oxlint-complexity.md b/.changeset/oxlint-complexity.md new file mode 100644 index 0000000000..636ab316ab --- /dev/null +++ b/.changeset/oxlint-complexity.md @@ -0,0 +1,61 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-acp': patch +'@tanstack/ai-angular': patch +'@tanstack/ai-anthropic': patch +'@tanstack/ai-bedrock': patch +'@tanstack/ai-byteplus': patch +'@tanstack/ai-claude-code': patch +'@tanstack/ai-client': patch +'@tanstack/ai-code-mode': patch +'@tanstack/ai-code-mode-snippets': patch +'@tanstack/ai-codex': patch +'@tanstack/ai-cohere': patch +'@tanstack/ai-devtools-core': patch +'@tanstack/ai-durable-stream': patch +'@tanstack/ai-elevenlabs': patch +'@tanstack/ai-event-client': patch +'@tanstack/ai-fal': patch +'@tanstack/ai-gemini': patch +'@tanstack/ai-grok': patch +'@tanstack/ai-grok-build': patch +'@tanstack/ai-groq': patch +'@tanstack/ai-isolate-cloudflare': patch +'@tanstack/ai-isolate-daytona': patch +'@tanstack/ai-isolate-node': patch +'@tanstack/ai-isolate-quickjs': patch +'@tanstack/ai-isolate-quickjs-bun': patch +'@tanstack/ai-llmgateway': patch +'@tanstack/ai-lovable': patch +'@tanstack/ai-mcp': patch +'@tanstack/ai-memory': patch +'@tanstack/ai-mistral': patch +'@tanstack/ai-octane': patch +'@tanstack/ai-ollama': patch +'@tanstack/ai-openai': patch +'@tanstack/ai-opencode': patch +'@tanstack/ai-openrouter': patch +'@tanstack/ai-perplexity': patch +'@tanstack/ai-persistence': patch +'@tanstack/ai-preact': patch +'@tanstack/ai-react': patch +'@tanstack/ai-react-ui': patch +'@tanstack/ai-sandbox': patch +'@tanstack/ai-sandbox-cloudflare': patch +'@tanstack/ai-sandbox-daytona': patch +'@tanstack/ai-sandbox-docker': patch +'@tanstack/ai-sandbox-local-process': patch +'@tanstack/ai-sandbox-sprites': patch +'@tanstack/ai-sandbox-vercel': patch +'@tanstack/ai-solid': patch +'@tanstack/ai-solid-ui': patch +'@tanstack/ai-svelte': patch +'@tanstack/ai-utils': patch +'@tanstack/ai-vercel-gateway': patch +'@tanstack/ai-vertex': patch +'@tanstack/ai-vue': patch +'@tanstack/ai-vue-ui': patch +'@tanstack/openai-base': patch +--- + +Internal readability lint: cyclomatic complexity max 20, named compound `if`s (type-narrowing left as-is), no calls in `for...of`, comments at most 2 lines. No public API change. diff --git a/.oxlintrc.json b/.oxlintrc.json index 89a849a54e..6f3713f406 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -3,7 +3,15 @@ "plugins": [], "jsPlugins": [ "eslint-plugin-unused-imports", - { "name": "eslint-js", "specifier": "oxlint-plugin-eslint" } + { "name": "eslint-js", "specifier": "oxlint-plugin-eslint" }, + { + "name": "comment-limits", + "specifier": "./scripts/oxlint-max-comment-lines.js" + }, + { + "name": "named-if", + "specifier": "./scripts/oxlint-named-if.js" + } ], "categories": { "correctness": "off" @@ -18,7 +26,8 @@ "**/coverage/**", "**/dist/**", "**/snap/**", - "**/vite.config.*.timestamp-*.*" + "**/vite.config.*.timestamp-*.*", + "**/*.tsrx.d.ts" ], "rules": { "unused-imports/no-unused-imports": "warn" @@ -242,8 +251,29 @@ { "selector": "TSAsExpression > TSAsExpression[typeAnnotation.type='TSUnknownKeyword']", "message": "Avoid `as unknown as ` — it bypasses TS's structural overlap check. Prefer plain `as `, fix the root cause, or opt out with `// oxlint-disable-next-line eslint-js/no-restricted-syntax -- `." + }, + { + "selector": "ForOfStatement[right.type='CallExpression']", + "message": "Do not call a function in `for...of`. Assign the iterable to a name first." + }, + { + "selector": "ForOfStatement[right.type='ParenthesizedExpression'][right.expression.type='CallExpression']", + "message": "Do not call a function in `for...of`. Assign the iterable to a name first." + }, + { + "selector": "ForInStatement[right.type='CallExpression']", + "message": "Do not call a function in `for...in`. Assign the object to a name first." + } + ], + "complexity": [ + "error", + { + "max": 20 } - ] + ], + "comment-limits/max-lines": "error", + "named-if/require-named-condition": "error", + "named-if/no-mechanical-name": "error" }, "plugins": ["typescript"] } diff --git a/package.json b/package.json index bf7b24b987..c516f5a5f1 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "test:knip": "knip", "test:docs": "tsx scripts/verify-links.ts", "test:dts": "node scripts/scan-dangling-dts.mjs", - "test:maintainer": "vitest run --root scripts/maintainer && vitest run scripts/model-sync scripts/publish-staged-docs.test.ts", + "test:maintainer": "vitest run --root scripts/maintainer && vitest run scripts/model-sync scripts/publish-staged-docs.test.ts scripts/oxlint-named-if.test.ts scripts/oxlint-max-comment-lines.test.ts", "maintainer:sweep": "tsx scripts/maintainer/sweep.ts", "maintainer:scorecard": "tsx scripts/maintainer/scorecard.ts", "test:kiira": "kiira check", diff --git a/packages/ai-acp/src/adapters/compatible.ts b/packages/ai-acp/src/adapters/compatible.ts index a0bc447c76..0ad7a2e34a 100644 --- a/packages/ai-acp/src/adapters/compatible.ts +++ b/packages/ai-acp/src/adapters/compatible.ts @@ -99,15 +99,6 @@ export interface AcpCompatibleConfig< * Omit to accept any string. */ models?: TModels - /** - * Type-only brand for the per-call options accepted via `chat({ modelOptions })`. - * Declare your harness's options here with `{} as { ... }` (the value is unused - * at runtime); they are merged with the base {@link AcpCompatibleProviderOptions} - * and exposed on {@link AcpHarnessContext.modelOptions} so `command` / - * `openTransport` can turn them into CLI flags. - * - * @example modelOptions: {} as { reasoningEffort?: 'low' | 'high' } - */ modelOptions?: TModelOptions /** * Build the shell command that launches the harness's ACP server over @@ -197,11 +188,6 @@ export interface AcpCompatibleProviderOptions { sessionId?: string /** Per-call override of the harness working directory. */ cwd?: string - /** - * `'api-key'` (default) uses {@link authMethodId}. - * `'host'` skips ACP authenticate. - * Not inferred from the sandbox. - */ authMode?: 'host' | 'api-key' /** Per-call override of the ACP auth method. Ignored when authMode is `'host'`. */ authMethodId?: string @@ -344,6 +330,314 @@ export class AcpCompatibleTextAdapter< resolvePermission(request, input.mode, input.bridgedToolNames) } + private resolveAcpLayout( + options: TextOptions>, + sandbox: SandboxHandle, + ) { + const modelOptions = options.modelOptions + /** Virtual cwd for `sandbox.process.spawn` (the provider maps `/workspace`). */ + const cwd = modelOptions?.cwd ?? this.harness.cwd ?? DEFAULT_WORKDIR + /** Literal cwd for the harness's own `--cwd` flag / ACP `newSession`. */ + const harnessCwd = resolveHarnessCwd(sandbox, cwd) + const runId = resolveDurableRunId(options.runId, { + durable: false, + adapter: 'acp', + fallback: () => this.generateId(), + }) + const threadId = options.threadId ?? this.generateId() + return { modelOptions, cwd, harnessCwd, runId, threadId } + } + + private enforceAcpDurability( + options: TextOptions>, + logger: TextOptions['logger'], + runId: string, + ): void { + const durability = options.capabilities + ? getSandboxDurability(options.capabilities, { optional: true }) + : undefined + if (durability === undefined) return + if (durability.attach) { + throw new DurableAttachNotSupportedError( + 'acp', + 'this adapter drives the harness over a bidirectional ACP ' + + 'connection and does not journal', + ) + } + logger.warn( + 'acp: sandbox durability is wired but this adapter never journals — ' + + 'this run will not be recoverable on reconnect. Use a journaling ' + + 'harness adapter for runs that must survive a host restart, or drop ' + + 'durability if these runs are not meant to.', + { runId, adapter: 'acp' }, + ) + } + + private async provisionAcpToolBridge( + options: TextOptions>, + sandbox: SandboxHandle, + channel: ReturnType, + externalSignal: AbortSignal | undefined, + ): Promise { + if (!options.tools) return undefined + if (options.tools.length === 0) return undefined + const provisioner = + (options.capabilities + ? getToolBridgeProvisioner(options.capabilities, { optional: true }) + : undefined) ?? nodeHttpBridgeProvisioner + return provisioner.provision(options.tools, { + provider: sandbox.provider, + context: options.context, + emitCustomEvent: channel.emitCustomEvent, + ...(externalSignal ? { signal: externalSignal } : {}), + }) + } + + private async projectAcpWorkspaceServers( + options: TextOptions>, + sandbox: SandboxHandle, + ): Promise> { + const projection = options.capabilities + ? getWorkspaceProjection(options.capabilities, { optional: true }) + : undefined + if (projection === undefined) return [] + await projectAcpWorkspace(sandbox, projection, { + ...(this.harness.skillsDir !== undefined && { + skillsDir: this.harness.skillsDir, + }), + harnessName: this.name, + }) + return workspaceMcpServers(projection) + } + + private resolveAcpAuth( + modelOptions: ResolvedOptions | undefined, + ): { mode: AcpPermissionMode; authMethodId: string | undefined } { + const mode = + modelOptions?.permissionMode ?? + this.harness.permissionMode ?? + 'bypassPermissions' + const authMode = + modelOptions?.authMode ?? this.harness.authMode ?? 'api-key' + const authMethodId = + authMode === 'host' + ? undefined + : (modelOptions?.authMethodId ?? this.harness.authMethodId) + return { mode, authMethodId } + } + + private collectAcpMcpServers( + bridge: HostToolBridge | undefined, + workspaceServers: Array, + ): Array { + return [ + ...(bridge !== undefined + ? [ + { + name: bridge.name, + url: bridge.url, + headers: [ + { name: 'Authorization', value: `Bearer ${bridge.token}` }, + ], + }, + ] + : []), + ...workspaceServers, + ] + } + + private bindAcpAbort( + externalSignal: AbortSignal | undefined, + session: AcpSessionHandle, + ): (() => void) | undefined { + if (externalSignal === undefined) return undefined + const onAbort = () => void session.cancel().catch(() => undefined) + if (externalSignal.aborted) onAbort() + else externalSignal.addEventListener('abort', onAbort, { once: true }) + return onAbort + } + + private composeAcpPromptText( + options: TextOptions>, + session: AcpSessionHandle, + sessionId: string | undefined, + resumePrompt: string, + ): string { + const systemPrompts = normalizeSystemPrompts(options.systemPrompts) + .map((p) => p.content) + .filter((c) => c.trim() !== '') + let promptText = this.applySystemPrompts( + systemPrompts, + session.resumed || sessionId === undefined + ? resumePrompt + : this.buildPrompt(options.messages, undefined).prompt, + ) + if (options.outputSchema) { + promptText = appendOutputSchemaInstruction( + promptText, + options.outputSchema, + ) + } + return promptText + } + + private startAcpPrompt( + session: AcpSessionHandle, + queue: AsyncQueue, + promptText: string, + ): void { + session + .prompt(promptText) + .then(({ stopReason, usage }) => { + queue.push({ + kind: 'done', + stopReason, + ...(usage !== undefined && { usage }), + }) + queue.end() + }) + .catch((error: unknown) => queue.fail(error)) + } + + private async *streamAcpChunks(input: { + options: TextOptions> + channel: ReturnType + queue: AsyncQueue + bridgedToolNames: ReadonlySet + threadId: string + runId: string + /** The sandbox the harness runs in (from `withSandbox(...)` middleware). */ + sandbox: SandboxHandle + cwd: string + approvalRequests: Array + }): AsyncIterable { + const { + options, + channel, + queue, + bridgedToolNames, + threadId, + runId, + sandbox, + cwd, + approvalRequests, + } = input + const wantsStructured = options.outputSchema !== undefined + let lastAssistantText = '' + let lastTextMessageId: string | undefined + let heldFinished: AdapterYieldChunk | undefined + const mergedChunks = mergeChunkStreams( + translateAcpStream(queue, { + model: this.model, + runId, + threadId, + ...(options.parentRunId !== undefined && { + parentRunId: options.parentRunId, + }), + genId: () => this.generateId(), + bridgedToolNames, + labels: { + sessionIdEvent: `${this.name}.session-id`, + // Surface non-text agent content (image/audio/resource) instead of + // dropping it — emitted as a CUSTOM `.message-content` event. + contentEvent: `${this.name}.message-content`, + ...(this.harness.planEventName !== undefined && { + planEvent: this.harness.planEventName, + }), + ...(this.harness.refusalMessage !== undefined && { + refusalMessage: this.harness.refusalMessage, + }), + }, + onAcpEvent: (event) => + options.logger.provider(`provider=${this.name} kind=${event.kind}`, { + chunk: event, + }), + }), + channel.stream, + ) + for await (const chunk of mergedChunks) { + const holdFinished = + wantsStructured && chunk.type === EventType.RUN_FINISHED + if (holdFinished) { + heldFinished = chunk + continue + } + if (wantsStructured) { + if (chunk.type === EventType.TEXT_MESSAGE_START) { + lastAssistantText = '' + if (typeof chunk.messageId === 'string' && chunk.messageId !== '') { + lastTextMessageId = chunk.messageId + } + } else if ( + chunk.type === EventType.TEXT_MESSAGE_CONTENT && + typeof chunk.delta === 'string' + ) { + lastAssistantText += chunk.delta + } + } + yield chunk + } + + if (options.outputSchema) { + yield* this.emitParsedStructuredOutput( + lastAssistantText, + threadId, + runId, + lastTextMessageId, + ) + } + if (heldFinished) yield heldFinished + + // Surface any pending approval requests (interactive ask-policy actions + // awaiting a client decision); the client approves and re-runs to continue. + for (const event of approvalRequests) yield event + + if (this.harness.emitDiff) { + yield* this.emitDiffChunks(sandbox, cwd, threadId, runId) + } + } + + private acpChatStreamErrorChunk( + error: unknown, + options: TextOptions>, + logger: TextOptions['logger'], + ): AdapterYieldChunk { + const err = error as Error & { code?: string } + const rawEvent = toRunErrorRawEvent(error) + logger.errors(`${this.name}.chatStream fatal`, { + error, + source: `${this.name}.chatStream`, + }) + return { + type: EventType.RUN_ERROR, + model: options.model, + timestamp: Date.now(), + message: err.message || 'Unknown error occurred', + ...(err.code !== undefined && { code: err.code }), + ...(rawEvent !== undefined && { rawEvent }), + error: { + message: err.message || 'Unknown error occurred', + ...(err.code !== undefined && { code: err.code }), + }, + } + } + + private async releaseAcpChatResources(input: { + externalSignal: AbortSignal | undefined + onAbort: (() => void) | undefined + handle: AcpSessionHandle | undefined + transport: AcpSessionTransport | undefined + bridge: HostToolBridge | undefined + }): Promise { + if (input.externalSignal !== undefined && input.onAbort !== undefined) { + input.externalSignal.removeEventListener('abort', input.onAbort) + } + if (input.handle !== undefined) await input.handle.dispose() + else if (input.transport !== undefined) + await disposeTransport(input.transport) + await input.bridge?.close() + } + async *chatStream( options: TextOptions>, ): AsyncIterable { @@ -357,58 +651,9 @@ export class AcpCompatibleTextAdapter< try { const sandbox = this.sandboxFrom(options) - const modelOptions = options.modelOptions - const cwd = modelOptions?.cwd ?? this.harness.cwd ?? DEFAULT_WORKDIR - const harnessCwd = resolveHarnessCwd(sandbox, cwd) - // This adapter does not journal yet, so a generated id is still fine. - // Routed through the helper anyway so that whenever it gains journaling - // it inherits the caller-supplied-runId requirement instead of - // re-deriving it (see `packages/ai-sandbox/src/durability.ts`). - const runId = resolveDurableRunId(options.runId, { - durable: false, - adapter: 'acp', - fallback: () => this.generateId(), - }) - const threadId = options.threadId ?? this.generateId() - - // Durability wired onto a path that cannot deliver it. Two outcomes, split - // by whether a first attempt has already run. - // - // ATTACH is fatal. `sandboxRunDriver`'s `drive()` sets `attach: true` only - // when a previous host was already streaming this run, so continuing past - // here reaches `startAcpSession` + `session.prompt(...)` and re-runs the - // agent from scratch against the workspace that attempt already mutated, - // appending its whole output to a log that still holds the first - // attempt's. This adapter has no journal to tail, no - // `awaitAttachableJournal` to refuse the attach up front, and no - // `alignedIfAttaching` to suppress the already-delivered prefix — so there - // is nothing between here and that corruption except this throw. - // - // A FRESH durable run only fails to be recoverable LATER, which an app may - // knowingly accept (it can wire `withSandbox({ runs, durability })` once at - // the middleware level and still route some runs through this adapter). So - // that is a warn, not a throw: audible, not fatal. Once per run, not per - // chunk — a per-chunk warning would be worse than none. Mirrors - // `ai-grok-build`'s `chatStreamAcp`. - const durability = options.capabilities - ? getSandboxDurability(options.capabilities, { optional: true }) - : undefined - if (durability !== undefined) { - if (durability.attach) { - throw new DurableAttachNotSupportedError( - 'acp', - 'this adapter drives the harness over a bidirectional ACP ' + - 'connection and does not journal', - ) - } - logger.warn( - 'acp: sandbox durability is wired but this adapter never journals — ' + - 'this run will not be recoverable on reconnect. Use a journaling ' + - 'harness adapter for runs that must survive a host restart, or drop ' + - 'durability if these runs are not meant to.', - { runId, adapter: 'acp' }, - ) - } + const { modelOptions, cwd, harnessCwd, runId, threadId } = + this.resolveAcpLayout(options, sandbox) + this.enforceAcpDurability(options, logger, runId) const channel = createBridgeEventChannel({ model: this.model, @@ -422,38 +667,19 @@ export class AcpCompatibleTextAdapter< sessionId, ) - // Bridge chat()-provided tools into the agent over MCP (ACP http server). const bridgedToolNames = new Set( (options.tools ?? []).map((tool) => tool.name), ) - if (options.tools && options.tools.length > 0) { - const provisioner = - (options.capabilities - ? getToolBridgeProvisioner(options.capabilities, { optional: true }) - : undefined) ?? nodeHttpBridgeProvisioner - bridge = await provisioner.provision(options.tools, { - provider: sandbox.provider, - context: options.context, - emitCustomEvent: channel.emitCustomEvent, - ...(externalSignal ? { signal: externalSignal } : {}), - }) - } - - // Project workspace skills declared via withSandbox. MCP skills ride ACP's - // native `mcpServers` (below); gitSkills are linked into `skillsDir`. - let workspaceServers: Array = [] - const projection = options.capabilities - ? getWorkspaceProjection(options.capabilities, { optional: true }) - : undefined - if (projection !== undefined) { - await projectAcpWorkspace(sandbox, projection, { - ...(this.harness.skillsDir !== undefined && { - skillsDir: this.harness.skillsDir, - }), - harnessName: this.name, - }) - workspaceServers = workspaceMcpServers(projection) - } + bridge = await this.provisionAcpToolBridge( + options, + sandbox, + channel, + externalSignal, + ) + const workspaceServers = await this.projectAcpWorkspaceServers( + options, + sandbox, + ) const ctx: AcpHarnessContext> = { sandbox, @@ -468,17 +694,7 @@ export class AcpCompatibleTextAdapter< ? await this.harness.openTransport(ctx) : await this.openStdioTransport(ctx) - const mode = - modelOptions?.permissionMode ?? - this.harness.permissionMode ?? - 'bypassPermissions' - const authMode = - modelOptions?.authMode ?? this.harness.authMode ?? 'api-key' - const authMethodId = - authMode === 'host' - ? undefined - : (modelOptions?.authMethodId ?? this.harness.authMethodId) - + const { mode, authMethodId } = this.resolveAcpAuth(modelOptions) const approvalRequests: Array = [] const permissionHandler = this.makePermissionHandler({ mode, @@ -496,22 +712,7 @@ export class AcpCompatibleTextAdapter< { provider: this.name, model: this.model }, ) - // The host tool-bridge (chat() tools) + workspace MCP skills, both over - // ACP's native MCP channel. - const mcpServers: Array = [ - ...(bridge !== undefined - ? [ - { - name: bridge.name, - url: bridge.url, - headers: [ - { name: 'Authorization', value: `Bearer ${bridge.token}` }, - ], - }, - ] - : []), - ...workspaceServers, - ] + const mcpServers = this.collectAcpMcpServers(bridge, workspaceServers) const onAcpUpdate = (update: AcpSessionUpdate) => queue.push({ kind: 'update', update }) @@ -529,141 +730,38 @@ export class AcpCompatibleTextAdapter< }) const session = handle - if (externalSignal !== undefined) { - onAbort = () => void session.cancel().catch(() => undefined) - if (externalSignal.aborted) onAbort() - else externalSignal.addEventListener('abort', onAbort, { once: true }) - } - + onAbort = this.bindAcpAbort(externalSignal, session) queue.push({ kind: 'session', sessionId: session.sessionId }) - const systemPrompts = normalizeSystemPrompts(options.systemPrompts) - .map((p) => p.content) - .filter((c) => c.trim() !== '') - let promptText = this.applySystemPrompts( - systemPrompts, - session.resumed || sessionId === undefined - ? resumePrompt - : this.buildPrompt(options.messages, undefined).prompt, + const promptText = this.composeAcpPromptText( + options, + session, + sessionId, + resumePrompt, ) - if (options.outputSchema) { - promptText = appendOutputSchemaInstruction( - promptText, - options.outputSchema, - ) - } - - session - .prompt(promptText) - .then(({ stopReason, usage }) => { - queue.push({ - kind: 'done', - stopReason, - ...(usage !== undefined && { usage }), - }) - queue.end() - }) - .catch((error: unknown) => queue.fail(error)) - - const wantsStructured = options.outputSchema !== undefined - let lastAssistantText = '' - let lastTextMessageId: string | undefined - let heldFinished: AdapterYieldChunk | undefined - for await (const chunk of mergeChunkStreams( - translateAcpStream(queue, { - model: this.model, - runId, - threadId, - ...(options.parentRunId !== undefined && { - parentRunId: options.parentRunId, - }), - genId: () => this.generateId(), - bridgedToolNames, - labels: { - sessionIdEvent: `${this.name}.session-id`, - // Surface non-text agent content (image/audio/resource) instead of - // dropping it — emitted as a CUSTOM `.message-content` event. - contentEvent: `${this.name}.message-content`, - ...(this.harness.planEventName !== undefined && { - planEvent: this.harness.planEventName, - }), - ...(this.harness.refusalMessage !== undefined && { - refusalMessage: this.harness.refusalMessage, - }), - }, - onAcpEvent: (event) => - logger.provider(`provider=${this.name} kind=${event.kind}`, { - chunk: event, - }), - }), - channel.stream, - )) { - if (wantsStructured && chunk.type === EventType.RUN_FINISHED) { - heldFinished = chunk - continue - } - if (wantsStructured) { - if (chunk.type === EventType.TEXT_MESSAGE_START) { - lastAssistantText = '' - if (typeof chunk.messageId === 'string' && chunk.messageId !== '') { - lastTextMessageId = chunk.messageId - } - } else if ( - chunk.type === EventType.TEXT_MESSAGE_CONTENT && - typeof chunk.delta === 'string' - ) { - lastAssistantText += chunk.delta - } - } - yield chunk - } + this.startAcpPrompt(session, queue, promptText) - if (options.outputSchema) { - yield* this.emitParsedStructuredOutput( - lastAssistantText, - threadId, - runId, - lastTextMessageId, - ) - } - if (heldFinished) yield heldFinished - - // Surface any pending approval requests (interactive ask-policy actions - // awaiting a client decision); the client approves and re-runs to continue. - for (const event of approvalRequests) yield event - - if (this.harness.emitDiff) { - yield* this.emitDiffChunks(sandbox, cwd, threadId, runId) - } - } catch (error: unknown) { - const err = error as Error & { code?: string } - const rawEvent = toRunErrorRawEvent(error) - logger.errors(`${this.name}.chatStream fatal`, { - error, - source: `${this.name}.chatStream`, + yield* this.streamAcpChunks({ + options, + channel, + queue, + bridgedToolNames, + threadId, + runId, + sandbox, + cwd, + approvalRequests, }) - yield { - type: EventType.RUN_ERROR, - model: options.model, - timestamp: Date.now(), - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - ...(rawEvent !== undefined && { rawEvent }), - error: { - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - }, - } + } catch (error: unknown) { + yield this.acpChatStreamErrorChunk(error, options, logger) } finally { - if (externalSignal !== undefined && onAbort !== undefined) { - externalSignal.removeEventListener('abort', onAbort) - } - // startAcpSession owns transport teardown once a handle exists (and tears - // it down itself on a failed init). Only dispose here if we opened a - // transport but never reached a session. - if (handle !== undefined) await handle.dispose() - else if (transport !== undefined) await disposeTransport(transport) - await bridge?.close() + await this.releaseAcpChatResources({ + externalSignal, + onAbort, + handle, + transport, + bridge, + }) } } @@ -695,7 +793,8 @@ export class AcpCompatibleTextAdapter< ): AsyncIterable { try { const diff = await sandbox.process.exec(`git -C ${q(cwd)} diff`, { cwd }) - if (diff.exitCode === 0 && diff.stdout.trim() !== '') { + const hasDiff = diff.exitCode === 0 && diff.stdout.trim() !== '' + if (hasDiff) { yield { type: EventType.CUSTOM, name: 'file.changed', diff --git a/packages/ai-acp/src/adapters/projection.ts b/packages/ai-acp/src/adapters/projection.ts index 47ee262cc1..20c4d26d64 100644 --- a/packages/ai-acp/src/adapters/projection.ts +++ b/packages/ai-acp/src/adapters/projection.ts @@ -1,22 +1,3 @@ -/** - * Generic workspace projector for ACP harnesses. - * - * `withSandbox` surfaces a portable {@link WorkspaceProjection} (skills, plugins, - * a secret resolver, a one-time marker path) via a capability. Most of it maps - * onto ACP natively: - * - * - **MCP skills** → passed straight through ACP's `newSession` `mcpServers` - * (see {@link workspaceMcpServers}); no config file is written, because an - * ACP agent receives MCP servers over the protocol. This is the key - * difference from file-based harnesses (Claude Code, Codex) that read MCP - * from disk. - * - **gitSkill repos** → linked into the harness's skills directory (when the - * harness declares one via `skillsDir`, e.g. `.pi/skills`). - * - **agentSkill / plugins** → no generic ACP primitive, so we warn-and-skip. - * - * `fileSkill` and `instructions` are already written by the provider-agnostic - * bootstrap (into the workspace root + `AGENTS.md`), so they need no projection. - */ import { discoverSkillDirs, isSecretRef, @@ -123,14 +104,8 @@ export async function projectAcpWorkspace( ) } } else { - // Create the skills dir via fs (which remaps the virtual root), then copy - // each clone in with paths relative to the exec cwd (the workspace root) - // so the shell command resolves on every provider. await handle.fs.mkdir(`${projection.root}/${skillsDir}`) for (const skill of gitSkills) { - // Keep virtual `/workspace` paths for fs discovery. handle.fs remaps - // them. Remap only when building shell-relative paths so Daytona and - // local-process both resolve the same clone. const source = skill.into ?? resolveGitSkillDir(projection.root, skill) const discovered = await discoverSkillDirs(handle, source) for (const { name, dir } of discovered) { diff --git a/packages/ai-acp/src/permissions.ts b/packages/ai-acp/src/permissions.ts index c0feaff9a0..fa0d551066 100644 --- a/packages/ai-acp/src/permissions.ts +++ b/packages/ai-acp/src/permissions.ts @@ -36,7 +36,9 @@ export function resolvePermission( return allow() } if (mode === 'bypassPermissions') return allow() - if (mode === 'acceptEdits' && EDIT_KINDS.has(request.toolCall.kind ?? '')) { + const allowEdit = + mode === 'acceptEdits' && EDIT_KINDS.has(request.toolCall.kind ?? '') + if (allowEdit) { return allow() } return reject() @@ -59,7 +61,9 @@ export function resolveInteractivePermission( return { outcome: allow() } } if (mode === 'bypassPermissions') return { outcome: allow() } - if (mode === 'acceptEdits' && EDIT_KINDS.has(request.toolCall.kind ?? '')) { + const allowEdit = + mode === 'acceptEdits' && EDIT_KINDS.has(request.toolCall.kind ?? '') + if (allowEdit) { return { outcome: allow() } } diff --git a/packages/ai-acp/src/session/acp-client.ts b/packages/ai-acp/src/session/acp-client.ts index dc9b776d2a..240aea3627 100644 --- a/packages/ai-acp/src/session/acp-client.ts +++ b/packages/ai-acp/src/session/acp-client.ts @@ -130,19 +130,11 @@ export async function startAcpSession( protocolVersion: PROTOCOL_VERSION, clientInfo: CLIENT_INFO, clientCapabilities: { - // The agent runs inside the sandbox with direct filesystem + shell - // access, so it never needs to delegate file/terminal I/O back to the - // client. We advertise these as unsupported; per the ACP spec the - // agent MUST then treat them as unavailable and not call them. fs: { readTextFile: false, writeTextFile: false }, }, }), ) - // Protocol-version negotiation: the agent echoes the version it will speak. - // The spec says a client SHOULD close the connection if that version is one - // it doesn't support. We only implement the current PROTOCOL_VERSION, so a - // higher number means the agent needs a newer client than this one. if ( typeof initResult.protocolVersion === 'number' && initResult.protocolVersion > PROTOCOL_VERSION diff --git a/packages/ai-acp/src/session/sandbox-server.ts b/packages/ai-acp/src/session/sandbox-server.ts index 26f222b1c2..9b0c27d92a 100644 --- a/packages/ai-acp/src/session/sandbox-server.ts +++ b/packages/ai-acp/src/session/sandbox-server.ts @@ -25,6 +25,7 @@ export interface StartAcpServerOptions { command: string /** Build the WebSocket URL once the server is ready and the port is exposed. */ buildWsUrl: (input: { + /** Sandbox channel used to build {@link wsUrl} (auth headers, when issued). */ channel: SandboxChannel port: number stdout: string @@ -49,6 +50,7 @@ function waitForReady( proc: SpawnHandle, options: Pick, ): Promise<{ stdout: string; stderr: string }> { + /** Substring/regex marker used when {@link isReady} is omitted. */ const readyMarker = options.readyMarker ?? DEFAULT_READY_MARKER const isReady = options.isReady ?? ((output: string) => output.includes(readyMarker)) @@ -78,7 +80,8 @@ function waitForReady( settle(() => resolve({ stdout, stderr })) return } - if (stdoutDone && stderrDone) { + const streamsEnded = stdoutDone && stderrDone + if (streamsEnded) { settle(() => reject( new Error( @@ -154,6 +157,7 @@ export async function startAcpServerInSandbox( const { stdout } = await waitForReady(proc, options) const channel = await sandbox.ports.connect(options.port) + /** WebSocket URL the orchestrator uses to reach the in-sandbox ACP server. */ const wsUrl = options.buildWsUrl({ channel, port: options.port, diff --git a/packages/ai-acp/src/stream/queue.ts b/packages/ai-acp/src/stream/queue.ts index 6167afe2e4..a54ac1247c 100644 --- a/packages/ai-acp/src/stream/queue.ts +++ b/packages/ai-acp/src/stream/queue.ts @@ -13,7 +13,8 @@ export class AsyncQueue implements AsyncIterable { private failed = false push(value: T): void { - if (this.ended || this.failed) return + const isClosed = this.ended || this.failed + if (isClosed) return const waiter = this.waiters.shift() if (waiter) { waiter.resolve({ value, done: false }) @@ -23,18 +24,22 @@ export class AsyncQueue implements AsyncIterable { } end(): void { - if (this.ended || this.failed) return + const isClosed = this.ended || this.failed + if (isClosed) return this.ended = true - for (const waiter of this.waiters.splice(0)) { + const pendingWaiters = this.waiters.splice(0) + for (const waiter of pendingWaiters) { waiter.resolve({ value: undefined, done: true }) } } fail(error: unknown): void { - if (this.ended || this.failed) return + const isClosed = this.ended || this.failed + if (isClosed) return this.failed = true this.error = error - for (const waiter of this.waiters.splice(0)) { + const pendingWaiters = this.waiters.splice(0) + for (const waiter of pendingWaiters) { waiter.reject(error) } } diff --git a/packages/ai-acp/src/stream/translate.ts b/packages/ai-acp/src/stream/translate.ts index b7c1945074..775857f1be 100644 --- a/packages/ai-acp/src/stream/translate.ts +++ b/packages/ai-acp/src/stream/translate.ts @@ -42,7 +42,8 @@ export function matchBridgedToolName( title: string | null | undefined, bridgedToolNames: ReadonlySet | undefined, ): string | undefined { - if (!title || !bridgedToolNames) return undefined + if (!title) return undefined + if (!bridgedToolNames) return undefined if (bridgedToolNames.has(title)) return title for (const name of bridgedToolNames) { if (title.startsWith(`${name} (`)) return name @@ -237,124 +238,169 @@ export async function* translateAcpStream( } } - function* handleUpdate( - update: AcpSessionUpdate, + function* handleAgentMessageChunk( + update: Extract, ): Generator { - if (update.sessionUpdate === 'agent_message_chunk') { - yield* closeReasoning() - // Non-text content (image / audio / resource / resource_link): surface it - // as a CUSTOM event when the harness opted in, instead of dropping it. - if (update.content.type !== 'text') { - if (labels.contentEvent !== undefined) { - yield* closeText() - yield { - type: EventType.CUSTOM, - model, - timestamp: now(), - name: labels.contentEvent, - value: { content: update.content }, - } - } - return - } - const text = - typeof update.content.text === 'string' ? update.content.text : '' - if (text === '') return - if (textMessageId === null) { - textMessageId = genId() + yield* closeReasoning() + // Non-text content (image / audio / resource / resource_link): surface it + // as a CUSTOM event when the harness opted in, instead of dropping it. + if (update.content.type !== 'text') { + if (labels.contentEvent !== undefined) { + yield* closeText() yield { - type: EventType.TEXT_MESSAGE_START, - messageId: textMessageId, + type: EventType.CUSTOM, model, timestamp: now(), - role: 'assistant', + name: labels.contentEvent, + value: { content: update.content }, } } - textContent += text + return + } + const text = + typeof update.content.text === 'string' ? update.content.text : '' + if (text === '') return + if (textMessageId === null) { + textMessageId = genId() yield { - type: EventType.TEXT_MESSAGE_CONTENT, + type: EventType.TEXT_MESSAGE_START, messageId: textMessageId, model, timestamp: now(), - delta: text, - content: textContent, - } - } else if (update.sessionUpdate === 'agent_thought_chunk') { - yield* closeText() - const thought = - typeof update.content.text === 'string' ? update.content.text : '' - if (thought === '') return - if (reasoningId === null) { - reasoningId = genId() - yield { - type: EventType.REASONING_START, - messageId: reasoningId, - model, - timestamp: now(), - } - yield { - type: EventType.REASONING_MESSAGE_START, - messageId: reasoningId, - role: 'reasoning' as const, - model, - timestamp: now(), - } + role: 'assistant', } + } + textContent += text + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: textMessageId, + model, + timestamp: now(), + delta: text, + content: textContent, + } + } + + function* handleAgentThoughtChunk( + update: Extract, + ): Generator { + yield* closeText() + const thought = + typeof update.content.text === 'string' ? update.content.text : '' + if (thought === '') return + if (reasoningId === null) { + reasoningId = genId() yield { - type: EventType.REASONING_MESSAGE_CONTENT, + type: EventType.REASONING_START, messageId: reasoningId, - delta: thought, model, timestamp: now(), } - } else if (update.sessionUpdate === 'tool_call') { - yield* closeText() - yield* closeReasoning() - yield* openToolCall(update) - if (update.status === 'completed' || update.status === 'failed') { - yield* resolveToolCall(update) - } - } else if (update.sessionUpdate === 'tool_call_update') { - if (update.status === 'completed' || update.status === 'failed') { - yield* resolveToolCall(update) - } else if ( - update.status === 'in_progress' && - update.rawInput !== undefined - ) { - yield* closeText() - yield* closeReasoning() - if (!knownToolCalls.has(update.toolCallId)) { - yield* openToolCall(update) - } else { - const input = { - ...(update.title != null && { title: update.title }), - ...(typeof update.rawInput === 'object' && update.rawInput !== null - ? (update.rawInput as Record) - : { input: update.rawInput }), - } - const args = JSON.stringify(input) - yield { - type: EventType.TOOL_CALL_ARGS, - toolCallId: update.toolCallId, - model, - timestamp: now(), - delta: args, - args, - } - } - } - } else if ( - update.sessionUpdate === 'plan' && - labels.planEvent !== undefined - ) { yield { - type: EventType.CUSTOM, + type: EventType.REASONING_MESSAGE_START, + messageId: reasoningId, + role: 'reasoning' as const, model, timestamp: now(), - name: labels.planEvent, - value: { entries: update.entries }, } } + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningId, + delta: thought, + model, + timestamp: now(), + } + } + + function* handleToolCall( + update: Extract, + ): Generator { + yield* closeText() + yield* closeReasoning() + yield* openToolCall(update) + const isTerminal = + update.status === 'completed' || update.status === 'failed' + if (isTerminal) { + yield* resolveToolCall(update) + } + } + + function* handleInProgressToolUpdate( + update: Extract, + ): Generator { + yield* closeText() + yield* closeReasoning() + if (!knownToolCalls.has(update.toolCallId)) { + yield* openToolCall(update) + return + } + const input = { + ...(update.title != null && { title: update.title }), + ...(typeof update.rawInput === 'object' && update.rawInput !== null + ? (update.rawInput as Record) + : { input: update.rawInput }), + } + const args = JSON.stringify(input) + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: update.toolCallId, + model, + timestamp: now(), + delta: args, + args, + } + } + + function* handleToolCallUpdate( + update: Extract, + ): Generator { + const isTerminal = + update.status === 'completed' || update.status === 'failed' + if (isTerminal) { + yield* resolveToolCall(update) + return + } + if (update.status === 'in_progress' && update.rawInput !== undefined) { + yield* handleInProgressToolUpdate(update) + } + } + + function* handlePlan( + update: Extract, + ): Generator { + if (labels.planEvent === undefined) return + yield { + type: EventType.CUSTOM, + model, + timestamp: now(), + name: labels.planEvent, + value: { entries: update.entries }, + } + } + + function* handleUpdate( + update: AcpSessionUpdate, + ): Generator { + if (update.sessionUpdate === 'agent_message_chunk') { + yield* handleAgentMessageChunk(update) + return + } + if (update.sessionUpdate === 'agent_thought_chunk') { + yield* handleAgentThoughtChunk(update) + return + } + if (update.sessionUpdate === 'tool_call') { + yield* handleToolCall(update) + return + } + if (update.sessionUpdate === 'tool_call_update') { + yield* handleToolCallUpdate(update) + return + } + if (update.sessionUpdate === 'plan') { + yield* handlePlan(update) + } } try { diff --git a/packages/ai-acp/src/transport/resolve.ts b/packages/ai-acp/src/transport/resolve.ts index 5b675bb89c..6212efbfda 100644 --- a/packages/ai-acp/src/transport/resolve.ts +++ b/packages/ai-acp/src/transport/resolve.ts @@ -33,10 +33,10 @@ export function resolveAcpTransportMode( } if (sandbox.capabilities.writableStdin === true) return 'stdio' - if ( + const canUseWebsocket = sandbox.capabilities.ports === true && sandbox.capabilities.backgroundProcesses === true - ) { + if (canUseWebsocket) { return 'websocket' } diff --git a/packages/ai-acp/src/transport/stdio.ts b/packages/ai-acp/src/transport/stdio.ts index de13ed215d..93e6428791 100644 --- a/packages/ai-acp/src/transport/stdio.ts +++ b/packages/ai-acp/src/transport/stdio.ts @@ -1,7 +1,3 @@ -/** - * Adapt a sandbox {@link SpawnHandle} into byte streams for ACP stdio - * (newline-delimited JSON-RPC). - */ import type { SpawnHandle } from '@tanstack/ai-sandbox' import type { AcpByteTransport } from './types' diff --git a/packages/ai-acp/src/transport/types.ts b/packages/ai-acp/src/transport/types.ts index 9360d63e85..e0ad92040d 100644 --- a/packages/ai-acp/src/transport/types.ts +++ b/packages/ai-acp/src/transport/types.ts @@ -27,6 +27,7 @@ export type AcpSessionTransport = kind: 'stream' stream: AcpJsonRpcStream dispose: () => Promise + /** Last bytes of stderr, for error messages. */ stderrTail?: () => string } diff --git a/packages/ai-acp/src/transport/websocket.ts b/packages/ai-acp/src/transport/websocket.ts index ab84b91668..bb18bc4669 100644 --- a/packages/ai-acp/src/transport/websocket.ts +++ b/packages/ai-acp/src/transport/websocket.ts @@ -133,9 +133,6 @@ function webSocketNdjsonToAcpStream(ws: WebSocket): AcpJsonRpcStream { const writable = new WritableStream({ write(chunk) { - // TS 5.7+ types Uint8Array as generic over its buffer (ArrayBufferLike), - // which no longer structurally matches the DOM `BufferSource` param. - // Runtime accepts any typed array; narrow to the lib's expected type. ws.send(chunk as BufferSource) }, close() { diff --git a/packages/ai-acp/src/types/acp-types.ts b/packages/ai-acp/src/types/acp-types.ts index 398aad3ecc..c1d45d9d8e 100644 --- a/packages/ai-acp/src/types/acp-types.ts +++ b/packages/ai-acp/src/types/acp-types.ts @@ -5,7 +5,6 @@ * Defined structurally (rather than imported from `@agentclientprotocol/sdk`) * so the stream translator stays a pure, fixture-testable state machine. */ - export type AcpContentBlock = | { type: 'text'; text: string } | { type: string; [key: string]: unknown } diff --git a/packages/ai-angular/src/inject-audio-recorder.ts b/packages/ai-angular/src/inject-audio-recorder.ts index 16627a1f78..28f89b22af 100644 --- a/packages/ai-angular/src/inject-audio-recorder.ts +++ b/packages/ai-angular/src/inject-audio-recorder.ts @@ -44,12 +44,6 @@ export interface InjectAudioRecorderResult { * failure (and `stop()` rejects with `Recording cancelled` if `cancel()` runs * while a stop is in flight, e.g. on destroy) — handle one channel, not both. */ -// The transforming overload requires `onComplete`. Without that constraint an -// options object carrying only unrelated keys (`injectAudioRecorder({ onError })`) -// still matches it, `TOnComplete` infers as `unknown`, and `recording`/`stop()` -// collapse to `unknown` — so passing any option would silently cost you the -// `AudioRecording` type. Requiring it here sends those calls to the second -// overload instead (issue #1001). export function injectAudioRecorder< TOnComplete extends (recording: AudioRecording) => unknown, >( @@ -72,7 +66,9 @@ export function injectAudioRecorder( ...(options.mimeType !== undefined && { mimeType: options.mimeType }), ...(options.onError !== undefined && { onError: options.onError }), }) + /** Reactive: true while actively capturing audio. */ const isRecording = signal(false) + /** Reactive: latest recording (transformed if `onComplete` provided), or null. */ const recording = signal(null) const unsubscribe = recorder.subscribe((state) => { @@ -83,6 +79,7 @@ export function injectAudioRecorder( recorder.cancel() }) + /** Stop and resolve with the completed recording (transformed if `onComplete` provided). */ const stop = async (): Promise => { const rawRecording = await recorder.stop() const transformed = await options.onComplete?.(rawRecording) diff --git a/packages/ai-angular/src/inject-chat.ts b/packages/ai-angular/src/inject-chat.ts index 58a4a13502..f6ffcad5d9 100644 --- a/packages/ai-angular/src/inject-chat.ts +++ b/packages/ai-angular/src/inject-chat.ts @@ -42,6 +42,33 @@ import type { const EMPTY_INTERRUPTS = Object.freeze([]) const EMPTY_INTERRUPT_ERRORS = Object.freeze([]) +function persistenceOptions< + TTools extends ReadonlyArray, + TSchema extends SchemaInput | undefined, + TContext, + TInterrupts extends ReadonlyArray>, +>( + options: InjectChatOptions, +): + | { persistence: NonNullable; threadId: string } + | { threadId?: typeof options.threadId } { + if (typeof options.threadId === 'string' && options.persistence) { + return { persistence: options.persistence, threadId: options.threadId } + } + return options.threadId !== undefined ? { threadId: options.threadId } : {} +} + +function definedFields( + fields: Record, +): Record { + const out: Record = {} + const fieldEntries = Object.entries(fields) + for (const [key, value] of fieldEntries) { + if (value !== undefined) out[key] = value + } + return out +} + export function injectChat< const TTools extends ReadonlyArray = any, TSchema extends SchemaInput | undefined = undefined, @@ -102,55 +129,20 @@ export function injectChat< const client = new ChatClient({ devtoolsBridgeFactory: createChatDevtoolsBridge, ...transport, - ...(options.initialMessages !== undefined && { - initialMessages: options.initialMessages, - }), - ...(typeof options.threadId === 'string' && options.persistence - ? { - persistence: options.persistence, - threadId: options.threadId, - } - : { - ...(options.threadId !== undefined && { threadId: options.threadId }), - }), - ...(options.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: options.initialResumeSnapshot, - }), - ...(bodySource !== undefined && { body: bodySource() }), - ...(forwardedPropsSource !== undefined && { - forwardedProps: forwardedPropsSource(), - }), - ...(options.byok !== undefined && { byok: options.byok }), + ...persistenceOptions(options), byokProvider: () => options.byokProvider?.(), - ...(contextSource !== undefined && { context: contextSource() }), - devtools: { - ...options.devtools, - framework: 'angular', - hookName: 'injectChat', - outputKind: options.outputSchema ? 'structured' : 'chat', - }, + tools: options.tools, onResponse: (response) => options.onResponse?.(response), onChunk: (chunk: StreamChunk) => options.onChunk?.(chunk), onFinish: (message) => options.onFinish?.(message), onError: (err) => options.onError?.(err), onRunIdChange: (nextRunId) => runId.set(nextRunId), - // No `onResumeStateChange`: the run identity is surfaced as the `runId` - // signal (via `onRunIdChange`) and pending interrupts arrive through - // `onInterruptStateChange`, so there is nothing left for it to do — and it - // is not a public option here, matching the other framework packages. onInterruptStateChange: (nextInterruptState, context) => { interruptState.set(nextInterruptState) options.onInterruptStateChange?.(nextInterruptState, context) }, - tools: options.tools, - ...(options.interrupts !== undefined && { - interrupts: options.interrupts, - }), onCustomEvent: (eventType, data, context) => options.onCustomEvent?.(eventType, data, context), - ...(options.streamProcessor !== undefined && { - streamProcessor: options.streamProcessor, - }), onMessagesChange: (m: Array>) => messages.set(m), onLoadingChange: (v: boolean) => isLoading.set(v), onStatusChange: (v: ChatClientState) => status.set(v), @@ -158,19 +150,29 @@ export function injectChat< onSubscriptionChange: (v: boolean) => isSubscribed.set(v), onConnectionStatusChange: (v: ConnectionStatus) => connectionStatus.set(v), onSessionGeneratingChange: (v: boolean) => sessionGenerating.set(v), - ...(options.queue !== undefined && { queue: options.queue }), onQueueChange: (nextQueue: Array) => queue.set(nextQueue), + devtools: { + ...options.devtools, + framework: 'angular', + hookName: 'injectChat', + outputKind: options.outputSchema ? 'structured' : 'chat', + }, + ...definedFields({ + initialMessages: options.initialMessages, + initialResumeSnapshot: options.initialResumeSnapshot, + body: bodySource?.(), + forwardedProps: forwardedPropsSource?.(), + byok: options.byok, + context: contextSource?.(), + interrupts: options.interrupts, + streamProcessor: options.streamProcessor, + queue: options.queue, + }), }) messages.set(client.getMessages()) interruptState.set(client.getInterruptState()) - // START TAILING HERE, not in the constructor. A client is idle until something - // attaches it, so a client that gets built and thrown away never opens a - // connection — an unreachable stream would hold one of the browser's ~6 - // connections per origin until the page reloaded. `inject*` runs in an injection - // context tied to the consumer's lifetime, and `destroyRef.onDestroy` below is the - // matching `detach`. client.attach() // Sync reactive body / forwardedProps / context to the client. @@ -209,9 +211,6 @@ export function injectChat< afterNextRender( () => { client.mountDevtools() - // Delivery-durability resume is transparent: the resumable SSE - // connection adapter reattaches via the browser's native Last-Event-ID - // on reconnect. No client-side auto-resume wiring is needed. }, { injector }, ) @@ -259,7 +258,8 @@ export function injectChat< const final = computed(() => { const part = activeStructuredPart() - if (!part || part.status !== 'complete') return null + if (!part) return null + if (part.status !== 'complete') return null return part.data as Final }) diff --git a/packages/ai-angular/src/inject-generate-audio.ts b/packages/ai-angular/src/inject-generate-audio.ts index f2d0b11036..8fd24f2a2a 100644 --- a/packages/ai-angular/src/inject-generate-audio.ts +++ b/packages/ai-angular/src/inject-generate-audio.ts @@ -118,6 +118,7 @@ export function injectGenerateAudio( ): InjectGenerateAudioResult< InferGenerationOutputFromReturn > { + /** Display options for TanStack AI Devtools. */ const devtools = { ...options.devtools, framework: 'angular', diff --git a/packages/ai-angular/src/inject-generate-video.ts b/packages/ai-angular/src/inject-generate-video.ts index 3287fbea29..8910f5f7ed 100644 --- a/packages/ai-angular/src/inject-generate-video.ts +++ b/packages/ai-angular/src/inject-generate-video.ts @@ -100,9 +100,6 @@ export interface InjectGenerateVideoResult { runId: Signal } -// `TTransformed` infers from the `onResult` return position so the callback -// parameter is typed as `VideoGenerateResult` and `result` narrows to the -// transform's return. See issue #848. export function injectGenerateVideo( options: Omit< InjectGenerateVideoOptions, @@ -160,9 +157,6 @@ export function injectGenerateVideo( hookName: 'injectGenerateVideo', outputKind: 'video' as const, }, - // The transform's raw return type (`TTransformed`) and the stored output - // (`TOutput`, with null/void/undefined stripped) are identical at runtime; - // the cast bridges the relationship that the conditional type hides. onResult: ((r: VideoGenerateResult) => options.onResult?.(r)) as ( result: VideoGenerateResult, ) => TOutput | null | void, diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index dfd095e079..8eb2a58b6f 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -123,21 +123,9 @@ export interface InjectGenerationResult< /** Clear result, error, and return to idle */ reset: () => void /** Identity of the in-flight run while one is streaming, or null after it ends */ - /** - * The id of the generation job currently running, or `null` when nothing is in - * flight. Each call to `generate` is one job with its own id. Pass it to your - * own endpoint to cancel or poll the provider job — `stop()` only aborts the - * local stream, it does not stop work already running on the provider. - */ runId: Signal } -// `TTransformed` infers from the `onResult` return position (a covariant -// inference site that works even for an optional nested property), which types -// the callback parameter as `TResult` and narrows `result`. Inferring the -// whole callback as a defaulted type parameter instead collapses to the -// default, leaving the parameter `any` — a hard error under `strict`. See -// issue #848. export function injectGeneration< TInput extends Record, TResult, @@ -160,10 +148,20 @@ export function injectGeneration< const destroyRef = inject(DestroyRef) const injector = inject(Injector) + /** The generation result, or null if not yet generated */ const result = signal(null) + /** Whether a generation is currently in progress */ const isLoading = signal(false) + /** Current error, if any */ const error = signal(undefined) + /** Current state of the generation client */ const status = signal('idle') + /** + * The id of the generation job currently running, or `null` when nothing is in + * flight. Each call to `generate` is one job with its own id. Pass it to your + * own endpoint to cancel or poll the provider job — `stop()` only aborts the + * local stream, it does not stop work already running on the provider. + */ const runId = signal(null) let disposed = false @@ -190,9 +188,6 @@ export function injectGeneration< framework: 'angular', hookName: 'injectGeneration', }, - // The transform's raw return type (`TTransformed`) and the stored output - // (`TOutput`, with null/void/undefined stripped) are identical at runtime; - // the cast bridges the relationship that the conditional type hides. onResult: ((r: TResult) => options.onResult?.(r)) as ( result: TResult, ) => TOutput | null | void, diff --git a/packages/ai-anthropic/src/adapters/text-stream.ts b/packages/ai-anthropic/src/adapters/text-stream.ts new file mode 100644 index 0000000000..2818aefa60 --- /dev/null +++ b/packages/ai-anthropic/src/adapters/text-stream.ts @@ -0,0 +1,627 @@ +import { EventType } from '@tanstack/ai' +import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals' +import { getAnthropicDefaultMaxTokens } from '../model-meta' +import { buildAnthropicUsage } from '../usage' +import type { InternalLogger } from '@tanstack/ai/adapter-internals' +import type { AdapterYieldChunk, TextOptions } from '@tanstack/ai' +import type Anthropic_SDK from '@anthropic-ai/sdk' + +type AnthropicStreamEvent = Anthropic_SDK.Beta.BetaRawMessageStreamEvent + +interface ToolCallBuffer { + id: string + name: string + input: string + started: boolean +} + +interface ServerToolBuffer { + id: string + name: string + input: string +} + +interface AnthropicStreamState { + options: TextOptions + genId: () => string + logger: InternalLogger + model: string + runId: string + threadId: string + messageId: string + accumulatedContent: string + accumulatedThinking: string + accumulatedSignature: string + toolCallsMap: Map + currentToolIndex: number + currentServerTool: ServerToolBuffer | null + completedServerTools: Map + stepId: string | null + reasoningMessageId: string | null + hasClosedReasoning: boolean + hasEmittedRunStarted: boolean + hasEmittedTextMessageStart: boolean + hasEmittedRunFinished: boolean + currentBlockType: string | null +} + +type AnthropicStreamHandler = ( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +) => Generator + +function parseJsonObject(input: string): unknown { + try { + const parsed = input ? JSON.parse(input) : {} + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +function* closeReasoning( + state: AnthropicStreamState, +): Generator { + if (state.reasoningMessageId && !state.hasClosedReasoning) { + state.hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: state.reasoningMessageId, + model: state.model, + timestamp: Date.now(), + } + yield { + type: EventType.REASONING_END, + messageId: state.reasoningMessageId, + model: state.model, + timestamp: Date.now(), + } + } +} + +function* handleServerToolResult( + event: Extract, + state: AnthropicStreamState, +): Generator { + const block = event.content_block + const isServerToolResult = + block.type === 'web_fetch_tool_result' || + block.type === 'web_search_tool_result' + if (!isServerToolResult) { + return + } + + const content = block.content as + | { type?: string; error_code?: string } + | Array + const errorBlock = + !Array.isArray(content) && + (content.type === 'web_fetch_tool_result_error' || + content.type === 'web_search_tool_result_error') + ? content + : null + if (errorBlock) { + state.logger.errors( + `anthropic.${block.type} error_code=${errorBlock.error_code}`, + { + toolUseId: block.tool_use_id, + blockType: block.type, + errorCode: errorBlock.error_code, + source: 'anthropic.processAnthropicStream', + }, + ) + } + + const serverTool = state.completedServerTools.get(block.tool_use_id) + if (!serverTool) return + + state.completedServerTools.delete(serverTool.id) + + const parsedInput = parseJsonObject(serverTool.input) + + const serverToolMetadata = { + providerExecuted: true, + anthropic: { + serverToolType: serverTool.name, + resultBlockType: block.type, + result: content, + }, + } + + state.currentToolIndex++ + yield { + type: EventType.TOOL_CALL_START, + toolCallId: serverTool.id, + toolCallName: serverTool.name, + toolName: serverTool.name, + parentMessageId: state.messageId, + model: state.model, + timestamp: Date.now(), + index: state.currentToolIndex, + metadata: serverToolMetadata, + } + yield { + type: EventType.TOOL_CALL_END, + toolCallId: serverTool.id, + toolCallName: serverTool.name, + toolName: serverTool.name, + model: state.model, + timestamp: Date.now(), + input: parsedInput, + } + + // Text after the server tool starts a fresh message segment. + state.hasEmittedTextMessageStart = false +} + +function* handleContentBlockStart( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +): Generator { + if (event.type !== 'content_block_start') return + + state.currentBlockType = event.content_block.type + if (event.content_block.type === 'tool_use') { + state.currentToolIndex++ + state.toolCallsMap.set(state.currentToolIndex, { + id: event.content_block.id, + name: event.content_block.name, + input: '', + started: false, + }) + return + } + if (event.content_block.type === 'server_tool_use') { + state.currentServerTool = { + id: event.content_block.id, + name: event.content_block.name, + input: '', + } + return + } + const isServerToolResult = + event.content_block.type === 'web_fetch_tool_result' || + event.content_block.type === 'web_search_tool_result' + if (isServerToolResult) { + yield* handleServerToolResult(event, state) + return + } + if (event.content_block.type !== 'thinking') return + + state.accumulatedThinking = '' + state.accumulatedSignature = '' + // Emit REASONING and STEP_STARTED for thinking + state.stepId = state.genId() + state.reasoningMessageId = state.genId() + + // Spec REASONING events + yield { + type: EventType.REASONING_START, + messageId: state.reasoningMessageId, + model: state.model, + timestamp: Date.now(), + } + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: state.reasoningMessageId, + role: 'reasoning' as const, + model: state.model, + timestamp: Date.now(), + } + + // Legacy STEP events (kept during transition) + yield { + type: EventType.STEP_STARTED, + stepName: state.stepId, + stepId: state.stepId, + model: state.model, + timestamp: Date.now(), + stepType: 'thinking', + } +} + +function* handleTextDelta( + event: Extract, + state: AnthropicStreamState, +): Generator { + if (event.delta.type !== 'text_delta') return + + // Close reasoning before text starts + yield* closeReasoning(state) + + // Emit TEXT_MESSAGE_START on first text content + if (!state.hasEmittedTextMessageStart) { + state.hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: state.messageId, + model: state.model, + timestamp: Date.now(), + role: 'assistant', + } + } + + const delta = event.delta.text + state.accumulatedContent += delta + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: state.messageId, + model: state.model, + timestamp: Date.now(), + delta, + content: state.accumulatedContent, + } +} + +function* handleInputJsonDelta( + event: Extract, + state: AnthropicStreamState, +): Generator { + if (event.delta.type !== 'input_json_delta') return + + // Route deltas by current block type so server_tool_use input + // never appends onto the prior client tool's buffer. + if (state.currentBlockType === 'tool_use') { + const existing = state.toolCallsMap.get(state.currentToolIndex) + if (!existing) return + + // Emit TOOL_CALL_START on first args delta + if (!existing.started) { + existing.started = true + yield { + type: EventType.TOOL_CALL_START, + toolCallId: existing.id, + toolCallName: existing.name, + toolName: existing.name, + parentMessageId: state.messageId, + model: state.model, + timestamp: Date.now(), + index: state.currentToolIndex, + } + } + + existing.input += event.delta.partial_json + + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: existing.id, + model: state.model, + timestamp: Date.now(), + delta: event.delta.partial_json, + args: existing.input, + } + return + } + + if (state.currentBlockType === 'server_tool_use' && state.currentServerTool) { + state.currentServerTool.input += event.delta.partial_json + } +} + +function* handleContentBlockDelta( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +): Generator { + if (event.type !== 'content_block_delta') return + + if (event.delta.type === 'text_delta') { + yield* handleTextDelta(event, state) + return + } + if (event.delta.type === 'thinking_delta' && state.reasoningMessageId) { + const delta = event.delta.thinking + state.accumulatedThinking += delta + + // Spec REASONING content event + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: state.reasoningMessageId, + delta, + model: state.model, + timestamp: Date.now(), + } + + // Legacy STEP event + yield { + type: EventType.STEP_FINISHED, + stepName: state.stepId || state.genId(), + stepId: state.stepId || state.genId(), + model: state.model, + timestamp: Date.now(), + delta, + content: state.accumulatedThinking, + } + return + } + if ((event.delta as { type: string }).type === 'signature_delta') { + state.accumulatedSignature += + (event.delta as { signature: string }).signature || '' + return + } + if (event.delta.type === 'input_json_delta') { + yield* handleInputJsonDelta(event, state) + } +} + +function* handleToolUseStop( + state: AnthropicStreamState, +): Generator { + const existing = state.toolCallsMap.get(state.currentToolIndex) + if (!existing) return + + // If tool call wasn't started yet (no args), start it now + if (!existing.started) { + existing.started = true + yield { + type: EventType.TOOL_CALL_START, + toolCallId: existing.id, + toolCallName: existing.name, + toolName: existing.name, + parentMessageId: state.messageId, + model: state.model, + timestamp: Date.now(), + index: state.currentToolIndex, + } + } + + // Emit TOOL_CALL_END + const parsedInput = parseJsonObject(existing.input) + + yield { + type: EventType.TOOL_CALL_END, + toolCallId: existing.id, + toolCallName: existing.name, + toolName: existing.name, + model: state.model, + timestamp: Date.now(), + input: parsedInput, + } + + // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls + state.hasEmittedTextMessageStart = false +} + +function* handleContentBlockStop( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +): Generator { + if (event.type !== 'content_block_stop') return + + if (state.currentBlockType === 'thinking') { + // Emit signature so it can be replayed in multi-turn context + if (state.accumulatedSignature && state.stepId) { + yield { + type: EventType.STEP_FINISHED, + stepName: state.stepId, + stepId: state.stepId, + model: state.model, + timestamp: Date.now(), + delta: '', + content: state.accumulatedThinking, + signature: state.accumulatedSignature, + } + } + } else if (state.currentBlockType === 'tool_use') { + yield* handleToolUseStop(state) + } else if (state.currentBlockType === 'server_tool_use') { + if (state.currentServerTool) { + // Anthropic executes the call; we only need a breadcrumb so + // consumers (devtools, telemetry) can see what ran. + state.logger.provider( + `provider=anthropic server_tool_use name=${state.currentServerTool.name}`, + { + toolUseId: state.currentServerTool.id, + name: state.currentServerTool.name, + input: state.currentServerTool.input, + }, + ) + // Hold the call until its result block arrives so we can emit + // both together as one provider-executed tool call. + state.completedServerTools.set( + state.currentServerTool.id, + state.currentServerTool, + ) + } + state.currentServerTool = null + } else { + const isServerToolResult = + state.currentBlockType === 'web_fetch_tool_result' || + state.currentBlockType === 'web_search_tool_result' + if (isServerToolResult) { + // The model already consumed the result; error variants were + // already surfaced at content_block_start. + } else if (state.hasEmittedTextMessageStart && state.accumulatedContent) { + yield { + type: EventType.TEXT_MESSAGE_END, + messageId: state.messageId, + model: state.model, + timestamp: Date.now(), + } + } + } + state.currentBlockType = null +} + +function* handleMessageStop( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +): Generator { + if (event.type !== 'message_stop') return + + // Close reasoning events if still open + yield* closeReasoning(state) + + if (!state.hasEmittedRunFinished) { + yield { + type: EventType.RUN_FINISHED, + runId: state.runId, + threadId: state.threadId, + model: state.model, + timestamp: Date.now(), + finishReason: 'stop', + } + } +} + +function* handleMaxTokensStop( + state: AnthropicStreamState, +): Generator { + if (state.options.modelOptions?.max_tokens == null) { + const defaultedMaxTokens = getAnthropicDefaultMaxTokens(state.model) + state.logger.warn( + `anthropic response truncated at the default max_tokens (${defaultedMaxTokens}) for model=${state.model}; pass maxTokens (or modelOptions.max_tokens) to raise the output ceiling`, + { + source: 'anthropic.processAnthropicStream', + model: state.model, + defaultedMaxTokens, + }, + ) + } + yield { + type: EventType.RUN_ERROR, + model: state.model, + timestamp: Date.now(), + message: + 'The response was cut off because the maximum token limit was reached.', + code: 'max_tokens', + error: { + message: + 'The response was cut off because the maximum token limit was reached.', + code: 'max_tokens', + }, + } +} + +function* handleMessageDelta( + event: AnthropicStreamEvent, + state: AnthropicStreamState, +): Generator { + if (event.type !== 'message_delta') return + if (!event.delta.stop_reason) return + + state.hasEmittedRunFinished = true + + // Close reasoning events if still open + yield* closeReasoning(state) + + switch (event.delta.stop_reason) { + case 'tool_use': { + yield { + type: EventType.RUN_FINISHED, + runId: state.runId, + threadId: state.threadId, + model: state.model, + timestamp: Date.now(), + finishReason: 'tool_calls', + usage: buildAnthropicUsage(event.usage), + } + break + } + case 'max_tokens': { + yield* handleMaxTokensStop(state) + break + } + case 'stop_sequence': + case 'end_turn': + case 'pause_turn': + case 'refusal': + case 'model_context_window_exceeded': + case 'compaction': + default: { + yield { + type: EventType.RUN_FINISHED, + runId: state.runId, + threadId: state.threadId, + model: state.model, + timestamp: Date.now(), + finishReason: 'stop', + usage: buildAnthropicUsage(event.usage), + } + } + } +} + +const anthropicStreamHandlers: Record = { + content_block_start: handleContentBlockStart, + content_block_delta: handleContentBlockDelta, + content_block_stop: handleContentBlockStop, + message_stop: handleMessageStop, + message_delta: handleMessageDelta, +} + +export async function* processAnthropicStream( + stream: AsyncIterable, + options: TextOptions, + genId: () => string, + logger: InternalLogger, +): AsyncIterable { + const state: AnthropicStreamState = { + options, + genId, + logger, + model: options.model, + runId: options.runId ?? genId(), + threadId: options.threadId ?? genId(), + messageId: genId(), + accumulatedContent: '', + accumulatedThinking: '', + accumulatedSignature: '', + toolCallsMap: new Map(), + currentToolIndex: -1, + currentServerTool: null, + completedServerTools: new Map(), + stepId: null, + reasoningMessageId: null, + hasClosedReasoning: false, + hasEmittedRunStarted: false, + hasEmittedTextMessageStart: false, + hasEmittedRunFinished: false, + currentBlockType: null, + } + + try { + for await (const event of stream) { + logger.provider(`provider=anthropic type=${event.type}`, { + chunk: event, + }) + // Emit RUN_STARTED on first event + if (!state.hasEmittedRunStarted) { + state.hasEmittedRunStarted = true + yield { + type: EventType.RUN_STARTED, + runId: state.runId, + threadId: state.threadId, + model: state.model, + timestamp: Date.now(), + parentRunId: options.parentRunId, + } + } + + const handler = anthropicStreamHandlers[event.type] + if (handler) { + yield* handler(event, state) + } + } + } catch (error: unknown) { + const err = error as Error & { status?: number; code?: string } + const rawEvent = toRunErrorRawEvent(error) + + logger.errors('anthropic.processAnthropicStream fatal', { + error, + source: 'anthropic.processAnthropicStream', + }) + yield { + type: EventType.RUN_ERROR, + model: state.model, + timestamp: Date.now(), + message: err.message || 'Unknown error occurred', + code: err.code || String(err.status), + // Forward the Anthropic SDK error's `.error` response body when present. + ...(rawEvent !== undefined && { rawEvent }), + error: { + message: err.message || 'Unknown error occurred', + code: err.code || String(err.status), + }, + } + } +} diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index 17a87f684a..0bcfb592d7 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -8,6 +8,7 @@ import { readCodeExecutionSkills, } from '../tools/code-execution-tool' import { validateTextProviderOptions } from '../text/text-provider-options' +import { processAnthropicStream } from './text-stream' import { buildAnthropicUsage } from '../usage' import { createAnthropicClient, @@ -99,21 +100,26 @@ interface AnthropicServerToolMetadata { function readAnthropicServerToolMetadata( metadata: unknown, ): AnthropicServerToolMetadata | null { - if (typeof metadata !== 'object' || metadata === null) return null + if (typeof metadata !== 'object') return null + if (metadata === null) return null const outer = metadata as { providerExecuted?: unknown; anthropic?: unknown } if (outer.providerExecuted !== true) return null const inner = outer.anthropic - if (typeof inner !== 'object' || inner === null) return null + if (typeof inner !== 'object') return null + if (inner === null) return null const { serverToolType, resultBlockType, result } = inner as { serverToolType?: unknown resultBlockType?: unknown + /** Raw result block content, preserved verbatim from the stream. */ result?: unknown } - if ( - typeof serverToolType !== 'string' || - (resultBlockType !== 'web_search_tool_result' && - resultBlockType !== 'web_fetch_tool_result') - ) { + const isServerToolResult = + resultBlockType === 'web_search_tool_result' || + resultBlockType === 'web_fetch_tool_result' + if (typeof serverToolType !== 'string') { + return null + } + if (!isServerToolResult) { return null } return { @@ -188,9 +194,6 @@ export function computeAnthropicBetas( ) if (codeExecTool) { const cfgType = readCodeExecutionConfig(codeExecTool)?.type - // Each code_execution tool version pairs with a specific beta. Known - // legacy variant maps explicitly; current/future variants (e.g. - // `code_execution_20250825` and later) use the latest `-08-25` beta. betas.add( cfgType === 'code_execution_20250522' ? 'code-execution-2025-05-22' @@ -198,9 +201,6 @@ export function computeAnthropicBetas( ) } - // Skills beta: scan ALL code_execution tools so this AGREES with the - // container-lift, which lifts skills from any code_execution tool that - // carries them (not just the first). const hasSkills = tools?.some( (tool) => getAnthropicProviderToolKind(tool) === 'code_execution' && @@ -211,6 +211,113 @@ export function computeAnthropicBetas( return betas.size > 0 ? Array.from(betas) : undefined } +const ANTHROPIC_MODEL_OPTION_KEYS: Array = [ + 'cache_control', + 'container', + 'context_management', + 'effort', + 'mcp_servers', + 'output_config', + 'service_tier', + 'stop_sequences', + 'thinking', + 'tool_choice', + 'top_k', + 'temperature', + 'top_p', +] + +function copyValidAnthropicModelOptions( + modelOptions: ExternalTextProviderOptions | undefined, + logger: InternalLogger, +): Partial { + const validProviderOptions: Partial = {} + if (!modelOptions) return validProviderOptions + + const droppedKeyExemptSet = new Set([ + ...ANTHROPIC_MODEL_OPTION_KEYS, + 'max_tokens', + ]) + const droppedKeys = Object.keys(modelOptions).filter( + (key) => !droppedKeyExemptSet.has(key), + ) + if (droppedKeys.length > 0) { + logger.errors( + `anthropic.mapCommonOptionsToAnthropic dropped unknown modelOptions key(s): ${droppedKeys.join(', ')}`, + { + source: 'anthropic.mapCommonOptionsToAnthropic', + droppedKeys, + hint: droppedKeys.includes('system') + ? 'pass system prompts via the top-level `systemPrompts` option; `modelOptions.system` is no longer honored' + : undefined, + }, + ) + } + for (const key of ANTHROPIC_MODEL_OPTION_KEYS) { + if (!(key in modelOptions)) continue + const value = modelOptions[key] + if (key === 'tool_choice' && typeof value === 'string') { + ;(validProviderOptions as Record)[key] = { + type: value, + } + } else { + ;(validProviderOptions as Record)[key] = value + } + } + return validProviderOptions +} + +function buildAnthropicSystemBlocks( + systemPrompts: TextOptions['systemPrompts'], +): Array | undefined { + const normalized = + normalizeSystemPrompts(systemPrompts) + if (normalized.length === 0) return undefined + return normalized.map( + (p): TextBlockParam => ({ + type: 'text', + text: p.content, + ...(p.metadata?.cache_control && { + cache_control: p.metadata.cache_control, + }), + }), + ) +} + +function applyCodeExecutionSkills( + tools: Array | undefined, + validProviderOptions: Partial, +): void { + const toolSkills = tools + ?.map((tool) => + getAnthropicProviderToolKind(tool) === 'code_execution' + ? readCodeExecutionSkills(tool) + : undefined, + ) + .find((skills) => skills && skills.length > 0) + + if (toolSkills && toolSkills.length > 0) { + const existingContainer = validProviderOptions.container ?? undefined + validProviderOptions.container = { + id: existingContainer?.id ?? null, + skills: toolSkills, + } + } +} + +function resolveAnthropicMaxTokens( + model: string, + modelOptions: { max_tokens?: number } | undefined, + thinkingBudget: number | undefined, + stream: boolean, +): number { + const defaultMaxTokens = + modelOptions?.max_tokens ?? getAnthropicDefaultMaxTokens(model, { stream }) + return thinkingBudget && thinkingBudget >= defaultMaxTokens + ? thinkingBudget + 1 + : defaultMaxTokens +} + /** * Configuration for Anthropic text adapter */ @@ -225,10 +332,6 @@ export type AnthropicTextAdapterConfig = */ export type AnthropicTextProviderOptions = ExternalTextProviderOptions -// =========================== -// Type Resolution Helpers -// =========================== - /** * Resolve provider options for a specific model. * If the model has explicit options in the map, use those; otherwise use base options. @@ -270,10 +373,6 @@ function asSdkAnthropicMessagesClient( return client as unknown as SdkAnthropicMessagesClient } -// =========================== -// Adapter Implementation -// =========================== - /** * Anthropic Text (Chat) Adapter * @@ -328,12 +427,6 @@ export class AnthropicTextAdapter< // because the beta set depends on both the tools and the modelOptions. const betas = computeAnthropicBetas(options.tools, options.modelOptions) - // `client.beta.messages` is Anthropic's permanent staging surface, not a - // sunset path: it's a superset of `client.messages` that additionally - // accepts the `betas: AnthropicBeta[]` header (e.g. interleaved - // thinking) plus richer `container` (skills) and `context_management` - // shapes that `InternalTextProviderOptions` carries. We route every - // Messages call through it so the request mapper stays single-shape. const stream = await this.client.beta.messages.create( { ...requestParams, @@ -346,7 +439,7 @@ export class AnthropicTextAdapter< }, ) - yield* this.processAnthropicStream( + yield* processAnthropicStream( stream, options, () => generateId(this.name), @@ -388,9 +481,6 @@ export class AnthropicTextAdapter< const { chatOptions, outputSchema } = options const { logger } = chatOptions - // `structuredOutput()` issues a non-streaming `messages.create({ stream: - // false })` below, so the defaulted `max_tokens` must stay under the SDK's - // non-streaming 10-minute guard (issue #849) — pass `stream: false`. const requestParams = this.mapCommonOptionsToAnthropic(chatOptions, { stream: false, }) @@ -437,7 +527,9 @@ export class AnthropicTextAdapter< let rawText = '' for (const block of response.content) { - if (block.type === 'tool_use' && block.name === 'structured_output') { + const isStructuredOutput = + block.type === 'tool_use' && block.name === 'structured_output' + if (isStructuredOutput) { parsed = block.input rawText = JSON.stringify(block.input) break @@ -484,115 +576,28 @@ export class AnthropicTextAdapter< options: TextOptions, { stream = true }: { stream?: boolean } = {}, ) { - const modelOptions = options.modelOptions - const formattedMessages = this.formatMessages(options.messages) const tools = options.tools ? convertToolsToProviderFormat(options.tools) : undefined - const validProviderOptions: Partial = {} - if (modelOptions) { - const validKeys: Array = [ - 'cache_control', - 'container', - 'context_management', - 'effort', - 'mcp_servers', - 'output_config', - 'service_tier', - 'stop_sequences', - 'thinking', - 'tool_choice', - 'top_k', - 'temperature', - 'top_p', - ] - // `max_tokens` is a legitimate public modelOptions field, but it is read - // via a dedicated path (defaultMaxTokens below) rather than copied into - // validProviderOptions. Exempt it from the dropped-key warning here so a - // correct `modelOptions: { max_tokens }` call doesn't log a spurious - // "dropped unknown key" error, while keeping it out of the copy loop. - const droppedKeyExemptSet = new Set([...validKeys, 'max_tokens']) - const droppedKeys = Object.keys(modelOptions).filter( - (key) => !droppedKeyExemptSet.has(key), - ) - if (droppedKeys.length > 0) { - // Reachable when callers cast around the public type (e.g. - // `modelOptions: { system: ... } as any`). Without this warning the - // unknown keys are silently dropped — `system` in particular was a - // previously-tested path for attaching `cache_control` and we don't - // want that to fail in production with no signal. - options.logger.errors( - `anthropic.mapCommonOptionsToAnthropic dropped unknown modelOptions key(s): ${droppedKeys.join(', ')}`, - { - source: 'anthropic.mapCommonOptionsToAnthropic', - droppedKeys, - hint: droppedKeys.includes('system') - ? 'pass system prompts via the top-level `systemPrompts` option; `modelOptions.system` is no longer honored' - : undefined, - }, - ) - } - for (const key of validKeys) { - if (key in modelOptions) { - const value = modelOptions[key] - if (key === 'tool_choice' && typeof value === 'string') { - ;(validProviderOptions as Record)[key] = { - type: value, - } - } else { - ;(validProviderOptions as Record)[key] = value - } - } - } - } + const validProviderOptions = copyValidAnthropicModelOptions( + options.modelOptions, + options.logger, + ) const thinkingBudget = validProviderOptions.thinking?.type === 'enabled' ? validProviderOptions.thinking.budget_tokens : undefined - // Anthropic's Messages API *requires* `max_tokens`, so we must always send a - // value. When the caller doesn't specify one, default to the resolved - // model's real output ceiling (from model-meta) rather than a low constant - // that silently truncates long responses with `stop_reason: "max_tokens"` - // (issue #849). `max_tokens` is a ceiling, not a reservation — billing is on - // tokens actually generated, so a higher default costs nothing extra. - // For non-streaming requests (the `structuredOutput()` path) the default is - // clamped to the SDK's non-streaming-safe limit so it doesn't trip the - // "streaming required" 10-minute guard — see getAnthropicDefaultMaxTokens. - const defaultMaxTokens = - modelOptions?.max_tokens ?? - getAnthropicDefaultMaxTokens(this.model, { stream }) - const maxTokens = - thinkingBudget && thinkingBudget >= defaultMaxTokens - ? thinkingBudget + 1 - : defaultMaxTokens - - // `InternalTextProviderOptions.system` is typed - // `string | Array` (no `| undefined`), so build it - // outside the literal and spread it conditionally rather than - // assigning `undefined` under exactOptionalPropertyTypes. - const systemBlocks = ((): Array | undefined => { - const normalized = normalizeSystemPrompts( - options.systemPrompts, - ) - if (normalized.length === 0) return undefined - return normalized.map( - (p): TextBlockParam => ({ - type: 'text', - text: p.content, - ...(p.metadata?.cache_control && { - cache_control: p.metadata.cache_control, - }), - }), - ) - })() - // Wire engine-threaded outputSchema into Messages `output_config.format` - // alongside any `tools` so the model emits tool calls during the agent - // loop and a single schema-constrained JSON message on its final turn. - // Merge into any existing `output_config` so callers can keep tuning - // `output_config.effort` alongside the schema. + const maxTokens = resolveAnthropicMaxTokens( + this.model, + options.modelOptions, + thinkingBudget, + stream, + ) + + const systemBlocks = buildAnthropicSystemBlocks(options.systemPrompts) const combinedSchema = options.outputSchema as | Record | undefined @@ -608,30 +613,8 @@ export class AnthropicTextAdapter< } : undefined - // Lift skills attached to a `code_execution` tool into the top-level - // `container.skills` request param (Anthropic's required shape). Preserve any - // `container.id` supplied via modelOptions for container reuse. This is the - // canonical path for skills; `modelOptions.container.skills` is deprecated. - const toolSkills = options.tools - ?.map((tool) => - getAnthropicProviderToolKind(tool) === 'code_execution' - ? readCodeExecutionSkills(tool) - : undefined, - ) - .find((skills) => skills && skills.length > 0) + applyCodeExecutionSkills(options.tools, validProviderOptions) - if (toolSkills && toolSkills.length > 0) { - const existingContainer = validProviderOptions.container ?? undefined - validProviderOptions.container = { - id: existingContainer?.id ?? null, - skills: toolSkills, - } - } - - // `temperature`/`top_p` arrive via `...validProviderOptions` (sourced from - // `modelOptions`). `InternalTextProviderOptions` declares `system` and - // `tools` as `T?: ...` (no `| undefined`), so spread them conditionally - // rather than passing explicit `undefined` under exactOptionalPropertyTypes. const requestParams: InternalTextProviderOptions = { model: options.model, max_tokens: maxTokens, @@ -736,6 +719,113 @@ export class AnthropicTextAdapter< } } + private formatToolResultMessage( + message: ModelMessage, + toolCallId: string, + ): InternalTextProviderOptions['messages'][number] { + const toolContent = message.content + return { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolCallId, + content: Array.isArray(toolContent) + ? toolContent.map((part) => + this.convertContentPartToAnthropic(part), + ) + : typeof toolContent === 'string' + ? toolContent + : '', + }, + ], + } + } + + private parseToolCallInput(toolCall: { + function: { arguments?: string } + }): unknown { + try { + const parsed = toolCall.function.arguments + ? JSON.parse(toolCall.function.arguments) + : {} + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return toolCall.function.arguments + } + } + + private formatAssistantToolCallMessage( + message: ModelMessage, + toolCalls: NonNullable, + ): InternalTextProviderOptions['messages'][number] { + const contentBlocks: Array = [] + + this.appendThinkingBlocks(contentBlocks, message.thinking) + + if (message.content) { + const content = typeof message.content === 'string' ? message.content : '' + const textBlock: TextBlockParam = { + type: 'text', + text: content, + } + contentBlocks.push(textBlock) + } + + for (const toolCall of toolCalls) { + const parsedInput = this.parseToolCallInput(toolCall) + + const serverMeta = readAnthropicServerToolMetadata(toolCall.metadata) + if (serverMeta) { + const serverToolUseBlock: ServerToolUseBlockParam = { + type: 'server_tool_use', + id: toolCall.id, + name: serverMeta.serverToolType, + input: parsedInput, + } + contentBlocks.push(serverToolUseBlock) + contentBlocks.push(buildServerToolResultBlock(toolCall.id, serverMeta)) + continue + } + + const toolUseBlock: ToolUseBlockParam = { + type: 'tool_use', + id: toolCall.id, + name: toolCall.function.name, + input: parsedInput, + } + contentBlocks.push(toolUseBlock) + } + + return { + role: 'assistant', + content: contentBlocks, + } + } + + private formatAssistantContentMessage( + message: ModelMessage, + ): InternalTextProviderOptions['messages'][number] { + const contentBlocks: Array = [] + this.appendThinkingBlocks(contentBlocks, message.thinking) + + if (Array.isArray(message.content)) { + for (const part of message.content) { + contentBlocks.push(this.convertContentPartToAnthropic(part)) + } + } else if (message.content) { + contentBlocks.push({ + type: 'text', + text: message.content, + }) + } + + return { + role: 'assistant', + content: contentBlocks.length > 0 ? contentBlocks : '', + } + } + private formatMessages( messages: Array, ): InternalTextProviderOptions['messages'] { @@ -745,117 +835,30 @@ export class AnthropicTextAdapter< const role = message.role if (role === 'tool' && message.toolCallId) { - const toolContent = message.content - formattedMessages.push({ - role: 'user', - content: [ - { - type: 'tool_result', - tool_use_id: message.toolCallId, - content: Array.isArray(toolContent) - ? toolContent.map((part) => - this.convertContentPartToAnthropic(part), - ) - : typeof toolContent === 'string' - ? toolContent - : '', - }, - ], - }) + formattedMessages.push( + this.formatToolResultMessage(message, message.toolCallId), + ) continue } if (role === 'assistant' && message.toolCalls?.length) { - const contentBlocks: Array = [] - - this.appendThinkingBlocks(contentBlocks, message.thinking) - - if (message.content) { - const content = - typeof message.content === 'string' ? message.content : '' - const textBlock: TextBlockParam = { - type: 'text', - text: content, - } - contentBlocks.push(textBlock) - } - - for (const toolCall of message.toolCalls) { - let parsedInput: unknown = {} - try { - const parsed = toolCall.function.arguments - ? JSON.parse(toolCall.function.arguments) - : {} - parsedInput = parsed && typeof parsed === 'object' ? parsed : {} - } catch { - parsedInput = toolCall.function.arguments - } - - // Provider-executed server tools (e.g. web_search) replay as the - // original `server_tool_use` + result blocks so the model still sees - // the prior evidence. Their result was captured verbatim during - // streaming (see processAnthropicStream). - const serverMeta = readAnthropicServerToolMetadata(toolCall.metadata) - if (serverMeta) { - const serverToolUseBlock: ServerToolUseBlockParam = { - type: 'server_tool_use', - id: toolCall.id, - name: serverMeta.serverToolType, - input: parsedInput, - } - contentBlocks.push(serverToolUseBlock) - contentBlocks.push( - buildServerToolResultBlock(toolCall.id, serverMeta), - ) - continue - } - - const toolUseBlock: ToolUseBlockParam = { - type: 'tool_use', - id: toolCall.id, - name: toolCall.function.name, - input: parsedInput, - } - contentBlocks.push(toolUseBlock) - } - - formattedMessages.push({ - role: 'assistant', - content: contentBlocks, - }) - + formattedMessages.push( + this.formatAssistantToolCallMessage(message, message.toolCalls), + ) continue } if (role === 'assistant') { - const contentBlocks: Array = [] - this.appendThinkingBlocks(contentBlocks, message.thinking) - - if (Array.isArray(message.content)) { - for (const part of message.content) { - contentBlocks.push(this.convertContentPartToAnthropic(part)) - } - } else if (message.content) { - contentBlocks.push({ - type: 'text', - text: message.content, - }) - } - - formattedMessages.push({ - role: 'assistant', - content: contentBlocks.length > 0 ? contentBlocks : '', - }) + formattedMessages.push(this.formatAssistantContentMessage(message)) continue } if (role === 'user' && Array.isArray(message.content)) { - const contentBlocks = message.content.map((part) => - this.convertContentPartToAnthropic(part), - ) formattedMessages.push({ role: 'user', - content: contentBlocks, + content: message.content.map((part) => + this.convertContentPartToAnthropic(part), + ), }) continue } @@ -873,9 +876,6 @@ export class AnthropicTextAdapter< }) } - // Post-process: Anthropic requires strictly alternating user/assistant roles. - // Tool results are sent as role:'user' messages, which can create consecutive - // user messages when followed by a new user message. Merge them. return this.mergeConsecutiveSameRoleMessages(formattedMessages) } @@ -939,9 +939,6 @@ export class AnthropicTextAdapter< } } - // De-duplicate tool_result blocks with the same tool_use_id. - // This can happen when the core layer generates tool results from both - // the tool-result part and the tool-call part's output field. for (const msg of merged) { if (Array.isArray(msg.content)) { const seenToolResultIds = new Set() @@ -959,547 +956,6 @@ export class AnthropicTextAdapter< return merged } - - private async *processAnthropicStream( - stream: AsyncIterable, - options: TextOptions, - genId: () => string, - logger: InternalLogger, - ): AsyncIterable { - const model = options.model - let accumulatedContent = '' - let accumulatedThinking = '' - let accumulatedSignature = '' - const toolCallsMap = new Map< - number, - { id: string; name: string; input: string; started: boolean } - >() - let currentToolIndex = -1 - // Server-side tools share the `input_json_delta` wire format with client - // `tool_use` blocks; routing both to the same buffer corrupts client tool - // input. - let currentServerTool: { id: string; name: string; input: string } | null = - null - // Completed server tools awaiting their matching result block. Anthropic - // emits `server_tool_use` then a separate `*_tool_result` block; we hold - // the call here (keyed by id) until the result arrives so we can emit a - // single provider-executed tool call carrying the raw result for round-trip. - const completedServerTools = new Map< - string, - { id: string; name: string; input: string } - >() - - // AG-UI lifecycle tracking - const runId = options.runId ?? genId() - const threadId = options.threadId ?? genId() - const messageId = genId() - let stepId: string | null = null - let reasoningMessageId: string | null = null - let hasClosedReasoning = false - let hasEmittedRunStarted = false - let hasEmittedTextMessageStart = false - let hasEmittedRunFinished = false - // Track current content block type for proper content_block_stop handling - let currentBlockType: string | null = null - - try { - for await (const event of stream) { - logger.provider(`provider=anthropic type=${event.type}`, { - chunk: event, - }) - // Emit RUN_STARTED on first event - if (!hasEmittedRunStarted) { - hasEmittedRunStarted = true - yield { - type: EventType.RUN_STARTED, - runId, - threadId, - model, - timestamp: Date.now(), - parentRunId: options.parentRunId, - } - } - - if (event.type === 'content_block_start') { - currentBlockType = event.content_block.type - if (event.content_block.type === 'tool_use') { - currentToolIndex++ - toolCallsMap.set(currentToolIndex, { - id: event.content_block.id, - name: event.content_block.name, - input: '', - started: false, - }) - } else if (event.content_block.type === 'server_tool_use') { - currentServerTool = { - id: event.content_block.id, - name: event.content_block.name, - input: '', - } - } else if ( - event.content_block.type === 'web_fetch_tool_result' || - event.content_block.type === 'web_search_tool_result' - ) { - // The result content arrives in full at content_block_start (no - // deltas). Surface error variants so a failed fetch/search isn't - // invisible to the consumer. - const content = event.content_block.content as - | { type?: string; error_code?: string } - | Array - const errorBlock = - !Array.isArray(content) && - (content.type === 'web_fetch_tool_result_error' || - content.type === 'web_search_tool_result_error') - ? content - : null - if (errorBlock) { - logger.errors( - `anthropic.${event.content_block.type} error_code=${errorBlock.error_code}`, - { - toolUseId: event.content_block.tool_use_id, - blockType: event.content_block.type, - errorCode: errorBlock.error_code, - source: 'anthropic.processAnthropicStream', - }, - ) - } - - // Emit the server tool as a single provider-executed tool call, - // carrying its raw result so the evidence (e.g. web_search sources) - // round-trips into the next turn's request. The agent loop skips - // provider-executed calls, so this never triggers client execution. - const serverTool = completedServerTools.get( - event.content_block.tool_use_id, - ) - if (serverTool) { - completedServerTools.delete(serverTool.id) - - let parsedInput: unknown = {} - try { - const parsed = serverTool.input - ? JSON.parse(serverTool.input) - : {} - parsedInput = parsed && typeof parsed === 'object' ? parsed : {} - } catch { - parsedInput = {} - } - - const serverToolMetadata = { - providerExecuted: true, - anthropic: { - serverToolType: serverTool.name, - resultBlockType: event.content_block.type, - result: content, - }, - } - - currentToolIndex++ - yield { - type: EventType.TOOL_CALL_START, - toolCallId: serverTool.id, - toolCallName: serverTool.name, - toolName: serverTool.name, - parentMessageId: messageId, - model, - timestamp: Date.now(), - index: currentToolIndex, - metadata: serverToolMetadata, - } - yield { - type: EventType.TOOL_CALL_END, - toolCallId: serverTool.id, - toolCallName: serverTool.name, - toolName: serverTool.name, - model, - timestamp: Date.now(), - input: parsedInput, - } - - // Text after the server tool starts a fresh message segment. - hasEmittedTextMessageStart = false - } - } else if (event.content_block.type === 'thinking') { - accumulatedThinking = '' - accumulatedSignature = '' - // Emit REASONING and STEP_STARTED for thinking - stepId = genId() - reasoningMessageId = genId() - - // Spec REASONING events - yield { - type: EventType.REASONING_START, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - yield { - type: EventType.REASONING_MESSAGE_START, - messageId: reasoningMessageId, - role: 'reasoning' as const, - model, - timestamp: Date.now(), - } - - // Legacy STEP events (kept during transition) - yield { - type: EventType.STEP_STARTED, - stepName: stepId, - stepId, - model, - timestamp: Date.now(), - stepType: 'thinking', - } - } - } else if (event.type === 'content_block_delta') { - if (event.delta.type === 'text_delta') { - // Close reasoning before text starts - if (reasoningMessageId && !hasClosedReasoning) { - hasClosedReasoning = true - yield { - type: EventType.REASONING_MESSAGE_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - yield { - type: EventType.REASONING_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - } - - // Emit TEXT_MESSAGE_START on first text content - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield { - type: EventType.TEXT_MESSAGE_START, - messageId, - model, - timestamp: Date.now(), - role: 'assistant', - } - } - - const delta = event.delta.text - accumulatedContent += delta - yield { - type: EventType.TEXT_MESSAGE_CONTENT, - messageId, - model, - timestamp: Date.now(), - delta, - content: accumulatedContent, - } - } else if ( - event.delta.type === 'thinking_delta' && - reasoningMessageId - ) { - const delta = event.delta.thinking - accumulatedThinking += delta - - // Spec REASONING content event - yield { - type: EventType.REASONING_MESSAGE_CONTENT, - messageId: reasoningMessageId, - delta, - model, - timestamp: Date.now(), - } - - // Legacy STEP event - yield { - type: EventType.STEP_FINISHED, - stepName: stepId || genId(), - stepId: stepId || genId(), - model, - timestamp: Date.now(), - delta, - content: accumulatedThinking, - } - } else if ( - (event.delta as { type: string }).type === 'signature_delta' - ) { - accumulatedSignature += - (event.delta as { signature: string }).signature || '' - } else if (event.delta.type === 'input_json_delta') { - // Route deltas by current block type so server_tool_use input - // never appends onto the prior client tool's buffer. - if (currentBlockType === 'tool_use') { - const existing = toolCallsMap.get(currentToolIndex) - if (existing) { - // Emit TOOL_CALL_START on first args delta - if (!existing.started) { - existing.started = true - yield { - type: EventType.TOOL_CALL_START, - toolCallId: existing.id, - toolCallName: existing.name, - toolName: existing.name, - parentMessageId: messageId, - model, - timestamp: Date.now(), - index: currentToolIndex, - } - } - - existing.input += event.delta.partial_json - - yield { - type: EventType.TOOL_CALL_ARGS, - toolCallId: existing.id, - model, - timestamp: Date.now(), - delta: event.delta.partial_json, - args: existing.input, - } - } - } else if ( - currentBlockType === 'server_tool_use' && - currentServerTool - ) { - // Accumulate server tool input internally. We don't emit - // TOOL_CALL_* events: the call is executed by Anthropic, not - // by our agent loop, so surfacing it as a client tool call - // would cause downstream code to try (and fail) to run it. - currentServerTool.input += event.delta.partial_json - } - } - } else if (event.type === 'content_block_stop') { - if (currentBlockType === 'thinking') { - // Emit signature so it can be replayed in multi-turn context - if (accumulatedSignature && stepId) { - yield { - type: EventType.STEP_FINISHED, - stepName: stepId, - stepId, - model, - timestamp: Date.now(), - delta: '', - content: accumulatedThinking, - signature: accumulatedSignature, - } - } - } else if (currentBlockType === 'tool_use') { - const existing = toolCallsMap.get(currentToolIndex) - if (existing) { - // If tool call wasn't started yet (no args), start it now - if (!existing.started) { - existing.started = true - yield { - type: EventType.TOOL_CALL_START, - toolCallId: existing.id, - toolCallName: existing.name, - toolName: existing.name, - parentMessageId: messageId, - model, - timestamp: Date.now(), - index: currentToolIndex, - } - } - - // Emit TOOL_CALL_END - let parsedInput: unknown = {} - try { - const parsed = existing.input ? JSON.parse(existing.input) : {} - parsedInput = parsed && typeof parsed === 'object' ? parsed : {} - } catch { - parsedInput = {} - } - - yield { - type: EventType.TOOL_CALL_END, - toolCallId: existing.id, - toolCallName: existing.name, - toolName: existing.name, - model, - timestamp: Date.now(), - input: parsedInput, - } - - // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls - hasEmittedTextMessageStart = false - } - } else if (currentBlockType === 'server_tool_use') { - if (currentServerTool) { - // Anthropic executes the call; we only need a breadcrumb so - // consumers (devtools, telemetry) can see what ran. - logger.provider( - `provider=anthropic server_tool_use name=${currentServerTool.name}`, - { - toolUseId: currentServerTool.id, - name: currentServerTool.name, - input: currentServerTool.input, - }, - ) - // Hold the call until its result block arrives so we can emit - // both together as one provider-executed tool call. - completedServerTools.set(currentServerTool.id, currentServerTool) - } - currentServerTool = null - } else if ( - currentBlockType === 'web_fetch_tool_result' || - currentBlockType === 'web_search_tool_result' - ) { - // The model already consumed the result; error variants were - // already surfaced at content_block_start. - } else { - // Emit TEXT_MESSAGE_END only for text blocks (not tool_use blocks) - if (hasEmittedTextMessageStart && accumulatedContent) { - yield { - type: EventType.TEXT_MESSAGE_END, - messageId, - model, - timestamp: Date.now(), - } - } - } - currentBlockType = null - } else if (event.type === 'message_stop') { - // Close reasoning events if still open - if (reasoningMessageId && !hasClosedReasoning) { - hasClosedReasoning = true - yield { - type: EventType.REASONING_MESSAGE_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - yield { - type: EventType.REASONING_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - } - - // Only emit RUN_FINISHED from message_stop if message_delta didn't already emit one. - // message_delta carries the real stop_reason (tool_use, end_turn, etc.), - // while message_stop is just a completion signal. - if (!hasEmittedRunFinished) { - yield { - type: EventType.RUN_FINISHED, - runId, - threadId, - model, - timestamp: Date.now(), - finishReason: 'stop', - } - } - } else if (event.type === 'message_delta') { - if (event.delta.stop_reason) { - hasEmittedRunFinished = true - - // Close reasoning events if still open - if (reasoningMessageId && !hasClosedReasoning) { - hasClosedReasoning = true - yield { - type: EventType.REASONING_MESSAGE_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - yield { - type: EventType.REASONING_END, - messageId: reasoningMessageId, - model, - timestamp: Date.now(), - } - } - - switch (event.delta.stop_reason) { - case 'tool_use': { - yield { - type: EventType.RUN_FINISHED, - runId, - threadId, - model, - timestamp: Date.now(), - finishReason: 'tool_calls', - usage: buildAnthropicUsage(event.usage), - } - break - } - case 'max_tokens': { - // Surface a warning when the truncating cap was the - // adapter-supplied default (caller didn't pass `max_tokens`), so - // the truncation isn't silently attributed to the model "doing - // nothing" (issue #849). When the caller set `max_tokens` - // themselves, hitting it is their own deliberate ceiling. - if (options.modelOptions?.max_tokens == null) { - const defaultedMaxTokens = getAnthropicDefaultMaxTokens(model) - logger.warn( - `anthropic response truncated at the default max_tokens (${defaultedMaxTokens}) for model=${model}; pass maxTokens (or modelOptions.max_tokens) to raise the output ceiling`, - { - source: 'anthropic.processAnthropicStream', - model, - defaultedMaxTokens, - }, - ) - } - yield { - type: EventType.RUN_ERROR, - model, - timestamp: Date.now(), - message: - 'The response was cut off because the maximum token limit was reached.', - code: 'max_tokens', - error: { - message: - 'The response was cut off because the maximum token limit was reached.', - code: 'max_tokens', - }, - } - break - } - case 'stop_sequence': - case 'end_turn': - case 'pause_turn': - case 'refusal': - case 'model_context_window_exceeded': - case 'compaction': - default: { - // All remaining Anthropic stop_reason variants map to the - // generic "stop" finish reason — they describe *why* the - // stream ended, but for AG-UI consumers the resulting event - // shape is identical. - yield { - type: EventType.RUN_FINISHED, - runId, - threadId, - model, - timestamp: Date.now(), - finishReason: 'stop', - usage: buildAnthropicUsage(event.usage), - } - } - } - } - } - } - } catch (error: unknown) { - const err = error as Error & { status?: number; code?: string } - const rawEvent = toRunErrorRawEvent(error) - - logger.errors('anthropic.processAnthropicStream fatal', { - error, - source: 'anthropic.processAnthropicStream', - }) - yield { - type: EventType.RUN_ERROR, - model, - timestamp: Date.now(), - message: err.message || 'Unknown error occurred', - code: err.code || String(err.status), - // Forward the Anthropic SDK error's `.error` response body when present. - ...(rawEvent !== undefined && { rawEvent }), - error: { - message: err.message || 'Unknown error occurred', - code: err.code || String(err.status), - }, - } - } - } } /** diff --git a/packages/ai-anthropic/src/index.ts b/packages/ai-anthropic/src/index.ts index 4890d488bc..2a8be2c2ca 100644 --- a/packages/ai-anthropic/src/index.ts +++ b/packages/ai-anthropic/src/index.ts @@ -1,7 +1,3 @@ -// ============================================================================ -// New Tree-Shakeable Adapters (Recommended) -// ============================================================================ - // Text (Chat) adapter - for chat/text completion export { AnthropicTextAdapter, @@ -22,9 +18,6 @@ export { type AnthropicSummarizeConfig, type AnthropicSummarizeModel, } from './adapters/summarize' -// ============================================================================ -// Type Exports -// ============================================================================ export type { AnthropicChatModel, diff --git a/packages/ai-anthropic/src/message-types.ts b/packages/ai-anthropic/src/message-types.ts index fec7615383..194b26a24e 100644 --- a/packages/ai-anthropic/src/message-types.ts +++ b/packages/ai-anthropic/src/message-types.ts @@ -1,11 +1,3 @@ -/** - * Anthropic-specific metadata types for multimodal content parts. - * These types extend the base ContentPart metadata with Anthropic-specific options. - * - * @see https://docs.anthropic.com/claude/docs/vision - * @see https://docs.anthropic.com/claude/docs/pdf-support - */ - import type { CacheControlEphemeral, CitationsConfigParam, @@ -36,9 +28,6 @@ export interface AnthropicImageMetadata { } export interface AnthropicTextMetadata { - /** - * Cache control settings for the text content. - */ cache_control?: CacheControlEphemeral /** * Text citations to include with the text content. @@ -54,13 +43,7 @@ export type AnthropicDocumentMediaType = 'application/pdf' * Metadata for Anthropic document content parts (e.g., PDFs). */ export interface AnthropicDocumentMetadata { - /** - * @deprecated Ignored. The media type is taken from `source.mimeType`. - */ mediaType?: AnthropicDocumentMediaType - /** - * Cache control settings for the document. - */ cache_control?: CacheControlEphemeral citations?: CitationsConfigParam @@ -80,9 +63,6 @@ export interface AnthropicDocumentMetadata { * Note: Audio support in Anthropic may be limited; check current API capabilities. */ export interface AnthropicAudioMetadata { - /** - * The MIME type of the audio. - */ mediaType?: | 'audio/mpeg' | 'audio/wav' @@ -96,9 +76,6 @@ export interface AnthropicAudioMetadata { * Note: Video support in Anthropic may be limited; check current API capabilities. */ export interface AnthropicVideoMetadata { - /** - * The MIME type of the video. - */ mediaType?: 'video/mp4' | 'video/webm' | 'video/mpeg' } diff --git a/packages/ai-anthropic/src/model-meta.ts b/packages/ai-anthropic/src/model-meta.ts index 1cdfa01654..1f2b21dfe4 100644 --- a/packages/ai-anthropic/src/model-meta.ts +++ b/packages/ai-anthropic/src/model-meta.ts @@ -309,9 +309,6 @@ const CLAUDE_OPUS_4_1 = { AnthropicSamplingOptions > -// Claude Opus 4.7 removed budget-based extended thinking and the sampling -// parameters (`temperature`, `top_p`, `top_k`) — sending either returns a -// 400. Use adaptive thinking with `output_config.effort` instead. const CLAUDE_OPUS_4_7 = { name: 'claude-opus-4-7', id: 'claude-opus-4-7', @@ -398,11 +395,6 @@ const CLAUDE_OPUS_4_8 = { AnthropicOutputConfigOptions > -// Claude Fable 5: thinking is always on — the only accepted explicit -// `thinking` config is `{type: 'adaptive'}` (disabled/budget_tokens 400), -// and the sampling parameters (`temperature`, `top_p`, `top_k`) are -// rejected. Its provider options therefore use the adaptive-only thinking -// shape and `max_tokens` without the sampling knobs. const CLAUDE_FABLE_5 = { name: 'claude-fable-5', id: 'claude-fable-5', @@ -445,12 +437,6 @@ const CLAUDE_FABLE_5 = { AnthropicOutputConfigOptions > -// Claude Sonnet 5: adaptive thinking is the default (omitting `thinking` -// runs adaptive); `{type: 'disabled'}` opts out, but the manual -// `{type: 'enabled', budget_tokens}` shape and non-default sampling -// parameters (`temperature`, `top_p`, `top_k`) are rejected with a 400. -// Pricing below is the sticker $3/$15 per MTok (an introductory $2/$10 -// applies through 2026-08-31). const CLAUDE_SONNET_5 = { name: 'claude-sonnet-5', id: 'claude-sonnet-5', @@ -695,15 +681,7 @@ export const ANTHROPIC_COMBINED_TOOLS_AND_SCHEMA_MODELS = new Set([ CLAUDE_HAIKU_4_5.id, ]) -// const ANTHROPIC_IMAGE_MODELS = [] as const -// const ANTHROPIC_EMBEDDING_MODELS = [] as const -// const ANTHROPIC_AUDIO_MODELS = [] as const -// const ANTHROPIC_VIDEO_MODELS = [] as const - export type AnthropicChatModel = (typeof ANTHROPIC_MODELS)[number] -// Manual type map for per-model provider options -// Models are differentiated by which thinking shapes and sampling -// parameters the API accepts. export type AnthropicChatModelProviderOptionsByName = { // 4.6 generation: adaptive thinking plus the deprecated budget-based // shape; sampling parameters still accepted. @@ -799,9 +777,6 @@ export type AnthropicChatModelProviderOptionsByName = { AnthropicToolChoiceOptions & AnthropicMaxTokensOptions & AnthropicOutputConfigOptions - // Claude Sonnet 5: adaptive thinking by default, explicit disable - // allowed; no budget_tokens, no sampling parameters — see the - // CLAUDE_SONNET_5 constant above. [CLAUDE_SONNET_5.id]: AnthropicCacheControlOptions & AnthropicContainerOptions & AnthropicContextManagementOptions & diff --git a/packages/ai-anthropic/src/text/text-provider-options.ts b/packages/ai-anthropic/src/text/text-provider-options.ts index c87017a2aa..3f03a507a8 100644 --- a/packages/ai-anthropic/src/text/text-provider-options.ts +++ b/packages/ai-anthropic/src/text/text-provider-options.ts @@ -78,10 +78,10 @@ export interface AnthropicContainerOptions { export interface AnthropicContextManagementOptions { /** - * Context management configuration. - -This allows you to control how Claude manages context across multiple requests, such as whether to clear function results or not. - */ + * Context management configuration. + + This allows you to control how Claude manages context across multiple requests, such as whether to clear function results or not. + */ context_management?: BetaContextManagementConfig | null } @@ -102,28 +102,28 @@ export interface AnthropicServiceTierOptions { export interface AnthropicStopSequencesOptions { /** - * Custom text sequences that will cause the model to stop generating. - -Anthropic models will normally stop when they have naturally completed their turn, which will result in a response stop_reason of "end_turn". - -If you want the model to stop generating when it encounters custom strings of text, you can use the stop_sequences parameter. If the model encounters one of the custom sequences, the response stop_reason value will be "stop_sequence" and the response stop_sequence value will contain the matched stop sequence. - */ + * Custom text sequences that will cause the model to stop generating. + + Anthropic models will normally stop when they have naturally completed their turn, which will result in a response stop_reason of "end_turn". + + If you want the model to stop generating when it encounters custom strings of text, you can use the stop_sequences parameter. If the model encounters one of the custom sequences, the response stop_reason value will be "stop_sequence" and the response stop_sequence value will contain the matched stop sequence. + */ stop_sequences?: Array } export interface AnthropicThinkingOptions { /** - * Configuration for enabling Claude's extended thinking. - -When enabled, responses include thinking content blocks showing Claude's thinking process before the final answer. Requires a minimum budget of 1,024 tokens and counts towards your max_tokens limit. - */ + * Configuration for enabling Claude's extended thinking. + + When enabled, responses include thinking content blocks showing Claude's thinking process before the final answer. Requires a minimum budget of 1,024 tokens and counts towards your max_tokens limit. + */ thinking?: | { /** -* Determines how many tokens Claude can use for its internal reasoning process. Larger budgets can enable more thorough analysis for complex problems, improving response quality. - -Must be ≥1024 and less than max_tokens -*/ + * Determines how many tokens Claude can use for its internal reasoning process. Larger budgets can enable more thorough analysis for complex problems, improving response quality. + + Must be ≥1024 and less than max_tokens + */ budget_tokens: number type: 'enabled' @@ -134,13 +134,6 @@ Must be ≥1024 and less than max_tokens } export interface AnthropicAdaptiveThinkingOptions { - /** - * Configuration for Claude's adaptive thinking (Opus 4.6+). - * - * In adaptive mode, Claude dynamically decides when and how much to think. - * Use the effort parameter to control thinking depth. - * `thinking: {type: "enabled"}` with `budget_tokens` is deprecated on Opus 4.6. - */ thinking?: | { type: 'adaptive' @@ -159,9 +152,6 @@ export interface AnthropicAdaptiveThinkingOptions { display?: 'summarized' | 'omitted' } | { - /** - * @deprecated Use `type: 'adaptive'` with the effort parameter on Opus 4.6+. - */ budget_tokens: number type: 'enabled' } @@ -182,14 +172,6 @@ export interface AnthropicAdaptiveThinkingOptions { export interface AnthropicAdaptiveOnlyThinkingOptions { thinking?: { type: 'adaptive' - /** - * Controls what (if any) thinking content is streamed back. - * - * - `'summarized'`: stream summarized thinking via `thinking_delta` - * events (the user-visible reasoning text). - * - `'omitted'` (default): stream the thinking block's - * `signature_delta` only (no reasoning text reaches the client). - */ display?: 'summarized' | 'omitted' } } @@ -208,11 +190,6 @@ export interface AnthropicAdaptiveOrDisabledThinkingOptions { thinking?: | { type: 'adaptive' - /** - * Controls what (if any) thinking content is streamed back. - * Defaults to `'omitted'` — set `'summarized'` to receive the - * reasoning text. - */ display?: 'summarized' | 'omitted' } | { @@ -262,10 +239,6 @@ export interface AnthropicOutputConfigOptions { * preserved when the engine adds `format`. */ output_config?: { - /** - * `'xhigh'` is accepted on Claude Opus 4.7+, Claude Sonnet 5, and - * Claude Fable 5; older models support `'low'`–`'max'` only. - */ effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | null } } @@ -276,13 +249,13 @@ export interface AnthropicToolChoiceOptions { export interface AnthropicSamplingOptions { /** - * Only sample from the top K options for each subsequent token. - -Used to remove "long tail" low probability responses. -Recommended for advanced use cases only. You usually only need to use temperature. - -Required range: x >= 0 - */ + * Only sample from the top K options for each subsequent token. + + Used to remove "long tail" low probability responses. + Recommended for advanced use cases only. You usually only need to use temperature. + + Required range: x >= 0 + */ top_k?: number /** * Amount of randomness injected into the response. @@ -297,10 +270,6 @@ Required range: x >= 0 * In nucleus sampling, we compute the cumulative distribution over all the options for each subsequent token in decreasing probability order and cut it off once it reaches a particular probability specified by top_p. You should either alter temperature or top_p, but not both. */ top_p?: number - /** - * The maximum number of tokens to generate before stopping. This parameter only specifies the absolute maximum number of tokens to generate. Required by the API; the adapter defaults to 1024 when omitted. - * Range x >= 1. - */ max_tokens?: number } @@ -322,10 +291,6 @@ export interface InternalTextProviderOptions extends ExternalTextProviderOptions messages: Array - /** - * The maximum number of tokens to generate before stopping. This parameter only specifies the absolute maximum number of tokens to generate. - * Range x >= 1. - */ max_tokens: number /** * Whether to incrementally stream the response using server-sent events. @@ -344,21 +309,6 @@ export interface InternalTextProviderOptions extends ExternalTextProviderOptions tools?: Array - /** - * Schema-constrained final answer in a single Messages request (issue - * #605). Set by the engine when the adapter declared - * `supportsCombinedToolsAndSchema` and a caller passed `outputSchema` - * to `chat()`. The model emits tool calls during the agent loop and a - * schema-matching JSON message on the natural final turn — no separate - * finalization round-trip needed. - * - * The SDK type (`BetaOutputConfig`) currently exposes only `effort`; - * `format` is accepted at runtime per the deprecation notice on the - * older `output_format` field - * (https://platform.claude.com/docs/en/build-with-claude/structured-outputs). - * We type it explicitly here so the adapter call site doesn't need a - * cast. - */ output_config?: { effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | null format?: { @@ -369,7 +319,9 @@ export interface InternalTextProviderOptions extends ExternalTextProviderOptions } const validateTopPandTemperature = (options: InternalTextProviderOptions) => { - if (options.top_p !== undefined && options.temperature !== undefined) { + const hasBothTopPAndTemperature = + options.top_p !== undefined && options.temperature !== undefined + if (hasBothTopPAndTemperature) { throw new Error('You should either set top_p or temperature, but not both.') } } diff --git a/packages/ai-anthropic/src/tools/code-execution-tool.ts b/packages/ai-anthropic/src/tools/code-execution-tool.ts index e726f45c02..aa611146a6 100644 --- a/packages/ai-anthropic/src/tools/code-execution-tool.ts +++ b/packages/ai-anthropic/src/tools/code-execution-tool.ts @@ -36,6 +36,7 @@ export interface CodeExecutionToolOptions { interface CodeExecutionToolMetadata { config: CodeExecutionToolConfig + /** Hosted skills to load into the code-execution container (max 8). */ skills?: Array } @@ -47,9 +48,6 @@ export type AnthropicCodeExecutionTool = ProviderTool< export function convertCodeExecutionToolToAdapterFormat( tool: Tool, ): CodeExecutionToolConfig { - // The converter is only called for real `code_execution` tools, so a - // non-undefined config is expected — but read via optional chaining so an - // absent metadata object doesn't throw. return readCodeExecutionConfig(tool) as CodeExecutionToolConfig } @@ -98,7 +96,9 @@ export function codeExecutionTool( }) } for (const skill of skills) { - if (skill.skill_id.length < 1 || skill.skill_id.length > 64) { + const skillIdOutOfRange = + skill.skill_id.length < 1 || skill.skill_id.length > 64 + if (skillIdOutOfRange) { throw new Error('skill_id must be between 1 and 64 characters.') } } diff --git a/packages/ai-anthropic/src/tools/web-search-tool.ts b/packages/ai-anthropic/src/tools/web-search-tool.ts index 30081c055d..3e65c59862 100644 --- a/packages/ai-anthropic/src/tools/web-search-tool.ts +++ b/packages/ai-anthropic/src/tools/web-search-tool.ts @@ -13,7 +13,10 @@ export type WebSearchTool = WebSearchToolConfig export type AnthropicWebSearchTool = ProviderTool<'anthropic', 'web_search'> const validateDomains = (tool: WebSearchToolConfig) => { - if (tool.allowed_domains && tool.blocked_domains) { + const hasConflictingDomains = Boolean( + tool.allowed_domains && tool.blocked_domains, + ) + if (hasConflictingDomains) { throw new Error( 'allowed_domains and blocked_domains cannot be used together.', ) @@ -23,32 +26,38 @@ const validateDomains = (tool: WebSearchToolConfig) => { const validateUserLocation = (tool: WebSearchToolConfig) => { const userLocation = tool.user_location if (userLocation) { - if ( - userLocation.city && - (userLocation.city.length < 1 || userLocation.city.length > 255) - ) { - throw new Error( - 'user_location.city must be between 1 and 255 characters.', - ) + const city = userLocation.city + if (city) { + const cityOutOfRange = city.length < 1 || city.length > 255 + if (cityOutOfRange) { + throw new Error( + 'user_location.city must be between 1 and 255 characters.', + ) + } } - if (userLocation.country && userLocation.country.length !== 2) { - throw new Error('user_location.country must be exactly 2 characters.') + const country = userLocation.country + if (country) { + if (country.length !== 2) { + throw new Error('user_location.country must be exactly 2 characters.') + } } - if ( - userLocation.region && - (userLocation.region.length < 1 || userLocation.region.length > 255) - ) { - throw new Error( - 'user_location.region must be between 1 and 255 characters.', - ) + const region = userLocation.region + if (region) { + const regionOutOfRange = region.length < 1 || region.length > 255 + if (regionOutOfRange) { + throw new Error( + 'user_location.region must be between 1 and 255 characters.', + ) + } } - if ( - userLocation.timezone && - (userLocation.timezone.length < 1 || userLocation.timezone.length > 255) - ) { - throw new Error( - 'user_location.timezone must be between 1 and 255 characters.', - ) + const timezone = userLocation.timezone + if (timezone) { + const timezoneOutOfRange = timezone.length < 1 || timezone.length > 255 + if (timezoneOutOfRange) { + throw new Error( + 'user_location.timezone must be between 1 and 255 characters.', + ) + } } } } @@ -56,10 +65,6 @@ const validateUserLocation = (tool: WebSearchToolConfig) => { export function convertWebSearchToolToAdapterFormat( tool: Tool, ): WebSearchToolConfig { - // The factory stores the SDK config (`allowed_domains`, `max_uses`, …) - // on `metadata`. Vendor `WebSearchTool20250305` declares those fields as - // `T | null` (no `| undefined`) under exactOptionalPropertyTypes, so - // spread them only when present rather than passing explicit `undefined`. const metadata = getAnthropicProviderToolMetadata(tool) return { name: 'web_search', diff --git a/packages/ai-anthropic/src/usage.ts b/packages/ai-anthropic/src/usage.ts index 066d27c5df..ca5d2bddd4 100644 --- a/packages/ai-anthropic/src/usage.ts +++ b/packages/ai-anthropic/src/usage.ts @@ -35,9 +35,6 @@ export function buildAnthropicUsage( if (!usage) return undefined const inputTokens = usage.input_tokens ?? 0 - // `|| 0` (rather than `?? 0`) matches the sibling builders and stays defensive - // against a runtime-absent count without tripping no-unnecessary-condition - // (the SDK types output_tokens as a required number). const outputTokens = usage.output_tokens || 0 const result = buildBaseUsage({ @@ -46,9 +43,6 @@ export function buildAnthropicUsage( totalTokens: inputTokens + outputTokens, }) - // Add prompt token details for cache tokens. Only attach the details object - // when at least one field is present so we don't emit an empty `{}` (every - // other adapter guards with the same Object.keys check). const cacheCreation = usage.cache_creation_input_tokens const cacheRead = usage.cache_read_input_tokens diff --git a/packages/ai-anthropic/src/vertex/auth.ts b/packages/ai-anthropic/src/vertex/auth.ts index a7829c4a7a..635e231730 100644 --- a/packages/ai-anthropic/src/vertex/auth.ts +++ b/packages/ai-anthropic/src/vertex/auth.ts @@ -24,14 +24,20 @@ export type AnthropicVertexConfig = Omit< } function nonEmpty(value: string | undefined): string | undefined { - if (value === undefined || value.length === 0) { + if (value === undefined) { + return undefined + } + if (value.length === 0) { return undefined } return value } function readEnv(name: string): string | undefined { - if (typeof process === 'undefined' || process.env === undefined) { + if (typeof process === 'undefined') { + return undefined + } + if (process.env === undefined) { return undefined } return nonEmpty(process.env[name]) diff --git a/packages/ai-bedrock/src/adapters/converse-text.ts b/packages/ai-bedrock/src/adapters/converse-text.ts index 46021ad420..e5d12effe3 100644 --- a/packages/ai-bedrock/src/adapters/converse-text.ts +++ b/packages/ai-bedrock/src/adapters/converse-text.ts @@ -46,6 +46,130 @@ import type { /** Config for the Converse adapter — same client config as the chat adapter. */ export interface BedrockConverseConfig extends BedrockClientConfig {} +function emitStructuredRunStarted( + runId: string, + threadId: string, + model: string, + parentRunId: string | undefined, +): AdapterYieldChunk { + return { + type: EventType.RUN_STARTED, + runId, + threadId, + model, + timestamp: Date.now(), + parentRunId, + } +} + +function structuredFragmentFromDelta( + ev: ConverseStreamOutput, +): string | undefined { + if (!('contentBlockDelta' in ev)) return undefined + const delta = ev.contentBlockDelta?.delta + if (!delta) return undefined + if (!('toolUse' in delta)) return undefined + return delta.toolUse?.input +} + +function* emitStructuredTextDelta(args: { + started: boolean + messageId: string + fragment: string + accumulatedRaw: string + model: string +}): Generator { + if (args.started) { + yield { + type: EventType.TEXT_MESSAGE_START, + messageId: args.messageId, + role: 'assistant', + model: args.model, + timestamp: Date.now(), + } + } + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: args.messageId, + delta: args.fragment, + content: args.accumulatedRaw, + model: args.model, + timestamp: Date.now(), + } +} + +function mapStructuredStopReason( + stopReason: string | undefined, +): 'stop' | 'length' | 'content_filter' { + if (stopReason === 'max_tokens') return 'length' + if (stopReason === 'content_filtered') return 'content_filter' + return 'stop' +} + +function* finishStructuredOutputStream(args: { + adapterName: string + accumulatedRaw: string + runId: string + threadId: string + model: string + finishReason: 'stop' | 'length' | 'content_filter' +}): Generator { + if (args.accumulatedRaw.length === 0) { + yield { + type: EventType.RUN_ERROR, + runId: args.runId, + model: args.model, + timestamp: Date.now(), + message: `${args.adapterName}.structuredOutputStream: response contained no content`, + code: 'empty-response', + error: { + message: `${args.adapterName}.structuredOutputStream: response contained no content`, + code: 'empty-response', + }, + } + return + } + + let parsed: unknown + try { + parsed = JSON.parse(args.accumulatedRaw) + } catch { + yield { + type: EventType.RUN_ERROR, + runId: args.runId, + model: args.model, + timestamp: Date.now(), + message: `Failed to parse structured output as JSON. Content: ${args.accumulatedRaw.slice(0, 200)}${args.accumulatedRaw.length > 200 ? '...' : ''}`, + code: 'parse-error', + error: { + message: 'Failed to parse structured output as JSON', + code: 'parse-error', + }, + } + return + } + + yield { + type: EventType.CUSTOM, + name: 'structured-output.complete', + value: { + object: parsed, + raw: args.accumulatedRaw, + }, + model: args.model, + timestamp: Date.now(), + } + + yield { + type: EventType.RUN_FINISHED, + runId: args.runId, + threadId: args.threadId, + model: args.model, + timestamp: Date.now(), + finishReason: args.finishReason, + } +} + /** * Bedrock Converse text adapter. Wires the Converse translation modules (message * converter, tool converter, stream processor, structured-output forced-tool @@ -62,12 +186,6 @@ export interface BedrockConverseConfig extends BedrockClientConfig {} */ export class BedrockConverseTextAdapter< TModel extends BedrockConverseModels, - // Constraint mirrors the chat adapter (text.ts): the base parameterises - // `TProviderOptions extends Record`, and our default - // `ResolveConverseProviderOptions` resolves to an interface lacking an - // implicit index signature — which `Record` would reject but - // `Record` accepts. Confined to the generic constraint (the - // established adapter pattern) — no value `as` cast is introduced. TProviderOptions extends Record = ResolveConverseProviderOptions, TInputModalities extends ReadonlyArray = @@ -85,9 +203,6 @@ export class BedrockConverseTextAdapter< constructor(config: BedrockConverseConfig, model: TModel) { super({}, model) - // Defer client construction and auth resolution: the AWS SDK is Node/ - // server-only, so we must not pull it into the static graph here. The - // client (and its dynamic import) is built lazily on first SDK call. this.clientConfig = config } @@ -167,10 +282,6 @@ export class BedrockConverseTextAdapter< } } - // --------------------------------------------------------------------------- - // SDK seams (overridden in tests so no real AWS call happens) - // --------------------------------------------------------------------------- - protected async sendStream( input: ConverseStreamCommandInput, ): Promise> { @@ -191,10 +302,6 @@ export class BedrockConverseTextAdapter< return client.send(new ConverseCommand(input)) } - // --------------------------------------------------------------------------- - // Public adapter surface - // --------------------------------------------------------------------------- - async *chatStream( options: TextOptions, ): AsyncIterable { @@ -313,21 +420,15 @@ export class BedrockConverseTextAdapter< } const stream = await this.sendStream(input) - // The forced tool streams its `input` as partial-JSON fragments inside - // `contentBlockDelta.delta.toolUse.input`. We surface them as - // TEXT_MESSAGE_CONTENT deltas (raw JSON text), matching openai-base which - // carries the structured JSON as text deltas. for await (const ev of stream) { if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield { - type: EventType.RUN_STARTED, + yield emitStructuredRunStarted( runId, threadId, - model: chatOptions.model, - timestamp: Date.now(), - parentRunId: chatOptions.parentRunId, - } + chatOptions.model, + chatOptions.parentRunId, + ) } // Surface in-band server/throttle/validation errors instead of @@ -335,44 +436,24 @@ export class BedrockConverseTextAdapter< throwIfConverseStreamError(ev) if ('contentBlockDelta' in ev) { - const delta = ev.contentBlockDelta?.delta - const fragment = - delta && 'toolUse' in delta ? delta.toolUse?.input : undefined + const fragment = structuredFragmentFromDelta(ev) if (fragment !== undefined) { - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield { - type: EventType.TEXT_MESSAGE_START, - messageId, - role: 'assistant', - model: chatOptions.model, - timestamp: Date.now(), - } - } + const started = !hasEmittedTextMessageStart + hasEmittedTextMessageStart = true accumulatedRaw += fragment - yield { - type: EventType.TEXT_MESSAGE_CONTENT, + yield* emitStructuredTextDelta({ + started, messageId, - delta: fragment, - content: accumulatedRaw, + fragment, + accumulatedRaw, model: chatOptions.model, - timestamp: Date.now(), - } + }) } continue } if ('messageStop' in ev) { - const stopReason = ev.messageStop?.stopReason - // The forced structured-output tool produces stopReason 'tool_use' on - // success, but that's an implementation detail — a cleanly-completed - // structured run reports 'stop', matching openai-base's contract. - finishReason = - stopReason === 'max_tokens' - ? 'length' - : stopReason === 'content_filtered' - ? 'content_filter' - : 'stop' + finishReason = mapStructuredStopReason(ev.messageStop?.stopReason) continue } } @@ -398,60 +479,14 @@ export class BedrockConverseTextAdapter< } } - if (accumulatedRaw.length === 0) { - yield { - type: EventType.RUN_ERROR, - runId, - model: chatOptions.model, - timestamp: Date.now(), - message: `${this.name}.structuredOutputStream: response contained no content`, - code: 'empty-response', - error: { - message: `${this.name}.structuredOutputStream: response contained no content`, - code: 'empty-response', - }, - } - return - } - - let parsed: unknown - try { - parsed = JSON.parse(accumulatedRaw) - } catch { - yield { - type: EventType.RUN_ERROR, - runId, - model: chatOptions.model, - timestamp: Date.now(), - message: `Failed to parse structured output as JSON. Content: ${accumulatedRaw.slice(0, 200)}${accumulatedRaw.length > 200 ? '...' : ''}`, - code: 'parse-error', - error: { - message: 'Failed to parse structured output as JSON', - code: 'parse-error', - }, - } - return - } - - yield { - type: EventType.CUSTOM, - name: 'structured-output.complete', - value: { - object: parsed, - raw: accumulatedRaw, - }, - model: chatOptions.model, - timestamp: Date.now(), - } - - yield { - type: EventType.RUN_FINISHED, + yield* finishStructuredOutputStream({ + adapterName: this.name, + accumulatedRaw, runId, threadId, model: chatOptions.model, - timestamp: Date.now(), finishReason, - } + }) } catch (error: unknown) { if (!hasEmittedRunStarted) { hasEmittedRunStarted = true @@ -497,10 +532,6 @@ export class BedrockConverseTextAdapter< return false } - // --------------------------------------------------------------------------- - // Request construction - // --------------------------------------------------------------------------- - /** * Translate `TextOptions` into a `ConverseCommandInput`. Shared by chatStream, * structuredOutput, and structuredOutputStream (the latter two override @@ -518,10 +549,6 @@ export class BedrockConverseTextAdapter< ? toToolConfig(convertTools(options.tools), 'auto') : undefined - // Sampling options live on `modelOptions` (typed as the narrowed - // `BedrockConverseProviderOptions`, which surfaces the OpenAI Chat - // Completions field names); translate them into Converse's `inferenceConfig`, - // which uses AWS-native camelCase keys. const modelOptions = options.modelOptions const temperature = modelOptions?.temperature const topP = modelOptions?.top_p @@ -584,11 +611,6 @@ function extractStructuredToolInput( const content: Array = message?.content ?? [] for (const block of content) { if ('toolUse' in block && block.toolUse) { - // Only accept the forced structured tool (an unnamed block is allowed, - // since the forced tool is the only one configured). A differently-named - // tool-use block is a hallucinated/leftover call whose arbitrary input - // must not be returned as the validated result — leave it to the caller's - // `throw` so the failure is accurate instead of silently wrong. if ( block.toolUse.name === STRUCTURED_TOOL_NAME || block.toolUse.name === undefined diff --git a/packages/ai-bedrock/src/adapters/embedding.ts b/packages/ai-bedrock/src/adapters/embedding.ts index e40846cad3..baae405ea6 100644 --- a/packages/ai-bedrock/src/adapters/embedding.ts +++ b/packages/ai-bedrock/src/adapters/embedding.ts @@ -37,17 +37,21 @@ export interface BedrockEmbeddingConfig extends Pick< > {} /** InvokeModel calls issued concurrently during a per-item fan-out. */ -const MAX_CONCURRENT_INVOCATIONS = 5 +const /** InvokeModel calls issued concurrently during a per-item fan-out. */ + MAX_CONCURRENT_INVOCATIONS = 5 /** Valid `dimensions` for `amazon.titan-embed-text-v2:0`. */ -const TITAN_TEXT_DIMENSIONS: ReadonlyArray = [256, 512, 1024] +const /** Valid `dimensions` for `amazon.titan-embed-text-v2:0`. */ + TITAN_TEXT_DIMENSIONS: ReadonlyArray = [256, 512, 1024] /** Valid `dimensions` (outputEmbeddingLength) for `amazon.titan-embed-image-v1`. */ -const TITAN_IMAGE_DIMENSIONS: ReadonlyArray = [256, 384, 1024] +const /** Valid `dimensions` (outputEmbeddingLength) for `amazon.titan-embed-image-v1`. */ + TITAN_IMAGE_DIMENSIONS: ReadonlyArray = [256, 384, 1024] const TITAN_IMAGE_DEFAULT_DIMENSIONS = 1024 /** Cohere embed accepts at most 96 texts per InvokeModel call. */ -const COHERE_MAX_BATCH_SIZE = 96 +const /** Cohere embed accepts at most 96 texts per InvokeModel call. */ + COHERE_MAX_BATCH_SIZE = 96 /** * Bedrock Embedding Adapter @@ -70,10 +74,6 @@ const COHERE_MAX_BATCH_SIZE = 96 */ export class BedrockEmbeddingAdapter< TModel extends BedrockEmbeddingModel, - // Same rationale as the text adapters: the base parameterises - // `TProviderOptions extends object`, and the per-model options interfaces - // lack implicit index signatures — `Record` (not `unknown`) - // accepts them. Confined to the generic constraint; no value cast. TProviderOptions extends Record = ResolveEmbeddingProviderOptions, > extends BaseEmbeddingAdapter< @@ -88,9 +88,6 @@ export class BedrockEmbeddingAdapter< constructor(config: BedrockEmbeddingConfig, model: TModel) { super(model, {}) - // Defer client construction and auth resolution: the AWS SDK is Node/ - // server-only, so we must not pull it into the static graph here. The - // client (and its dynamic import) is built lazily on first SDK call. this.clientConfig = config } @@ -160,10 +157,6 @@ export class BedrockEmbeddingAdapter< } } - // --------------------------------------------------------------------------- - // SDK seam (overridden in tests so no real AWS call happens) - // --------------------------------------------------------------------------- - /** Send one InvokeModel call and parse its JSON response body. */ protected async invokeModel( modelId: string, @@ -182,10 +175,6 @@ export class BedrockEmbeddingAdapter< return JSON.parse(new TextDecoder().decode(response.body)) } - // --------------------------------------------------------------------------- - // Public adapter surface - // --------------------------------------------------------------------------- - async createEmbeddings( options: EmbeddingOptions, ): Promise { @@ -218,10 +207,6 @@ export class BedrockEmbeddingAdapter< } } - // --------------------------------------------------------------------------- - // Per-model request mapping - // --------------------------------------------------------------------------- - /** * `amazon.titan-embed-text-v2:0` — one text per InvokeModel call, fanned * out with a concurrency cap; result order matches input order and per-call @@ -373,10 +358,6 @@ export class BedrockEmbeddingAdapter< } } -// --------------------------------------------------------------------------- -// Response-body narrowing (SDK JSON boundary) -// --------------------------------------------------------------------------- - interface TitanEmbeddingBody { embedding: Array /** 0 when the response omits it (e.g. image-only Titan Multimodal calls). */ @@ -399,6 +380,7 @@ function readTitanEmbeddingBody( `${context}: response body is missing the "embedding" array`, ) } + /** 0 when the response omits it (e.g. image-only Titan Multimodal calls). */ const inputTextTokenCount = isRecord(raw) && typeof raw.inputTextTokenCount === 'number' ? raw.inputTextTokenCount @@ -421,10 +403,6 @@ function readCohereEmbeddingBody( return embeddings } -// --------------------------------------------------------------------------- -// Input mapping helpers -// --------------------------------------------------------------------------- - /** * Map an ImagePart to Titan's `inputImage` (RAW base64, no data: prefix). * Accepts `data` sources as-is and `url` sources ONLY when the value is a @@ -481,10 +459,6 @@ async function mapWithConcurrency( return results } -// --------------------------------------------------------------------------- -// Factories -// --------------------------------------------------------------------------- - /** * Creates a Bedrock embedding adapter with an explicit API key (bearer). * Type resolution happens here at the call site. diff --git a/packages/ai-bedrock/src/adapters/responses-text.ts b/packages/ai-bedrock/src/adapters/responses-text.ts index 41448f006d..6e2292ec86 100644 --- a/packages/ai-bedrock/src/adapters/responses-text.ts +++ b/packages/ai-bedrock/src/adapters/responses-text.ts @@ -28,12 +28,6 @@ type ResolveToolCapabilities = */ export class BedrockResponsesTextAdapter< TModel extends BedrockResponsesModels, - // Constraint mirrors the chat adapter (and ai-groq / ai-openai) and the base, - // which parameterises `TProviderOptions extends Record`. Our - // default `ExternalResponsesProviderOptions` is an interface that (lacking an - // implicit index signature) `Record` would reject but - // `Record` accepts. This `any` is confined to the generic - // constraint — no value/shape `as` cast is introduced. TProviderOptions extends Record = ExternalResponsesProviderOptions, TInputModalities extends ReadonlyArray = diff --git a/packages/ai-bedrock/src/adapters/text.ts b/packages/ai-bedrock/src/adapters/text.ts index 103fde3aef..a4eafd7dd8 100644 --- a/packages/ai-bedrock/src/adapters/text.ts +++ b/packages/ai-bedrock/src/adapters/text.ts @@ -28,13 +28,6 @@ type ResolveToolCapabilities = */ export class BedrockTextAdapter< TModel extends BedrockChatModels, - // Constraint mirrors ai-groq and the base, which parameterises - // `TProviderOptions extends Record`. Our default - // `ResolveProviderOptions` resolves to the `BedrockTextProviderOptions` - // interface, which (lacking an implicit index signature) `Record` would reject but `Record` accepts. This `any` is - // confined to the generic constraint (the established ai-groq pattern) — no - // value/shape `as` cast is introduced. TProviderOptions extends Record = ResolveProviderOptions, TInputModalities extends ReadonlyArray = ResolveInputModalities, @@ -69,14 +62,17 @@ export class BedrockTextAdapter< /** Cast-free narrowing of a Chat Completions chunk's reasoning delta. */ function readDeltaReasoning(chunk: unknown): { text: string } | undefined { - if (typeof chunk !== 'object' || chunk === null || !('choices' in chunk)) - return undefined + if (typeof chunk !== 'object') return undefined + if (chunk === null) return undefined + if (!('choices' in chunk)) return undefined if (!Array.isArray(chunk.choices)) return undefined const choice: unknown = chunk.choices[0] - if (typeof choice !== 'object' || choice === null || !('delta' in choice)) - return undefined + if (typeof choice !== 'object') return undefined + if (choice === null) return undefined + if (!('delta' in choice)) return undefined const delta = choice.delta - if (typeof delta !== 'object' || delta === null) return undefined + if (typeof delta !== 'object') return undefined + if (delta === null) return undefined const raw = 'reasoning' in delta && typeof delta.reasoning === 'string' ? delta.reasoning diff --git a/packages/ai-bedrock/src/converse/message-converter.ts b/packages/ai-bedrock/src/converse/message-converter.ts index 45203a6514..2e64ea020c 100644 --- a/packages/ai-bedrock/src/converse/message-converter.ts +++ b/packages/ai-bedrock/src/converse/message-converter.ts @@ -16,10 +16,6 @@ import type { } from '@aws-sdk/client-bedrock-runtime' import type { DocumentType } from '@smithy/types' -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - function base64ToBytes(b64: string): Uint8Array { return new Uint8Array(Buffer.from(b64, 'base64')) } @@ -75,7 +71,8 @@ function documentFormat( function stringContent( content: string | null | undefined | Array, ): string { - if (content === null || content === undefined) return '' + if (content === null) return '' + if (content === undefined) return '' if (typeof content === 'string') return content return content .filter((p): p is TextPart => p.type === 'text') @@ -180,11 +177,6 @@ function messageToBlocks( } // null → no text blocks - // Append toolUse blocks for assistant tool calls. Malformed or non-object - // arguments come from a prior assistant turn the engine already accepted, so - // they signal a real upstream problem — throw rather than silently coercing to - // `{}` and forwarding a corrupted tool call (the adapter's catch surfaces it - // as a RUN_ERROR). if (msg.role === 'assistant' && msg.toolCalls) { for (const call of msg.toolCalls) { const rawArguments = call.function.arguments || '{}' @@ -218,10 +210,6 @@ function messageToBlocks( return blocks } -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - /** * Convert TanStack AI messages + system prompts into the Converse API format. * @@ -252,9 +240,6 @@ export function toConverseMessages( const blocks = messageToBlocks(msg, docCounter) - // Skip messages that produce no content blocks (e.g. assistant with - // null content and no toolCalls). Pushing an empty-content message to - // Converse triggers a ValidationException. if (blocks.length === 0) continue const last = converseMessages[converseMessages.length - 1] diff --git a/packages/ai-bedrock/src/converse/stream-processor.ts b/packages/ai-bedrock/src/converse/stream-processor.ts index e5d2af9a01..d842f3e7fa 100644 --- a/packages/ai-bedrock/src/converse/stream-processor.ts +++ b/packages/ai-bedrock/src/converse/stream-processor.ts @@ -2,6 +2,30 @@ import { EventType } from '@tanstack/ai' import type { AdapterYieldChunk } from '@tanstack/ai' import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime' +function usageFromMetadata( + ev: ConverseStreamOutput, +): + | { promptTokens: number; completionTokens: number; totalTokens: number } + | undefined { + if (!('metadata' in ev)) return undefined + const u = ev.metadata?.usage + if (!u) return undefined + return { + promptTokens: u.inputTokens ?? 0, + completionTokens: u.outputTokens ?? 0, + totalTokens: u.totalTokens ?? 0, + } +} + +function mapConverseStopReason( + stopReason: string | undefined, +): NonNullable { + if (stopReason === 'tool_use') return 'tool_calls' + if (stopReason === 'max_tokens') return 'length' + if (stopReason === 'content_filtered') return 'content_filter' + return 'stop' +} + /** * Converse delivers server-side failures — throttling, request validation, * mid-stream model faults, and service-unavailable — as in-band stream events @@ -78,17 +102,11 @@ export async function* processConverseStream( let reasoningMessageId: string | undefined let hasClosedReasoning = false - // Tool-call lifecycle, keyed by Converse contentBlockIndex. Converse opens a - // tool-use block with `contentBlockStart`, streams arg fragments via - // `contentBlockDelta`, and closes it with `contentBlockStop`. const toolCallsByIndex = new Map< number, { id: string; name: string; started: boolean } >() - // Usage + finish-reason are captured during iteration and folded into the - // single terminal RUN_FINISHED, matching openai-base's deferred-finish - // contract (usage may arrive after the finish signal). let usage: | { promptTokens: number; completionTokens: number; totalTokens: number } | undefined @@ -121,148 +139,146 @@ export async function* processConverseStream( } } - for await (const ev of stream) { - yield* ensureRunStarted() - - // Surface in-band server/throttle/validation errors instead of dropping them. - throwIfConverseStreamError(ev) + function* handleContentBlockStart( + ev: Extract, + ): Generator { + const start = ev.contentBlockStart + const toolUse = start?.start?.toolUse + if (!start) return + if (!toolUse) return + yield* closeReasoning() + const id = toolUse.toolUseId ?? newMessageId() + const name = toolUse.name ?? '' + const index = start.contentBlockIndex ?? 0 + toolCallsByIndex.set(index, { + id, + name, + started: true, + }) + yield { + type: EventType.TOOL_CALL_START, + toolCallId: id, + toolCallName: name, + toolName: name, + timestamp: Date.now(), + index, + } + } - // messageStart carries only the role; no AG-UI event maps to it. - if ('messageStart' in ev) continue + function* handleContentBlockDelta( + ev: Extract, + ): Generator { + const block = ev.contentBlockDelta + const delta = block?.delta + const index = block?.contentBlockIndex ?? 0 - if ('contentBlockStart' in ev) { - const start = ev.contentBlockStart - const toolUse = start?.start?.toolUse - if (start && toolUse) { - yield* closeReasoning() - const id = toolUse.toolUseId ?? newMessageId() - const name = toolUse.name ?? '' - const index = start.contentBlockIndex ?? 0 - toolCallsByIndex.set(index, { - id, - name, - started: true, - }) + if (delta && 'toolUse' in delta && delta.toolUse?.input !== undefined) { + const toolCall = toolCallsByIndex.get(index) + if (toolCall?.started) { yield { - type: EventType.TOOL_CALL_START, - toolCallId: id, - toolCallName: name, - toolName: name, + type: EventType.TOOL_CALL_ARGS, + toolCallId: toolCall.id, timestamp: Date.now(), - index, + delta: delta.toolUse.input, } } - continue + return } - if ('contentBlockDelta' in ev) { - const block = ev.contentBlockDelta - const delta = block?.delta - const index = block?.contentBlockIndex ?? 0 - - // Tool-call argument fragments (partial JSON). - if (delta && 'toolUse' in delta && delta.toolUse?.input !== undefined) { - const toolCall = toolCallsByIndex.get(index) - if (toolCall?.started) { - yield { - type: EventType.TOOL_CALL_ARGS, - toolCallId: toolCall.id, - timestamp: Date.now(), - delta: delta.toolUse.input, - } - } - continue - } - - // Reasoning content. - if ( - delta && - 'reasoningContent' in delta && - delta.reasoningContent && - 'text' in delta.reasoningContent && - delta.reasoningContent.text !== undefined - ) { - if (!reasoningMessageId) { - reasoningMessageId = newMessageId() - yield { - type: EventType.REASONING_MESSAGE_START, - messageId: reasoningMessageId, - role: 'reasoning', - timestamp: Date.now(), - } - } + if ( + delta && + 'reasoningContent' in delta && + delta.reasoningContent && + 'text' in delta.reasoningContent && + delta.reasoningContent.text !== undefined + ) { + if (!reasoningMessageId) { + reasoningMessageId = newMessageId() yield { - type: EventType.REASONING_MESSAGE_CONTENT, + type: EventType.REASONING_MESSAGE_START, messageId: reasoningMessageId, - delta: delta.reasoningContent.text, + role: 'reasoning', timestamp: Date.now(), } - continue } + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: delta.reasoningContent.text, + timestamp: Date.now(), + } + return + } - // Text content. - if (delta && 'text' in delta && delta.text !== undefined) { - yield* closeReasoning() - if (!hasEmittedTextMessageStart) { - hasEmittedTextMessageStart = true - yield { - type: EventType.TEXT_MESSAGE_START, - messageId, - role: 'assistant', - timestamp: Date.now(), - } - } - accumulatedContent += delta.text + if (delta && 'text' in delta && delta.text !== undefined) { + yield* closeReasoning() + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true yield { - type: EventType.TEXT_MESSAGE_CONTENT, + type: EventType.TEXT_MESSAGE_START, messageId, - delta: delta.text, - content: accumulatedContent, + role: 'assistant', timestamp: Date.now(), } } + accumulatedContent += delta.text + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: delta.text, + content: accumulatedContent, + timestamp: Date.now(), + } + } + } + + function* handleContentBlockStop( + ev: Extract, + ): Generator { + const stopIndex = ev.contentBlockStop?.contentBlockIndex ?? 0 + const toolCall = toolCallsByIndex.get(stopIndex) + if (!toolCall?.started) return + yield { + type: EventType.TOOL_CALL_END, + toolCallId: toolCall.id, + toolCallName: toolCall.name, + toolName: toolCall.name, + timestamp: Date.now(), + } + toolCallsByIndex.delete(stopIndex) + } + + for await (const ev of stream) { + yield* ensureRunStarted() + + // Surface in-band server/throttle/validation errors instead of dropping them. + throwIfConverseStreamError(ev) + + // messageStart carries only the role; no AG-UI event maps to it. + if ('messageStart' in ev) continue + + if ('contentBlockStart' in ev) { + yield* handleContentBlockStart(ev) + continue + } + + if ('contentBlockDelta' in ev) { + yield* handleContentBlockDelta(ev) continue } if ('contentBlockStop' in ev) { - const stopIndex = ev.contentBlockStop?.contentBlockIndex ?? 0 - const toolCall = toolCallsByIndex.get(stopIndex) - if (toolCall?.started) { - yield { - type: EventType.TOOL_CALL_END, - toolCallId: toolCall.id, - toolCallName: toolCall.name, - toolName: toolCall.name, - timestamp: Date.now(), - } - toolCallsByIndex.delete(stopIndex) - } + yield* handleContentBlockStop(ev) continue } if ('messageStop' in ev) { - const stopReason = ev.messageStop?.stopReason - // Map Converse stopReason to AG-UI's narrower finishReason vocabulary. - finishReason = - stopReason === 'tool_use' - ? 'tool_calls' - : stopReason === 'max_tokens' - ? 'length' - : stopReason === 'content_filtered' - ? 'content_filter' - : 'stop' + finishReason = mapConverseStopReason(ev.messageStop?.stopReason) continue } if ('metadata' in ev) { - const u = ev.metadata?.usage - if (u) { - usage = { - promptTokens: u.inputTokens ?? 0, - completionTokens: u.outputTokens ?? 0, - totalTokens: u.totalTokens ?? 0, - } - } + usage = usageFromMetadata(ev) ?? usage continue } } diff --git a/packages/ai-bedrock/src/converse/tool-converter.ts b/packages/ai-bedrock/src/converse/tool-converter.ts index 05367062cb..303063634d 100644 --- a/packages/ai-bedrock/src/converse/tool-converter.ts +++ b/packages/ai-bedrock/src/converse/tool-converter.ts @@ -40,7 +40,8 @@ export function toToolConfig( function mapChoice( choice: ToolChoiceInput | undefined, ): ToolChoice | undefined { - if (!choice || choice === 'auto') return { auto: {} } + if (!choice) return { auto: {} } + if (choice === 'auto') return { auto: {} } if (choice === 'required') return { any: {} } // `none` is handled earlier in toToolConfig (omits the tool config); this // branch keeps the string union narrowed so `choice.name` type-checks. diff --git a/packages/ai-bedrock/src/embedding/embedding-provider-options.ts b/packages/ai-bedrock/src/embedding/embedding-provider-options.ts index e43524c3b2..d99d3d8291 100644 --- a/packages/ai-bedrock/src/embedding/embedding-provider-options.ts +++ b/packages/ai-bedrock/src/embedding/embedding-provider-options.ts @@ -1,13 +1,3 @@ -/** - * Provider options for the Bedrock embedding models. - * - * `dimensions` is deliberately absent from every options shape: it's a - * first-class top-level option on `embed()`. The adapter maps it onto each - * model's native field (Titan Text `dimensions`, Titan Multimodal - * `embeddingConfig.outputEmbeddingLength`) and rejects it for the Cohere - * models, whose output size is fixed. - */ - /** Options for `amazon.titan-embed-text-v2:0`. */ export interface BedrockTitanTextEmbeddingProviderOptions { /** diff --git a/packages/ai-bedrock/src/index.ts b/packages/ai-bedrock/src/index.ts index 8fd85eb9b6..cd5280c6c0 100644 --- a/packages/ai-bedrock/src/index.ts +++ b/packages/ai-bedrock/src/index.ts @@ -1,12 +1,3 @@ -/** - * @module @tanstack/ai-bedrock - * - * Amazon Bedrock adapter for TanStack AI via Bedrock's OpenAI-compatible APIs - * and the native Converse API. The public `bedrockText` / `createBedrockText` - * factory branches between the Converse adapter (DEFAULT), the Chat Completions - * adapter (`api: 'chat'`), and the Responses adapter (`api: 'responses'`). - */ - import { BedrockTextAdapter } from './adapters/text' import { BedrockResponsesTextAdapter } from './adapters/responses-text' import { BedrockConverseTextAdapter } from './adapters/converse-text' diff --git a/packages/ai-bedrock/src/model-meta.ts b/packages/ai-bedrock/src/model-meta.ts index f3ba35fce5..387a20dd38 100644 --- a/packages/ai-bedrock/src/model-meta.ts +++ b/packages/ai-bedrock/src/model-meta.ts @@ -26,8 +26,9 @@ export type BedrockResponsesModels = IdsWhere<'responses'> /** Runtime catalogs. Cast-free narrowing via a type predicate (the ai-bedrock pattern). */ // Every catalog entry advertises `converse: true` (Converse is the universal // Bedrock surface), so the id list is the full catalog — no runtime filter needed. -export const BEDROCK_CONVERSE_MODELS: ReadonlyArray = - GENERATED_BEDROCK_MODELS.map((m) => m.id) +export const /** Runtime catalogs. Cast-free narrowing via a type predicate (the ai-bedrock pattern). */ + BEDROCK_CONVERSE_MODELS: ReadonlyArray = + GENERATED_BEDROCK_MODELS.map((m) => m.id) export const BEDROCK_CHAT_MODELS: ReadonlyArray = GENERATED_BEDROCK_MODELS.filter( @@ -74,10 +75,6 @@ export type ResolveInputModalities = ? BedrockModelInputModalitiesByName[TModel] : readonly ['text'] -// ============================================================================ -// Embedding models -// ============================================================================ - /** * Embedding models reachable through Bedrock's `InvokeModel` API. These are * not part of the generated Converse catalog (embedding models have no diff --git a/packages/ai-bedrock/src/utils/auth.ts b/packages/ai-bedrock/src/utils/auth.ts index babcd647df..6f72f79c77 100644 --- a/packages/ai-bedrock/src/utils/auth.ts +++ b/packages/ai-bedrock/src/utils/auth.ts @@ -20,12 +20,6 @@ export type ResolvedBedrockAuth = const DEFAULT_REGION = 'us-east-1' function readApiKeyFromEnv(): string | undefined { - // Bedrock is server-only (the AWS SDK is Node-only), so the key always comes - // from process.env. Read it directly: a property access never throws, so — - // unlike the previous try/catch around getApiKeyFromEnv — we no longer swallow - // unrelated errors as "key not set". Each var is blank-checked independently - // so a present-but-blank BEDROCK_API_KEY falls through to - // AWS_BEARER_TOKEN_BEDROCK (and only then to SigV4) rather than masking it. const env = typeof process !== 'undefined' ? process.env : undefined for (const value of [env?.BEDROCK_API_KEY, env?.AWS_BEARER_TOKEN_BEDROCK]) { if (value && value.trim() !== '') return value @@ -62,13 +56,6 @@ export function resolveBedrockAuth( kind: 'sigv4', region, service: sigv4Service(endpoint), - // Lazy credential provider: the AWS SDK is Node/server-only, so we defer the - // dynamic import until SigV4 actually needs to resolve credentials. The - // specifier is held in a variable (not a string literal) so bundler dep - // scanners (e.g. Vite/esbuild optimizeDeps) cannot statically discover the - // AWS SDK and try to pre-bundle it for the browser — it would fail on the - // SDK's Node-only `fromTokenFile` export chain. `typeof import(...)` is a - // type-only reference (erased at emit) so we keep full typing. credentials: async (...args) => { const mod = '@aws-sdk/credential-providers' const { fromNodeProviderChain } = (await import( diff --git a/packages/ai-bedrock/src/utils/client.ts b/packages/ai-bedrock/src/utils/client.ts index 75fee20ef3..28c9145dec 100644 --- a/packages/ai-bedrock/src/utils/client.ts +++ b/packages/ai-bedrock/src/utils/client.ts @@ -25,7 +25,8 @@ export interface BedrockClientConfig extends Omit< const DEFAULT_REGION = 'us-east-1' /** OpenAI SDK requires a non-empty apiKey even when a signed fetch overrides Authorization. */ -const SIGV4_PLACEHOLDER_KEY = 'bedrock-sigv4' +const /** OpenAI SDK requires a non-empty apiKey even when a signed fetch overrides Authorization. */ + SIGV4_PLACEHOLDER_KEY = 'bedrock-sigv4' function buildBaseURL(region: string, endpoint: BedrockEndpoint): string { return endpoint === 'mantle' diff --git a/packages/ai-byteplus/src/adapters/image.ts b/packages/ai-byteplus/src/adapters/image.ts index 1680fb1c0c..3855ea2ffb 100644 --- a/packages/ai-byteplus/src/adapters/image.ts +++ b/packages/ai-byteplus/src/adapters/image.ts @@ -173,7 +173,9 @@ export class BytePlusImageAdapter< const resolved = resolveMediaPrompt(options.prompt) - if (resolved.videos.length > 0 || resolved.audios.length > 0) { + const hasUnsupportedMedia = + resolved.videos.length > 0 || resolved.audios.length > 0 + if (hasUnsupportedMedia) { throw new Error( `byteplus.generateImages does not support video / audio prompt parts on model ${model}.`, ) @@ -249,12 +251,6 @@ export class BytePlusImageAdapter< logger: InternalLogger, numberOfImages: number | undefined, ): ImageGenerationResult { - // Shape pinned by a live seedream-4-0-250828 call and the Ark OpenAPI - // document. Validate rather than cast: `readJsonBody` returns `undefined` - // for an empty body and the raw text for a non-JSON one (an HTML error - // page from a proxy in front of the API), and casting either would report - // "returned no images" with the body — the only evidence of what actually - // happened — thrown away. if (typeof body !== 'object' || body === null) { throw bytePlusArkError( 200, @@ -266,11 +262,6 @@ export class BytePlusImageAdapter< const images: Array = [] const failures: Array = [] - // Items matching none of the three known shapes. Ark's OpenAPI document - // describes a second, nested item form, so this is a live possibility - // rather than a defensive branch — and an unrecognized item that is - // neither counted nor reported turns provider drift into an - // "returned no images" with no attribution at all. let unrecognized = 0 for (const item of payload.data ?? []) { if (item.b64_json) { @@ -322,10 +313,6 @@ export class BytePlusImageAdapter< failures, }, ) - // The caller asked for a group and is getting a short array. Warn - // unconditionally: the `numberOfImages` warning below only fires when - // the count was set explicitly, so a partial failure would otherwise - // return successfully with no signal at all. logger.warn( `byteplus: ${failures.length} of ${failures.length + images.length} ` + `images failed to generate; returning ${images.length}.`, diff --git a/packages/ai-byteplus/src/adapters/text.ts b/packages/ai-byteplus/src/adapters/text.ts index bc6fde1360..ab89e5bd88 100644 --- a/packages/ai-byteplus/src/adapters/text.ts +++ b/packages/ai-byteplus/src/adapters/text.ts @@ -55,9 +55,6 @@ type ResolveToolCapabilities = */ export interface BytePlusTextConfig extends BytePlusArkConfig {} -/** - * Re-export of the public provider options type. - */ export type { BytePlusTextProviderOptions } from '../text/text-provider-options' /** @@ -83,10 +80,6 @@ export type { BytePlusTextProviderOptions } from '../text/text-provider-options' */ export class BytePlusTextAdapter< TModel extends (typeof BYTEPLUS_CHAT_MODELS)[number], - // `Record` (not `unknown`) mirrors the OpenAI/Groq/Grok text - // adapters: the resolved provider options are an interface with no index - // signature, assignable to `Record` but not to - // `Record`. See issue #821. TProviderOptions extends Record = ResolveProviderOptions, TInputModalities extends ReadonlyArray = ResolveInputModalities, @@ -158,26 +151,17 @@ export class BytePlusTextAdapter< ): AsyncIterable { const captured: { encryptedContent?: string } = {} - for await (const event of super.processStreamChunks( + const streamChunks = super.processStreamChunks( captureEncryptedContent(stream, captured), options, aguiState, - )) { - if ( + ) + for await (const event of streamChunks) { + const attachSignature = event.type === EventType.STEP_FINISHED && captured.encryptedContent !== undefined && event.signature === undefined - ) { - // `delta` is stamped alongside the signature because the two consumers - // read this event differently. `chat()`'s server agent loop accumulates - // thinking ONLY from `STEP_FINISHED.delta` and then drops the whole - // step — signature included — when the accumulated content is empty - // (`finalizeCurrentThinkingStep`); the OpenAI base emits `content` but - // never `delta`, so without this the blob never reaches the - // continuation message. The client `StreamProcessor` can't double-count - // it: it short-circuits STEP_FINISHED content once - // `hasSeenReasoningEvents` is set, which the REASONING_MESSAGE_CONTENT - // events preceding every STEP_FINISHED here always set. + if (attachSignature) { yield { ...event, signature: captured.encryptedContent, @@ -210,16 +194,15 @@ export class BytePlusTextAdapter< message: ModelMessage, ): ChatCompletionMessageParam { const converted = super.convertMessage(message) - if (converted.role !== 'assistant' || !emitsEncryptedContent(this.model)) { + const skipEncryptedContent = + converted.role !== 'assistant' || !emitsEncryptedContent(this.model) + if (skipEncryptedContent) { return converted } const encryptedContent = lastThinkingSignature(message) if (encryptedContent === undefined) return converted - // Intersection rather than a cast: `encrypted_content` is an Ark-only - // field with no slot on the OpenAI message param, and the intersection is - // still assignable to `ChatCompletionMessageParam`. const withEncrypted: typeof converted & BytePlusEncryptedContentFields = { ...converted, encrypted_content: encryptedContent, @@ -331,9 +314,6 @@ export class BytePlusTextAdapter< ): AsyncIterable { const unsupported = this.structuredOutputUnsupportedMessage() if (unsupported) { - // Mirror the base's contract: failures inside structuredOutputStream - // surface as a RUN_STARTED → RUN_ERROR pair rather than a throw, so - // consumers keep a single error-handling path. const runId = generateId(this.name) yield { type: EventType.RUN_STARTED, @@ -438,7 +418,8 @@ function asChatContentPart( * inline base64 becomes a `data:` URI. */ function toUrlOrDataUri(source: ContentPartSource): string { - if (source.type !== 'data' || source.value.startsWith('data:')) { + const alreadyUri = source.type !== 'data' || source.value.startsWith('data:') + if (alreadyUri) { return source.value } // A missing mimeType would interpolate as "data:undefined;base64,…" and be diff --git a/packages/ai-byteplus/src/adapters/transcription.ts b/packages/ai-byteplus/src/adapters/transcription.ts index 4a57f042ca..1a1e8ebe2f 100644 --- a/packages/ai-byteplus/src/adapters/transcription.ts +++ b/packages/ai-byteplus/src/adapters/transcription.ts @@ -32,7 +32,8 @@ import type { import type { BytePlusTranscriptionProviderOptions } from '../audio/transcription-provider-options' /** Path of the synchronous ("flash") Seed ASR endpoint. */ -const RECOGNIZE_FLASH_PATH = '/api/v3/auc/bigmodel/recognize/flash' +const /** Path of the synchronous ("flash") Seed ASR endpoint. */ + RECOGNIZE_FLASH_PATH = '/api/v3/auc/bigmodel/recognize/flash' /** * BytePlus-specific extension of `TranscriptionWord` carrying the per-word @@ -50,7 +51,8 @@ export interface BytePlusTranscriptionWord extends TranscriptionWord { } /** Default `user.uid` echoed into BytePlus' request logs. */ -const DEFAULT_UID = 'tanstack-ai' +const /** Default `user.uid` echoed into BytePlus' request logs. */ + DEFAULT_UID = 'tanstack-ai' /** * BytePlus Seed Speech transcription (ASR) adapter. @@ -130,9 +132,6 @@ export class BytePlusTranscriptionAdapter< ) } - // The flash endpoint answers with one JSON shape and offers no format - // negotiation, so srt/vtt/text/verbose_json can't be honoured. `segments` - // on the result carry the timings a caller would have wanted from srt/vtt. if (responseFormat !== undefined && responseFormat !== 'json') { logger.warn( `BytePlus Seed ASR always returns JSON — the requested responseFormat "${responseFormat}" is ignored. Build srt/vtt from result.segments if you need them.`, @@ -172,18 +171,12 @@ export class BytePlusTranscriptionAdapter< const data = payload as BytePlusASRRecognizeResponse const text = data.result?.text ?? data.transcript - // The flash endpoint can answer HTTP 200 while carrying the numeric - // error envelope, so an absent transcript is a failure rather than an - // empty result. if (typeof text !== 'string') { throw bytePlusVoiceError(response.status, payload, 'transcription') } - // An empty string is well-formed, so it isn't an error — silence is a - // legitimate transcription. But it is also what a 200-wrapped failure - // looks like, so say so rather than handing back a successful, empty - // result with no signal. - if (text === '' && !hasUtterances(data)) { + const emptyTranscript = text === '' && !hasUtterances(data) + if (emptyTranscript) { logger.warn( `byteplus: transcription returned an empty transcript with no ` + `utterances. This is a valid result for silent audio, and is also ` + @@ -192,9 +185,6 @@ export class BytePlusTranscriptionAdapter< ) } - // Seed ASR doesn't echo the language back, so report the one that was - // actually sent — which is `modelOptions.language` when it overrode the - // cross-provider hint. const requestedLanguage = modelOptions?.language ?? language return { @@ -270,13 +260,9 @@ export function mapRecognizeResponse( const rawWords = utterances.flatMap((utterance) => utterance.words ?? []) const words = rawWords.flatMap((word) => { - if ( - typeof word.text !== 'string' || - typeof word.start_time !== 'number' || - typeof word.end_time !== 'number' - ) { - return [] - } + if (typeof word.text !== 'string') return [] + if (typeof word.start_time !== 'number') return [] + if (typeof word.end_time !== 'number') return [] const mapped: BytePlusTranscriptionWord = { word: word.text, start: msToSeconds(word.start_time), @@ -286,11 +272,6 @@ export function mapRecognizeResponse( return [mapped] }) - // Untimed entries are dropped rather than emitted with NaN timings, but a - // silent drop leaves the caller unable to tell "the provider sent no - // timings" from "the adapter discarded them" — the two have very different - // fixes, and a field rename upstream (e.g. `text` → `word`) would empty - // these arrays without a single error anywhere. const droppedWords = rawWords.length - words.length if (droppedWords > 0) { logger?.warn( @@ -314,10 +295,6 @@ export function mapRecognizeResponse( ? msToSeconds(durationMs) : undefined - // Seed ASR is duration-billed and reports no token counts, so `usage` - // carries only the audio length — the same shape the Grok and OpenAI - // whisper paths use. `durationSeconds` is deprecated but still populated - // alongside the self-describing `billed` pair. const usage: TokenUsage | undefined = duration !== undefined ? { @@ -338,10 +315,6 @@ export function mapRecognizeResponse( } } -/** - * Convert one utterance into a segment, or nothing when it carries no - * timings. The `id` is a placeholder — the caller renumbers after filtering. - */ /** * True when the response carries at least one utterance, in either envelope * form. Used to tell "silent audio" from a 200-wrapped failure: a genuinely @@ -355,12 +328,8 @@ function hasUtterances(data: BytePlusASRRecognizeResponse): boolean { function toSegment( utterance: BytePlusASRUtterance, ): Array { - if ( - typeof utterance.start_time !== 'number' || - typeof utterance.end_time !== 'number' - ) { - return [] - } + if (typeof utterance.start_time !== 'number') return [] + if (typeof utterance.end_time !== 'number') return [] const speaker = utterance.additions?.speaker return [ { @@ -437,10 +406,12 @@ function extensionOf(pathOrName: string): string | undefined { } function formatFromMime(mime: string | undefined): string | undefined { - if (!mime || !mime.startsWith('audio/')) return undefined + if (!mime) return undefined + if (!mime.startsWith('audio/')) return undefined const subtype = mime.slice('audio/'.length).toLowerCase() if (subtype === 'mpeg') return 'mp3' - if (subtype === 'x-wav' || subtype === 'wave') return 'wav' + if (subtype === 'x-wav') return 'wav' + if (subtype === 'wave') return 'wav' return subtype.replace(/^x-/, '') } diff --git a/packages/ai-byteplus/src/adapters/tts.ts b/packages/ai-byteplus/src/adapters/tts.ts index 1c100585ca..f2f39839b0 100644 --- a/packages/ai-byteplus/src/adapters/tts.ts +++ b/packages/ai-byteplus/src/adapters/tts.ts @@ -25,7 +25,8 @@ import type { } from '../audio/tts-provider-options' /** Path of the synchronous Seed Speech synthesis endpoint. */ -const TTS_CREATE_PATH = '/api/v3/tts/create' +const /** Path of the synchronous Seed Speech synthesis endpoint. */ + TTS_CREATE_PATH = '/api/v3/tts/create' /** * Name of the request field carrying the text to speak. @@ -154,9 +155,6 @@ export class BytePlusTTSAdapter< method: 'POST', headers: bytePlusVoiceHeaders(this.apiKey, { ...this.defaultHeaders, - // Client-generated per-request id. BytePlus echoes it in their - // request logs, which is what support asks for when diagnosing a - // synthesis failure. 'X-Api-Request-Id': newRequestId(), }), body: JSON.stringify(body), @@ -171,25 +169,17 @@ export class BytePlusTTSAdapter< const data = payload as BytePlusTTSCreateResponse - // Seed Speech reports status in the body, not only in the HTTP status: - // a 200 can carry a non-zero `code`. Check it before looking at `audio`, - // because a failed call may still return a partial or placeholder - // payload that would otherwise be handed back as if it were valid. - // - // `code` is accepted as a number *or* a string. The success envelope was - // never confirmed against a live key (no voice key yet — see - // `audio/wire-types.ts`), and `readStringField` already tolerates both - // forms when rendering the error, so requiring a number here would let - // `{"code": "45000010"}` through both this gate and the one below. if (!isZeroCode(data.code)) { throw bytePlusVoiceError(response.status, payload, 'text-to-speech') } - // Belt and braces for a 200 that reports success but carries nothing to - // play. Say that the adapter rejected it, rather than reusing the - // envelope-error phrasing — a bare "failed (200)" gives no hint that the - // response was well-formed and simply empty. - if (typeof data.audio !== 'string' || data.audio.length === 0) { + if (typeof data.audio !== 'string') { + throw new Error( + `BytePlus Seed Speech text-to-speech returned a success response ` + + `with no audio (model ${model}).`, + ) + } + if (data.audio.length === 0) { throw new Error( `BytePlus Seed Speech text-to-speech returned a success response ` + `with no audio (model ${model}).`, @@ -248,6 +238,39 @@ export function buildTTSRequestBody(options: { // rates the endpoint accepts, so relying on it is a coin flip. const sampleRate = modelOptions?.sample_rate ?? DEFAULT_SAMPLE_RATE + const audioConfig = buildTTSAudioConfig({ + audioFormat, + sampleRate, + speed, + modelOptions, + logger, + }) + + const body: BytePlusTTSCreateRequest = { + model, + [TTS_TEXT_FIELD]: text, + references: modelOptions?.references ?? [ + { + speaker: modelOptions?.speaker ?? voice ?? BYTEPLUS_DEFAULT_TTS_SPEAKER, + }, + ], + audio_config: audioConfig, + } + if (modelOptions?.watermark !== undefined) { + body.watermark = modelOptions.watermark + } + + return { body, audioFormat, sampleRate } +} + +function buildTTSAudioConfig(options: { + audioFormat: BytePlusTTSAudioFormat + sampleRate: number + speed: number | undefined + modelOptions: BytePlusTTSProviderOptions | undefined + logger: InternalLogger +}): BytePlusTTSAudioConfig { + const { audioFormat, sampleRate, speed, modelOptions, logger } = options const audioConfig: BytePlusTTSAudioConfig = { format: audioFormat, sample_rate: sampleRate, @@ -270,26 +293,7 @@ export function buildTTSRequestBody(options: { if (speechRate !== undefined) { audioConfig.speech_rate = speechRate } - - const body: BytePlusTTSCreateRequest = { - model, - [TTS_TEXT_FIELD]: text, - // The voice belongs inside `references`, not at the top level — a - // top-level `speaker` is silently ignored by the server. The flat member - // shape here is the best-supported reading of the docs; see - // `BytePlusTTSReference` for the unresolved part and the live-probe flag. - references: modelOptions?.references ?? [ - { - speaker: modelOptions?.speaker ?? voice ?? BYTEPLUS_DEFAULT_TTS_SPEAKER, - }, - ], - audio_config: audioConfig, - } - if (modelOptions?.watermark !== undefined) { - body.watermark = modelOptions.watermark - } - - return { body, audioFormat, sampleRate } + return audioConfig } /** @@ -404,9 +408,9 @@ export function toDurationSeconds( raw: number | string | undefined, ): number | undefined { const value = typeof raw === 'string' ? Number(raw) : raw - if (value === undefined || !Number.isFinite(value) || value <= 0) { - return undefined - } + if (value === undefined) return undefined + if (!Number.isFinite(value)) return undefined + if (value <= 0) return undefined return value } diff --git a/packages/ai-byteplus/src/adapters/video.ts b/packages/ai-byteplus/src/adapters/video.ts index 8f827946ac..6ee17efb5f 100644 --- a/packages/ai-byteplus/src/adapters/video.ts +++ b/packages/ai-byteplus/src/adapters/video.ts @@ -57,7 +57,8 @@ import type { BytePlusArkConfig } from '../utils/client' export interface BytePlusVideoConfig extends BytePlusArkConfig {} /** Path of the Seedance task API, relative to the Ark base URL. */ -const TASKS_PATH = '/contents/generations/tasks' +const /** Path of the Seedance task API, relative to the Ark base URL. */ + TASKS_PATH = '/contents/generations/tasks' /** * `content.video_url` and `content.last_frame_url` are deleted 24 hours after @@ -82,6 +83,174 @@ function mediaPartToUrl( return `data:${source.mimeType.toLowerCase()};base64,${source.value}` } +interface BytePlusMediaCounts { + firstFrames: number + lastFrames: number + visualReferences: number + audioReferences: number +} + +function appendBytePlusImageParts( + content: Array, + images: ReturnType['images'], + model: string, + gated: boolean, + counts: BytePlusMediaCounts, +): void { + for (const part of images) { + const role = part.metadata?.role + switch (role) { + case 'mask': + case 'control': + throw new Error( + `byteplus: Seedance has no '${role}' image input on model ${model}. ` + + `Use 'start_frame', 'end_frame' or 'reference'.`, + ) + case 'end_frame': { + if (gated && !supportsLastFrame(model)) { + throw new Error( + `byteplus: ${model} does not support a closing frame — it does ` + + `text-to-video and first-frame image-to-video only. Drop the ` + + `'end_frame' image or switch to a model with first-and-last-frame support.`, + ) + } + counts.lastFrames++ + content.push({ + type: 'image_url', + image_url: { url: mediaPartToUrl(part) }, + role: 'last_frame', + }) + break + } + case 'reference': + case 'character': { + if (gated && !supportsReferenceMedia(model)) { + throw new Error( + `byteplus: ${model} does not support reference images. Reference ` + + `media is available on Seedance 2.5 and the 2.0 family; on this ` + + `model use 'start_frame' / 'end_frame' images instead.`, + ) + } + counts.visualReferences++ + content.push({ + type: 'image_url', + image_url: { url: mediaPartToUrl(part) }, + role: 'reference_image', + }) + break + } + // An un-roled image is the opening frame, matching the API's own + // default and the fal / Veo adapters' positional convention. + case 'start_frame': + case undefined: { + counts.firstFrames++ + content.push({ + type: 'image_url', + image_url: { url: mediaPartToUrl(part) }, + role: 'first_frame', + }) + break + } + } + } +} + +function appendBytePlusVideoParts( + content: Array, + videos: ReturnType['videos'], + model: string, + gated: boolean, + counts: BytePlusMediaCounts, +): void { + for (const part of videos) { + if (gated && !supportsReferenceMedia(model)) { + throw new Error( + `byteplus: ${model} does not accept video prompt parts. Reference ` + + `video is available on Seedance 2.5 and the 2.0 family only.`, + ) + } + counts.visualReferences++ + content.push({ + type: 'video_url', + video_url: { url: mediaPartToUrl(part) }, + role: 'reference_video', + }) + } +} + +function appendBytePlusAudioParts( + content: Array, + audios: ReturnType['audios'], + model: string, + gated: boolean, + counts: BytePlusMediaCounts, +): void { + for (const part of audios) { + if (gated && !supportsReferenceMedia(model)) { + throw new Error( + `byteplus: ${model} does not accept audio prompt parts. Reference ` + + `audio is available on Seedance 2.5 and the 2.0 family only.`, + ) + } + counts.audioReferences++ + content.push({ + type: 'audio_url', + audio_url: { url: mediaPartToUrl(part) }, + role: 'reference_audio', + }) + } +} + +function assertBytePlusModeRules( + model: string, + gated: boolean, + counts: BytePlusMediaCounts, +): void { + if (!gated) return + const { firstFrames, lastFrames, visualReferences, audioReferences } = counts + const mixesFramesAndReferences = + firstFrames + lastFrames > 0 && visualReferences + audioReferences > 0 + if (mixesFramesAndReferences) { + throw new Error( + `byteplus: first/last frame inputs cannot be combined with reference ` + + `media on model ${model}. Use either frame roles ('start_frame', ` + + `'end_frame') or reference roles ('reference', 'character', video, ` + + `audio) — not both.`, + ) + } + if (firstFrames > 1) { + throw new Error( + `byteplus: ${model} accepts at most one opening frame; received ` + + `${firstFrames} un-roled or 'start_frame' images. Use metadata.role ` + + `('end_frame', 'reference') to disambiguate the others.`, + ) + } + if (lastFrames > 1) { + throw new Error( + `byteplus: ${model} accepts at most one closing frame; received ` + + `${lastFrames} 'end_frame' images.`, + ) + } + const closingWithoutOpening = lastFrames > 0 && firstFrames === 0 + if (closingWithoutOpening) { + throw new Error( + `byteplus: a closing frame needs an opening frame alongside it on ` + + `model ${model}. Add a 'start_frame' image, or drop the 'end_frame' role.`, + ) + } + if ( + audioReferences > 0 && + visualReferences === 0 && + !supportsAudioOnlyReference(model) + ) { + throw new Error( + `byteplus: a reference audio input cannot be the only reference on ` + + `model ${model}. Pair it with a reference image or video, or use ` + + `Seedance 2.5 which accepts audio-only reference input.`, + ) + } +} + /** Coerces a usage count that the API types as a string but sends as a number. */ function toTokenCount(value: number | string | undefined): number | undefined { if (typeof value === 'number') return value @@ -108,7 +277,9 @@ function buildBytePlusVideoUsage( const completionTokens = toTokenCount(usage.completion_tokens) const totalTokens = toTokenCount(usage.total_tokens) - if (completionTokens === undefined && totalTokens === undefined) { + const missingTokenCounts = + completionTokens === undefined && totalTokens === undefined + if (missingTokenCounts) { return undefined } @@ -236,159 +407,17 @@ export class BytePlusVideoAdapter< const content: Array = [] if (resolved.text) content.push({ type: 'text', text: resolved.text }) - // Every rule below except the role vocabulary itself is a claim about a - // *specific* model's capabilities, drawn from the known Seedance catalog. - // None of it can be true of a model that does not exist yet, so for an - // unknown id the guards stand down and Ark rules — otherwise the escape - // hatch would block exactly the requests it exists to enable (see - // BytePlusVideoModelOrString). 'mask' / 'control' still throw: Seedance's - // wire format has no field to carry them on any model. const gated = isKnownBytePlusVideoModel(model) - - let firstFrames = 0 - let lastFrames = 0 - // Audio counts as a reference for the mode-exclusivity check. On Seedance - // 2.0 it also needs a visual reference; 2.5 allows audio-only. - let visualReferences = 0 - let audioReferences = 0 - - for (const part of resolved.images) { - const role = part.metadata?.role - switch (role) { - case 'mask': - case 'control': - throw new Error( - `byteplus: Seedance has no '${role}' image input on model ${model}. ` + - `Use 'start_frame', 'end_frame' or 'reference'.`, - ) - case 'end_frame': { - if (gated && !supportsLastFrame(model)) { - throw new Error( - `byteplus: ${model} does not support a closing frame — it does ` + - `text-to-video and first-frame image-to-video only. Drop the ` + - `'end_frame' image or switch to a model with first-and-last-frame support.`, - ) - } - lastFrames++ - content.push({ - type: 'image_url', - image_url: { url: mediaPartToUrl(part) }, - role: 'last_frame', - }) - break - } - case 'reference': - case 'character': { - if (gated && !supportsReferenceMedia(model)) { - throw new Error( - `byteplus: ${model} does not support reference images. Reference ` + - `media is available on Seedance 2.5 and the 2.0 family; on this ` + - `model use 'start_frame' / 'end_frame' images instead.`, - ) - } - visualReferences++ - content.push({ - type: 'image_url', - image_url: { url: mediaPartToUrl(part) }, - role: 'reference_image', - }) - break - } - // An un-roled image is the opening frame, matching the API's own - // default and the fal / Veo adapters' positional convention. - case 'start_frame': - case undefined: { - firstFrames++ - content.push({ - type: 'image_url', - image_url: { url: mediaPartToUrl(part) }, - role: 'first_frame', - }) - break - } - } - } - - // Video and audio parts only exist in reference mode: Seedance rejects an - // un-roled video with "reference media mode requires video role to be - // reference_video", and has no frame-style role for either modality. - for (const part of resolved.videos) { - if (gated && !supportsReferenceMedia(model)) { - throw new Error( - `byteplus: ${model} does not accept video prompt parts. Reference ` + - `video is available on Seedance 2.5 and the 2.0 family only.`, - ) - } - visualReferences++ - content.push({ - type: 'video_url', - video_url: { url: mediaPartToUrl(part) }, - role: 'reference_video', - }) - } - - for (const part of resolved.audios) { - if (gated && !supportsReferenceMedia(model)) { - throw new Error( - `byteplus: ${model} does not accept audio prompt parts. Reference ` + - `audio is available on Seedance 2.5 and the 2.0 family only.`, - ) - } - audioReferences++ - content.push({ - type: 'audio_url', - audio_url: { url: mediaPartToUrl(part) }, - role: 'reference_audio', - }) - } - - const frames = firstFrames + lastFrames - if (gated && frames > 0 && visualReferences + audioReferences > 0) { - throw new Error( - `byteplus: first/last frame inputs cannot be combined with reference ` + - `media on model ${model}. Use either frame roles ('start_frame', ` + - `'end_frame') or reference roles ('reference', 'character', video, ` + - `audio) — not both.`, - ) - } - - if (gated && firstFrames > 1) { - throw new Error( - `byteplus: ${model} accepts at most one opening frame; received ` + - `${firstFrames} un-roled or 'start_frame' images. Use metadata.role ` + - `('end_frame', 'reference') to disambiguate the others.`, - ) - } - - if (gated && lastFrames > 1) { - throw new Error( - `byteplus: ${model} accepts at most one closing frame; received ` + - `${lastFrames} 'end_frame' images.`, - ) - } - - // Seedance treats a closing frame as the second half of first-and-last- - // frame mode: on its own it fails with "last frame image content cannot be - // mixed with first frame or reference image content". - if (gated && lastFrames > 0 && firstFrames === 0) { - throw new Error( - `byteplus: a closing frame needs an opening frame alongside it on ` + - `model ${model}. Add a 'start_frame' image, or drop the 'end_frame' role.`, - ) - } - - if ( - gated && - audioReferences > 0 && - visualReferences === 0 && - !supportsAudioOnlyReference(model) - ) { - throw new Error( - `byteplus: a reference audio input cannot be the only reference on ` + - `model ${model}. Pair it with a reference image or video, or use ` + - `Seedance 2.5 which accepts audio-only reference input.`, - ) + const counts = { + firstFrames: 0, + lastFrames: 0, + visualReferences: 0, + audioReferences: 0, } + appendBytePlusImageParts(content, resolved.images, model, gated, counts) + appendBytePlusVideoParts(content, resolved.videos, model, gated, counts) + appendBytePlusAudioParts(content, resolved.audios, model, gated, counts) + assertBytePlusModeRules(model, gated, counts) if (content.length === 0) { throw new Error( @@ -417,14 +446,6 @@ export class BytePlusVideoAdapter< const parsedSize = size !== undefined ? resolveBytePlusVideoSize(model, size) : undefined - // Coerce the requested duration into the model's range rather than letting - // the API reject it. `modelOptions.duration` is deliberately not snapped: - // it is the escape hatch for `-1` (model picks the length). - // - // An unknown model's duration goes through verbatim. Snapping it would - // mean clamping against the ranges today's models happen to have, so a - // future model's legitimate 20-second request would silently become 15 — - // corrupting the request instead of protecting it. const duration = options.duration !== undefined ? isKnownBytePlusVideoModel(model) @@ -446,9 +467,6 @@ export class BytePlusVideoAdapter< content, } - // Validate what actually ships, not just what `size` contributed: a - // `modelOptions.resolution` overriding an already-checked size would - // otherwise reach Ark unchecked. if (request.resolution !== undefined) { request.resolution = resolveBytePlusVideoResolution( model, @@ -520,10 +538,6 @@ export class BytePlusVideoAdapter< try { task = await this.retrieveTask(jobId) } catch (error) { - // A task record lives 7 days from creation; past that the id 404s. Keep - // Ark's own code/message: a 404 from a wrong baseURL, a proxy, or a - // region mismatch is not an expired job id, and collapsing them all to - // "Job not found" sends the caller hunting the wrong thing. if ((error as { status?: number }).status === 404) { return { jobId, @@ -572,13 +586,6 @@ export class BytePlusVideoAdapter< ) } - // The 24-hour window runs from when the output was produced, which is the - // last status change on a succeeded task — `created_at` anchors the - // separate 7-day retention of the task record itself, and can be far - // earlier (a live `flex` task sat queued ~15 minutes). Corroborated by the - // signed TOS link itself, which carries `X-Tos-Expires=86400` from an - // `X-Tos-Date` matching `updated_at`. Fall back to `created_at` only when - // `updated_at` is missing. const anchorSeconds = task.updated_at ?? task.created_at const expiresAt = anchorSeconds !== undefined diff --git a/packages/ai-byteplus/src/audio/wire-types.ts b/packages/ai-byteplus/src/audio/wire-types.ts index 04d1fc29f4..8a00b5d70f 100644 --- a/packages/ai-byteplus/src/audio/wire-types.ts +++ b/packages/ai-byteplus/src/audio/wire-types.ts @@ -1,34 +1,3 @@ -/** - * Minimal wire types for the BytePlus **Seed Speech** HTTP API (TTS + ASR). - * - * Seed Speech is a separate product from Ark: it lives on - * `voice.ap-southeast-1.bytepluses.com`, authenticates with `X-Api-Key` - * (a different key from `ARK_API_KEY`), and returns a flat numeric error - * envelope instead of Ark's OpenAI-shaped one. - * - * Only the fields the adapters read or write are modelled here — this is a - * hand-written subset, not a generated schema. - * - * Provenance: - * - Endpoints, auth header, format/rate ranges and the 120 s TTS output cap: - * BytePlus Seed Speech docs (`docs.byteplus.com/en/docs/byteplusvoice`), - * captured in the Phase 0 research notes. - * - Error envelope `{code, message}`: verified live — an Ark key sent as - * `X-Api-Key` returns HTTP 401 `{"code":45000010,"message":"Invalid X-Api-Key"}`. - * - ASR request/response shape (`user`/`audio`/`request` in, `audio_info` + - * `result.utterances` out, all timings in **milliseconds**): the Volcengine - * flash-recognition reference the BytePlus endpoint is derived from - * (`docs.volcengine.com/docs/6561/1631584`). - * - * No Seed Speech API key was available when these were written, so the TTS - * response fields are documented-but-unverified; the adapters parse them - * defensively rather than assuming they are always present. - */ - -// ============================================================================ -// TTS — POST /api/v3/tts/create -// ============================================================================ - /** Output container/codec accepted by `audio_config.format`. */ export type BytePlusTTSAudioFormat = 'wav' | 'mp3' | 'pcm' | 'ogg_opus' @@ -179,10 +148,6 @@ export interface BytePlusTTSCreateResponse { subtitle?: BytePlusTTSSubtitle } -// ============================================================================ -// ASR — POST /api/v3/auc/bigmodel/recognize/flash -// ============================================================================ - /** * Value of the `X-Api-Resource-Id` header that selects the Seed ASR turbo * model. The flash endpoint takes no `model` field in its body — the model is @@ -191,7 +156,8 @@ export interface BytePlusTTSCreateResponse { export const BYTEPLUS_ASR_RESOURCE_ID = 'volc.seedasr.auc_turbo' /** Header name carrying {@link BYTEPLUS_ASR_RESOURCE_ID}. */ -export const BYTEPLUS_ASR_RESOURCE_HEADER = 'X-Api-Resource-Id' +export const /** Header name carrying {@link BYTEPLUS_ASR_RESOURCE_ID}. */ + BYTEPLUS_ASR_RESOURCE_HEADER = 'X-Api-Resource-Id' /** * Audio input. Exactly one of `url` or `data` is sent — the endpoint accepts @@ -227,6 +193,7 @@ export interface BytePlusASRRequestOptions { /** Request body for `POST /api/v3/auc/bigmodel/recognize/flash`. */ export interface BytePlusASRRecognizeRequest { user?: { uid?: string } + /** Base64-encoded audio in the requested `audio_config.format`. */ audio: BytePlusASRAudio request?: BytePlusASRRequestOptions } @@ -255,6 +222,7 @@ export interface BytePlusASRUtterance { export interface BytePlusASRResult { text?: string + /** Flat alias for `result.utterances`. */ utterances?: Array } @@ -275,10 +243,6 @@ export interface BytePlusASRRecognizeResponse { utterances?: Array } -// ============================================================================ -// Errors -// ============================================================================ - /** * Seed Speech error envelope: a flat numeric `code` plus a `message`, e.g. * `{"code": 45000010, "message": "Invalid X-Api-Key"}` (verified live on a diff --git a/packages/ai-byteplus/src/byok.ts b/packages/ai-byteplus/src/byok.ts index 38962ab2f9..bba74a73f3 100644 --- a/packages/ai-byteplus/src/byok.ts +++ b/packages/ai-byteplus/src/byok.ts @@ -7,8 +7,9 @@ export const byteplusByok = defineByokProvider({ }) /** Seed Speech TTS/ASR. Different product and key from {@link byteplusByok}. */ -export const byteplusVoiceByok = defineByokProvider({ - id: 'byteplus-voice', - label: 'BytePlus Seed Speech', - env: 'BYTEPLUS_VOICE_API_KEY', -}) +export const /** Seed Speech TTS/ASR. Different product and key from {@link byteplusByok}. */ + byteplusVoiceByok = defineByokProvider({ + id: 'byteplus-voice', + label: 'BytePlus Seed Speech', + env: 'BYTEPLUS_VOICE_API_KEY', + }) diff --git a/packages/ai-byteplus/src/image/image-provider-options.ts b/packages/ai-byteplus/src/image/image-provider-options.ts index d3c68a6b0a..91ef1eb4b5 100644 --- a/packages/ai-byteplus/src/image/image-provider-options.ts +++ b/packages/ai-byteplus/src/image/image-provider-options.ts @@ -1,10 +1,3 @@ -/** - * Provider options and request validation for Seedream image generation. - * - * Field names, enums, defaults and ranges come from the harvested Ark - * OpenAPI document for the `ImageGenerations` action; the Seedream 4.0 - * behaviour noted below was confirmed live on 2026-07-31. - */ import { BYTEPLUS_IMAGE_MAX_REFERENCE_IMAGES } from '../model-meta' import type { BytePlusImageOutputFormat, @@ -160,7 +153,8 @@ export function parseBytePlusImageSize( if (pixels) { const width = Number(pixels[1]) const height = Number(pixels[2]) - if (width > 0 && height > 0) return { kind: 'pixels', width, height } + const hasPositivePixels = width > 0 && height > 0 + if (hasPositivePixels) return { kind: 'pixels', width, height } } return undefined @@ -264,6 +258,7 @@ export function resolveBytePlusSequentialImages( numberOfImages: number | undefined, ): { sequential_image_generation?: BytePlusSequentialImageGeneration + /** Bounds for group-image mode. Only read when the mode is `auto`. */ sequential_image_generation_options?: BytePlusSequentialImageGenerationOptions } { if (numberOfImages === undefined) return {} diff --git a/packages/ai-byteplus/src/image/wire-types.ts b/packages/ai-byteplus/src/image/wire-types.ts index 6dc307b95e..0b956e1f44 100644 --- a/packages/ai-byteplus/src/image/wire-types.ts +++ b/packages/ai-byteplus/src/image/wire-types.ts @@ -1,22 +1,3 @@ -/** - * Wire types for the BytePlus Ark image endpoint (`POST /images/generations`). - * - * Hand-written minimal shapes covering only the fields this adapter sends and - * reads. Provenance for every field is noted inline. Two sources: - * - * 1. The harvested OpenAPI 3.1 document for the `ark` service, action - * `ImageGenerations` (`x-updated-time: 2026-06-08`) — authoritative for - * field names, enum values, defaults and ranges. - * 2. A live `seedream-4-0-250828` call against - * `https://ark.ap-southeast.bytepluses.com/api/v3` on 2026-07-31, which - * pinned the actual response shape. - * - * The endpoint deviates from OpenAI's `/images/generations` in three ways that - * matter: there is no `n` parameter, `size` accepts a shorthand token as well - * as `WxH`, and input images for editing ride along in a top-level `image` - * field rather than a separate `/images/edits` endpoint. - */ - /** How generated images come back. `url` links expire 24 hours after generation. */ export type BytePlusImageResponseFormat = 'url' | 'b64_json' @@ -132,6 +113,7 @@ export interface BytePlusImageData { url?: string b64_json?: string size?: string + /** Present when the request as a whole failed. */ error?: BytePlusImageErrorObject } @@ -159,6 +141,7 @@ export interface BytePlusImageErrorObject { /** Response body of `POST /images/generations`. */ export interface BytePlusImageGenerationResponse { + /** Seedream model id (or a preconfigured endpoint id). */ model?: string /** Unix timestamp (seconds) of creation. */ created?: number diff --git a/packages/ai-byteplus/src/index.ts b/packages/ai-byteplus/src/index.ts index 0a752d6e1a..6411b22d98 100644 --- a/packages/ai-byteplus/src/index.ts +++ b/packages/ai-byteplus/src/index.ts @@ -1,16 +1,3 @@ -// ============================================================================ -// Adapters -// ============================================================================ -// -// Tree-shakeable adapters live in ./adapters and are re-exported here, one -// block per generation kind: -// -// - text → ./adapters/text (Seed chat models on Ark) -// - video → ./adapters/video (Seedance task API) -// - image → ./adapters/image (Seedream) -// - speech → ./adapters/tts (Seed Speech TTS) -// - transcription → ./adapters/transcription (Seed Speech ASR) -// export { BytePlusVideoAdapter, byteplusVideo, @@ -142,10 +129,6 @@ export type { BytePlusVideoUrlContentPart, } from './message-types' -// ============================================================================ -// Client configuration -// ============================================================================ - export { BYTEPLUS_ARK_BASE_URL, BYTEPLUS_VOICE_BASE_URL, @@ -160,10 +143,6 @@ export { } from './utils/client' export type { BytePlusArkConfig, BytePlusVoiceConfig } from './utils/client' -// ============================================================================ -// Provider options -// ============================================================================ - export type { BytePlusNamedToolChoice, BytePlusReasoningEffort, @@ -173,10 +152,6 @@ export type { BytePlusToolChoice, } from './text/text-provider-options' -// ============================================================================ -// Model metadata -// ============================================================================ - export { BYTEPLUS_CHAT_MODELS, BYTEPLUS_IMAGE_MAX_REFERENCE_IMAGES, diff --git a/packages/ai-byteplus/src/message-types.ts b/packages/ai-byteplus/src/message-types.ts index 69ba690b63..6340af6c64 100644 --- a/packages/ai-byteplus/src/message-types.ts +++ b/packages/ai-byteplus/src/message-types.ts @@ -1,20 +1,3 @@ -/** - * BytePlus ModelArk chat message types. - * - * Ark's `/chat/completions` wire format is OpenAI Chat Completions plus a few - * Ark-only extensions, so the OpenAI SDK types (via `@tanstack/openai-base`) - * cover everything except the fields below. This file is the source of truth - * for the Ark-only parts of a chat message: - * - * - `encrypted_content` on the assistant message (thinking-summary models) - * - `video_url` content parts (no OpenAI equivalent) - * - `input_audio` accepting a `url` as well as inline base64 - * - * Field shapes verified live against - * `https://ark.ap-southeast.bytepluses.com/api/v3` on 2026-07-31 — see the - * probe findings referenced from `model-meta.ts`. - */ - /** * Opaque signature blob emitted alongside `reasoning_content` by the * thinking-summary models (see `BYTEPLUS_THINKING_SUMMARY_MODELS`). @@ -58,8 +41,19 @@ export interface BytePlusImagePixelLimit { export interface BytePlusImageUrlContentPart { type: 'image_url' image_url: { + /** Public audio URL. Mutually exclusive with `data`. */ url: string + /** + * Processing detail for the image. Ark adds `xhigh` to OpenAI's set. + * + * @default 'auto' + */ detail?: 'auto' | 'low' | 'high' | 'xhigh' + /** + * Bounds the pixel count the image is scaled to before tokenization — + * see {@link BytePlusImagePixelLimit}. Lower `max_pixels` trades detail for + * input tokens. + */ image_pixel_limit?: BytePlusImagePixelLimit } } @@ -116,18 +110,8 @@ export interface BytePlusTextMetadata {} * Metadata for BytePlus image content parts. */ export interface BytePlusImageMetadata { - /** - * Processing detail for the image. Ark adds `xhigh` to OpenAI's set. - * - * @default 'auto' - */ detail?: 'auto' | 'low' | 'high' | 'xhigh' - /** - * Bounds the pixel count the image is scaled to before tokenization — - * see {@link BytePlusImagePixelLimit}. Lower `max_pixels` trades detail for - * input tokens. - */ image_pixel_limit?: BytePlusImagePixelLimit } @@ -135,10 +119,7 @@ export interface BytePlusImageMetadata { * Metadata for BytePlus audio content parts. */ export interface BytePlusAudioMetadata { - /** - * Container format for inline base64 audio. Inferred from the part's - * `mimeType` when omitted. - */ + /** Container format of `data`. Required whenever `data` is set. */ format?: BytePlusInputAudioContentPart['input_audio']['format'] } diff --git a/packages/ai-byteplus/src/model-meta.ts b/packages/ai-byteplus/src/model-meta.ts index 9f1a126bc3..d784c83d35 100644 --- a/packages/ai-byteplus/src/model-meta.ts +++ b/packages/ai-byteplus/src/model-meta.ts @@ -1,24 +1,3 @@ -/** - * BytePlus ModelArk model metadata. - * - * Every Ark model id in this file — chat, video and image — was verified live - * against `https://ark.ap-southeast.bytepluses.com/api/v3` on 2026-07-31. The - * two Seed Speech ids are the exception: they live on the voice host, which - * needs a separate key that was not available, so they are docs-derived. - * Capability metadata is a mix of probed and docs-derived facts; anything not - * confirmed against the live API is annotated as such at its declaration. - * BytePlus - * deactivates model ids aggressively (the whole `seedance-1-0-lite-*` family, - * `seed-1-6-lite-*`, `seedream-3-0-*`, and the `doubao-`/`skylark-` names are - * all 404s internationally), so only dated, probe-confirmed ids are shipped. - * - * Prefix rules, also probe-confirmed: - * - `dola-seed-2-1-turbo-260628` and `dola-seedream-5-0-pro-260628` are the - * canonical ids; the bare forms resolve as aliases but the API echoes the - * prefixed id back. - * - The Seedance 2.0 family *requires* the `dreamina-` prefix. - * - Older models reject the `dola-` prefix outright. - */ import type { DurationOptions } from '@tanstack/ai/adapters' import type { BytePlusTextProviderOptions } from './text/text-provider-options' @@ -48,10 +27,6 @@ interface ModelMeta { max_output_tokens?: number } -// ============================================================================ -// Chat models (Seed / GLM / DeepSeek / gpt-oss on Ark) -// ============================================================================ - const DOLA_SEED_2_1_TURBO = { name: 'dola-seed-2-1-turbo-260628', context_window: 256_000, @@ -235,10 +210,6 @@ const GLM_4_7_251222 = { supports: { input: ['text'], output: ['text'], - // Adherence-probed 2026-07-31: ACCEPTS a json_schema with 200 but ignores - // it and answers in prose, so it is not a structured-output model. A - // status-code-only probe reads this as support — see the note on - // BYTEPLUS_STRUCTURED_OUTPUT_CHAT_MODELS. capabilities: ['reasoning', 'tool_calling'], tools: [] as const, }, @@ -468,10 +439,6 @@ export type BytePlusChatModelProviderOptionsByName = { [K in BytePlusChatModel]: BytePlusTextProviderOptions } -// ============================================================================ -// Video models (Seedance, async task API) -// ============================================================================ - /** * Output aspect ratios accepted by the Seedance task API. `adaptive` is only * meaningful for image-to-video, where the ratio follows the input frame. @@ -509,10 +476,6 @@ export type BytePlusVideoSize< TResolution extends BytePlusVideoResolution = BytePlusVideoResolution, > = BytePlusVideoRatio | `${BytePlusVideoRatio}_${TResolution}` -// Multimodal reference-media capabilities (reference images / video / audio) -// are docs-derived from the ModelArk create-task page. Model ids and the -// resolution / duration tables for 2.0 were also live-probed on 2026-07-31; -// 2.5 lands from the public docs once the model was fully opened (2026-08-07). const DREAMINA_SEEDANCE_2_5 = { name: 'dreamina-seedance-2-5-260628', supports: { @@ -795,10 +758,6 @@ export function getBytePlusVideoDurationOptions( : BYTEPLUS_VIDEO_FALLBACK_DURATIONS } -// ============================================================================ -// Image models (Seedream) -// ============================================================================ - /** * Shorthand size tokens accepted by `/images/generations`. A request uses * either a token or an explicit `WxH` string — never both. @@ -893,10 +852,6 @@ export const BYTEPLUS_IMAGE_MAX_REFERENCE_IMAGES: { 'seedream-4-0-250828': 14, } -// ============================================================================ -// Seed Speech models (voice host — separate product and API key) -// ============================================================================ - const SEED_AUDIO_1_0 = { name: 'seed-audio-1.0', supports: { @@ -905,11 +860,6 @@ const SEED_AUDIO_1_0 = { }, } as const satisfies ModelMeta -// Seed Speech ASR is endpoint-addressed: `POST /api/v3/auc/bigmodel/recognize/ -// flash` selects the model through the `X-Api-Resource-Id` header -// (`volc.seedasr.auc_turbo`) and takes no `model` field in the body. This -// synthetic identifier satisfies the SDK's `TranscriptionOptions.model` -// contract and gives logging and fixture matching a stable value. const SEED_ASR = { name: 'seed-asr', supports: { @@ -942,10 +892,6 @@ export type BytePlusTTSModel = (typeof BYTEPLUS_TTS_MODELS)[number] export type BytePlusTranscriptionModel = (typeof BYTEPLUS_TRANSCRIPTION_MODELS)[number] -// ============================================================================ -// Type resolution helpers -// ============================================================================ - /** * Resolve provider options for a specific model. Models listed in the chat * map get their explicit options; anything else falls back to the base chat diff --git a/packages/ai-byteplus/src/text/text-provider-options.ts b/packages/ai-byteplus/src/text/text-provider-options.ts index f4e77ebb97..45733775ef 100644 --- a/packages/ai-byteplus/src/text/text-provider-options.ts +++ b/packages/ai-byteplus/src/text/text-provider-options.ts @@ -1,22 +1,3 @@ -/** - * BytePlus ModelArk chat provider options. - * - * Ark's `/chat/completions` is OpenAI-compatible, so most of this is the - * familiar sampling surface; `thinking`, `reasoning_effort`, - * `repetition_penalty` and `service_tier` are the Ark-only additions. - * - * Every field below was accepted by a live request against - * `https://ark.ap-southeast.bytepluses.com/api/v3` on 2026-07-31. Two probe - * results are encoded as TSDoc warnings rather than types because they are - * cross-field constraints TypeScript can't express: `max_tokens` and - * `max_completion_tokens` are mutually exclusive, and `reasoning_effort` - * combined with `thinking: {type: 'disabled'}` is a 400. - * - * `response_format` is deliberately absent — the chat activity owns it via - * `outputSchema` / structured output, and Ark rejects `json_object` outright - * on every model. - */ - /** * Reasoning ("deep thinking") switch. * @@ -73,10 +54,6 @@ export type BytePlusToolChoice = * Provider options for BytePlus chat models. */ export interface BytePlusTextProviderOptions { - // -------------------------------------------------------------------- - // Ark-only - // -------------------------------------------------------------------- - /** Reasoning switch — see {@link BytePlusThinkingOption}. */ thinking?: BytePlusThinkingOption @@ -91,10 +68,6 @@ export interface BytePlusTextProviderOptions { /** Request routing tier — see {@link BytePlusServiceTier}. */ service_tier?: BytePlusServiceTier - // -------------------------------------------------------------------- - // OpenAI-compatible sampling surface - // -------------------------------------------------------------------- - /** Sampling temperature. Higher values produce more varied output. */ temperature?: number diff --git a/packages/ai-byteplus/src/utils/client.ts b/packages/ai-byteplus/src/utils/client.ts index 164756f930..cfb3bab7d6 100644 --- a/packages/ai-byteplus/src/utils/client.ts +++ b/packages/ai-byteplus/src/utils/client.ts @@ -1,19 +1,6 @@ import { getApiKeyFromEnv } from '@tanstack/ai-utils' import type { ClientOptions } from 'openai' -/** - * BytePlus splits its APIs across two hosts with two different products, - * two different auth headers, and two different API keys: - * - * - **Ark (ModelArk)** — chat, video (Seedance) and image (Seedream). - * `Authorization: Bearer $ARK_API_KEY`. - * - **Seed Speech** — TTS and ASR on the voice host. - * `X-Api-Key: $BYTEPLUS_VOICE_API_KEY`. - * - * Ark keys are region-isolated: a key issued for `ap-southeast` does not work - * against the EU host and vice versa. - */ - /** * Default Ark data-plane base URL (Asia-Pacific south-east). * @@ -51,6 +38,7 @@ export const BYTEPLUS_VOICE_BASE_URL = * which owns its own retry policy. */ export interface BytePlusArkConfig extends Omit { + /** Seed Speech API key — *not* the Ark key. Sent as `X-Api-Key`. */ apiKey: string } @@ -126,7 +114,10 @@ export function getBytePlusVoiceApiKeyFromEnv(): string { */ export function withBytePlusArkDefaults( config: TConfig, -): Omit & { baseURL: string } { +): Omit & { + /** Overrides {@link BYTEPLUS_VOICE_BASE_URL}. */ + baseURL: string +} { return { ...config, baseURL: (config.baseURL || BYTEPLUS_ARK_BASE_URL).replace(/\/+$/, ''), @@ -184,10 +175,8 @@ export function toHeaderRecord( return record } - // Record form. A value may be null/undefined (openai's "unset this header" - // signal) or an array for a repeated header; neither maps onto a single - // string, so both are dropped. - for (const [key, value] of Object.entries(headers)) { + const headerEntries = Object.entries(headers) + for (const [key, value] of headerEntries) { if (typeof value === 'string') record[key] = value } @@ -214,7 +203,8 @@ function applyReservedHeaders( ): Record { const blocked = new Set(Object.keys(reserved).map((key) => key.toLowerCase())) const merged: Record = {} - for (const [key, value] of Object.entries(extraHeaders ?? {})) { + const extraHeaderEntries = Object.entries(extraHeaders ?? {}) + for (const [key, value] of extraHeaderEntries) { if (!blocked.has(key.toLowerCase())) merged[key] = value } return { ...merged, ...reserved } @@ -305,7 +295,8 @@ export async function readJsonBody(response: Response): Promise { */ export function describeBody(body: unknown): string | undefined { if (typeof body === 'string') return body || undefined - if (typeof body !== 'object' || body === null) return undefined + if (typeof body !== 'object') return undefined + if (body === null) return undefined try { return JSON.stringify(body) } catch { @@ -315,9 +306,9 @@ export function describeBody(body: unknown): string | undefined { } function readStringField(value: unknown, field: string): string | undefined { - if (typeof value !== 'object' || value === null || !(field in value)) { - return undefined - } + if (typeof value !== 'object') return undefined + if (value === null) return undefined + if (!(field in value)) return undefined const candidate = Reflect.get(value, field) if (typeof candidate === 'string') return candidate if (typeof candidate === 'number') return String(candidate) diff --git a/packages/ai-byteplus/src/video/video-provider-options.ts b/packages/ai-byteplus/src/video/video-provider-options.ts index 175abefb28..5edd1eaf89 100644 --- a/packages/ai-byteplus/src/video/video-provider-options.ts +++ b/packages/ai-byteplus/src/video/video-provider-options.ts @@ -1,37 +1,3 @@ -/** - * Provider options and per-model capability tables for the BytePlus Seedance - * video models. - * - * Applicability for Seedance 1.x / 2.0 was probed live against - * `https://ark.ap-southeast.bytepluses.com/api/v3` on 2026-07-31. The probe - * sent an out-of-range `seed` alongside the field under test, so requests that - * passed validation still failed before a task was created (nothing billed): - * an error naming the field under test means "rejected", an error naming - * `seed` means "accepted". Ark reports only one arbitrary invalid parameter - * per request, so each cell was retried until a verdict repeated. Seedance 2.5 - * cells come from the public ModelArk create-task docs (opened 2026-08-07); - * its resolution row was refreshed 2026-08-19 when native 1080p shipped. - * - * Ark rejects an inapplicable field outright — "the specified parameter - * `draft` is not supported for model seedance-1-0-pro in t2v, must be empty" — - * so these tables are not cosmetic: sending a field to the wrong model is a - * 400, not a no-op. - * - * **Where the adapter guards, and where it doesn't** (deliberate, not an - * oversight). Scalar applicability — `service_tier`, `draft`, `priority`, - * `frames`, `camera_fixed`, `output_format` — is left to Ark, whose 400 names - * the offending field and the model precisely enough to act on, and whose - * per-model rules shift as BytePlus ships models. Duplicating that here would - * mean a table that silently goes stale and starts rejecting requests the API - * would have accepted. The adapter guards locally only where the API's own - * error is misleading or arrives too late to be actionable: prompt media shape - * (role vocabulary, frame-vs-reference exclusivity, frame cardinality, - * audio-only reference) and the resolution tier, both of which are derived - * from a caller's `prompt` / `size` rather than passed through verbatim. - * - * @experimental Video generation is an experimental feature and may change. - */ - import { isKnownBytePlusVideoModel } from '../model-meta' import type { BytePlusVideoModel, @@ -388,7 +354,15 @@ export function resolveBytePlusVideoSize( ): { ratio: string; resolution?: string } { const parsed = parseBytePlusVideoSize(size) const known = isKnownBytePlusVideoModel(model) - if (!parsed || (known && !BYTEPLUS_VIDEO_RATIOS.includes(parsed.ratio))) { + if (!parsed) { + throw new Error( + `byteplus: size "${size}" is not supported by model "${model}". Expected ` + + `"ratio" or "ratio_resolution" (e.g. "16:9_720p") with ratio one of: ` + + `${BYTEPLUS_VIDEO_RATIOS.join(', ')}.`, + ) + } + const unknownRatio = known && !BYTEPLUS_VIDEO_RATIOS.includes(parsed.ratio) + if (unknownRatio) { throw new Error( `byteplus: size "${size}" is not supported by model "${model}". Expected ` + `"ratio" or "ratio_resolution" (e.g. "16:9_720p") with ratio one of: ` + diff --git a/packages/ai-byteplus/src/video/wire-types.ts b/packages/ai-byteplus/src/video/wire-types.ts index 224aa38cff..7b45a25e61 100644 --- a/packages/ai-byteplus/src/video/wire-types.ts +++ b/packages/ai-byteplus/src/video/wire-types.ts @@ -1,28 +1,3 @@ -/** - * Wire types for the BytePlus Ark Seedance video task API - * (`/contents/generations/tasks`). - * - * Hand-written minimal shapes covering only the fields this adapter sends and - * reads, with provenance noted inline. Three sources: - * - * 1. The harvested OpenAPI 3.1 documents for the `ark` service, actions - * `CreateContentsGenerationsTasks` (`x-updated-time: 2026-05-07`), - * `GetContentsGenerationsTask` (`2026-04-14`), - * `ListContentsGenerationsTasks` (`2026-03-24`) and - * `DeleteContentsGenerationsTasks` — authoritative for field names, - * defaults and response shapes. - * 2. Live calls against `https://ark.ap-southeast.bytepluses.com/api/v3` on - * 2026-07-31 with a real `ARK_API_KEY`, which pinned the create response, - * the per-model parameter applicability (see - * `video-provider-options.ts`) and the `content[]` role vocabulary. - * 3. The Seedance prose docs, for the retention windows. - * - * Two casing traps worth knowing: the response frame-rate field is - * `framespersecond` (all lowercase, no underscores), and `resolution` is - * matched case-insensitively on the way in (`4K`, `4k` and even `1080P` are - * all accepted — live-verified), so this package standardizes on lowercase. - */ - /** * Task lifecycle states. * @@ -80,7 +55,9 @@ export interface BytePlusVideoImageContent { /** A video input. Reference-media mode requires `role: 'reference_video'`. */ export interface BytePlusVideoVideoContent { type: 'video_url' + /** MP4 download URL. */ video_url: { url: string } + /** Omitted for a bare first frame — the API defaults to `first_frame`. */ role?: BytePlusVideoContentRole } @@ -245,25 +222,32 @@ export interface BytePlusVideoTask { /** Unix seconds of the last status change — for a succeeded task, when the * output (and its 24-hour URL) was produced. */ updated_at?: number + /** Prompt text plus any image / video / audio inputs. */ content?: BytePlusVideoTaskContent + /** Randomness seed; integers in `[-1, 2^32-1]`, where `-1` means unseeded. */ seed?: number + /** Resolution tier, e.g. `720p`. Matched case-insensitively by the API. */ resolution?: string + /** Output aspect ratio, e.g. `16:9`. `adaptive` follows the input frame. */ ratio?: string duration?: number | string + /** Frame count, an alternative to `duration` that allows fractional seconds. */ frames?: number /** Frame rate. Lowercase and unseparated on the wire — not `frames_per_second`. */ framespersecond?: number + /** Generate a synchronized audio track. Default `false`. */ generate_audio?: boolean + /** `default` (online) or `flex` (offline batch, half price). */ service_tier?: string + /** Cheap low-fidelity preview render. Default `false`. */ draft?: boolean draft_task_id?: string + /** Seconds from `created_at` after which the task is marked `expired`. */ execution_expires_after?: number + /** Opaque per-end-user identifier for abuse attribution, max 64 chars. */ safety_identifier?: string usage?: BytePlusVideoTaskUsage - // The two fields below came back on a live succeeded task - // (`seedance-1-0-pro-fast-251015`, 2026-07-31) but are absent from the - // harvested Get schema. /** Queue priority the task ran at. */ priority?: number /** Container of the generated video, e.g. `mp4`. */ @@ -296,7 +280,3 @@ export interface BytePlusVideoTaskListResponse { items?: Array total?: number } - -// `DELETE /contents/generations/tasks/{id}` cancels a `queued` task and -// deletes anything already terminal; its documented success body is an empty -// object, so no response interface is declared for it. diff --git a/packages/ai-claude-code/src/adapters/claude-run-source.ts b/packages/ai-claude-code/src/adapters/claude-run-source.ts index b12670db40..302963a52c 100644 --- a/packages/ai-claude-code/src/adapters/claude-run-source.ts +++ b/packages/ai-claude-code/src/adapters/claude-run-source.ts @@ -1,5 +1,6 @@ /** Placeholder swapped for the schema JSON after the runner reads the files. */ -export const CLAUDE_JSON_SCHEMA_PLACEHOLDER = '__TANSTACK_SCHEMA__' +export const /** Placeholder swapped for the schema JSON after the runner reads the files. */ + CLAUDE_JSON_SCHEMA_PLACEHOLDER = '__TANSTACK_SCHEMA__' /** * Written into the sandbox and run with `node`. diff --git a/packages/ai-claude-code/src/adapters/policy-map.ts b/packages/ai-claude-code/src/adapters/policy-map.ts index 02a04f6642..78e57c47d0 100644 --- a/packages/ai-claude-code/src/adapters/policy-map.ts +++ b/packages/ai-claude-code/src/adapters/policy-map.ts @@ -1,21 +1,3 @@ -/** - * Map a portable {@link SandboxPolicy} onto Claude Code CLI permission flags. - * - * This is a best-effort, coarse mapping (the CLI's permission model is - * tool-level + a permission mode, not arbitrary command globs): - * - * - `default` decision → `--permission-mode`: - * `'allow'` → `bypassPermissions`, `'acceptEdits'`-ish `'ask'` → `acceptEdits`, - * `'deny'` → `default` (in `-p` mode, prompts are auto-denied). - * - `capabilities.fileWrite === 'deny'` → disallow `Write`,`Edit`,`MultiEdit`. - * - `capabilities.network === 'deny'` → disallow `WebFetch`,`WebSearch`. - * - `commands.deny` that name a bare built-in tool (e.g. `Bash`) are added to - * `--disallowedTools`; fine-grained command-glob enforcement is left to the - * MCP permission-prompt tool (interactive approvals). - * - * Returns the permission mode plus tool allow/deny additions; the adapter - * merges these with its own config. - */ import type { PolicyDecision, SandboxPolicy } from '@tanstack/ai-sandbox' import type { ClaudeCodePermissionMode } from './text' diff --git a/packages/ai-claude-code/src/adapters/projection.ts b/packages/ai-claude-code/src/adapters/projection.ts index f0c99150ca..5ee35e9687 100644 --- a/packages/ai-claude-code/src/adapters/projection.ts +++ b/packages/ai-claude-code/src/adapters/projection.ts @@ -1,31 +1,3 @@ -/** - * Claude Code workspace projector — the reference implementation the other - * harness projectors (codex, opencode) mirror. - * - * `withSandbox` surfaces a portable `WorkspaceProjection` (skills, plugins, a - * secret resolver, and a one-time marker path) via a capability. Each harness - * adapter reads it in its `chatStream` setup and projects those inputs into the - * CLI's native format. For Claude Code that means: - * - * - MCP servers → a project-scoped `.mcp.json` at the workspace root. - * - gitSkill repos → linked under `.claude/skills/`. - * - agentSkill → no reliable claude primitive pulls a public skill by - * bare name, so we warn and skip rather than invent one. - * - plugins → `claude plugin install --scope project` - * (best-effort; project scope so the adapter's default - * `--setting-sources project` loads it). - * - * The secret-bearing `.mcp.json` is (re)written on EVERY call, re-resolving - * secrets each time, so claude always reads current values and a snapshot can - * never serve a stale or rotated secret. Only the safe, idempotent, non-secret - * operations (gitSkill links, plugin installs, agentSkill handling) are guarded - * by a one-time marker file under the workspace. - * - * External-convention caveat: the `.mcp.json` location/shape, the skills dir, - * and the plugin-install command are verified against the installed `claude` - * CLI. Where claude has no clean primitive (agentSkill by bare name) we no-op - * with a warning instead of fabricating a command. - */ import { discoverSkillDirs, isSecretRef, @@ -91,7 +63,8 @@ function buildMcpConfig( count += 1 const headers: Record = {} const rawHeaders = skill.config.headers ?? {} - for (const [name, value] of Object.entries(rawHeaders)) { + const headerEntries = Object.entries(rawHeaders) + for (const [name, value] of headerEntries) { headers[name] = resolveHeaderValue(value, resolveSecret) } const rawUrl = skill.config['url'] diff --git a/packages/ai-claude-code/src/adapters/text.ts b/packages/ai-claude-code/src/adapters/text.ts index c69b575b14..1db997219f 100644 --- a/packages/ai-claude-code/src/adapters/text.ts +++ b/packages/ai-claude-code/src/adapters/text.ts @@ -123,6 +123,7 @@ function q(value: string): string { /** Copy host Anthropic auth into the sandbox process. Docker `exec` Env replaces the container env, so a key set only at create time can vanish. */ function hostClaudeAuthEnv(): Record { + /** Extra environment variables for the claude process inside the sandbox. */ const env: Record = {} const apiKey = process.env.ANTHROPIC_API_KEY if (apiKey) env.ANTHROPIC_API_KEY = apiKey @@ -131,6 +132,36 @@ function hostClaudeAuthEnv(): Record { return env } +function chatRunErrorChunk(error: unknown, model: string): AdapterYieldChunk { + const err = error as Error & { code?: string } + const rawEvent = toRunErrorRawEvent(error) + const message = err.message || 'Unknown error occurred' + return { + type: EventType.RUN_ERROR, + model, + timestamp: Date.now(), + message, + ...(err.code !== undefined && { code: err.code }), + ...(rawEvent !== undefined && { rawEvent }), + error: { + message, + ...(err.code !== undefined && { code: err.code }), + }, + } +} + +function abortSignalFields( + options: TextOptions, +): { signal: AbortSignal } | Record { + if (options.abortController?.signal) { + return { signal: options.abortController.signal } + } + if (options.request?.signal) { + return { signal: options.request.signal } + } + return {} +} + /** * Windows Node often has USERPROFILE but no HOME. Claude then cannot find * `~/.claude.json` (the `claude login` file) and prints "Not logged in". @@ -217,15 +248,8 @@ export class ClaudeCodeTextAdapter< const config = this.adapterConfig const modelOptions = options.modelOptions const exeParts = (config.claudeExecutable ?? 'claude').split(' ') - // Claude only reads project-scoped config (CLAUDE.md, `.claude/skills`, - // `.mcp.json`) with `project` in the sources. `user` is off by default so - // a local-process run does not pull in the host's `~/.claude`. const settingSources = config.settingSources ?? ['project'] - // `--setting-sources` before `-p`. Do not pass `--bare`: that flag - // skips stored `claude login` credentials and prints - // "Not logged in · Please run /login" (claude-code#51047). - // `-p` can take the next token as the prompt, so these flags stay first. const args: Array = [ ...exeParts, '--setting-sources', @@ -248,11 +272,34 @@ export class ClaudeCodeTextAdapter< 'bypassPermissions' args.push('--permission-mode', permissionMode) + /** Maximum harness-internal turns (`--max-turns`). */ const maxTurns = modelOptions?.maxTurns ?? config.maxTurns if (maxTurns !== undefined) args.push('--max-turns', String(maxTurns)) for (const dir of config.addDirs ?? []) args.push('--add-dir', dir) + this.pushClaudeToolFlags(args, options, policyFlags) + this.pushClaudeSystemPromptFlags(args, options) + + if (mcpConfigPath !== undefined) args.push('--mcp-config', mcpConfigPath) + if (hasJsonSchema) { + args.push('--json-schema', CLAUDE_JSON_SCHEMA_PLACEHOLDER) + } + if (permissionPromptTool !== undefined) { + args.push('--permission-prompt-tool', permissionPromptTool) + } + + return args + } + + private pushClaudeToolFlags( + args: Array, + options: TextOptions, + policyFlags: ClaudePolicyFlags, + ): void { + const config = this.adapterConfig + const modelOptions = options.modelOptions + /** Built-in tools the harness may use (`--allowedTools`). */ const allowedTools = [ ...(modelOptions?.allowedTools ?? config.allowedTools ?? []), ...policyFlags.allowedTools, @@ -260,6 +307,7 @@ export class ClaudeCodeTextAdapter< if (allowedTools.length > 0) { args.push('--allowedTools', [...new Set(allowedTools)].join(',')) } + /** Built-in tools removed from the harness (`--disallowedTools`). */ const disallowedTools = [ ...(modelOptions?.disallowedTools ?? config.disallowedTools ?? []), ...policyFlags.disallowedTools, @@ -267,28 +315,21 @@ export class ClaudeCodeTextAdapter< if (disallowedTools.length > 0) { args.push('--disallowedTools', [...new Set(disallowedTools)].join(',')) } + } + private pushClaudeSystemPromptFlags( + args: Array, + options: TextOptions, + ): void { const systemPrompts = normalizeSystemPrompts(options.systemPrompts) .map((prompt) => prompt.content) .filter((content) => content.trim() !== '') - if (systemPrompts.length > 0) { - const joined = systemPrompts.join('\n\n') - const flag = - config.systemPromptMode === 'replace' - ? '--system-prompt' - : '--append-system-prompt' - args.push(flag, joined) - } - - if (mcpConfigPath !== undefined) args.push('--mcp-config', mcpConfigPath) - if (hasJsonSchema) { - args.push('--json-schema', CLAUDE_JSON_SCHEMA_PLACEHOLDER) - } - if (permissionPromptTool !== undefined) { - args.push('--permission-prompt-tool', permissionPromptTool) - } - - return args + if (systemPrompts.length === 0) return + const flag = + this.adapterConfig.systemPromptMode === 'replace' + ? '--system-prompt' + : '--append-system-prompt' + args.push(flag, systemPrompts.join('\n\n')) } /** @@ -358,6 +399,215 @@ export class ClaudeCodeTextAdapter< } } + private claudePermissionTool( + policy: SandboxPolicy | undefined, + options: TextOptions, + scripts: Record | undefined, + approvalRequests: Array, + threadId: string, + runId: string, + ): + | { + toolName: string + resolve: (input: { + tool_name?: string + input?: unknown + }) => PermissionToolResult + } + | undefined { + if (policy === undefined) return undefined + return { + toolName: 'approval_prompt', + resolve: this.buildPermissionResolver( + policy, + options.approvals, + scripts, + approvalRequests, + threadId, + runId, + ), + } + } + + private async maybeProvisionClaudeBridge( + options: TextOptions, + sandbox: SandboxHandle, + channel: BridgeEventChannel, + permission: + | { + toolName: string + resolve: (input: { + tool_name?: string + input?: unknown + }) => PermissionToolResult + } + | undefined, + ): Promise { + const hasTools = options.tools !== undefined && options.tools.length > 0 + const skipBridge = !hasTools && permission === undefined + if (skipBridge) return undefined + const provisioner = + (options.capabilities + ? getToolBridgeProvisioner(options.capabilities, { optional: true }) + : undefined) ?? nodeHttpBridgeProvisioner + return await provisioner.provision(options.tools ?? [], { + provider: sandbox.provider, + context: options.context, + emitCustomEvent: channel.emitCustomEvent, + ...(permission !== undefined ? { permission } : {}), + ...(options.abortController?.signal + ? { signal: options.abortController.signal } + : {}), + }) + } + + private async writeClaudeRunFiles(args: { + sandbox: SandboxHandle + cwd: string + runIdSegment: string + bridge: HostToolBridge | undefined + permission: + | { + toolName: string + resolve: (input: { + tool_name?: string + input?: unknown + }) => PermissionToolResult + } + | undefined + options: TextOptions + resume: string | undefined + policy: SandboxPolicy | undefined + prompt: string + tempFiles: Array + }): Promise<{ runCommand: string; stdinInput: string | undefined }> { + const { + sandbox, + cwd, + runIdSegment, + bridge, + permission, + options, + resume, + policy, + prompt, + tempFiles, + } = args + let mcpConfigArg: string | undefined + if (bridge) { + const mcpConfigFile = `.tanstack-mcp-bridge-${runIdSegment}.json` + const mcpConfigPath = `${cwd}/${mcpConfigFile}` + await sandbox.fs.write(mcpConfigPath, bridgeToMcpConfig(bridge)) + tempFiles.push(mcpConfigPath) + mcpConfigArg = mcpConfigFile + } + let jsonSchemaFile: string | undefined + if (options.outputSchema !== undefined) { + jsonSchemaFile = `tanstack-output-schema-${runIdSegment}.json` + const schemaPath = `${cwd}/${jsonSchemaFile}` + await sandbox.fs.write(schemaPath, JSON.stringify(options.outputSchema)) + tempFiles.push(schemaPath) + } + const runnerFile = `tanstack-claude-run-${runIdSegment}.mjs` + await sandbox.fs.write(`${cwd}/${runnerFile}`, CLAUDE_RUNNER_SOURCE) + tempFiles.push(`${cwd}/${runnerFile}`) + const argv = this.buildArgv( + options, + resume, + mapPolicyToClaudeFlags(policy), + mcpConfigArg, + bridge && permission + ? `mcp__${bridge.name}__${permission.toolName}` + : undefined, + jsonSchemaFile !== undefined, + ) + const argvFile = `tanstack-claude-argv-${runIdSegment}.json` + await sandbox.fs.write(`${cwd}/${argvFile}`, JSON.stringify(argv)) + tempFiles.push(`${cwd}/${argvFile}`) + const command = + jsonSchemaFile === undefined + ? `node ${q(runnerFile)} ${q(argvFile)}` + : `node ${q(runnerFile)} ${q(argvFile)} ${q(jsonSchemaFile)}` + if (sandbox.capabilities.writableStdin !== false) { + return { runCommand: command, stdinInput: prompt } + } + const promptPath = `/tmp/tanstack-claude-prompt-${runIdSegment}` + await sandbox.fs.write(promptPath, prompt) + tempFiles.push(promptPath) + return { + runCommand: `${command} < ${q(promptPath)}`, + stdinInput: undefined, + } + } + + private claudeSpawnEnv( + sandbox: SandboxHandle, + options: TextOptions, + ): Record { + const authMode = + options.modelOptions?.authMode ?? this.adapterConfig.authMode ?? 'api-key' + return { + ...(sandbox.provider === 'local-process' + ? {} + : { + IS_SANDBOX: '1', + CLAUDE_CODE_SANDBOXED: '1', + }), + ...(authMode === 'api-key' ? hostClaudeAuthEnv() : {}), + ...localProcessHomeEnv(sandbox.provider), + ...this.adapterConfig.env, + } + } + + private spawnClaudeNdjson( + options: TextOptions, + sandbox: SandboxHandle, + cwd: string, + prepared: { runCommand: string; stdinInput: string | undefined }, + durability: ReturnType | undefined, + runId: string, + ) { + const journalOptions = journalOptionsFor(durability, runId) + return spawnNdjson(sandbox, prepared.runCommand, { + cwd, + ...(prepared.stdinInput !== undefined + ? { input: prepared.stdinInput } + : {}), + env: this.claudeSpawnEnv(sandbox, options), + ...abortSignalFields(options), + onNonJsonLine: (line) => + options.logger.provider(`provider=claude-code non-json line: ${line}`, { + chunk: line, + }), + ...(journalOptions === undefined ? {} : { journal: journalOptions }), + }) + } + + private async *emitClaudeDiff( + sandbox: SandboxHandle, + cwd: string, + threadId: string, + runId: string, + ): AsyncIterable { + if (this.adapterConfig.emitDiff === false) return + try { + const diff = await sandbox.process.exec(`git -C ${q(cwd)} diff`, { cwd }) + const hasDiff = diff.exitCode === 0 && diff.stdout.trim() !== '' + if (hasDiff) { + yield { + type: EventType.CUSTOM, + name: 'file.changed', + value: { path: '.', diff: diff.stdout }, + timestamp: Date.now(), + threadId, + runId, + } + } + } catch { + // not a git repo / git unavailable — skip the diff event + } + } + async *chatStream( options: TextOptions, ): AsyncIterable { @@ -365,9 +615,6 @@ export class ClaudeCodeTextAdapter< let bridge: HostToolBridge | undefined let channel: BridgeEventChannel | undefined const approvalRequests: Array = [] - // Temp files written for the run (bridge MCP config, redirected prompt) that - // carry the bearer token / prompt; removed in `finally` so they don't linger - // in the sandbox after the run. let cleanupSandbox: SandboxHandle | undefined const tempFiles: Array = [] try { @@ -375,31 +622,14 @@ export class ClaudeCodeTextAdapter< cleanupSandbox = sandbox const cwd = this.workdir(options) - // Durability comes from `withSandbox(sandbox, { runs, durability })`, read - // back off the capability bus. Absent it, everything below resolves to - // exactly today's behavior (no journal option, no alignment, and a - // generated `runId` when the caller didn't supply one). const durability = options.capabilities ? getSandboxDurability(options.capabilities, { optional: true }) : undefined - // The journaled path below derives its journal path and its - // message-id generator from `runId`. A resuming host must recompute - // the same `runId` to find the same journal file and reproduce the - // same translated ids — that's only possible when the caller supplies - // `runId` explicitly. `resolveDurableRunId` enforces that loudly (via - // `DurableRunIdRequiredError`) whenever durability is wired, and falls - // back to a fresh `this.generateId()` otherwise, preserving today's - // behavior for non-durable runs. const runId = resolveDurableRunId(options.runId, { durable: durability !== undefined, adapter: 'claude-code', fallback: () => this.generateId(), }) - // `threadId` is stamped on every chunk `translateSdkStream` emits, so an - // ATTACHING run that mints a fresh one replays a stream the stored log - // cannot match at index 0. `resolveDurableThreadId` refuses that up front - // instead of letting alignment discover it mid-stream; a durable FRESH run - // and a non-durable run both keep the generated fallback untouched. const threadId = resolveDurableThreadId(options.threadId, { durable: durability !== undefined, attaching: durability?.attach === true, @@ -420,42 +650,20 @@ export class ClaudeCodeTextAdapter< const policy = options.capabilities ? getSandboxPolicy(options.capabilities, { optional: true }) : undefined - - // A permission-prompt tool gates the agent's native tools when a policy - // can `ask`/`deny` (interactive approvals). - const permission = - policy !== undefined - ? { - toolName: 'approval_prompt', - resolve: this.buildPermissionResolver( - policy, - options.approvals, - projection?.scripts, - approvalRequests, - threadId, - runId, - ), - } - : undefined - - // Bridge chat()-provided server tools (and/or the permission tool) into - // the sandbox over MCP. - const hasTools = options.tools !== undefined && options.tools.length > 0 - if (hasTools || permission !== undefined) { - const provisioner = - (options.capabilities - ? getToolBridgeProvisioner(options.capabilities, { optional: true }) - : undefined) ?? nodeHttpBridgeProvisioner - bridge = await provisioner.provision(options.tools ?? [], { - provider: sandbox.provider, - context: options.context, - emitCustomEvent: channel.emitCustomEvent, - ...(permission !== undefined ? { permission } : {}), - ...(options.abortController?.signal - ? { signal: options.abortController.signal } - : {}), - }) - } + const permission = this.claudePermissionTool( + policy, + options, + projection?.scripts, + approvalRequests, + threadId, + runId, + ) + bridge = await this.maybeProvisionClaudeBridge( + options, + sandbox, + channel, + permission, + ) const built = buildPrompt( options.messages, @@ -466,140 +674,38 @@ export class ClaudeCodeTextAdapter< options.outputSchema !== undefined ? appendOutputSchemaInstruction(built.prompt, options.outputSchema) : built.prompt - // Both files below name themselves after `runId`, and durability makes - // `runId` CALLER-chosen. Raw, a `/` in it would silently turn each basename - // into a nested path (writing outside the intended directory, or failing on - // one that does not exist), `..` would climb out of that directory, and a - // long id would fail the spawn with `ENAMETOOLONG`. `encodeRunId` collapses - // any id to one bounded, injective path segment — the same encoder - // `journalPaths` uses, so these files and the journal agree on how a given - // id spells. Computed once so the two filenames cannot drift apart. const runIdSegment = encodeRunId(runId) - - // The bridge MCP config carries the per-run bearer token. Write it to a - // file and pass claude the PATH, so the token never appears in argv (where - // any process in the sandbox could read it via `ps` / `/proc//cmdline`). - let mcpConfigArg: string | undefined - if (bridge) { - // Pass claude a path RELATIVE to its cwd (the real workdir the handle - // runs the process in). An absolute VIRTUAL path like `/workspace/…` is - // wrong wherever claude runs outside a sandbox that literally uses - // `/workspace` — e.g. local-process on Windows, where git-bash resolves - // `/workspace` to `C:\Program Files\Git\workspace` and the file is "not - // found". The bare filename resolves correctly on every provider. - const mcpConfigFile = `.tanstack-mcp-bridge-${runIdSegment}.json` - const mcpConfigPath = `${cwd}/${mcpConfigFile}` - await sandbox.fs.write(mcpConfigPath, bridgeToMcpConfig(bridge)) - tempFiles.push(mcpConfigPath) - mcpConfigArg = mcpConfigFile - } - let jsonSchemaFile: string | undefined - if (options.outputSchema !== undefined) { - jsonSchemaFile = `tanstack-output-schema-${runIdSegment}.json` - const schemaPath = `${cwd}/${jsonSchemaFile}` - await sandbox.fs.write(schemaPath, JSON.stringify(options.outputSchema)) - tempFiles.push(schemaPath) - } - const runnerFile = `tanstack-claude-run-${runIdSegment}.mjs` - await sandbox.fs.write(`${cwd}/${runnerFile}`, CLAUDE_RUNNER_SOURCE) - tempFiles.push(`${cwd}/${runnerFile}`) - const argv = this.buildArgv( + const prepared = await this.writeClaudeRunFiles({ + sandbox, + cwd, + runIdSegment, + bridge, + permission, options, resume, - mapPolicyToClaudeFlags(policy), - mcpConfigArg, - bridge && permission - ? `mcp__${bridge.name}__${permission.toolName}` - : undefined, - jsonSchemaFile !== undefined, - ) - // Filenames only on the shell line. JSON (schema, system prompt, MCP - // config path) lives in the argv file so git-bash cannot retokenize it. - const argvFile = `tanstack-claude-argv-${runIdSegment}.json` - await sandbox.fs.write(`${cwd}/${argvFile}`, JSON.stringify(argv)) - tempFiles.push(`${cwd}/${argvFile}`) - const command = - jsonSchemaFile === undefined - ? `node ${q(runnerFile)} ${q(argvFile)}` - : `node ${q(runnerFile)} ${q(argvFile)} ${q(jsonSchemaFile)}` - - // Deliver the prompt. The default feeds it over stdin (keeps it out of - // argv). Providers without a writable host→process stdin (e.g. Cloudflare) - // can't accept that write, so write the prompt to a file and redirect the - // CLI's stdin from it in-shell (`claude -p … < file`) — still out of argv. - let runCommand = command - let stdinInput: string | undefined = prompt - if (sandbox.capabilities.writableStdin === false) { - const promptPath = `/tmp/tanstack-claude-prompt-${runIdSegment}` - await sandbox.fs.write(promptPath, prompt) - tempFiles.push(promptPath) - runCommand = `${command} < ${q(promptPath)}` - stdinInput = undefined - } + policy, + prompt, + tempFiles, + }) logger.request( `activity=chat provider=claude-code model=${this.model} sandbox=${sandbox.provider} messages=${options.messages.length} resume=${resume ?? 'none'}`, { provider: 'claude-code', model: this.model }, ) - const authMode = - options.modelOptions?.authMode ?? - this.adapterConfig.authMode ?? - 'api-key' - const injectApiKey = authMode === 'api-key' - - const journalOptions = journalOptionsFor(durability, runId) - const rawEvents = spawnNdjson(sandbox, runCommand, { + const rawEvents = this.spawnClaudeNdjson( + options, + sandbox, cwd, - ...(stdinInput !== undefined ? { input: stdinInput } : {}), - // Isolated sandboxes often run as root. Claude refuses - // `--dangerously-skip-permissions` as root unless IS_SANDBOX=1. - // CLAUDE_CODE_SANDBOXED marks a real isolation boundary. Do not set - // either on local-process: that provider runs on the host. - env: { - ...(sandbox.provider === 'local-process' - ? {} - : { - IS_SANDBOX: '1', - CLAUDE_CODE_SANDBOXED: '1', - }), - ...(injectApiKey ? hostClaudeAuthEnv() : {}), - ...localProcessHomeEnv(sandbox.provider), - ...this.adapterConfig.env, - }, - ...(options.abortController?.signal - ? { signal: options.abortController.signal } - : options.request?.signal - ? { signal: options.request.signal } - : {}), - onNonJsonLine: (line) => - logger.provider(`provider=claude-code non-json line: ${line}`, { - chunk: line, - }), - // Journal + attach both come from the sandbox durability capability, so - // the attach route configures takeover by passing `attach: true` to - // `withSandbox` — `chat()` stays free of sandbox vocabulary. Omitted - // entirely (not passed as `undefined`) when the run isn't durable, so - // `spawnNdjson` takes its original, unjournaled path byte-for-byte. - ...(journalOptions === undefined ? {} : { journal: journalOptions }), - }) + prepared, + durability, + runId, + ) async function* asMessages(): AsyncIterable { for await (const event of rawEvents) yield event as AgentSdkMessage } - // `mergeChunkStreams` splices `channel.stream` (host-tool-bridge CUSTOM - // events from LIVE tool execution) into the deterministic translator - // output. Those events do not occur on a replay, so a takeover's replay is - // NOT chunk-for-chunk identical to what the log holds. `alignedIfAttaching` - // handles it: alignment skips stored out-of-band CUSTOM entries within a - // bounded window (see `align.ts`), so a bridged-tool run can be taken over - // without a spurious `JournalReplayDivergedError` — while a genuine - // determinism regression still throws. The wrap goes OUTSIDE the merge: - // the stored log holds the previous host's merged output, so aligning the - // pre-merge translated stream alone would compare against a log it never - // produced. On a non-attaching run this is a pure passthrough (see - // `alignedIfAttaching`'s `attach` guard). yield* alignedIfAttaching( mergeChunkStreams( translateSdkStream(asMessages(), { @@ -609,11 +715,6 @@ export class ClaudeCodeTextAdapter< ...(options.parentRunId !== undefined && { parentRunId: options.parentRunId, }), - // Deterministic on the journaled path: two translations of the same - // journal prefix from a fresh generator seeded with the same - // `runId` mint the same message ids (unlike `this.generateId()`, - // which mixes in `Date.now()` / `Math.random()`). See - // `createRunScopedIdGen` in `@tanstack/ai-sandbox`. genId: createRunScopedIdGen(runId), ...(options.outputSchema ? { expectStructuredOutput: true } : {}), onSdkMessage: (message) => @@ -627,49 +728,14 @@ export class ClaudeCodeTextAdapter< logger, ) - // Surface the working-tree diff so UIs can render what the agent changed. - if (this.adapterConfig.emitDiff !== false) { - try { - const diff = await sandbox.process.exec(`git -C ${q(cwd)} diff`, { - cwd, - }) - if (diff.exitCode === 0 && diff.stdout.trim() !== '') { - yield { - type: EventType.CUSTOM, - name: 'file.changed', - value: { path: '.', diff: diff.stdout }, - timestamp: Date.now(), - threadId, - runId, - } - } - } catch { - // not a git repo / git unavailable — skip the diff event - } - } - - // Surface any pending approval requests (policy `ask` actions awaiting a - // client decision); the client approves and re-runs to continue. + yield* this.emitClaudeDiff(sandbox, cwd, threadId, runId) for (const event of approvalRequests) yield event } catch (error: unknown) { - const err = error as Error & { code?: string } - const rawEvent = toRunErrorRawEvent(error) logger.errors('claude-code.chatStream fatal', { error, source: 'claude-code.chatStream', }) - yield { - type: EventType.RUN_ERROR, - model: options.model, - timestamp: Date.now(), - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - ...(rawEvent !== undefined && { rawEvent }), - error: { - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - }, - } + yield chatRunErrorChunk(error, options.model) } finally { channel?.close() if (bridge) await bridge.close() diff --git a/packages/ai-claude-code/src/stream/sdk-types.ts b/packages/ai-claude-code/src/stream/sdk-types.ts index 9ad3b11e29..dcee15786b 100644 --- a/packages/ai-claude-code/src/stream/sdk-types.ts +++ b/packages/ai-claude-code/src/stream/sdk-types.ts @@ -7,7 +7,6 @@ * and the package's public types don't depend on the agent SDK's bundled * `@anthropic-ai/sdk` type imports. */ - export interface SdkInitMessage { type: 'system' subtype: 'init' diff --git a/packages/ai-claude-code/src/stream/translate.ts b/packages/ai-claude-code/src/stream/translate.ts index 2393e5052f..00b0e9d4eb 100644 --- a/packages/ai-claude-code/src/stream/translate.ts +++ b/packages/ai-claude-code/src/stream/translate.ts @@ -16,10 +16,12 @@ import type { } from './sdk-types' /** Name of the CUSTOM event carrying the Claude Code session id. */ -export const SESSION_ID_EVENT = 'claude-code.session-id' +export const /** Name of the CUSTOM event carrying the Claude Code session id. */ + SESSION_ID_EVENT = 'claude-code.session-id' /** Server name used for bridged TanStack tools (model sees `mcp__tanstack__`). */ -export const BRIDGED_MCP_SERVER_NAME = 'tanstack' +export const /** Server name used for bridged TanStack tools (model sees `mcp__tanstack__`). */ + BRIDGED_MCP_SERVER_NAME = 'tanstack' const BRIDGED_MCP_PREFIX = `mcp__${BRIDGED_MCP_SERVER_NAME}__` @@ -135,14 +137,16 @@ export async function* translateSdkStream( let runStarted = false /** Tool calls started but with no result yet. */ - const unresolvedToolCalls = new Set() + const /** Tool calls started but with no result yet. */ + unresolvedToolCalls = new Set() const syntheticOutputToolIds = new Set() let capturedStructuredOutput: unknown let assistantTextForHarvest = '' let partialStructuredJson = '' let partialIsStructuredOutput = false /** Anthropic message ids whose text/thinking already streamed via partials. */ - const streamedMessageIds = new Set() + const /** Anthropic message ids whose text/thinking already streamed via partials. */ + streamedMessageIds = new Set() // Partial-stream state let partialMessageId: string | null = null @@ -238,10 +242,10 @@ export async function* translateSdkStream( input: unknown }): Generator { const toolCallName = stripMcpPrefix(block.name) - if ( + const isSyntheticStructured = ctx.expectStructuredOutput === true && toolCallName === SYNTHETIC_STRUCTURED_OUTPUT_TOOL - ) { + if (isSyntheticStructured) { capturedStructuredOutput = rememberStructuredOutput( capturedStructuredOutput, block.input, @@ -399,7 +403,8 @@ export async function* translateSdkStream( ? capturedStructuredOutput : undefined let fromText: unknown - if (fromResult === undefined && fromTool === undefined) { + const harvestFromText = fromResult === undefined && fromTool === undefined + if (harvestFromText) { const raw = assistantTextForHarvest || message.result || '' if (raw.trim() !== '') { try { @@ -450,117 +455,152 @@ export async function* translateSdkStream( } } - function* handleStreamEvent( - message: SdkPartialAssistantMessage, - ): Generator { - const event = message.event - if (event.type === 'message_start') { - partialMessageId = event.message.id ?? genId() - streamedMessageIds.add(partialMessageId) - } else if (event.type === 'content_block_start') { - partialBlockType = event.content_block.type - const startedBlock = event.content_block - partialIsStructuredOutput = - ctx.expectStructuredOutput === true && - startedBlock.type === 'tool_use' && - 'name' in startedBlock && - startedBlock.name === SYNTHETIC_STRUCTURED_OUTPUT_TOOL - if (partialIsStructuredOutput) { - partialStructuredJson = '' - if ('id' in startedBlock && typeof startedBlock.id === 'string') { - syntheticOutputToolIds.add(startedBlock.id) - } - if ('input' in startedBlock) { - capturedStructuredOutput = rememberStructuredOutput( - capturedStructuredOutput, - startedBlock.input, - ) - } + function* handleContentBlockStart(startedBlock: { + type: string + id?: string + name?: string + input?: unknown + }): Generator { + partialBlockType = startedBlock.type + partialIsStructuredOutput = + ctx.expectStructuredOutput === true && + startedBlock.type === 'tool_use' && + 'name' in startedBlock && + startedBlock.name === SYNTHETIC_STRUCTURED_OUTPUT_TOOL + if (partialIsStructuredOutput) { + partialStructuredJson = '' + if ('id' in startedBlock && typeof startedBlock.id === 'string') { + syntheticOutputToolIds.add(startedBlock.id) } - if (partialBlockType === 'text') { - partialTextMessageId = partialMessageId ?? genId() - partialTextContent = '' - if (!partialTextStarted) { - partialTextStarted = true - yield { - type: EventType.TEXT_MESSAGE_START, - messageId: partialTextMessageId, - model, - timestamp: now(), - role: 'assistant', - } - } - } else if (partialBlockType === 'thinking') { - partialReasoningId = genId() - yield { - type: EventType.REASONING_START, - messageId: partialReasoningId, - model, - timestamp: now(), - } - yield { - type: EventType.REASONING_MESSAGE_START, - messageId: partialReasoningId, - role: 'reasoning' as const, - model, - timestamp: now(), - } + if ('input' in startedBlock) { + capturedStructuredOutput = rememberStructuredOutput( + capturedStructuredOutput, + startedBlock.input, + ) } - } else if (event.type === 'content_block_delta') { - if ( - event.delta.type === 'text_delta' && - partialTextStarted && - partialTextMessageId && - typeof event.delta.text === 'string' - ) { - partialTextContent += event.delta.text - assistantTextForHarvest += event.delta.text + } + if (partialBlockType === 'text') { + partialTextMessageId = partialMessageId ?? genId() + partialTextContent = '' + if (!partialTextStarted) { + partialTextStarted = true yield { - type: EventType.TEXT_MESSAGE_CONTENT, + type: EventType.TEXT_MESSAGE_START, messageId: partialTextMessageId, model, timestamp: now(), - delta: event.delta.text, - content: partialTextContent, - } - } else if ( - event.delta.type === 'input_json_delta' && - partialIsStructuredOutput && - typeof event.delta.partial_json === 'string' - ) { - partialStructuredJson += event.delta.partial_json - } else if ( - event.delta.type === 'thinking_delta' && - partialReasoningId && - typeof event.delta.thinking === 'string' - ) { - yield { - type: EventType.REASONING_MESSAGE_CONTENT, - messageId: partialReasoningId, - delta: event.delta.thinking, - model, - timestamp: now(), + role: 'assistant', } } - } else if (event.type === 'content_block_stop') { - if (partialIsStructuredOutput && partialStructuredJson !== '') { - try { - capturedStructuredOutput = rememberStructuredOutput( - capturedStructuredOutput, - JSON.parse(partialStructuredJson), - ) - } catch { - // Incomplete JSON; the complete assistant tool_use may still arrive. - } + return + } + if (partialBlockType === 'thinking') { + partialReasoningId = genId() + yield { + type: EventType.REASONING_START, + messageId: partialReasoningId, + model, + timestamp: now(), } - if (partialBlockType === 'text') { - yield* closePartialText() - } else if (partialBlockType === 'thinking') { - yield* closePartialReasoning() + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: partialReasoningId, + role: 'reasoning' as const, + model, + timestamp: now(), } - partialBlockType = null - partialIsStructuredOutput = false - partialStructuredJson = '' + } + } + + function* handleContentBlockDelta( + event: Extract< + SdkPartialAssistantMessage['event'], + { type: 'content_block_delta' } + >, + ): Generator { + if ( + event.delta.type === 'text_delta' && + partialTextStarted && + partialTextMessageId && + typeof event.delta.text === 'string' + ) { + partialTextContent += event.delta.text + assistantTextForHarvest += event.delta.text + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: partialTextMessageId, + model, + timestamp: now(), + delta: event.delta.text, + content: partialTextContent, + } + return + } + if ( + event.delta.type === 'input_json_delta' && + partialIsStructuredOutput && + typeof event.delta.partial_json === 'string' + ) { + partialStructuredJson += event.delta.partial_json + return + } + if ( + event.delta.type === 'thinking_delta' && + partialReasoningId && + typeof event.delta.thinking === 'string' + ) { + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: partialReasoningId, + delta: event.delta.thinking, + model, + timestamp: now(), + } + } + } + + function* handleContentBlockStop(): Generator { + const hasPartialStructuredJson = + partialIsStructuredOutput && partialStructuredJson !== '' + if (hasPartialStructuredJson) { + try { + capturedStructuredOutput = rememberStructuredOutput( + capturedStructuredOutput, + JSON.parse(partialStructuredJson), + ) + } catch { + // Incomplete JSON; the complete assistant tool_use may still arrive. + } + } + if (partialBlockType === 'text') { + yield* closePartialText() + } else if (partialBlockType === 'thinking') { + yield* closePartialReasoning() + } + partialBlockType = null + partialIsStructuredOutput = false + partialStructuredJson = '' + } + + function* handleStreamEvent( + message: SdkPartialAssistantMessage, + ): Generator { + const event = message.event + if (event.type === 'message_start') { + partialMessageId = event.message.id ?? genId() + streamedMessageIds.add(partialMessageId) + return + } + if (event.type === 'content_block_start') { + yield* handleContentBlockStart(event.content_block) + return + } + if (event.type === 'content_block_delta') { + yield* handleContentBlockDelta(event) + return + } + if (event.type === 'content_block_stop') { + yield* handleContentBlockStop() } } @@ -568,22 +608,24 @@ export async function* translateSdkStream( for await (const sdkMessage of sdkMessages) { ctx.onSdkMessage?.(sdkMessage) - if (sdkMessage.type === 'system' && sdkMessage.subtype === 'init') { - yield* startRun() - ctx.onSessionId?.(sdkMessage.session_id) - yield { - type: EventType.CUSTOM, - model, - timestamp: now(), - name: SESSION_ID_EVENT, - value: { - sessionId: sdkMessage.session_id, - model: sdkMessage.model, - tools: sdkMessage.tools, - skills: sdkMessage.skills ?? [], - }, + if (sdkMessage.type === 'system') { + if (sdkMessage.subtype === 'init') { + yield* startRun() + ctx.onSessionId?.(sdkMessage.session_id) + yield { + type: EventType.CUSTOM, + model, + timestamp: now(), + name: SESSION_ID_EVENT, + value: { + sessionId: sdkMessage.session_id, + model: sdkMessage.model, + tools: sdkMessage.tools, + skills: sdkMessage.skills ?? [], + }, + } + continue } - continue } // Anything before init still needs RUN_STARTED first. @@ -605,10 +647,6 @@ export async function* translateSdkStream( // harness-internal and intentionally ignored. } } catch (error) { - // The run is dying (abort or SDK failure). Pair any started tool calls - // with a synthetic result first so the next request's pending-tool-call - // scan doesn't try to execute them, then let the adapter surface the - // error as RUN_ERROR. yield* synthesizeUnresolvedResults() throw error } diff --git a/packages/ai-client/src/audio-recorder.ts b/packages/ai-client/src/audio-recorder.ts index 0f2b79737f..ccfdb888bd 100644 --- a/packages/ai-client/src/audio-recorder.ts +++ b/packages/ai-client/src/audio-recorder.ts @@ -105,7 +105,8 @@ export class AudioRecorder { } async start(): Promise { - if (this._state !== 'idle' || this.starting) { + const cannotStart = this._state !== 'idle' || this.starting + if (cannotStart) { return } this.starting = true @@ -113,9 +114,6 @@ export class AudioRecorder { const stream = await navigator.mediaDevices.getUserMedia({ audio: this.options.audio ?? true, }) - // cancel()/teardown ran while we were awaiting the mic: release the - // freshly acquired stream and bail rather than starting a recording the - // caller can no longer stop (a leaked live microphone). if (this.pendingCancel) { stream.getTracks().forEach((t) => t.stop()) return @@ -168,13 +166,13 @@ export class AudioRecorder { } stop(): Promise { - if (this._state !== 'recording' || !this.recorder) { + const recorder = this.recorder + if (this._state !== 'recording' || recorder === null) { return Promise.reject( new Error('AudioRecorder.stop() called while not recording'), ) } this.setState('stopping') - const recorder = this.recorder return new Promise((resolve, reject) => { // Some browsers/codecs never fire onstop; this watchdog unwedges the // recorder instead of leaking this promise forever. @@ -186,9 +184,6 @@ export class AudioRecorder { // can't reach back in and fire finalize()/onError a second time. this.detachRecorder() if (this.chunks.length > 0) { - // onstop never fired, but ondataavailable already delivered the - // audio — finalize from the buffered chunks rather than discarding a - // recording the user successfully captured. void this.finalize() } else { this.handleError( @@ -226,12 +221,9 @@ export class AudioRecorder { try { recorder.stop() } catch (err) { - // Stopping an already-inactive recorder throws InvalidStateError — - // that's expected here. Anything else is unexpected; surface it rather - // than swallowing it silently. - if ( - !(err instanceof DOMException && err.name === 'InvalidStateError') - ) { + const isInvalidStateError = + err instanceof DOMException && err.name === 'InvalidStateError' + if (!isInvalidStateError) { this.notifyError( err instanceof Error ? err : new Error('Failed to stop recorder'), ) @@ -250,9 +242,12 @@ export class AudioRecorder { private async finalize(): Promise { const mimeType = this.recorder?.mimeType || 'audio/webm' + /** Recording length in milliseconds. */ const durationMs = Date.now() - this.startedAt try { + /** The raw recorded media blob. */ const blob = new Blob(this.chunks, { type: mimeType }) + /** Base64 of the recorded bytes (no `data:` prefix). */ const base64 = arrayBufferToBase64(await blob.arrayBuffer()) const recording: AudioRecording = { blob, @@ -287,9 +282,6 @@ export class AudioRecorder { this.stopResolve = null this.stopReject = null this.setState('idle') - // Settle the pending stop() promise before invoking the user callback so a - // throwing onError can't strand the awaiter (the two error channels are - // independent — see start()/stop() docs). reject?.(error) this.notifyError(error) } diff --git a/packages/ai-client/src/byok/client.ts b/packages/ai-client/src/byok/client.ts index 0324349e1e..5b69a66243 100644 --- a/packages/ai-client/src/byok/client.ts +++ b/packages/ai-client/src/byok/client.ts @@ -69,7 +69,8 @@ function isByokCeremonyCancel(error: unknown): boolean { function sanitizeKeyring(value: unknown): Keyring { if (typeof value !== 'object' || value === null) return {} const keys: Keyring = {} - for (const [provider, key] of Object.entries(value)) { + const objectEntries = Object.entries(value) + for (const [provider, key] of objectEntries) { if (isProviderId(provider) && typeof key === 'string' && key.length > 0) { keys[provider] = key } @@ -154,7 +155,8 @@ export class ByokClient { if (key) headers[byokHeaderName(provider)] = key return headers } - for (const [id, key] of Object.entries(this.#keys)) { + const objectEntries = Object.entries(this.#keys) + for (const [id, key] of objectEntries) { if (key) headers[byokHeaderName(id)] = key } return headers @@ -162,7 +164,8 @@ export class ByokClient { async prepare(provider?: ProviderId): Promise { await this.#ready - if (this.storage.unlockable && this.#locked) { + const needsUnlock = this.storage.unlockable && this.#locked + if (needsUnlock) { await this.unlock() } if (!provider) return @@ -194,7 +197,8 @@ export class ByokClient { if (nextKey.length === 0) { throw new Error('BYOK key must be non-empty') } - if (this.storage.unlockable && this.#locked) { + const needsUnlock = this.storage.unlockable && this.#locked + if (needsUnlock) { await this.unlock() } const previousKeys = this.#keys @@ -231,7 +235,8 @@ export class ByokClient { await this.#ready if (provider) { requireProviderId(provider) - if (this.storage.unlockable && this.#locked) { + const needsUnlock = this.storage.unlockable && this.#locked + if (needsUnlock) { await this.unlock() } const previousKeys = this.#keys @@ -279,10 +284,13 @@ export class ByokClient { try { const loaded = sanitizeKeyring(await this.storage.load()) this.#keys = { ...loaded, ...this.#keys } - for (const [id, value] of Object.entries(loaded)) { - if (!isProviderId(id) || !value) continue + const objectEntries = Object.entries(loaded) + for (const [id, value] of objectEntries) { + if (!isProviderId(id)) continue + if (!value) continue const existing = this.#statuses[id] - if (!existing || existing.state === 'locked') { + const shouldSetStatus = !existing || existing.state === 'locked' + if (shouldSetStatus) { this.#statuses[id] = { state: 'set', masked: maskKey(value) } } } @@ -304,10 +312,12 @@ export class ByokClient { } #lockedProvider(): ProviderId | undefined { - if (this.#prompt && isProviderId(this.#prompt.provider)) { - return this.#prompt.provider + const prompt = this.#prompt + if (prompt && isProviderId(prompt.provider)) { + return prompt.provider } - for (const [id, status] of Object.entries(this.#statuses)) { + const objectEntries = Object.entries(this.#statuses) + for (const [id, status] of objectEntries) { if (status?.state === 'locked' && isProviderId(id)) return id } return undefined @@ -318,8 +328,10 @@ export class ByokClient { if (!this.storage.peek) return try { const preview = await this.storage.peek() - for (const [id, last4] of Object.entries(preview)) { - if (!isProviderId(id) || this.#statuses[id]) continue + const objectEntries = Object.entries(preview) + for (const [id, last4] of objectEntries) { + if (!isProviderId(id)) continue + if (this.#statuses[id]) continue this.#statuses[id] = { state: 'locked', masked: last4 ? last4 : '••', @@ -337,8 +349,10 @@ export class ByokClient { try { const loaded = sanitizeKeyring(await this.storage.load()) this.#keys = { ...loaded, ...this.#keys } - for (const [id, value] of Object.entries(loaded)) { - if (!isProviderId(id) || !value) continue + const objectEntries2 = Object.entries(loaded) + for (const [id, value] of objectEntries2) { + if (!isProviderId(id)) continue + if (!value) continue this.#statuses[id] = { state: 'set', masked: maskKey(value) } } this.#storageError = null diff --git a/packages/ai-client/src/byok/passkey.ts b/packages/ai-client/src/byok/passkey.ts index f6d61bf55b..2d1cafe1c8 100644 --- a/packages/ai-client/src/byok/passkey.ts +++ b/packages/ai-client/src/byok/passkey.ts @@ -15,7 +15,6 @@ import type { KeyPreview, Keyring, KeyringStorage } from './storage' * an attacker running JS in the origin after the user unlocks can read the * decrypted keys from memory. */ - const STORE_NAME = 'keyring' const RECORD_ID = 'default' const HKDF_INFO = 'byok:keyring:v1' @@ -42,7 +41,8 @@ interface StoredRecord { function sanitizeKeyring(value: unknown): Keyring { if (typeof value !== 'object' || value === null) return {} const keys: Keyring = {} - for (const [provider, key] of Object.entries(value)) { + const objectEntries = Object.entries(value) + for (const [provider, key] of objectEntries) { if (isProviderId(provider) && typeof key === 'string' && key.length > 0) { keys[provider] = key } @@ -53,8 +53,10 @@ function sanitizeKeyring(value: unknown): Keyring { /** Build the non-sensitive `provider → last 4` preview from a keyring. */ function previewOf(keys: Keyring): KeyPreview { const preview: KeyPreview = {} - for (const [provider, key] of Object.entries(keys)) { - if (!key || !isProviderId(provider)) continue + const objectEntries = Object.entries(keys) + for (const [provider, key] of objectEntries) { + if (!key) continue + if (!isProviderId(provider)) continue // Keys of length ≤ 4 would make last-4 the whole secret — store presence only. preview[provider] = key.length > 4 ? key.slice(-4) : '' } @@ -75,10 +77,6 @@ export function isPasskeyStorageSupported(): boolean { ) } -// --------------------------------------------------------------------------- -// Crypto (exported for testing; the WebAuthn ceremony below feeds `deriveAesKey`) -// --------------------------------------------------------------------------- - /** Derive a non-extractable AES-256-GCM key from a 32-byte PRF output. */ export async function deriveAesKey( prfOutput: BufferSource, @@ -103,7 +101,11 @@ export async function deriveAesKey( export async function encryptKeyring( key: CryptoKey, keys: Keyring, -): Promise<{ iv: ArrayBuffer; ciphertext: ArrayBuffer }> { +): Promise<{ + /** AES-GCM initialization vector for this ciphertext. */ + iv: ArrayBuffer /** Encrypted keyring JSON. */ + ciphertext: ArrayBuffer +}> { const iv = crypto.getRandomValues(new Uint8Array(12)) const plaintext = new TextEncoder().encode(JSON.stringify(keys)) const ciphertext = await crypto.subtle.encrypt( @@ -128,10 +130,6 @@ export async function decryptKeyring( return sanitizeKeyring(parsed) } -// --------------------------------------------------------------------------- -// IndexedDB -// --------------------------------------------------------------------------- - function openDb(dbName: string): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(dbName, 1) @@ -181,10 +179,6 @@ function idbClear(dbName: string): Promise { ) } -// --------------------------------------------------------------------------- -// WebAuthn ceremonies -// --------------------------------------------------------------------------- - function requirePublicKeyCredential( credential: Credential | null, action: string, @@ -201,7 +195,9 @@ async function registerPasskey( userName: string, rpId?: string, ): Promise<{ + /** The passkey's raw credential id, replayed in the unlock ceremony. */ credentialId: ArrayBuffer + /** Fixed per-install PRF evaluation input (not secret). */ salt: Uint8Array prf?: BufferSource }> { @@ -263,10 +259,6 @@ async function evaluatePrf( return result } -// --------------------------------------------------------------------------- -// Storage strategy -// --------------------------------------------------------------------------- - export interface PasskeyStorageOptions { /** Relying-party name shown in the passkey prompt. */ rpName?: string @@ -293,9 +285,12 @@ export interface PasskeyStorageOptions { export function passkeyStorage( options: PasskeyStorageOptions = {}, ): KeyringStorage { + /** Relying-party name shown in the passkey prompt. */ const rpName = options.rpName ?? 'BYOK' + /** Username label attached to the created passkey. */ const userName = options.userName ?? 'byok-keyring' const { rpId } = options + /** IndexedDB database name. Defaults to `byok`. */ const dbName = options.dbName ?? DEFAULT_DB let cachedKey: CryptoKey | null = null @@ -356,7 +351,8 @@ export function passkeyStorage( const hasKeys = Object.values(keys).some(Boolean) // First save with an empty keyring is a no-op — avoids a passkey ceremony // when another storage tier writes an empty ring. - if (!hasKeys && !existing) return + const isEmptyFirstSave = !hasKeys && !existing + if (isEmptyFirstSave) return const { key, credentialId, salt } = await ensureKey() const { iv, ciphertext } = await encryptKeyring(key, keys) @@ -388,7 +384,8 @@ export function defaultByokStorage( const secure = typeof globalThis.isSecureContext !== 'boolean' || globalThis.isSecureContext - if (!isPasskeyStorageSupported() || !secure) { + const canUsePasskeys = isPasskeyStorageSupported() && secure + if (!canUsePasskeys) { return { ...memoryStorage(), warning: diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 1e8bd237f6..269236915d 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -81,6 +81,7 @@ import type { /** Internal queue entry — public {@link QueuedMessage} plus optional per-send body. */ interface InternalQueuedMessage extends QueuedMessage { + /** @deprecated Use `forwardedProps` instead. */ body?: Record } @@ -154,7 +155,8 @@ function resolveTransport(transport: { fetcher?: ChatFetcher }): ConnectionAdapter { const { connection, fetcher } = transport - if (connection && fetcher) { + const hasBothTransports = connection && fetcher + if (hasBothTransports) { throw new Error( 'ChatClient: pass either `connection` or `fetcher`, not both.', ) @@ -164,6 +166,11 @@ function resolveTransport(transport: { throw new Error('ChatClient: either `connection` or `fetcher` is required.') } +/** + * `connect()` adapters push the full HTTP body into the subscribe queue, then + * wait until that queue is idle. After `send()` returns, every chunk from this + * request has been processed. Subscribe/send sockets do not drain that way. + */ function connectionDrainsOnSend(connection: ConnectionAdapter): boolean { return 'connect' in connection } @@ -200,7 +207,8 @@ export function normalizeQueueOption( const maxSize = option.maxSize if (maxSize !== undefined) { - if (!Number.isInteger(maxSize) || maxSize < 0) { + const isValidMaxSize = Number.isInteger(maxSize) && maxSize >= 0 + if (!isValidMaxSize) { throw new Error( 'ChatClient: queue.maxSize must be a non-negative integer', ) @@ -330,6 +338,51 @@ const REJOIN_REBUILD_TRIGGERS = new Set([ 'MESSAGES_SNAPSHOT', ]) +function createClientToolsMap( + tools: ReadonlyArray | undefined, +): Map { + const map = new Map() + if (!tools) return map + for (const tool of tools) { + map.set(tool.name, tool) + } + return map +} + +function createChatClientCallbacks< + TTools extends ReadonlyArray, + TInterrupts extends ReadonlyArray>, +>(options: ChatClientOptions) { + return { + current: { + onResponse: options.onResponse || (() => {}), + onChunk: options.onChunk || (() => {}), + onFinish: options.onFinish || (() => {}), + onError: options.onError || (() => {}), + onMessagesChange: options.onMessagesChange || (() => {}), + onLoadingChange: options.onLoadingChange || (() => {}), + onErrorChange: options.onErrorChange || (() => {}), + onStatusChange: options.onStatusChange || (() => {}), + onSubscriptionChange: options.onSubscriptionChange || (() => {}), + onConnectionStatusChange: options.onConnectionStatusChange || (() => {}), + onSessionGeneratingChange: + options.onSessionGeneratingChange || (() => {}), + onQueueChange: options.onQueueChange || (() => {}), + onResumeStateChange: options.onResumeStateChange || (() => {}), + onRunIdChange: options.onRunIdChange || (() => {}), + onInterruptStateChange: options.onInterruptStateChange || (() => {}), + onCustomEvent: options.onCustomEvent || (() => {}), + }, + } +} + +function snapshotHasPendingInterrupts(snapshot: ChatResumeSnapshot): boolean { + return ( + Array.isArray(snapshot.pendingInterrupts) && + snapshot.pendingInterrupts.length > 0 + ) +} + export class ChatClient< TTools extends ReadonlyArray = any, TContext = unknown, @@ -340,21 +393,10 @@ export class ChatClient< private connection: SubscribeConnectionAdapter private uniqueId: string private threadId: string - // Durable chat persistence (optional): messages + resume snapshot as one - // combined record, so a full page reload restores the transcript, rehydrates - // pending interrupts, and rejoins an in-flight run. Clear-during-stream - // suppression is always on via ClearedStreamTracker so `clear()` works - // without a storage adapter. private readonly persistor?: ChatPersistor private readonly clearedStreamTracker = new ClearedStreamTracker() private currentRunId: string | null = null - // Interrupt-resume tracking: the run/thread of the most recent interrupted - // run, so approvals/client-tool results can be sent back. Cleared when the - // run terminates. This is STATE (interrupt) resume, not delivery/cursor. private lastResume: ChatResumeState | null = null - // The in-flight run id already handed to `resumeInFlightRun`, so a persisted - // run is rejoined at most once even when both the sync read and the async - // hydrate surface the same resume pointer. private rejoinedRunId: string | null = null private readonly interruptManager: InterruptManager private activeInterruptSubmission: InterruptManagerSubmission | undefined @@ -369,10 +411,6 @@ export class ChatClient< private pendingResumeItems: Array | null = null private activeResumeThreadId: string | null = null private activeResumeRunId: string | null = null - // Track the legacy `body` option and the canonical `forwardedProps` - // option as separate slots so that `updateOptions({ forwardedProps })` - // doesn't wipe a previously-set `body` (and vice versa). They are - // merged on every send, with `forwardedProps` winning on key collision. private bodyOption: Record = {} private forwardedPropsOption: Record = {} private byok: ByokClient | undefined @@ -430,18 +468,11 @@ export class ChatClient< private continuationPending = false private subscriptionAbortController: AbortController | null = null private processingResolve: (() => void) | null = null - /** - * `connect()` adapters push the full HTTP body into the subscribe queue, then - * wait until that queue is idle. After `send()` returns, every chunk from this - * request has been processed. Subscribe/send sockets do not drain that way. - */ + /** `connect()` send() drains the subscribe queue. Sockets wait for a terminal event. */ private connectionDrainsOnSend = false private errorReportedGeneration: number | null = null private streamGeneration = 0 private continuationGeneration = 0 - // Generation of the run that opened the current stream. Public - // `addToolResult` must use this, not the live counter: `stop()` increments - // the live counter, so a post-stop call would otherwise look current. private streamContinuationGeneration = 0 // Tracks whether a queued checkForContinuation was skipped because // continuationPending was true (chained approval scenario) @@ -491,47 +522,11 @@ export class ChatClient< constructor(options: ChatClientOptions) { assertUniqueInterruptDefinitions(options.interrupts) - // Do not mint a random thread id during construct. Framework hooks build - // this client during render (SSR included). The wire/devtools identity is - // `threadId`; it is assigned here when the caller passed one, or later in - // `ensureThreadId()` from attach / mount / send. this.threadId = options.threadId || '' this.uniqueId = this.threadId - // `persistence` is `false`/omitted (ephemeral, in-memory), `true` - // (server-authoritative: cache nothing client-side, hydrate the thread from - // the server by `threadId` on mount), or a storage adapter - // (client-authoritative: cache the transcript plus resume pointer). Only the - // server-authoritative mode turns transcript caching off; that is what gates - // the mount hydration and keeps a client record from shadowing server history. - let cachesMessages = true - if (options.persistence === true) { - if (!options.threadId) { - throw new Error( - '[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).', - ) - } - cachesMessages = false - } else if (options.persistence) { - // A storage adapter: keep the combined record (transcript + resume pointer) - // in the browser. Persistence keys on `threadId` (the conversation - // identity) so a reload with the same `threadId` finds the same record. - if (!options.threadId) { - throw new Error( - '[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).', - ) - } - this.persistor = new ChatPersistor( - options.persistence, - options.threadId, - (messages) => this.processor.setMessages(messages), - (snapshot) => this.applyPersistedResume(snapshot), - ) - } - // Both `body` (deprecated) and `forwardedProps` populate the AG-UI - // `RunAgentInput.forwardedProps` wire field. They are stored - // separately so `updateOptions` can replace one without touching the - // other; the merge happens at send time, with `forwardedProps` - // winning on key collision. + const persistence = this.createChatPersistor(options) + this.persistor = persistence.persistor + const cachesMessages = persistence.cachesMessages this.bodyOption = options.body || {} this.forwardedPropsOption = options.forwardedProps || {} this.byok = options.byok @@ -542,41 +537,14 @@ export class ChatClient< this.connectionDrainsOnSend = connectionDrainsOnSend(transport) this.connection = normalizeConnectionAdapter(transport) - // Build client tools map - this.clientToolsRef = { current: new Map() } - if (options.tools) { - for (const tool of options.tools) { - this.clientToolsRef.current.set(tool.name, tool) - } - } + this.clientToolsRef = { current: createClientToolsMap(options.tools) } this.devtoolsBridge = ( options.devtoolsBridgeFactory ?? createNoOpChatDevtoolsBridge )(this.buildDevtoolsBridgeOptions(options.devtools)) this.events = this.devtoolsBridge.events - this.callbacksRef = { - current: { - onResponse: options.onResponse || (() => {}), - onChunk: options.onChunk || (() => {}), - onFinish: options.onFinish || (() => {}), - onError: options.onError || (() => {}), - onMessagesChange: options.onMessagesChange || (() => {}), - onLoadingChange: options.onLoadingChange || (() => {}), - onErrorChange: options.onErrorChange || (() => {}), - onStatusChange: options.onStatusChange || (() => {}), - onSubscriptionChange: options.onSubscriptionChange || (() => {}), - onConnectionStatusChange: - options.onConnectionStatusChange || (() => {}), - onSessionGeneratingChange: - options.onSessionGeneratingChange || (() => {}), - onQueueChange: options.onQueueChange || (() => {}), - onResumeStateChange: options.onResumeStateChange || (() => {}), - onRunIdChange: options.onRunIdChange || (() => {}), - onInterruptStateChange: options.onInterruptStateChange || (() => {}), - onCustomEvent: options.onCustomEvent || (() => {}), - }, - } + this.callbacksRef = createChatClientCallbacks(options) this.interruptManager = new InterruptManager({ ...(options.tools !== undefined ? { tools: options.tools } : {}), @@ -587,59 +555,21 @@ export class ChatClient< onChange: (source) => this.notifyResumeStateChange(source), }) - // In-memory rehydrate of interrupt descriptors (e.g. after a page reload - // when the host supplies a snapshot). Durable storage of that snapshot is - // a persistence-stack concern — not wired here. if (options.initialResumeSnapshot) { this.applyResumeSnapshot(options.initialResumeSnapshot) } - // Create StreamProcessor with event handlers. - // Use conditional spreads so we don't pass `undefined` into - // `StreamProcessorOptions` fields under `exactOptionalPropertyTypes`. const persistedState = this.persistor?.readInitial() const syncPersistedState = persistedState instanceof Promise ? undefined : persistedState - // A persistor exists only in client-authoritative mode, so a synchronously - // read record's transcript is the conversation; adopt it over host - // `initialMessages`. (Server-authoritative mode has no persistor and instead - // hydrates from the server on mount, keyed by threadId.) const initialMessages = syncPersistedState ? syncPersistedState.messages : options.initialMessages - // A durable snapshot read synchronously from storage wins over the - // in-memory `initialResumeSnapshot` fallback applied above. A snapshot with - // pending interrupts rehydrates the interrupt UI; a bare in-flight run is - // rejoined after the processor is ready (see `rejoinRunId` below). - let rejoinRunId: string | null = null - if (syncPersistedState?.resume) { - const snapshot = syncPersistedState.resume - const hasPendingInterrupts = - Array.isArray(snapshot.pendingInterrupts) && - snapshot.pendingInterrupts.length > 0 - if (hasPendingInterrupts) { - // Interrupts are run-scoped state, restored from the cached snapshot. - this.applyResumeSnapshot(snapshot) - } else if (snapshot.resumeState.runId) { - // A bare in-flight run pointer drives a client-authoritative rejoin. - rejoinRunId = snapshot.resumeState.runId - } - } - // A host-supplied `initialResumeSnapshot` carrying a bare in-flight run is - // rejoined too, not just its interrupts (which `applyResumeSnapshot` above - // already restored). This is how a server-authoritative app hands a FRESH - // client an in-flight run to tail — e.g. opening the thread on a second - // device / browser, where hydration reports the active run id but no local - // resume pointer exists. A run named by the persisted store wins. - if (!rejoinRunId && options.initialResumeSnapshot) { - const snapshot = options.initialResumeSnapshot - const hasPendingInterrupts = - Array.isArray(snapshot.pendingInterrupts) && - snapshot.pendingInterrupts.length > 0 - if (!hasPendingInterrupts && snapshot.resumeState.runId) { - rejoinRunId = snapshot.resumeState.runId - } - } + /** Constructor inputs `attach()` needs on every re-attach, not just the first. */ + const rejoinRunId = this.resolveConstructorRejoinRunId( + options, + syncPersistedState, + ) this.processor = new StreamProcessor({ ...(options.streamProcessor?.chunkStrategy @@ -766,10 +696,6 @@ export class ChatClient< const executeFunc = clientTool?.execute if (executeFunc) { const continuationGeneration = this.continuationGeneration - // Capture the run context at execution-start so a tool whose - // result lands AFTER the originating run finishes still reports - // back against the originating run, not whatever run is - // current when the result emits. const runEventContext = this.devtoolsBridge.getCurrentRunEventContext() // Create and track the execution promise @@ -844,10 +770,6 @@ export class ChatClient< data: unknown, context: { toolCallId?: string }, ) => { - // Server-side memory middleware transports its state as a `memory:state` - // CUSTOM event (its own event bus never reaches this browser runtime). - // Route it to the devtools bridge here — the designated custom-event - // path — then still forward to the app's callback. if (eventType === 'memory:state') { this.devtoolsBridge.recordMemoryState(data) } @@ -863,23 +785,58 @@ export class ChatClient< this.rejoinRunId = rejoinRunId this.cachesMessages = cachesMessages - // NO TAILING HERE, deliberately. Constructing a client must not open a - // connection. - // - // A UI framework may build a client and then throw it away — React does it on - // every double-invoked render, and the discarded instance is never mounted, so - // nothing ever calls `detach()` or `dispose()` on it. When the constructor - // opened a stream, that stream became unreachable and held one of the browser's - // ~6 connections per origin until the page reloaded. Traced with CDP: connection - // ids 1374/1396/1428/1437 were still held after eight thread switches, and a - // later request waited 210 SECONDS for a free slot (`stallMs: 210752`). - // - // Guarding inside the client cannot fix that, because the leaking instance is - // the one the framework discarded — every guard runs on the instance it kept. - // Only "idle until a view attaches" makes a thrown-away client harmless. - // - // Callers therefore drive the lifecycle: `attach()` when a view mounts, - // `detach()` when it unmounts. Every framework wrapper in this repo does. + } + + private createChatPersistor( + options: ChatClientOptions, + ): { cachesMessages: boolean; persistor?: ChatPersistor } { + if (options.persistence === true) { + if (!options.threadId) { + throw new Error( + '[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).', + ) + } + return { cachesMessages: false } + } + if (!options.persistence) { + return { cachesMessages: true } + } + if (!options.threadId) { + throw new Error( + '[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).', + ) + } + return { + cachesMessages: true, + persistor: new ChatPersistor( + options.persistence, + options.threadId, + (messages) => this.processor.setMessages(messages), + (snapshot) => this.applyPersistedResume(snapshot), + ), + } + } + + private resolveConstructorRejoinRunId( + options: ChatClientOptions, + syncPersistedState: { resume?: ChatResumeSnapshot } | undefined, + ): string | null { + if (syncPersistedState?.resume) { + const snapshot = syncPersistedState.resume + if (snapshotHasPendingInterrupts(snapshot)) { + // Interrupts are run-scoped state, restored from the cached snapshot. + this.applyResumeSnapshot(snapshot) + } else if (snapshot.resumeState.runId) { + // A bare in-flight run pointer drives a client-authoritative rejoin. + return snapshot.resumeState.runId + } + } + if (!options.initialResumeSnapshot) return null + const snapshot = options.initialResumeSnapshot + if (!snapshotHasPendingInterrupts(snapshot) && snapshot.resumeState.runId) { + return snapshot.resumeState.runId + } + return null } /** @@ -898,24 +855,17 @@ export class ChatClient< * over two minutes while the same request from outside the browser took 17ms). */ attach(): void { - if (this.disposed || this.tailing) return + const cannotAttach = this.disposed || this.tailing + if (cannotAttach) return this.ensureThreadId() this.tailing = true - // Full page reload with an in-flight run persisted (synchronous store): - // re-attach to it off the server's delivery-durability log so the stream - // finishes here. Async stores rejoin from `applyPersistedResume` once the - // hydrate resolves. Best-effort and non-blocking. if (this.rejoinRunId) { this.maybeRejoinInFlight(this.rejoinRunId) } - // Server-authoritative (`persistence: true`): the client caches no transcript - // and no run pointer — it re-hydrates from the server on mount, keyed by the - // stable threadId. `hydrate` returns the stored transcript plus a cursor to - // any in-flight run, which is tailed via the same joinRun path. This is what - // makes reload AND a fresh device work with zero app glue (no loader/prop). - if (!this.cachesMessages && this.connection.hydrate) { + const needsServerHydrate = !this.cachesMessages && this.connection.hydrate + if (needsServerHydrate) { this.hydrateFromServer() } } @@ -938,10 +888,6 @@ export class ChatClient< */ detach(): void { if (!this.tailing) return - // BEFORE the abort, because `resumeInFlightRun`'s cleanup reads it: a join - // aborted before its first chunk normally means "this run is unreachable" and - // clears the resume pointer. A detach is not that — the run is fine and we - // intend to come back — so the pointer must survive. this.tailing = false this.cancelInFlightStream({ setReadyStatus: true }) this.rejoinedRunId = null @@ -986,10 +932,6 @@ export class ChatClient< Array.isArray(snapshot.pendingInterrupts) && snapshot.pendingInterrupts.length > 0 const runId = snapshot.resumeState?.runId - // A cached run pointer only reaches here through the persistor, which exists - // only in client-authoritative mode, so a bare in-flight run rejoins here. - // (Server-authoritative reconnect is resolved from the server by threadId in - // `hydrateFromServer`.) if (!hasInterrupts && runId) { this.maybeRejoinInFlight(runId) } @@ -1002,17 +944,12 @@ export class ChatClient< */ private maybeRejoinInFlight(runId: string): void { if (!this.connection.joinRun) return - // A client with no view attached must never open a connection. `tailing` is - // the load-bearing half: a view switch calls `detach()`, NOT `dispose()`, and - // an in-flight hydration resolves a moment later and lands right here — so - // guarding only on `disposed` let every switch open a fresh tail that nothing - // would ever abort. Measured with CDP: connection ids 1366/1397/1429/1460 were - // still held after eight switches, and a later request waited 97 SECONDS for a - // slot (`stallMs: 97691`). - if (this.disposed || !this.tailing) return + const isDetached = this.disposed || !this.tailing + if (isDetached) return if (this.rejoinedRunId === runId) return // A fresh send (or an already-running rejoin) owns the client; don't stomp it. - if (this.isLoading || this.abortController) return + const hasActiveStream = this.isLoading || this.abortController + if (hasActiveStream) return this.rejoinedRunId = runId this.resumeInFlightRun(runId) } @@ -1029,7 +966,8 @@ export class ChatClient< private hydrateFromServer(): void { const hydrate = this.connection.hydrate if (!hydrate) return - if (this.isLoading || this.abortController) return + const hasActiveStream = this.isLoading || this.abortController + if (hasActiveStream) return if (this.disposed) return void (async () => { let result: ChatHydrationResult @@ -1038,32 +976,15 @@ export class ChatClient< } catch { return } - // NO VIEW IS WATCHING ANY MORE (it unmounted while this fetch was in - // flight). Applying anything now is pointless, and one thing is actively - // harmful: the branch below calls `maybeRejoinInFlight`, which opens a TAIL. - // A tail started here belongs to a view that has gone, so nothing will ever - // abort it, and a browser allows only ~6 connections per origin — so a - // handful of switches starve the page and every later request queues. - // - // `!this.tailing` is the case that actually bites: a switch calls `detach()`, - // not `dispose()`, so a `disposed`-only check let the leak straight through. - if (this.disposed || !this.tailing) return + const isDetached = this.disposed || !this.tailing + if (isDetached) return // A send may have started while the fetch was in flight — don't stomp it. - if (this.isLoading || this.abortController) return + const hasActiveStream = this.isLoading || this.abortController + if (hasActiveStream) return if (result.messages.length > 0) { this.processor.setMessages(normalizeMessagesDates(result.messages)) } if (result.interrupts && result.interrupts.pending.length > 0) { - // Pending interrupt = the thread is paused awaiting a human decision, so - // there is nothing to tail (no chunks stream until it resolves). Restore - // the approval/wait from the SERVER — identical to reconstructing it from - // a resume snapshot — so the reload re-prompts the decision and the resume - // targets the run it paused. This is checked BEFORE `activeRun` on - // purpose: a run that just paused can momentarily still read as `running` - // on the server, so a racing hydrate reports both an `activeRun` cursor - // AND the pending interrupt. Tailing that "active" run would drop the - // approval card (and hang on a stream that never comes), so the interrupt - // always wins. this.applyResumeSnapshot({ resumeState: { threadId: this.threadId, @@ -1110,7 +1031,9 @@ export class ChatClient< } private retireIgnoredClearedTerminalChunk(chunk: StreamChunk): void { - if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') return + const isTerminalChunk = + chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR' + if (!isTerminalChunk) return const runId = getChunkRunId(chunk) ?? this.clearedStreamTracker.takeRunlessRunId() if (!runId) return @@ -1135,14 +1058,9 @@ export class ChatClient< this.activeRunIds.add(chunkRunId) this.clearedStreamTracker.onRunStarted(chunkRunId) this.setSessionGenerating(true) - // Persist a live-run resume snapshot so a full page reload can rejoin this - // in-flight run via joinRun. Only a persistor writes it, and a persistor - // exists only in client-authoritative mode; server-authoritative reconnect - // is resolved from the server by threadId in `hydrateFromServer`, so no - // client-cached run pointer (which goes stale the moment a turn spans a - // second run) is ever written. Interrupt/terminal handling overwrites or - // clears it in observeInterruptState. - if (this.persistor && this.connection.joinRun && !this.lastResume) { + const shouldPersistResume = + this.persistor && this.connection.joinRun && !this.lastResume + if (shouldPersistResume) { this.persistResumeSnapshot({ threadId: this.activeResumeThreadId ?? this.threadId, runId: chunkRunId, @@ -1151,7 +1069,9 @@ export class ChatClient< return } - if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') { + const isTerminalChunk = + chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR' + if (!isTerminalChunk) { return } @@ -1167,7 +1087,9 @@ export class ChatClient< this.setSessionGenerating(this.activeRunIds.size > 0) const skipProcessingResolve = chunk.type === 'RUN_FINISHED' && isIntermediateToolTurn(chunk) - if (options?.resolveProcessing !== false && !skipProcessingResolve) { + const shouldResolveProcessing = + options?.resolveProcessing !== false && !skipProcessingResolve + if (shouldResolveProcessing) { this.resolveProcessing() } } @@ -1179,20 +1101,45 @@ export class ChatClient< * state. This is interrupt (state) resume — there is no delivery cursor. */ private observeInterruptState(chunk: StreamChunk): void { - if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') { + const isTerminalChunk = + chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR' + if (!isTerminalChunk) { return } - if (this.activeInterruptSubmission && chunk.type === 'RUN_ERROR') { + const isInterruptSubmitError = + this.activeInterruptSubmission && chunk.type === 'RUN_ERROR' + if (isInterruptSubmitError) { return } const runId = getChunkRunId(chunk) - const threadId = - 'threadId' in chunk && typeof chunk.threadId === 'string' - ? chunk.threadId - : this.activeResumeThreadId + if (this.hydrateInterruptedRun(chunk, runId)) { + return + } + if (this.shouldClearInterruptState(chunk, runId)) { + this.lastResume = null + // Run settled without an interrupt: drop the durable resume snapshot so a + // later reload does not try to rejoin a finished run. + this.persistor?.persistResumeSnapshot(null) + this.interruptManager.reset() + return + } + this.notifyResumeStateChange('live') + } - if (chunk.type === 'RUN_FINISHED' && chunk.outcome?.type === 'interrupt') { + private hydrateInterruptedRun( + chunk: StreamChunk, + runId: string | undefined, + ): boolean { + if ( + chunk.type === 'RUN_FINISHED' && + chunk.outcome != null && + chunk.outcome.type === 'interrupt' + ) { + const threadId = + 'threadId' in chunk && typeof chunk.threadId === 'string' + ? chunk.threadId + : this.activeResumeThreadId // Track the REQUEST run id (what the client sent) so a resume targets the // same run even when provider events carry their own run id. const interruptedRunId = @@ -1210,53 +1157,44 @@ export class ChatClient< }, 'live', ) - return + return true } + return false + } - const isRunlessSessionError = chunk.type === 'RUN_ERROR' && !runId - const isTrackedRunTerminal = Boolean( - runId && this.lastResume?.runId === runId, + private isTrackedOrCurrentRunTerminal(runId: string | undefined): boolean { + const isLastResumeRun = runId && this.lastResume?.runId === runId + if (isLastResumeRun) return true + const isCurrentRun = runId && this.currentRunId === runId + if (isCurrentRun) return true + return Boolean( + this.currentRunId && this.lastResume?.runId === this.currentRunId, ) - const isCurrentRunTerminal = Boolean( - (runId && this.currentRunId === runId) || - (this.currentRunId && this.lastResume?.runId === this.currentRunId), - ) - // Provider adapters sometimes stamp a different run id on continuation - // events than the client-generated request id. RUN_STARTED updates - // `activeResumeRunId`, so match that too. - const isActiveStreamRunTerminal = Boolean( - this.isLoading && - runId && - (runId === this.activeResumeRunId || runId === this.currentRunId), - ) - const isCurrentStreamTerminal = + } + + private isActiveStreamRunTerminal(runId: string | undefined): boolean { + const hasNoActiveRun = !this.isLoading || !runId + if (hasNoActiveRun) return false + return runId === this.activeResumeRunId || runId === this.currentRunId + } + + private shouldClearInterruptState( + chunk: StreamChunk, + runId: string | undefined, + ): boolean { + const isSessionRunError = chunk.type === 'RUN_ERROR' && !runId + if (isSessionRunError) return true + if (this.isTrackedOrCurrentRunTerminal(runId)) return true + if (this.isActiveStreamRunTerminal(runId)) return true + const isRunlessFinish = this.isLoading && chunk.type === 'RUN_FINISHED' && !runId - // A resume batch that finishes successfully (or with a non-interrupt - // terminal) must always clear pending interrupts — even when the provider - // run id does not correlate. Otherwise Approve works once but the UI - // keeps showing a stale prompt and blocks follow-up turns. - const isActiveInterruptSubmissionTerminal = Boolean( + if (isRunlessFinish) return true + return Boolean( this.activeInterruptSubmission && this.isLoading && chunk.type === 'RUN_FINISHED' && chunk.outcome?.type !== 'interrupt', ) - if ( - isRunlessSessionError || - isTrackedRunTerminal || - isCurrentRunTerminal || - isActiveStreamRunTerminal || - isCurrentStreamTerminal || - isActiveInterruptSubmissionTerminal - ) { - this.lastResume = null - // Run settled without an interrupt: drop the durable resume snapshot so a - // later reload does not try to rejoin a finished run. - this.persistor?.persistResumeSnapshot(null) - this.interruptManager.reset() - return - } - this.notifyResumeStateChange('live') } /** @@ -1355,10 +1293,6 @@ export class ChatClient< if (continuationGeneration !== this.continuationGeneration) { return Promise.resolve(false) } - // Auto-executed client tools resolve during the parent stream's - // `pendingToolExecutions` wait — while `isLoading` is still true. - // Defer the child continuation until that stream settles so we do not - // race the parent cleanup or return a false "could not start" failure. if (this.isLoading) { return new Promise((resolve, reject) => { this.queuePostStreamAction(async () => { @@ -1396,9 +1330,6 @@ export class ChatClient< const continuationGeneration = this.continuationGeneration this.activeInterruptSubmission = submission this.interruptSubmissionFailure = undefined - // Reflect approval decisions in the local message tree immediately so a - // follow-up turn does not re-serialize tool-calls still stuck in - // `approval-requested` (issue #532). for (const resolution of submission.resolutions) { const approved = readApprovalApproved(resolution.payload) if (approved === undefined) continue @@ -1427,9 +1358,6 @@ export class ChatClient< if (!resumed) { throw new Error('Interrupt continuation could not be started.') } - // Belt-and-suspenders: if the continuation stream finished successfully - // but correlation failed to clear resume state, drop it now so the next - // user turn is not blocked by a stale interrupt prompt. if (this.lastResume?.runId === submission.interruptedRunId) { this.lastResume = null this.interruptManager.reset() @@ -1509,9 +1437,6 @@ export class ChatClient< // Capture state before invoking callbacks so a synchronous nested change // cannot pair this publication's source with a later manager snapshot. const interruptState = this.interruptManager.getState() - // Persist (or clear) the durable resume snapshot so a full page reload can - // rehydrate pending interrupts and rejoin the run. Folded into the same - // persistence adapter that stores messages (one record per chat). this.persistResumeSnapshot(resumeState) this.callbacksRef.current.onResumeStateChange( resumeState, @@ -1649,11 +1574,11 @@ export class ChatClient< this.setError(error) // Preserve request-level error semantics even if a RUN_ERROR arrives // slightly after loading flips false during stream teardown. - if ( + const isInFlightRequest = this.isLoading || this.status === 'submitted' || this.status === 'streaming' - ) { + if (isInFlightRequest) { this.setStatus('error') } if (!alreadyReported) { @@ -1671,7 +1596,9 @@ export class ChatClient< this.consumeSubscription(signal) .catch((err) => { - if (err instanceof Error && err.name !== 'AbortError') { + const isNonAbortError = + err instanceof Error && err.name !== 'AbortError' + if (isNonAbortError) { this.setConnectionStatus('error') this.resetSessionGenerating() this.setIsSubscribed(false) @@ -1686,7 +1613,8 @@ export class ChatClient< return } this.subscriptionAbortController = null - if (!signal.aborted && this.isSubscribed) { + const isLiveSubscription = !signal.aborted && this.isSubscribed + if (isLiveSubscription) { this.setIsSubscribed(false) if (this.connectionStatus !== 'error') { this.setConnectionStatus('disconnected') @@ -1735,90 +1663,88 @@ export class ChatClient< const controller = new AbortController() this.abortController = controller this.setCurrentRunId(runId) - // Record the resume state in-memory BEFORE replaying. Otherwise the - // replayed `RUN_STARTED` (which carries the PROVIDER run id, not the - // client/durability-log run id the pointer is keyed by) trips the - // `!this.lastResume` guard in `updateRunLifecycle` and rewrites the - // persisted pointer with the provider id — so a SECOND reload would - // `joinRun` an id the log isn't keyed by and never re-attach. this.lastResume = { threadId: this.threadId, runId } this.streamContinuationGeneration = this.continuationGeneration this.setIsLoading(true) this.setStatus('streaming') - void (async () => { - let rebuilt = false - let attached = false - // Whether the join FAILED (a thrown non-abort error before any chunk), as - // opposed to merely not delivering in time. Only a failure proves the - // pointer dead — see the `finally`. - let refused = false - const connectTimer = setTimeout(() => { - if (!attached) controller.abort() - }, REJOIN_CONNECT_DEADLINE_MS) - try { - for await (const chunk of joinRun(runId, controller.signal)) { - if (controller.signal.aborted) break - if (!attached) { - attached = true - clearTimeout(connectTimer) - } - if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) { - rebuilt = true - this.dropTrailingInFlightAssistant() - } - await this.processIncomingChunk(chunk, { defer: false }) - } - // Same contract as `streamResponse`: client tools may finish (and - // queue a resume) while `isLoading` is still true. Wait for them - // before teardown so `drainPostStreamActions` below sees the queue. - if (this.pendingToolExecutions.size > 0) { - await Promise.all(this.pendingToolExecutions.values()) - } - } catch (error) { - // Pre-attach failures (unknown/evicted run, connect deadline abort) - // stay soft: keep the restored transcript. Post-attach transport/parser - // failures are real stream errors and must surface so the UI is not - // left truncated and silent. - const isAbort = - error instanceof Error && - (error.name === 'AbortError' || error.name === 'TimeoutError') - if (!attached && !isAbort) refused = true - if (attached && !isAbort) { - this.reportStreamError( - error instanceof Error ? error : new Error(String(error)), - ) - } - } finally { - clearTimeout(connectTimer) - if (!attached && refused && this.tailing && !this.disposed) { - // The server REFUSED the join (unknown / evicted run): the pointer is - // dead. Clear it so it does not retry and re-pin the UI on the next - // load. The server's persisted transcript is still loaded. - // - // A connect-deadline abort (or an external abort) deliberately does - // NOT clear it: the run may simply not have produced yet — a durable - // run whose middleware is still booting a sandbox emits nothing for - // a while — and clearing on a timeout would permanently orphan a run - // that is still going. The pointer survives for the next load, which - // costs that load one more bounded connect attempt. - // - // `tailing`/`disposed` guard the same pointer from the other side: a - // DETACH aborts before the first chunk exactly like an unreachable run - // does, and a refusal that lands after the view is gone belongs to - // nobody. `refused` already spares the timeout case; these two spare - // the "no view is watching any more" case, so the pointer only ever - // dies for a client that is still looking at the run. - this.lastResume = null - this.persistor?.persistResumeSnapshot(null) + void this.consumeRejoinStream(controller, runId, joinRun) + } + + private isRejoinAbortError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === 'AbortError' || error.name === 'TimeoutError') + ) + } + + private handleRejoinFailure(error: unknown, attached: boolean): boolean { + const isAbort = this.isRejoinAbortError(error) + const isUnreachableRun = !attached && !isAbort + if (isUnreachableRun) return true + const isRejoinError = attached && !isAbort + if (isRejoinError) { + this.reportStreamError( + error instanceof Error ? error : new Error(String(error)), + ) + } + return false + } + + private clearDeadRejoinPointer(attached: boolean, refused: boolean): void { + const shouldKeepResumePointer = + attached || !refused || !this.tailing || this.disposed + if (shouldKeepResumePointer) return + this.lastResume = null + this.persistor?.persistResumeSnapshot(null) + } + + private async finishRejoin(controller: AbortController): Promise { + if (this.abortController !== controller) return + this.abortController = null + this.setIsLoading(false) + if (this.status === 'streaming') this.setStatus('ready') + await this.drainPostStreamActions() + } + + private async consumeRejoinStream( + controller: AbortController, + runId: string, + joinRun: ( + runId: string, + abortSignal?: AbortSignal, + ) => AsyncIterable, + ): Promise { + let rebuilt = false + let attached = false + let refused = false + const connectTimer = setTimeout(() => { + if (!attached) controller.abort() + }, REJOIN_CONNECT_DEADLINE_MS) + try { + const joinChunks = joinRun(runId, controller.signal) + for await (const chunk of joinChunks) { + if (controller.signal.aborted) break + if (!attached) { + attached = true + clearTimeout(connectTimer) } - if (this.abortController === controller) { - this.abortController = null - this.setIsLoading(false) - if (this.status === 'streaming') this.setStatus('ready') - await this.drainPostStreamActions() + const needsRebuild = !rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type) + if (needsRebuild) { + rebuilt = true + this.dropTrailingInFlightAssistant() } + await this.processIncomingChunk(chunk, { defer: false }) } - })() + if (this.pendingToolExecutions.size > 0) { + await Promise.all(this.pendingToolExecutions.values()) + } + } catch (error) { + refused = this.handleRejoinFailure(error, attached) + } finally { + clearTimeout(connectTimer) + this.clearDeadRejoinPointer(attached, refused) + await this.finishRejoin(controller) + } } /** @@ -1830,7 +1756,8 @@ export class ChatClient< private dropTrailingInFlightAssistant(): void { const messages = this.processor.getMessages() const last = messages[messages.length - 1] - if (last && last.role === 'assistant') { + const hasTrailingAssistant = last && last.role === 'assistant' + if (hasTrailingAssistant) { this.processor.setMessages(messages.slice(0, -1)) } } @@ -1840,10 +1767,10 @@ export class ChatClient< options?: { defer?: boolean }, ): Promise { chunk = restoreInboundChunk(chunk) - if ( + const isFailedInterruptSubmit = chunk.type === 'RUN_ERROR' && this.isActiveInterruptSubmissionFailure(chunk) - ) { + if (isFailedInterruptSubmit) { const interruptErrors = tanstackMetadata(chunk)?.interruptErrors this.interruptSubmissionFailure = { errors: Array.isArray(interruptErrors) ? interruptErrors : [], @@ -1854,7 +1781,9 @@ export class ChatClient< } const shouldIgnore = this.clearedStreamTracker.shouldIgnoreChunk(chunk) if (shouldIgnore) { - if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { + const isTerminalChunk = + chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR' + if (isTerminalChunk) { if (getChunkRunId(chunk)) { this.updateRunLifecycle(chunk, { resolveProcessing: false }) } else { @@ -1870,13 +1799,10 @@ export class ChatClient< this.processor.processChunk(chunk) this.updateRunLifecycle(chunk) this.observeInterruptState(chunk) - // Live path: yield a macrotask so the UI can paint. Skip when the page is - // hidden. Browsers clamp setTimeout there, and that wait paces stream pull. - // Replay passes defer: false so a backlog applies in one batch. - if ( + const shouldYieldToPaint = options?.defer !== false && (typeof document === 'undefined' || !document.hidden) - ) { + if (shouldYieldToPaint) { await new Promise((resolve) => setTimeout(resolve, 0)) } this.resolveJoinedRun(chunk) @@ -1887,11 +1813,18 @@ export class ChatClient< ): boolean { const submission = this.activeInterruptSubmission const errors = tanstackMetadata(chunk)?.interruptErrors - if (!submission || !Array.isArray(errors) || errors.length === 0) { + if (!submission) { + return false + } + if (!Array.isArray(errors)) { + return false + } + if (errors.length === 0) { return false } const runId = getChunkRunId(chunk) - if (runId !== undefined && runId !== this.currentRunId) return false + const isForeignRun = runId !== undefined && runId !== this.currentRunId + if (isForeignRun) return false if ( typeof chunk.threadId === 'string' && chunk.threadId !== submission.threadId @@ -1917,7 +1850,9 @@ export class ChatClient< } private resolveJoinedRun(chunk: StreamChunk): void { - if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') return + const isTerminalChunk = + chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR' + if (!isTerminalChunk) return const runId = getChunkRunId(chunk) if (runId === undefined) return const resolve = this.joinedRunWaiters.get(runId) @@ -1934,10 +1869,10 @@ export class ChatClient< this.subscribe() return } - if ( + const needsSubscriptionRestart = !this.subscriptionAbortController || this.subscriptionAbortController.signal.aborted - ) { + if (needsSubscriptionRestart) { this.subscribe({ restart: true }) } } @@ -2033,11 +1968,6 @@ export class ChatClient< this.enqueueMessage(content, resolvedBody, id) return } - // 'interrupt': abort the current stream, then send now. - // Unlike stop(), does not flush already-queued messages — they drain - // after this interrupting send settles successfully. - // Claim sendInFlight *before* cancelling so a concurrent send cannot - // slip in between cancel and the deliver below. this.stopMessageQueueDrain = true this.sendInFlight = true this.cancelInFlightStream({ setReadyStatus: true }) @@ -2088,7 +2018,8 @@ export class ChatClient< content: string | MultimodalContent, body?: Record, ): Promise { - if (this.isLoading || this.deliverClaim) { + const isDeliverBusy = this.isLoading || this.deliverClaim + if (isDeliverBusy) { return false } this.deliverClaim = true @@ -2141,9 +2072,12 @@ export class ChatClient< id?: string, ): void { const { maxSize, onOverflow } = this.queueConfig - if (maxSize !== undefined && this.messageQueue.length >= maxSize) { + const isQueueFull = + maxSize !== undefined && this.messageQueue.length >= maxSize + if (isQueueFull) { // maxSize 0 is a hard cap (never queue). drop-oldest cannot make room. - if (onOverflow === 'reject' || maxSize === 0) { + const cannotMakeRoom = onOverflow === 'reject' || maxSize === 0 + if (cannotMakeRoom) { return } this.messageQueue.shift() // drop-oldest @@ -2243,17 +2177,11 @@ export class ChatClient< this.activeResumeRunId = runId this.setIsLoading(true) - // Hand off from deliverClaim to isLoading so nested drain can call - // deliverMessage after this stream settles (while the outer deliver - // is still on the stack). this.deliverClaim = false this.setStatus('submitted') this.setError(undefined) this.errorReportedGeneration = null this.abortController = new AbortController() - // Capture the signal immediately so that a concurrent stop() or - // sendMessage() that reassigns this.abortController cannot cause - // connect() to receive a stale or null signal. const signal = this.abortController.signal // Reset pending tool executions for the new stream this.pendingToolExecutions.clear() @@ -2261,68 +2189,33 @@ export class ChatClient< let activeDevtoolsRunId: string | null = null let runTerminalEventEmitted = false - try { - // Get UIMessages with parts (preserves approval state and client tool results) + const executeStream = async (): Promise => { const messages = this.processor.getMessages() const clientTools = new Map(this.clientToolsRef.current) const runtimeContext = this.context - // Call onResponse callback await this.callbacksRef.current.onResponse() - // If the stream was cancelled during the onResponse await (e.g. stop() - // from a callback or unmount, or reload() superseding this stream), - // bail out before allocating waitForProcessing() — otherwise the - // resolveProcessing() that ran during cancellation is a no-op and the - // await processingComplete below would deadlock. if (signal.aborted) { - return false + return } - // Merge sources for the wire `forwardedProps` field, in priority - // order (later spreads win): - // 1. Legacy `body` option (deprecated). - // 2. Canonical `forwardedProps` option (wins over `body`). - // 3. Per-call body (`pendingMessageBody`: positional + sendOptions.body). - // The AG-UI standard `threadId` is sent at the wire's top level for - // run/conversation correlation, so we no longer auto-emit a separate - // `conversationId` here — `chat({ threadId })` server-side covers the - // same role for devtools/observability. const mergedBody = { ...this.bodyOption, ...this.forwardedPropsOption, ...this.pendingMessageBody, } - // Clear the pending message body after use this.pendingMessageBody = undefined - - // Generate stream ID — assistant message will be created by stream events this.currentStreamId = this.generateUniqueId('stream') this.devtoolsBridge.setCurrentStreamId(this.currentStreamId) this.currentMessageId = null this.activeClientTools = clientTools this.activeContext = runtimeContext - - // Reset processor stream state for new response — prevents stale - // messageStates entries (from a previous stream) from blocking - // creation of a new assistant message (e.g. after reload). this.processor.prepareAssistantMessage() - - // Ensure subscription loop is running this.ensureSubscription() - - // Set up promise that resolves when onStreamEnd fires const processingComplete = this.waitForProcessing() - // Build per-send run context for AG-UI compliance - // Note: mergedBody already contains the merged this.body + pendingMessageBody - // (pendingMessageBody was cleared above, so we use mergedBody as forwardedProps) - // Convert each client tool's `inputSchema` (a Standard Schema: - // Zod, ArkType, Valibot, etc.) to JSON Schema for the wire. Foreign - // AG-UI servers consuming `RunAgentInput.tools[].parameters` expect - // JSON Schema; sending a Standard Schema instance directly would - // serialize to an unusable shape. let byokHeaders: Record | undefined if (this.byok) { const provider = resolveByokProviderId( @@ -2363,33 +2256,27 @@ export class ChatClient< ) this.devtoolsBridge.emitSnapshot() - // Send through normalized connection (pushes chunks to subscription queue) await this.connection.send(messages, mergedBody, signal, runContext) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- mutated asynchronously during await - if (generation !== this.streamGeneration || signal.aborted) { - return false + const isStaleStream = + generation !== this.streamGeneration || signal.aborted + if (isStaleStream) { + return } - // connect() send() already waited until the subscribe queue was idle. - // Kick the processing wait so a stream that ends on tool_calls (no - // interrupt / stop) cannot hang. Subscribe/send sockets still wait for - // a request-ending terminal below. + // connect() send() already drained the subscribe queue. Kick processing + // so a tool_calls end cannot hang. Sockets still wait for a terminal. if (this.connectionDrainsOnSend) { this.resolveProcessing() } - // Wait for subscription loop to finish processing all chunks await processingComplete - // If this stream was superseded (e.g. by reload()), bail out — - // the new stream owns the processor and processingResolve now. if (generation !== this.streamGeneration) { - return false + return } - // A RUN_ERROR from the stream transitions status to error. - // Do not treat this stream as a successful completion. if (this.status === 'error') { if (activeDevtoolsRunId) { this.devtoolsBridge.emitRunLifecycle( @@ -2400,18 +2287,18 @@ export class ChatClient< ) runTerminalEventEmitted = true } - return false + return } - // Wait for pending client tool executions if (this.pendingToolExecutions.size > 0) { await Promise.all(this.pendingToolExecutions.values()) } - // Finalize (idempotent — may already be done by RUN_FINISHED handler) this.processor.finalizeStream() streamCompletedSuccessfully = true - } catch (err: unknown) { + } + + const handleStreamFailure = (err: unknown): void => { const error = err instanceof Error ? err : new Error(String(err)) if (error.name === 'AbortError') { if (activeDevtoolsRunId) { @@ -2422,12 +2309,14 @@ export class ChatClient< ) runTerminalEventEmitted = true } - return false + return } if (error instanceof ByokMissingError) { this.byok?.request(error.provider, 'missing') } - if (error instanceof ByokBlockedError && error.reason === 'locked') { + const isByokLocked = + error instanceof ByokBlockedError && error.reason === 'locked' + if (isByokLocked) { this.byok?.request(error.provider, 'locked') } if (generation === this.streamGeneration) { @@ -2442,69 +2331,64 @@ export class ChatClient< runTerminalEventEmitted = true } } - if ( + const shouldRethrowByok = generation === this.streamGeneration && (error instanceof ByokMissingError || error instanceof ByokBlockedError || error instanceof ByokUnresolvedProviderError) - ) { + if (shouldRethrowByok) { throw error } - } finally { - // Only clean up if this is still the active stream. - // A superseded stream (e.g. reload() started a new one) must not - // clobber the new stream's abortController or isLoading state. - if (generation === this.streamGeneration) { - this.currentStreamId = null - this.devtoolsBridge.setCurrentStreamId(null) - this.currentMessageId = null - this.setCurrentRunId(null) - this.activeClientTools = null - this.activeContext = undefined - this.abortController = null - this.setIsLoading(false) - this.pendingMessageBody = undefined // Ensure it's cleared even on error - - if (activeDevtoolsRunId && !runTerminalEventEmitted) { - if (streamCompletedSuccessfully) { - this.devtoolsBridge.emitRunLifecycle( - 'run:completed', - activeDevtoolsRunId, - 'completed', - ) - } else if (signal.aborted) { - this.devtoolsBridge.emitRunLifecycle( - 'run:cancelled', - activeDevtoolsRunId, - 'cancelled', - ) - } - } + } - // Drain any actions that were queued while the stream was in progress - await this.drainPostStreamActions() + const finishStream = async (): Promise => { + if (generation !== this.streamGeneration) return + this.currentStreamId = null + this.devtoolsBridge.setCurrentStreamId(null) + this.currentMessageId = null + this.setCurrentRunId(null) + this.activeClientTools = null + this.activeContext = undefined + this.abortController = null + this.setIsLoading(false) + this.pendingMessageBody = undefined + if (activeDevtoolsRunId && !runTerminalEventEmitted) { if (streamCompletedSuccessfully) { - if (this.status !== 'ready') { - // Terminal run, but onStreamEnd never fired: the processor had - // no assistant message to emit it for (e.g. a bare - // RUN_FINISHED{stop}, #421). The normal path already set - // 'ready', so this is a no-op. - this.setStatus('ready') - } - // Auto-send queued messages once the run fully settles. Skip if a - // drain loop is already walking the queue (avoids nested re-entry). - if (!this.messageQueueDraining) { - await this.drainQueue() - } - } else { - // Error/abort settle for the active generation: don't strand or - // later mis-order queued messages. A failed turn flushes the queue - // (consistent with stop()); it must NOT auto-drain into a likely - // broken endpoint. - this.flushQueue() + this.devtoolsBridge.emitRunLifecycle( + 'run:completed', + activeDevtoolsRunId, + 'completed', + ) + } else if (signal.aborted) { + this.devtoolsBridge.emitRunLifecycle( + 'run:cancelled', + activeDevtoolsRunId, + 'cancelled', + ) } } + + await this.drainPostStreamActions() + + if (!streamCompletedSuccessfully) { + this.flushQueue() + return + } + if (this.status !== 'ready') { + this.setStatus('ready') + } + if (!this.messageQueueDraining) { + await this.drainQueue() + } + } + + try { + await executeStream() + } catch (err: unknown) { + handleStreamFailure(err) + } finally { + await finishStream() } return streamCompletedSuccessfully @@ -2516,11 +2400,13 @@ export class ChatClient< */ subscribe(options?: { restart?: boolean }): void { const restart = options?.restart === true - if (this.isSubscribed && !restart) { + const isAlreadySubscribed = this.isSubscribed && !restart + if (isAlreadySubscribed) { return } - if (this.isSubscribed && restart) { + const shouldRestartSubscription = this.isSubscribed && restart + if (shouldRestartSubscription) { this.abortSubscriptionLoop() } @@ -2605,7 +2491,8 @@ export class ChatClient< currentRunId: this.currentRunId, }) // Always cancel in-flight work so clear works without message persistence. - if (this.isLoading || hadLocalStream) { + const hasLocalWork = this.isLoading || hadLocalStream + if (hasLocalWork) { this.cancelInFlightStream({ setReadyStatus: true }) this.resetSessionGenerating({ preserveClearedStreamTracking: true }) } else if (this.activeRunIds.size > 0) { @@ -2724,10 +2611,6 @@ export class ChatClient< id: string // approval.id, not toolCallId approved: boolean }): Promise { - // Reflect the decision on the tool-call part so approval UIs that render - // from `part.state` (the deprecated pre-interrupt pattern) clear the prompt - // and show the response. The bound interrupt resolution below drives the - // actual continuation; this keeps the legacy message-state surface in sync. this.processor.addToolApprovalResponse(response.id, response.approved) this.devtoolsBridge.emitSnapshot() @@ -2813,7 +2696,8 @@ export class ChatClient< if (this.hasPendingInterrupts()) return // Prevent duplicate continuation attempts - if (this.continuationPending || this.isLoading) { + const isContinuationBusy = this.continuationPending || this.isLoading + if (isContinuationBusy) { this.continuationSkipped = true return } @@ -2827,12 +2711,9 @@ export class ChatClient< } finally { this.continuationPending = false } - // If a queued check was skipped while continuationPending was true - // (e.g. a chained approval responded to during the stream), re-evaluate - // now that the flag is cleared. Only replay after a successful stream — - // aborted or errored streams should not trigger further continuation. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- mutated asynchronously during await - if (this.continuationSkipped && succeeded) { + const needsRetryContinuation = this.continuationSkipped && succeeded + if (needsRetryContinuation) { this.continuationSkipped = false await this.checkForContinuation() } @@ -2848,12 +2729,9 @@ export class ChatClient< // A pending interrupt owns the next send. Auto-continuing after a // completed server tool would start a sibling run and hide the card. if (this.lastResume) return false - // Ownership follows the descriptors, not the submission handle. Generic - // interrupts settle the resume stream through a post-stream action that - // runs before `submitInterruptBatch`'s `finally` clears the handle, so - // gating on the handle alone would strand a legacy client tool that the - // native resume itself emitted (#1106). - if (this.activeInterruptSubmission && this.hasPendingInterrupts()) { + const isInterruptOwned = + this.activeInterruptSubmission && this.hasPendingInterrupts() + if (isInterruptOwned) { return false } if (this.interruptManager.getInterrupts().length > 0) return false @@ -2896,14 +2774,11 @@ export class ChatClient< * busy/queue policy (which would re-queue items and strand the rest). */ private async drainQueue(): Promise { - // Note: do not gate on `sendInFlight`. Normal sends still hold - // `sendInFlight` while `streamResponse`'s finally invokes drain; blocking - // on it would permanently strand the queue. - if ( + const cannotDrainQueue = this.messageQueueDraining || this.isLoading || this.messageQueue.length === 0 - ) { + if (cannotDrainQueue) { return } @@ -2923,7 +2798,9 @@ export class ChatClient< merged.body, ) // Failed/aborted deliver flushes the rest of the queue in streamResponse. - if (!completed || this.shouldAbortMessageQueueDrain()) { + const shouldStopDrain = + !completed || this.shouldAbortMessageQueueDrain() + if (shouldStopDrain) { return } } @@ -2943,7 +2820,9 @@ export class ChatClient< this.emitQueueChange() const completed = await this.deliverMessage(next.content, next.body) // Failed/aborted deliver flushes the rest of the queue in streamResponse. - if (!completed || this.shouldAbortMessageQueueDrain()) { + const shouldStopDrain = + !completed || this.shouldAbortMessageQueueDrain() + if (shouldStopDrain) { return } } @@ -3065,36 +2944,52 @@ export class ChatClient< context?: TContext | undefined }, ): void { - if (options.connection !== undefined || options.fetcher !== undefined) { - const wasSubscribed = this.isSubscribed + this.applyConnectionUpdate(options) + this.applyClientOptionSlots(options) + this.applyCallbackUpdates(options) + } - if (this.isLoading) { - this.cancelInFlightStream({ - setReadyStatus: true, - abortSubscription: true, - }) - } else if (wasSubscribed) { - this.abortSubscriptionLoop() - } + private applyConnectionUpdate( + options: ChatClientUpdateOptionsWithoutContext & { + context?: TContext | undefined + }, + ): void { + const hasNoTransportUpdate = + options.connection === undefined && options.fetcher === undefined + if (hasNoTransportUpdate) { + return + } + const wasSubscribed = this.isSubscribed - this.resetSessionGenerating() - this.setIsSubscribed(false) - this.setConnectionStatus('disconnected') - const transport = resolveTransport({ - connection: options.connection, - fetcher: options.fetcher, + if (this.isLoading) { + this.cancelInFlightStream({ + setReadyStatus: true, + abortSubscription: true, }) - this.connectionDrainsOnSend = connectionDrainsOnSend(transport) - this.connection = normalizeConnectionAdapter(transport) + } else if (wasSubscribed) { + this.abortSubscriptionLoop() + } - if (wasSubscribed) { - this.subscribe() - } + this.resetSessionGenerating() + this.setIsSubscribed(false) + this.setConnectionStatus('disconnected') + const transport = resolveTransport({ + connection: options.connection, + fetcher: options.fetcher, + }) + this.connectionDrainsOnSend = connectionDrainsOnSend(transport) + this.connection = normalizeConnectionAdapter(transport) + + if (wasSubscribed) { + this.subscribe() } - // Replace each wire-payload slot independently so callers can update one - // without wiping the other. Passing `undefined` for `body` or - // `forwardedProps` leaves that slot unchanged; context is cleared when the - // key is present with an `undefined` value. + } + + private applyClientOptionSlots( + options: ChatClientUpdateOptionsWithoutContext & { + context?: TContext | undefined + }, + ): void { if (options.body !== undefined) { this.bodyOption = options.body } @@ -3112,15 +3007,19 @@ export class ChatClient< } if (options.tools !== undefined) { this.interruptManager.updateTools(options.tools) - this.clientToolsRef.current = new Map() - for (const tool of options.tools) { - this.clientToolsRef.current.set(tool.name, tool) - } + this.clientToolsRef.current = createClientToolsMap(options.tools) this.devtoolsBridge.notifyToolsChanged() } if (options.queue !== undefined) { this.queueConfig = normalizeQueueOption(options.queue) } + } + + private applyCallbackUpdates( + options: ChatClientUpdateOptionsWithoutContext & { + context?: TContext | undefined + }, + ): void { if (options.onResponse !== undefined) { this.callbacksRef.current.onResponse = options.onResponse } @@ -3165,14 +3064,7 @@ export class ChatClient< } dispose(): void { - // FIRST, and latched: everything below is teardown, and an async callback that - // lands mid-teardown must not start new work. In particular a hydration fetch - // that resolves after this point must not open a tail — see `hydrateFromServer`. this.disposed = true - // `unsubscribe()` below already aborts the in-flight stream (it calls - // `cancelInFlightStream({ abortSubscription: true })`), so disposal does drop - // an open tail. Verified by mutation: removing an extra abort here changes - // nothing, because unsubscribe covers it. this.unsubscribe() this.devtoolsBridge.dispose() this.devtoolsMounted = false diff --git a/packages/ai-client/src/cleared-stream-tracker.ts b/packages/ai-client/src/cleared-stream-tracker.ts index e9471ad537..3db1c166b6 100644 --- a/packages/ai-client/src/cleared-stream-tracker.ts +++ b/packages/ai-client/src/cleared-stream-tracker.ts @@ -48,7 +48,8 @@ export class ClearedStreamTracker { shouldIgnoreChunk(chunk: StreamChunk): boolean { const runId = getChunkRunId(chunk) - if (runId && this.clearedRunIds.has(runId)) { + const hasClearedRunId = runId && this.clearedRunIds.has(runId) + if (hasClearedRunId) { if (chunk.type === 'RUN_STARTED') { this.ignoredActiveRunIds.add(runId) this.currentRunlessRunId = runId @@ -57,7 +58,8 @@ export class ClearedStreamTracker { return true } - if (runId && this.ignoredActiveRunIds.has(runId)) { + const hasIgnoredActiveRunId = runId && this.ignoredActiveRunIds.has(runId) + if (hasIgnoredActiveRunId) { this.markIgnoredChunkIds(chunk) return true } @@ -68,12 +70,16 @@ export class ClearedStreamTracker { } const toolCallId = getChunkToolCallId(chunk) - if (toolCallId && this.clearedToolCallIds.has(toolCallId)) { + const hasClearedToolCallId = + toolCallId && this.clearedToolCallIds.has(toolCallId) + if (hasClearedToolCallId) { return true } const parentMessageId = getChunkParentMessageId(chunk) - if (parentMessageId && this.clearedMessageIds.has(parentMessageId)) { + const hasClearedMessageId = + parentMessageId && this.clearedMessageIds.has(parentMessageId) + if (hasClearedMessageId) { if (toolCallId) { this.clearedToolCallIds.add(toolCallId) } @@ -130,11 +136,12 @@ export class ClearedStreamTracker { private isRunlessChunkFromIgnoredRun(chunk: StreamChunk): boolean { const runId = getChunkRunId(chunk) - if (runId || !this.currentRunlessRunId) return false - if ( + if (runId) return false + if (!this.currentRunlessRunId) return false + const isUnknownRunlessId = !this.ignoredActiveRunIds.has(this.currentRunlessRunId) && !this.clearedRunIds.has(this.currentRunlessRunId) - ) { + if (isUnknownRunlessId) { return false } return ( diff --git a/packages/ai-client/src/client-persistor.ts b/packages/ai-client/src/client-persistor.ts index b71610cde3..48b860fa52 100644 --- a/packages/ai-client/src/client-persistor.ts +++ b/packages/ai-client/src/client-persistor.ts @@ -16,10 +16,6 @@ function normalizePersistedState( return undefined } -// `StreamChunk` is a discriminated union; `toolCallId` / `messageId` / -// `parentMessageId` exist on only some members. Narrow with `in` (matching -// `getChunkRunId`) instead of asserting a shape, so the field's real type is -// preserved and a protocol rename can't be read past silently. function getChunkToolCallId(chunk: StreamChunk): string | undefined { return 'toolCallId' in chunk && typeof chunk.toolCallId === 'string' ? chunk.toolCallId @@ -89,10 +85,8 @@ export class ChatPersistor { /** Persist the current state as one combined `{ messages, resume? }` record. */ private writeState(): void { const messages = [...this.lastMessages] - // Nothing to persist (no transcript, no resume pointer): remove the key - // rather than writing an empty `{ messages: [] }`, so a cleared - // conversation does not leave a stale record behind. - if (messages.length === 0 && !this.lastResume) { + const isEmptyState = messages.length === 0 && !this.lastResume + if (isEmptyState) { const generation = this.generation this.runOperation(() => { if (generation !== this.generation) { @@ -115,10 +109,6 @@ export class ChatPersistor { }) } - // --------------------------------------------------------------------------- - // Storage orchestration - // --------------------------------------------------------------------------- - /** * Synchronously read the persisted state for constructor-time hydration. * Returns the normalized combined record, or a promise of it for async stores. @@ -160,7 +150,10 @@ export class ChatPersistor { const hydrationGeneration = this.messagesGeneration persistedState .then((state) => { - if (!state || this.messagesGeneration !== hydrationGeneration) { + if (!state) { + return + } + if (this.messagesGeneration !== hydrationGeneration) { return } this.lastResume = state.resume ?? null @@ -249,10 +242,6 @@ export class ChatPersistor { } } - // --------------------------------------------------------------------------- - // Clear-during-stream suppression - // --------------------------------------------------------------------------- - /** * Capture the message/run ids that exist at the moment of a clear so chunks * still arriving for them can be ignored. @@ -283,7 +272,8 @@ export class ChatPersistor { /** Whether a chunk belongs to cleared state and should not be processed. */ shouldIgnoreChunk(chunk: StreamChunk): boolean { const runId = getChunkRunId(chunk) - if (runId && this.clearedRunIds.has(runId)) { + const hasClearedRunId = runId && this.clearedRunIds.has(runId) + if (hasClearedRunId) { if (chunk.type === 'RUN_STARTED') { this.ignoredActiveRunIds.add(runId) this.currentRunlessRunId = runId @@ -292,7 +282,8 @@ export class ChatPersistor { return true } - if (runId && this.ignoredActiveRunIds.has(runId)) { + const hasIgnoredActiveRunId = runId && this.ignoredActiveRunIds.has(runId) + if (hasIgnoredActiveRunId) { this.markIgnoredChunkIds(chunk) return true } @@ -303,12 +294,16 @@ export class ChatPersistor { } const toolCallId = getChunkToolCallId(chunk) - if (toolCallId && this.clearedToolCallIds.has(toolCallId)) { + const hasClearedToolCallId = + toolCallId && this.clearedToolCallIds.has(toolCallId) + if (hasClearedToolCallId) { return true } const parentMessageId = getChunkParentMessageId(chunk) - if (parentMessageId && this.clearedMessageIds.has(parentMessageId)) { + const hasClearedMessageId = + parentMessageId && this.clearedMessageIds.has(parentMessageId) + if (hasClearedMessageId) { if (toolCallId) { this.clearedToolCallIds.add(toolCallId) } @@ -364,9 +359,6 @@ export class ChatPersistor { if (!runId) return null this.ignoredActiveRunIds.delete(runId) this.clearedRunIds.delete(runId) - // Advance to another still-ignored run (mirroring `onRunSettled`) so that - // when two cleared runs drain concurrently, draining one via a runId-less - // RUN_ERROR doesn't stop suppressing the other's runless content. this.currentRunlessRunId = this.ignoredActiveRunIds.values().next().value ?? null return runId @@ -385,11 +377,12 @@ export class ChatPersistor { private isRunlessChunkFromIgnoredRun(chunk: StreamChunk): boolean { const runId = getChunkRunId(chunk) - if (runId || !this.currentRunlessRunId) return false - if ( + if (runId) return false + if (!this.currentRunlessRunId) return false + const isUnknownRunlessId = !this.ignoredActiveRunIds.has(this.currentRunlessRunId) && !this.clearedRunIds.has(this.currentRunlessRunId) - ) { + if (isUnknownRunlessId) { return false } return ( diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 6f6c30a658..e08f22cfed 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -37,10 +37,6 @@ const chunkRunIds = new WeakMap() * run the connect wrapper stamped it with. */ export function getChunkRunId(chunk: StreamChunk): string | undefined { - // Prefer the client's request run id (stamped in `chunkRunIds`) over a - // provider-assigned `chunk.runId`. Interrupt continuation correlation needs - // the client's run identity to win when a provider stamps its own id; for - // resumable reconnect/join the two ids match, so precedence is moot there. const requestRunId = chunkRunIds.get(chunk) return requestRunId ?? getNormalizedChunkRunId(chunk) } @@ -116,6 +112,7 @@ export interface ReconnectOptions { interface ResolvedReconnectOptions { maxAttempts: number + /** Delay between reconnect attempts, in ms, to avoid hammering. Default 250. */ delayMs: number } @@ -124,15 +121,14 @@ function resolveReconnectOptions( ): ResolvedReconnectOptions { const maxAttempts = options?.maxAttempts ?? 5 const delayMs = options?.delayMs ?? 250 - // Reject non-finite / negative bounds up front: a NaN or Infinity maxAttempts - // would make the ceiling ineffective (unbounded reconnects), and a non-finite - // delayMs would remove throttling. Fail loudly on misconfiguration. - if (!Number.isInteger(maxAttempts) || maxAttempts < 0) { + const isInvalidMaxAttempts = !Number.isInteger(maxAttempts) || maxAttempts < 0 + if (isInvalidMaxAttempts) { throw new Error( `Invalid reconnect.maxAttempts: ${maxAttempts}. Must be a non-negative integer.`, ) } - if (!Number.isFinite(delayMs) || delayMs < 0) { + const isInvalidDelayMs = !Number.isFinite(delayMs) || delayMs < 0 + if (isInvalidDelayMs) { throw new Error( `Invalid reconnect.delayMs: ${delayMs}. Must be a non-negative finite number.`, ) @@ -172,13 +168,10 @@ export interface ReconnectTracker { export function createReconnectTracker( options?: ReconnectOptions, ): ReconnectTracker { + /** Bounding for resumable-SSE reconnection (throttle delay + attempt ceiling). */ const reconnect = resolveReconnectOptions(options) - // Retains every delivered offset for the run's lifetime. Intentionally - // bounded by run length (not evicted): a conforming server replays strictly - // after the acknowledged offset, so this only needs to catch the single - // boundary event on reconnect, but keeping the full set keeps de-dup - // correct even if a server replays a wider overlap. const seen = new Set() + /** The most recently accepted (non-duplicate, non-empty) offset, if any. */ let lastEventId: string | undefined let reconnectAttempts = 0 return { @@ -199,11 +192,6 @@ export function createReconnectTracker( lastEventId = id return 'new' }, - // Bound only CONSECUTIVE no-progress reconnects. A reconnect that made - // forward progress resets the counter, so a healthy long run (even one - // whose socket rolls after every event) never approaches the ceiling; it - // fires only when the run is genuinely stuck — reconnecting repeatedly - // with nothing new. async waitBeforeReconnect(madeProgress, signal) { if (madeProgress) { reconnectAttempts = 0 @@ -220,7 +208,8 @@ export function createReconnectTracker( /** Resolve after `ms`, or immediately once `signal` aborts. Never rejects. */ function abortableDelay(ms: number, signal?: AbortSignal): Promise { - if (ms <= 0 || signal?.aborted) return Promise.resolve() + const shouldSkipDelay = ms <= 0 || signal?.aborted + if (shouldSkipDelay) return Promise.resolve() return new Promise((resolve) => { const onAbort = () => { clearTimeout(timer) @@ -300,7 +289,8 @@ function withSearchParams(url: string, values: Record): string { const search = new URLSearchParams( queryIndex === -1 ? '' : withoutHash.slice(queryIndex + 1), ) - for (const [key, value] of Object.entries(values)) search.set(key, value) + const objectEntries = Object.entries(values) + for (const [key, value] of objectEntries) search.set(key, value) const query = search.toString() return `${base}${query.length === 0 ? '' : `?${query}`}${hash}` } @@ -334,9 +324,6 @@ async function* readStreamLines( buffer = lines.pop() || '' for (const line of lines) { - // Strip a trailing CR so a CRLF stream matches the LF path (and the - // XHR reader). Without this an exact-equality check like the `[DONE]` - // sentinel in linesToSSEEvents would miss `data: [DONE]\r`. const normalized = line.endsWith('\r') ? line.slice(0, -1) : line if (normalized.trim()) { yield normalized @@ -344,18 +331,10 @@ async function* readStreamLines( } } - // Flush the decoder: a connection cut mid-multibyte-character leaves bytes - // held inside the streaming TextDecoder. Draining them here (as U+FFFD) - // makes the trailing-buffer check below see the incomplete tail and report - // truncation instead of silently swallowing it. buffer += decoder.decode() - // A non-empty trailing buffer means the connection was cut mid-line. - // Surface this as an error so the chat client transitions to 'error' - // state instead of silently presenting a partial stream as success. - // Skip when the consumer aborted — a user-initiated stop() interrupting - // mid-line is expected, not a truncation bug. - if (buffer.trim() && !abortSignal?.aborted) { + const hasLeftoverBytes = buffer.trim() && !abortSignal?.aborted + if (hasLeftoverBytes) { throw new StreamTruncatedError() } } finally { @@ -404,6 +383,60 @@ function sseChunkModel(chunk: StreamChunk): string | undefined { return undefined } +interface SseEventParseState { + lastThreadId?: string + lastRunId?: string + lastModel?: string + pendingId?: string +} + +function readSseIdLine(line: string): string | false { + const isIdLine = line === 'id' || line.startsWith('id:') + if (!isIdLine) return false + const rawId = line === 'id' ? '' : line.slice(3) + return rawId.startsWith(' ') ? rawId.slice(1) : rawId +} + +function isSseControlLine(line: string): boolean { + return ( + line.startsWith(':') || + line.startsWith('event:') || + line.startsWith('retry:') + ) +} + +function createDoneStreamChunk( + state: SseEventParseState, + fallbackIds?: { threadId?: string; runId?: string }, +): StreamChunk { + return withTanstackMetadata( + { + type: EventType.RUN_FINISHED, + threadId: state.lastThreadId ?? fallbackIds?.threadId ?? '', + runId: state.lastRunId ?? fallbackIds?.runId ?? '', + timestamp: Date.now(), + }, + { + finishReason: 'stop', + ...(state.lastModel !== undefined ? { model: state.lastModel } : {}), + }, + ) as StreamChunk +} + +function recordSseChunkIds( + chunk: StreamChunk, + state: SseEventParseState, +): void { + if ('threadId' in chunk && typeof chunk.threadId === 'string') { + state.lastThreadId = chunk.threadId + } + if ('runId' in chunk && typeof chunk.runId === 'string') { + state.lastRunId = chunk.runId + } + const model = sseChunkModel(chunk) + if (model !== undefined) state.lastModel = model +} + /** * Parse SSE-format lines into stream events, pairing each chunk with the `id:` * offset of the event it arrived on. Shared by the fetch- and XHR-backed SSE @@ -422,61 +455,25 @@ async function* linesToSSEEvents( lines: AsyncIterable, fallbackIds?: { threadId?: string; runId?: string }, ): AsyncGenerator { - let lastThreadId: string | undefined - let lastRunId: string | undefined - let lastModel: string | undefined - let pendingId: string | undefined + const state: SseEventParseState = {} for await (const line of lines) { - if (line === 'id' || line.startsWith('id:')) { - // SSE spec: strip a single leading space after the colon, preserve the - // rest verbatim so an opaque adapter offset round-trips exactly (do NOT - // trim, which would mangle a legitimate offset). An empty value is kept as - // '' and resets the resume cursor downstream (see resumableStream). - const rawId = line === 'id' ? '' : line.slice(3) - pendingId = rawId.startsWith(' ') ? rawId.slice(1) : rawId + const idValue = readSseIdLine(line) + if (idValue !== false) { + state.pendingId = idValue continue } - // Assumes the durability wire emits one `id:` immediately followed by one - // `data:` per event (both shipped sinks do). `pendingId` attaches to the - // next data line and is cleared after it; blank-line event boundaries are - // stripped upstream, so a hand-rolled server that emits an id-only event or - // a persistent `id:` across events is not supported here. - if ( - line.startsWith(':') || - line.startsWith('event:') || - line.startsWith('retry:') - ) { + if (isSseControlLine(line)) { continue } const data = parseSseDataLine(line) if (data === '[DONE]') { - yield { - chunk: withTanstackMetadata( - { - type: EventType.RUN_FINISHED, - threadId: lastThreadId ?? fallbackIds?.threadId ?? '', - runId: lastRunId ?? fallbackIds?.runId ?? '', - timestamp: Date.now(), - }, - { - finishReason: 'stop', - ...(lastModel !== undefined ? { model: lastModel } : {}), - }, - ) as StreamChunk, - } + yield { chunk: createDoneStreamChunk(state, fallbackIds) } return } const chunk = restoreInboundUsage(JSON.parse(data) as StreamChunk) - if ('threadId' in chunk && typeof chunk.threadId === 'string') { - lastThreadId = chunk.threadId - } - if ('runId' in chunk && typeof chunk.runId === 'string') { - lastRunId = chunk.runId - } - const model = sseChunkModel(chunk) - if (model !== undefined) lastModel = model - const id = pendingId - pendingId = undefined + recordSseChunkIds(chunk, state) + const id = state.pendingId + state.pendingId = undefined yield { chunk, ...(id !== undefined ? { id } : {}) } } } @@ -554,6 +551,11 @@ async function fetchThreadHydration( const data = (await response.json()) as { messages?: Array activeRun?: { runId?: unknown } | null + /** + * Pending human-in-the-loop interrupts for the thread and the run they paused, + * so a reload (or another device) re-prompts the approval from the server. The + * client restores them exactly as a persisted resume snapshot would. + */ interrupts?: { runId?: unknown pending?: unknown @@ -608,9 +610,6 @@ async function fetchGenerationHydration( if (raw === null) { return { resumeSnapshot: null, activeRun: null } } - // Any OTHER non-object body is a broken endpoint, not an empty thread. - // Reporting it as a miss would present a misconfigured route as a fresh - // thread; the client surfaces this through its own error channel instead. if (typeof raw !== 'object' || Array.isArray(raw)) { throw new Error( `Generation hydration expected a JSON object from ${url}, received ${Array.isArray(raw) ? 'an array' : typeof raw}.`, @@ -655,7 +654,8 @@ async function* responseToSSEChunks( response: Response, abortSignal?: AbortSignal, ): AsyncGenerator { - for await (const { chunk } of responseToSSEEvents(response, abortSignal)) { + const sseEvents = responseToSSEEvents(response, abortSignal) + for await (const { chunk } of sseEvents) { yield chunk } } @@ -696,14 +696,6 @@ function fetchEventSource( ...(abortSignal ? { signal: abortSignal } : {}), }) } catch (error) { - // A fetch REJECTION (device offline, DNS blip, connection refused) is a - // recoverable transport failure, not a fatal one — surface it as - // StreamReadError so resumableStream retries from the last offset, mirroring - // the XHR path (whose onerror wraps the same way). On a genuine abort this - // wraps the AbortError too, but that's harmless: resumableStream checks - // `abortSignal.aborted` first and returns, so the wrapped error's type is - // never inspected. Without an offset (initial connect / non-durable), it - // still surfaces as a hard failure. throw new StreamReadError(error) } yield* parseResponse(response, abortSignal) @@ -737,41 +729,27 @@ async function* resumableStream( : {} let sawTerminal = false + /** Made forward progress (a new, non-duplicate chunk) since the last (re)connect. */ let progressed = false try { - for await (const { chunk, id } of openEventSource( - extraHeaders, - abortSignal, - )) { + const sourceEvents = openEventSource(extraHeaders, abortSignal) + for await (const { chunk, id } of sourceEvents) { if (tracker.note(id) === 'duplicate') continue progressed = true - if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { + const isTerminalChunk = + chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR' + if (isTerminalChunk) { sawTerminal = true } yield chunk - // Do NOT stop on a terminal mid-source: an agent loop emits one - // RUN_STARTED/RUN_FINISHED pair PER turn, so a tool-calling run carries - // several RUN_FINISHED events before the run is truly done. Returning on - // the first one would drop every subsequent turn (the tool result and - // the final answer). Instead, drain the event source to its natural end - // — the server closes the response only when the run is actually - // complete — and use `sawTerminal` below to decide done-vs-reconnect. } } catch (error) { if (abortSignal?.aborted) return - // A transport drop is resumable once we hold an offset — retry from it, - // even if THIS attempt made no new progress. A caught-up run whose parked - // long-poll socket drops (or a proxy that drops just after replaying the - // de-duped overlap) is transient, not fatal; the consecutive-no-progress - // ceiling in waitBeforeReconnect already bounds a genuinely stuck flapper, - // so a per-attempt progress requirement here would only convert - // recoverable drops into hard failures on flaky (mobile/edge) networks. - // Without an offset (a non-durable stream), surface the failure. - if ( + const canReconnect = (error instanceof StreamTruncatedError || error instanceof StreamReadError) && tracker.lastEventId !== undefined - ) { + if (canReconnect) { await tracker.waitBeforeReconnect(progressed, abortSignal) continue } @@ -780,34 +758,14 @@ async function* resumableStream( if (abortSignal?.aborted) return - // The source ended after delivering a terminal event: the run is genuinely - // finished (for an agentic run this is the LAST turn's terminal, since we no - // longer stop on intermediate ones). Stop — reconnecting a durable run here - // would re-open past the final offset and see an empty window. if (sawTerminal) return if (tracker.lastEventId !== undefined) { // A durable (id-tagged) run. if (progressed) { - // Clean end WITHOUT a terminal event but we advanced — the producer is - // still going (or the socket rolled over). Reconnect from the last - // offset (backing off to avoid a hot loop against the origin). Progress - // resets the no-progress ceiling. await tracker.waitBeforeReconnect(true, abortSignal) continue } - // Ended without a terminal event AND made no forward progress on this - // pass: the run cannot complete. Surface an error rather than returning - // silently, which would leave the consumer with neither a terminal event - // nor a failure. - // - // Invariant this relies on: a durable transport must never surface an - // empty long-poll window as a CLEAN end while the producer is still - // alive. Both shipped backends honor it — memoryStream parks until data - // or completion, and durableStream keeps one continuous response across - // windows — so this fires only on a genuinely complete-but-unterminated - // log. A custom StreamDurability transport that ends a response empty - // mid-run would trip this; keep the response open until data or terminal. throw new DurableStreamIncompleteError() } @@ -920,11 +878,6 @@ export interface GenerationHydrationResult { export interface ChatHydrationResult { messages: Array activeRun: { runId: string } | null - /** - * Pending human-in-the-loop interrupts for the thread and the run they paused, - * so a reload (or another device) re-prompts the approval from the server. The - * client restores them exactly as a persisted resume snapshot would. - */ interrupts: { runId: string pending: Array @@ -937,21 +890,10 @@ export interface ChatHydrationResult { * the ordered stream from the start off the server's delivery-durability sink. */ export interface ResumableConnectConnectionAdapter extends ConnectConnectionAdapter { - /** - * Join an in-flight or finished run by id, replaying from the start - * (`?offset=-1`). Read-only — sends no messages. - */ joinRun: ( runId: string, abortSignal?: AbortSignal, ) => AsyncIterable - /** - * Fetch server-authoritative hydration for `threadId`: the stored transcript, - * and a cursor to an in-flight run if one exists. The client calls this itself - * on mount (no loader/prop), then tails `activeRun` via `joinRun`. Read-only - * JSON GET (`?threadId`), so it is transport-agnostic regardless of how the - * delivery stream is served. - */ hydrate?: (threadId: string) => Promise } @@ -969,21 +911,10 @@ export interface SubscribeConnectionAdapter { abortSignal?: AbortSignal, runContext?: RunAgentInputContext, ) => Promise - /** - * Re-attach to an existing run by id, replaying its stream from the start off - * the server's delivery-durability sink. Present only when the underlying - * connection is resumable (a `ResumableConnectConnectionAdapter`). Used to - * rejoin an in-flight run after a full page reload. - */ joinRun?: ( runId: string, abortSignal?: AbortSignal, ) => AsyncIterable - /** - * Server-authoritative hydration for a thread (transcript + in-flight-run - * cursor). Present only when the underlying connection supports it. The client - * calls it on mount to re-hydrate without any app-side loader or prop. - */ hydrate?: (threadId: string) => Promise } @@ -995,6 +926,85 @@ export type ConnectionAdapter = | ConnectConnectionAdapter | SubscribeConnectionAdapter +interface ConnectSendState { + hasTerminalEvent: boolean + upstreamThreadId?: string + upstreamRunId?: string +} + +function noteConnectChunk(chunk: StreamChunk, state: ConnectSendState): void { + if ('threadId' in chunk && typeof chunk.threadId === 'string') { + state.upstreamThreadId = chunk.threadId + } + if ('runId' in chunk && typeof chunk.runId === 'string') { + state.upstreamRunId = chunk.runId + } + const isTerminalChunk = + chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR' + if (isTerminalChunk) { + state.hasTerminalEvent = true + } +} + +function pushSyntheticRunFinished( + state: ConnectSendState, + abortSignal: AbortSignal | undefined, + runContext: RunAgentInputContext | undefined, + push: (chunk: StreamChunk, runId?: string) => void, +): void { + const shouldSkipSynthetic = abortSignal?.aborted || state.hasTerminalEvent + if (shouldSkipSynthetic) return + push( + withTanstackMetadata( + { + type: EventType.RUN_FINISHED, + threadId: requireSyntheticId( + state.upstreamThreadId ?? runContext?.threadId, + 'threadId', + ), + runId: requireSyntheticId( + state.upstreamRunId ?? runContext?.runId, + 'runId', + ), + timestamp: Date.now(), + }, + { finishReason: 'stop', model: 'connect-wrapper' }, + ) as StreamChunk, + runContext?.runId, + ) +} + +function pushSyntheticRunError( + state: ConnectSendState, + abortSignal: AbortSignal | undefined, + runContext: RunAgentInputContext | undefined, + err: unknown, + push: (chunk: StreamChunk, runId?: string) => void, +): void { + const shouldSkipSynthetic = abortSignal?.aborted || state.hasTerminalEvent + if (shouldSkipSynthetic) return + try { + const message = + err instanceof Error ? err.message : 'Unknown error in connect()' + const synthetic: RunErrorEvent = { + type: EventType.RUN_ERROR, + threadId: requireSyntheticId( + state.upstreamThreadId ?? runContext?.threadId, + 'threadId', + ), + runId: requireSyntheticId( + state.upstreamRunId ?? runContext?.runId, + 'runId', + ), + timestamp: Date.now(), + message, + } + push(synthetic, runContext?.runId) + } catch { + // fall through to rethrow the original error + } +} + /** * Normalize a ConnectionAdapter to subscribe/send operations. * @@ -1012,13 +1022,15 @@ export function normalizeConnectionAdapter( const hasSubscribe = 'subscribe' in connection const hasSend = 'send' in connection - if (hasConnect && (hasSubscribe || hasSend)) { + const hasMixedModes = hasConnect && (hasSubscribe || hasSend) + if (hasMixedModes) { throw new Error( 'Connection adapter must provide either connect or both subscribe and send, not both modes', ) } - if (hasSubscribe && hasSend) { + const isSubscribeMode = hasSubscribe && hasSend + if (isSubscribeMode) { const joinRun = (connection as SubscribeConnectionAdapter).joinRun?.bind( connection, ) @@ -1058,9 +1070,7 @@ export function normalizeConnectionAdapter( async function waitUntilSubscriberIdle( abortSignal?: AbortSignal, ): Promise { - // Idle means the subscriber is waiting for the next chunk, so the - // previous chunk has left processIncomingChunk. Empty waiters with an - // empty buffer is in-flight delivery, not idle. + // Idle: subscriber is waiting. Empty waiters with a buffer is in-flight, not idle. const idle = () => activeBuffer.length === 0 && (activeWaiters.length > 0 || abortSignal?.aborted) @@ -1073,7 +1083,9 @@ export function normalizeConnectionAdapter( if (idle()) return await new Promise((resolve) => setTimeout(resolve, 0)) macrotaskWaits++ - if (activeWaiters.length === 0 && macrotaskWaits >= 32) return + const exceededWaitBudget = + activeWaiters.length === 0 && macrotaskWaits >= 32 + if (exceededWaitBudget) return } } @@ -1107,10 +1119,34 @@ export function normalizeConnectionAdapter( })() }, async send(messages, data, abortSignal, runContext) { - let hasTerminalEvent = false - let upstreamThreadId: string | undefined - let upstreamRunId: string | undefined + const state: ConnectSendState = { hasTerminalEvent: false } try { + /** + * Create a direct stream connection adapter (for server functions or direct streams) + * + * @param streamFactory - A function that returns an async iterable of StreamChunks + * @param handlers - Optional persistence handlers (`hydrate`, + * `hydrateGeneration`, `joinRun`) that let server-driven persistence work + * without an HTTP endpoint — each is usually a one-line server-function call + * @returns A connection adapter for direct streams + * + * @example + * ```typescript + * // With TanStack Start server function + * const connection = stream(() => serverFunction({ messages })); + * + * const client = new ChatClient({ connection }); + * + * // With generation persistence over server functions + * const connection = stream( + * () => generateImageFn({ data: input }), + * { + * hydrateGeneration: (threadId) => getImageHydrationFn({ data: threadId }), + * joinRun: (runId) => joinImageRunFn({ data: runId }), + * }, + * ); + * ``` + */ const stream = connection.connect( messages, data, @@ -1118,78 +1154,17 @@ export function normalizeConnectionAdapter( runContext, ) for await (const chunk of stream) { - if ('threadId' in chunk && typeof chunk.threadId === 'string') { - upstreamThreadId = chunk.threadId - } - if ('runId' in chunk && typeof chunk.runId === 'string') { - upstreamRunId = chunk.runId - } - if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { - hasTerminalEvent = true - } + noteConnectChunk(chunk, state) push(chunk, runContext?.runId) } - // If the connect stream ended cleanly without a terminal event, - // synthesize RUN_FINISHED so request-scoped consumers can complete. - // The event payload may carry an upstream/provider runId when one was - // observed, but stamp the caller's request runId so getChunkRunId() - // correlates to activeRunIds / currentRunId (same as real stream chunks). - if (!abortSignal?.aborted && !hasTerminalEvent) { - push( - withTanstackMetadata( - { - type: EventType.RUN_FINISHED, - threadId: requireSyntheticId( - upstreamThreadId ?? runContext?.threadId, - 'threadId', - ), - runId: requireSyntheticId( - upstreamRunId ?? runContext?.runId, - 'runId', - ), - timestamp: Date.now(), - }, - { finishReason: 'stop', model: 'connect-wrapper' }, - ) as StreamChunk, - runContext?.runId, - ) - } + pushSyntheticRunFinished(state, abortSignal, runContext, push) } catch (err) { - if (!abortSignal?.aborted && !hasTerminalEvent) { - // Guard synthesis: requireSyntheticId throws when no id is available, - // and that must not replace the original `err` we are about to - // rethrow. If we can't synthesize a terminal, the real failure still - // surfaces below. - try { - const message = - err instanceof Error ? err.message : 'Unknown error in connect()' - const synthetic: RunErrorEvent = { - type: EventType.RUN_ERROR, - threadId: requireSyntheticId( - upstreamThreadId ?? runContext?.threadId, - 'threadId', - ), - runId: requireSyntheticId( - upstreamRunId ?? runContext?.runId, - 'runId', - ), - timestamp: Date.now(), - message, - } - push(synthetic, runContext?.runId) - } catch { - // fall through to rethrow the original error - } - } + pushSyntheticRunError(state, abortSignal, runContext, err, push) throw err } await waitUntilSubscriberIdle(abortSignal) }, - // Expose joinRun only when the underlying connection is resumable. Require - // a real function — `'joinRun' in connection` is true for - // `{ joinRun: undefined }`, which would wrap a non-callable and throw on - // rehydration rejoin. ...(typeof (connection as ResumableConnectConnectionAdapter).joinRun === 'function' ? { @@ -1215,6 +1190,7 @@ export function normalizeConnectionAdapter( * Options for fetch-based connection adapters */ export interface FetchConnectionOptions { + /** Extra request headers for this run (e.g. BYOK keys). POST only. */ headers?: Record | Headers credentials?: RequestCredentials signal?: AbortSignal @@ -1251,6 +1227,7 @@ function buildRunAgentInputBody( // Precedence (later spreads win): static adapter `body` is the base, // overridden by `runContext.forwardedProps`, overridden by per-message `data`. const wireMessages = uiMessagesToWire(messages) + /** Arbitrary user-controlled passthrough data. */ const forwardedProps = { ...options.body, ...(runContext?.forwardedProps ?? {}), @@ -1328,13 +1305,6 @@ export function fetchServerSentEvents( ...runIdHeader(runContext?.runId), } - // Build AG-UI RunAgentInput payload. - // - // Precedence (later spreads win): static adapter `body` is the base, - // overridden by `runContext.forwardedProps` (constructor body / - // forwardedProps options), overridden by per-message `data` passed - // to `connection.send`. Runtime values win over static config — - // this matches the documented "forwardedProps wins" semantic. const requestBody = buildRunAgentInputBody( messages, data, @@ -1343,19 +1313,9 @@ export function fetchServerSentEvents( ) const fetchClient = resolvedOptions.fetchClient ?? fetch - // `RequestInit.signal` is typed `AbortSignal | null` (no `undefined` - // under `exactOptionalPropertyTypes`), so spread it conditionally - // rather than passing `undefined` explicitly. const signal = abortSignal || resolvedOptions.signal - // POST URL is byte-identical to a plain request; the run id (when set) - // rides in the X-Run-Id header so durability can key the log by it - // without changing the request URL existing clients rely on. const requestUrl = resolvedUrl - // Resumable SSE: if the server tags events with `id:` offsets (delivery - // durability), a dropped/rolled-over connection auto-reconnects with a - // `Last-Event-ID` header and de-dupes the replayed prefix. With no tags, - // this is a single plain fetch. yield* resumableStream( fetchEventSource( fetchClient, @@ -1366,9 +1326,6 @@ export function fetchServerSentEvents( body: JSON.stringify(requestBody), credentials: resolvedOptions.credentials || 'same-origin', }, - // Thread the run's ids so a `[DONE]`-terminating server that doesn't - // stamp them onto events still yields a correlated terminal (parity - // with the XHR adapter's xhrSSEParser). (response, sseSignal) => responseToSSEEvents(response, sseSignal, { ...(runContext?.threadId !== undefined @@ -1384,9 +1341,6 @@ export function fetchServerSentEvents( ) }, async *joinRun(runId, abortSignal) { - // Read an in-flight or finished run from the start. `?offset=-1` tells the - // server's delivery-durability sink to replay from the beginning; `runId` - // identifies which run. This is a read-only GET — no messages are sent. const resolvedUrl = typeof url === 'function' ? url() : url const resolvedOptions = typeof options === 'function' ? await options() : options @@ -1500,13 +1454,6 @@ export function fetchHttpStream( ...runIdHeader(runContext?.runId), } - // Build AG-UI RunAgentInput payload. - // - // Precedence (later spreads win): static adapter `body` is the base, - // overridden by `runContext.forwardedProps` (constructor body / - // forwardedProps options), overridden by per-message `data` passed - // to `connection.send`. Runtime values win over static config — - // this matches the documented "forwardedProps wins" semantic. const requestBody = buildRunAgentInputBody( messages, data, @@ -1515,20 +1462,9 @@ export function fetchHttpStream( ) const fetchClient = resolvedOptions.fetchClient ?? fetch - // `RequestInit.signal` is typed `AbortSignal | null` (no `undefined` - // under `exactOptionalPropertyTypes`), so spread it conditionally - // rather than passing `undefined` explicitly. const signal = abortSignal || resolvedOptions.signal - // POST URL is byte-identical to a plain request; the run id (when set) - // rides in the X-Run-Id header so durability can key the log by it - // without changing the request URL existing clients rely on. const requestUrl = resolvedUrl - // Resumable NDJSON: if the server envelopes each line with an - // `{ id, chunk }` offset (delivery durability), a dropped/rolled-over - // connection auto-reconnects with a `Last-Event-ID` header and de-dupes - // the replayed prefix. With bare lines (no durability), this is a single - // plain fetch — identical to before. yield* resumableStream( fetchEventSource( fetchClient, @@ -1546,9 +1482,6 @@ export function fetchHttpStream( ) }, async *joinRun(runId, abortSignal) { - // Read an in-flight or finished run from the start. `?offset=-1` tells the - // server's delivery-durability sink to replay from the beginning; `runId` - // identifies which run. This is a read-only GET — no messages are sent. const resolvedUrl = typeof url === 'function' ? url() : url const resolvedOptions = typeof options === 'function' ? await options() : options @@ -1649,7 +1582,9 @@ function readXhrLines( } const enqueueDelta = () => { - if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) { + const isHttpError = + xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300) + if (isHttpError) { error = errorFromXhrStatus(xhr) done = true return @@ -1675,13 +1610,15 @@ function readXhrLines( const finish = () => { enqueueDelta() - // Tolerate a transient status === 0 (matches enqueueDelta): a real non-2xx - // is an error, but status 0 here is not — treat the trailing buffer as a - // truncation check instead of fabricating a bogus "status: 0" error. - if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) { + const isHttpError = + xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300) + if (isHttpError) { error = errorFromXhrStatus(xhr) - } else if (buffer.trim() && !aborted) { - error = new StreamTruncatedError() + } else { + const hasLeftoverBytes = buffer.trim() && !aborted + if (hasLeftoverBytes) { + error = new StreamTruncatedError() + } } done = true wake() @@ -1693,9 +1630,6 @@ function readXhrLines( } xhr.onload = finish xhr.onerror = () => { - // Surface as StreamReadError so a durable (id-tagged) run whose socket - // drops mid-stream is eligible for auto-reconnect, matching the fetch path. - // A non-durable run has no offset, so resumableStream rethrows it as-is. error = new StreamReadError(new Error('XHR request failed')) done = true wake() @@ -1738,7 +1672,8 @@ function readXhrLines( } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (done || abortSignal?.aborted) { + const isComplete = done || abortSignal?.aborted + if (isComplete) { return } @@ -1783,7 +1718,8 @@ function createConfiguredXhrRequest( ...extraHeaders, } - for (const [name, value] of Object.entries(requestHeaders)) { + const objectEntries = Object.entries(requestHeaders) + for (const [name, value] of objectEntries) { xhr.setRequestHeader(name, value) } @@ -1838,9 +1774,6 @@ function xhrEventSource( try { yield* parseLines(lines) } finally { - // Tear the socket down on an early exit (terminal reached or reconnect - // break) so late bytes stop downloading. When the abort signal fired, - // `readXhrLines` already aborted — skip here to avoid a double abort(). if (!abortSignal?.aborted) request.xhr.abort() } } @@ -1874,9 +1807,6 @@ export function xhrServerSentEvents( const resolvedUrl = typeof url === 'function' ? url() : url const resolvedOptions = await resolveXhrConnectionOptions(options) const signal = abortSignal || resolvedOptions.signal - // POST URL is byte-identical to a plain request; the run id (when set) - // rides in the X-Run-Id header so durability can key the log by it - // without changing the request URL existing clients rely on. const requestUrl = resolvedUrl yield* resumableStream( xhrEventSource( @@ -1959,9 +1889,6 @@ export function xhrHttpStream( const resolvedUrl = typeof url === 'function' ? url() : url const resolvedOptions = await resolveXhrConnectionOptions(options) const signal = abortSignal || resolvedOptions.signal - // POST URL is byte-identical to a plain request; the run id (when set) - // rides in the X-Run-Id header so durability can key the log by it - // without changing the request URL existing clients rely on. const requestUrl = resolvedUrl yield* resumableStream( xhrEventSource( @@ -2089,28 +2016,16 @@ function createChunkPipe( const iterable = (async function* () { try { while (!abortSignal?.aborted) { - // Drain buffered chunks before ever awaiting a new promise — a - // fatal drop that lands while chunks are still queued (fail() - // finds no pending waiter, since the consumer hasn't caught up - // to its buffer yet) must not be lost. const buffered = queue.shift() if (buffered !== undefined) { yield buffered continue } - // Buffer exhausted: surface a failure recorded while we were - // draining, rather than awaiting a promise that will never - // resolve (the connection is dead — no future push/fail). if (failure !== undefined) throw failure if (ended) return const chunk = await new Promise((r) => waiters.push(r), ) - // The wait resolved because fail() woke us — surface the error - // instead of treating the null sentinel as a clean end. TS narrows - // `failure` to `undefined` from the check above and doesn't know - // the `fail()` closure can reassign it while we were awaiting — - // this check is very much still reachable. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (failure !== undefined) throw failure if (chunk === null) return @@ -2162,17 +2077,7 @@ export function webSocket( } { const Impl = options.WebSocketImpl ?? WebSocket let socket: WebSocket | undefined - // Whether the current socket is the conversation socket ('run') or a - // read-only replay connection opened by a reconnect ('resume'). Only the - // conversation socket accepts run frames server-side. let socketMode: 'run' | 'resume' | undefined - // Memoized per-socket open promise. `openOnce` sets `onopen`/`onerror` - // exactly ONCE, at socket-creation time, and stores the resulting promise - // here. Without this, `waitOpen` assigning `onopen`/`onerror` on every call - // would clobber a still-pending prior caller's handlers: `openOnce` reuses - // the same in-flight socket for concurrent callers (`readyState <= 1`), so a - // second `send()` issued before the handshake completes would overwrite the - // first call's handlers and leave its promise permanently unresolved. let openPromise: Promise | undefined const listeners = new Set() let currentSession: WebSocketRunSession | undefined @@ -2182,12 +2087,6 @@ export function webSocket( } function openOnce(target: string, mode: 'run' | 'resume'): WebSocket { - // Only the conversation socket is reused — it multiplexes many turns. A - // 'resume' handshake carries ?offset and must reach the server as its own - // connection (reusing any open socket would discard that query, so no - // replay would ever be requested), and a run frame must never be written - // to a read-only resume socket (the server registers no message listener - // there, so the frame would be silently ignored). if ( socket && socket.readyState <= 1 && @@ -2226,10 +2125,6 @@ export function webSocket( isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk), ) - // Thread durable chunks through the active run session's tracker (if - // any) so a later reconnect knows the last offset and can skip a - // replayed boundary. A socket with no active session dispatches chunks - // as-is. const session = currentSession if (session) { if (session.tracker.note(envelopeId) === 'duplicate') return @@ -2237,7 +2132,9 @@ export function webSocket( if (session.runId === undefined) { session.runId = getChunkRunId(chunk) } - if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { + const isTerminalChunk = + chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR' + if (isTerminalChunk) { session.sawTerminal = true } } @@ -2253,22 +2150,16 @@ export function webSocket( failAll(new StreamReadError(new Error('WebSocket connection closed'))) return } - if (session.signal?.aborted || session.sawTerminal) return + const isSessionDone = session.signal?.aborted || session.sawTerminal + if (isSessionDone) return const lastEventId = session.tracker.lastEventId if (lastEventId === undefined) { - // Non-durable run (no offset ever observed) — nothing to resume - // from. Surface a hard failure rather than silently reconnecting - // forever against a server that never tags its events. currentSession = undefined failAll(new StreamReadError(new Error('WebSocket connection closed'))) return } void reconnect(session, lastEventId) } - // Retire a superseded socket (e.g. a lingering resume socket when send() - // opens the next conversation socket) so two sockets never feed the - // shared listeners at once. Its handlers see it is no longer current and - // ignore the close. if (prior && prior.readyState <= 1) prior.close() return ws } @@ -2290,13 +2181,9 @@ export function webSocket( return } if (session.signal?.aborted) return - // A send() issued during the backoff supersedes this resume: a newer run - // (or a resubmit of this one) already owns a fresh conversation socket, - // and its turn re-delivers from the durability log — the tracker de-dupes - // any overlap. Opening the resume socket anyway would retire that live - // conversation socket. if (currentSession !== session) return - if (socket && socket.readyState <= 1) return + const hasOpenSocket = socket && socket.readyState <= 1 + if (hasOpenSocket) return session.progressed = false const base = typeof url === 'function' ? url() : url const target = withSearchParams(base, { @@ -2308,9 +2195,6 @@ export function webSocket( function waitOpen(ws: WebSocket): Promise { if (ws.readyState === 1) return Promise.resolve() - // Concurrent callers awaiting the SAME in-flight socket share the SAME - // memoized promise (set once in `openOnce`), so none of them clobber - // another's onopen/onerror handler. return openPromise ?? Promise.resolve() } @@ -2325,11 +2209,6 @@ export function webSocket( const target = typeof url === 'function' ? url() : url const ws = openOnce(runIdQuery(target, runContext?.runId), 'run') await waitOpen(ws) - // Establish (or continue) the run session this socket is driving, so - // an unterminated drop can auto-resume it. A distinct runId starts a - // fresh tracker (a new run's offsets are unrelated to the last one's); - // the same runId reuses the tracker so a repeat send() on an - // already-tracked run doesn't lose its de-dupe/offset state. if (!currentSession || currentSession.runId !== runContext?.runId) { currentSession = { runId: runContext?.runId, @@ -2339,18 +2218,11 @@ export function webSocket( signal: abortSignal, } } else { - // Same-runId resubmit (e.g. a client-tool continuation): keep the - // tracker, but this is a NEW turn — with the previous turn's - // `sawTerminal` left set, a drop during the resubmitted turn would - // neither reconnect nor surface an error. currentSession.signal = abortSignal currentSession.sawTerminal = false currentSession.progressed = false } const session = currentSession - // stop() must reach the server: the conversation socket outlives the - // turn, so without an abort frame the model keeps generating (and - // billing) server-side. The frame aborts only this run's turn. abortSignal?.addEventListener( 'abort', () => { @@ -2383,11 +2255,6 @@ export function webSocket( offset: '-1', runId, }) - // A replay handshake must reach the server as its own connection: - // reusing the conversation socket would discard the ?offset query (no - // replay ever requested), and the conversation socket must not be - // replaced by a read-only replay socket. So joinRun owns a dedicated - // socket and never touches the shared socket or run session. const ws = options.protocols ? new Impl(target, options.protocols) : new Impl(target) @@ -2410,9 +2277,6 @@ export function webSocket( ) } ws.onclose = (event?: CloseEvent) => { - // 1000 = the server finished replaying the log and closed cleanly. - // Anything else is a drop or a policy refusal (e.g. 1008 "no resume - // offset") and must surface — a joinRun socket never auto-reconnects. if (event?.code === 1000) { pipe.end() return @@ -2443,53 +2307,14 @@ export function webSocket( * feature detection (`connection.hydrateGeneration` etc.) keeps working. */ export interface StreamConnectionHandlers { - /** - * Server-driven chat hydration for `persistence: true`: the stored - * transcript for `threadId` plus a cursor to an in-flight run. - */ hydrate?: (threadId: string) => Promise - /** - * Server-driven generation hydration for `persistence: true`: the last - * generation's resume snapshot for `threadId` plus a cursor to a run still - * generating. See {@link ConnectConnectionAdapter.hydrateGeneration}. - */ hydrateGeneration?: (threadId: string) => Promise - /** - * Re-attach to a run still generating and replay it from the start. See - * {@link ConnectConnectionAdapter.joinRun}. - */ joinRun?: ( runId: string, abortSignal?: AbortSignal, ) => AsyncIterable } -/** - * Create a direct stream connection adapter (for server functions or direct streams) - * - * @param streamFactory - A function that returns an async iterable of StreamChunks - * @param handlers - Optional persistence handlers (`hydrate`, - * `hydrateGeneration`, `joinRun`) that let server-driven persistence work - * without an HTTP endpoint — each is usually a one-line server-function call - * @returns A connection adapter for direct streams - * - * @example - * ```typescript - * // With TanStack Start server function - * const connection = stream(() => serverFunction({ messages })); - * - * const client = new ChatClient({ connection }); - * - * // With generation persistence over server functions - * const connection = stream( - * () => generateImageFn({ data: input }), - * { - * hydrateGeneration: (threadId) => getImageHydrationFn({ data: threadId }), - * joinRun: (runId) => joinImageRunFn({ data: runId }), - * }, - * ); - * ``` - */ export function stream( streamFactory: ( messages: Array | Array, diff --git a/packages/ai-client/src/devtools-noop.ts b/packages/ai-client/src/devtools-noop.ts index 6ea536da08..20567d96b4 100644 --- a/packages/ai-client/src/devtools-noop.ts +++ b/packages/ai-client/src/devtools-noop.ts @@ -1,10 +1,3 @@ -// No-op devtools bridge implementations + factories. The chat / generation / -// video clients import the real bridge classes as types only and accept a -// factory in options; when no factory is supplied they fall back to the -// no-op factories here, which never touch `aiEventClient` or any of the -// heavy preview/fixture machinery in `./devtools`. This keeps `./devtools` -// outside the main-entry import graph — consumers opt into functional -// devtools via `@tanstack/ai-client/devtools` (see `package.json#exports`). import { ChatClientEventEmitter } from './events' import type { AIDevtoolsToolFixture, @@ -33,22 +26,12 @@ export type VideoDevtoolsBridgeFactory = ( options: VideoDevtoolsBridgeOptions, ) => VideoDevtoolsBridge -// =========================================================================== -// No-op event emitter — extends the abstract base so it satisfies the type -// without dragging in any of the event-bus runtime cost. -// =========================================================================== - class NoOpChatClientEventEmitter extends ChatClientEventEmitter { protected emitEvent(): void { // intentionally empty } } -// =========================================================================== -// No-op bridges. Methods exist to satisfy the structural shape of the real -// classes; every emit/record call short-circuits. -// =========================================================================== - export class NoOpChatDevtoolsBridge { readonly events: ChatClientEventEmitter @@ -118,9 +101,6 @@ export class NoOpGenerationDevtoolsBridge { // generation-specific surface beginRun(_input: unknown): string { - // Real factories supply a stable id; the no-op still returns a - // unique value because the generation client passes this run id to - // the adapter's RunAgentInputContext. return `noop-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } ensureRunStarted(_runId: string): void {} @@ -153,11 +133,6 @@ export class NoOpVideoDevtoolsBridge< recordVideoStatusChange(): void {} } -// Compile-time parity checks. If a public method is added to the real -// bridge class without a matching stub on the no-op, the corresponding -// `Exclude<...>` resolves to a non-`never` union, which violates the -// `extends never` constraint below and fails the build — surfacing the -// drift at build time instead of as a runtime TypeError later. type AssertBridgeParity = TMissing type _ChatBridgeMissing = Exclude< keyof ChatDevtoolsBridge, @@ -180,15 +155,6 @@ const _bridgeParity: | undefined = undefined void _bridgeParity -// =========================================================================== -// Factories — these are what the clients call when no real factory was -// supplied in options. -// =========================================================================== - -// Casts use `unknown` because the no-op classes don't `extend` the real bridge -// (that would pull the real implementation into the main-entry import graph). -// Structural parity is enforced by the `_*BridgeMissing` checks above. - export const createNoOpChatDevtoolsBridge: ChatDevtoolsBridgeFactory = ( options, ) => diff --git a/packages/ai-client/src/devtools.ts b/packages/ai-client/src/devtools.ts index 647ca92c66..2d80989901 100644 --- a/packages/ai-client/src/devtools.ts +++ b/packages/ai-client/src/devtools.ts @@ -593,7 +593,8 @@ export class ClientDevtoolsBridge { activeBridgeByHookId.delete(this.options.hookId) } - for (const unsubscribe of this.unsubscribers.splice(0)) { + const splicedUnsubscribers = this.unsubscribers.splice(0) + for (const unsubscribe of splicedUnsubscribers) { unsubscribe() } } @@ -637,7 +638,8 @@ export class ClientDevtoolsBridge { } private prepareForEmit(): boolean { - if (this.disposed || this.superseded) { + const isInactive = this.disposed || this.superseded + if (isInactive) { return false } this.activate() @@ -696,12 +698,14 @@ export class ClientDevtoolsBridge { private handleRequestState( event: AIDevtoolsEvent<{ targetHookId?: string }>, ): void { - if (this.disposed || this.superseded) { + const isInactive = this.disposed || this.superseded + if (isInactive) { return } const targetHookId = event.payload.targetHookId - if (targetHookId && targetHookId !== this.options.hookId) { + const isOtherHook = targetHookId && targetHookId !== this.options.hookId + if (isOtherHook) { return } @@ -730,21 +734,19 @@ export class ClientDevtoolsBridge { } private matchesFixtureTarget(fixture: AIDevtoolsToolFixture): boolean { - if (!fixture.hookId && !fixture.threadId) { + const hasNoTarget = !fixture.hookId && !fixture.threadId + if (hasNoTarget) { return false } - // Fixture routing: `hookId` wins when present (latest bridge for that - // registry key). `threadId` is the fallback for fixtures scoped only to a - // conversation / generation slot without a hook id. if (fixture.hookId) { return fixture.hookId === this.options.hookId } - if ( + const isOtherThread = fixture.threadId && (!this.options.threadId || fixture.threadId !== this.options.threadId) - ) { + if (isOtherThread) { return false } return true @@ -794,10 +796,6 @@ export class ClientDevtoolsBridge { } } -// Owns the chat-client devtools surface so the chat client itself stays a -// pure transport. Fixture replay, per-run / per-stream event context, and -// snapshot emission all live here; a no-op bridge can drop in for prod. - export interface ChatDevtoolsBridgeOptions extends AIDevtoolsBridgeOptions { getMessages: () => Array setMessages: (messages: Array) => void @@ -885,9 +883,6 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge, ): { message: UIMessage; toolCallId: string } | undefined { const sourceMessage = fixture.message - if (!sourceMessage || !Array.isArray(sourceMessage.parts)) { + if (!sourceMessage) { + return undefined + } + if (!Array.isArray(sourceMessage.parts)) { return undefined } @@ -1291,7 +1292,8 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge { const ids = new Map() for (const part of parts) { - if (!isRecord(part) || part.type !== 'tool-call') continue + const isToolCallPart = isRecord(part) && part.type === 'tool-call' + if (!isToolCallPart) continue if (typeof part.id !== 'string') continue ids.set(part.id, this.resolveFixtureToolCallId(part.id, messages)) } @@ -1432,10 +1434,6 @@ function hasToolCallId( ) } -// Devtools surface for GenerationClient / VideoGenerationClient. Owns per-run -// history, active-run lifecycle, and snapshot emission; the generation client -// pushes its core state in via the record* methods. - export interface AIDevtoolsGenerationSnapshotBase { input: unknown result: TOutput | null @@ -1515,14 +1513,13 @@ export class GenerationDevtoolsBridge extends ClientDevtoolsBridge< } ensureRunStarted(runId: string): void { - if (this.activeRunStarted && this.activeRunId === runId) return - - if ( - !this.activeRunStarted && - this.activeRunId && - this.activeRunId !== runId - ) { - this.renameRun(this.activeRunId, runId) + if (this.activeRunStarted) { + if (this.activeRunId === runId) return + } else { + const activeRunId = this.activeRunId + if (activeRunId && activeRunId !== runId) { + this.renameRun(activeRunId, runId) + } } this.activeRunId = runId @@ -1721,10 +1718,6 @@ export class GenerationDevtoolsBridge extends ClientDevtoolsBridge< } } -// Video-job specialization: snapshots also carry the job id and the latest -// provider-reported video status so the panel can show streaming progress -// before the final URL lands. - export interface AIDevtoolsVideoSnapshotBase< TOutput, > extends AIDevtoolsGenerationSnapshotBase { @@ -1831,10 +1824,6 @@ export class VideoDevtoolsBridge< } } -// Wraps the plain emitter so callers can do `this.events.X(...)` and get: -// auto-attached run/thread context on every event that accepts one, -// an auto-emitted snapshot after each event, and passive streamId tracking -// so resolveStreamId() works without the chat client telling it. class ChatDevtoolsAwareEventEmitter extends DefaultChatClientEventEmitter { constructor( private readonly getClientId: () => string, diff --git a/packages/ai-client/src/events.ts b/packages/ai-client/src/events.ts index 1d73ed3b43..d0819a6938 100644 --- a/packages/ai-client/src/events.ts +++ b/packages/ai-client/src/events.ts @@ -136,9 +136,6 @@ export abstract class ChatClientEventEmitter { }) } - /** - * Emit tool result state change event - */ /** * Emit thinking update event */ @@ -323,9 +320,6 @@ export abstract class ChatClientEventEmitter { * Default implementation of ChatClientEventEmitter */ export class DefaultChatClientEventEmitter extends ChatClientEventEmitter { - /** - * Emit an event with automatic clientId and timestamp for client/tool events - */ protected emitEvent(eventName: string, data?: Record): void { const timestamp = Date.now() const isUserVisibleEvent = diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index b61634bd9b..45a0109a4c 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -43,9 +43,6 @@ import type { /** * Callbacks stored in a ref so hooks can update them without recreating the client. */ -// All optional fields explicitly allow `| undefined` so callers can spread -// option bags (where each callback may be `undefined`) into the callbacks -// ref under `exactOptionalPropertyTypes`. interface GenerationCallbacks { onResult?: ((result: TResult) => TOutput | null | void) | undefined onError?: ((error: Error) => void) | undefined @@ -108,9 +105,6 @@ export class GenerationClient< > { private readonly connection: ConnectConnectionAdapter | undefined private readonly fetcher: GenerationFetcher | undefined - // Persistence handlers supplied as options (e.g. alongside a `fetcher`), used - // when the connection doesn't carry its own — the connection's handlers take - // precedence when both exist. private readonly hydrateGenerationHandler: | ConnectConnectionAdapter['hydrateGeneration'] | undefined @@ -155,9 +149,6 @@ export class GenerationClient< // construct: hooks build this client during render. this.threadId = options.threadId ?? '' this.uniqueId = this.threadId - // The persistence scope is the explicit `threadId` and nothing else. - // The types require it whenever `persistence` is set. This field keeps a - // generated wire id from becoming a storage key for JS callers. this.persistenceScope = options.threadId this.connection = options.connection this.fetcher = options.fetcher @@ -169,11 +160,8 @@ export class GenerationClient< // `persistence` is `false`/omitted (ephemeral) or `true` (server-driven: // hydrate the last generation for `threadId` from the server on mount). this.serverDriven = options.persistence === true - // The types require `threadId` alongside `persistence`, so this only fires - // for JS callers. Warn rather than fall back silently: keying on the - // generated wire id would write a different slot every reload, restoring - // nothing while accumulating orphaned records. - if (options.persistence && !this.persistenceScope) { + const needsThreadId = options.persistence && !this.persistenceScope + if (needsThreadId) { console.warn( '[TanStack AI] `persistence` needs a stable `threadId` to key on. Without one nothing will be restored after a reload. Pass a `threadId` derived from your own domain (e.g. `product-123-hero`).', ) @@ -196,13 +184,6 @@ export class GenerationClient< this.devtoolsBridge = ( options.devtoolsBridgeFactory ?? createNoOpGenerationDevtoolsBridge )(this.buildDevtoolsBridgeOptions()) - - // Mount hydration (`maybeHydrateFromServer`) is deliberately NOT run here. The framework - // hooks build this client inside `useMemo`, so the constructor executes in - // React's render phase; hydrating here would re-fire the hydrate GET on - // every discarded/speculative render, flooding the connection pool when - // several clients mount together. It is kicked off once from - // `mountDevtools`, which the hooks call from a commit-phase mount effect. } private buildDevtoolsBridgeOptions(): GenerationDevtoolsBridgeOptions { @@ -231,18 +212,8 @@ export class GenerationClient< mountDevtools(): void { this.ensureThreadId() - // Mounting revives a disposed client. Framework hooks call this from - // their mount effect, so a dispose → remount cycle (e.g. React - // StrictMode's mount → cleanup → mount replay against the same memoized - // client) leaves the client usable again. this.disposed = false this.maybeHydrateFromServer() - // Re-attach to an in-flight run whose snapshot is already loaded — the - // remount case. On the FIRST mount the snapshot loads asynchronously and - // `repaintRestoredSnapshot` starts the rejoin; on a StrictMode remount the - // snapshot is already present but the prior rejoin was aborted by - // `dispose()`, so retrigger it here. Guarded by `rejoinInFlight`'s own - // dedupe/in-flight checks, so this never double-joins. this.maybeResumeInFlight() if (this.devtoolsMounted) { return @@ -275,81 +246,9 @@ export class GenerationClient< const { signal } = abortController try { - let headers: Record | undefined - if (this.byok) { - const provider = resolveByokProviderId( - this.byokProvider, - this.body.provider, - ) - headers = await prepareResolvedByokHeaders(this.byok, provider) - } - - if (this.fetcher) { - // Direct fetch path - const result = await this.fetcher( - input, - headers === undefined ? { signal } : { signal, headers }, - ) - if (signal.aborted) return - if (result instanceof Response) { - // Server function returned SSE Response — parse stream - await this.processStream( - parseSSEResponse(result, signal), - runId, - signal, - ) - } else { - this.devtoolsBridge.ensureRunStarted(runId) - this.setResult(result) - this.setStatus('success') - this.completePlainFetcherResumeSnapshot(result) - } - } else if (this.connection) { - // Streaming adapter path - const mergedData = { ...this.body, ...input } - const stream = this.connection.connect( - [], - mergedData, - signal, - this.createRunContext(runId, headers), - ) - await this.processStream(stream, runId, signal) - } else { - throw new Error( - 'GenerationClient requires either a connection or fetcher option', - ) - } - if (!signal.aborted && this.status === 'success') { - // Bump progress to 100 on successful completion so devtools - // snapshots reflect the final state. The bridge mirrors this in - // the run's recorded progress, but the snapshot reads `progress` - // from the client's core state. - this.progress = completeProgressValue(this.progress) - this.devtoolsBridge.finishRun( - this.devtoolsBridge.getActiveRunId() ?? runId, - 'run:completed', - 'completed', - ) - } + await this.executeGenerate(input, runId, signal) } catch (err: unknown) { - if (signal.aborted) return - const error = err instanceof Error ? err : new Error(String(err)) - if (error instanceof ByokMissingError) { - this.byok?.request(error.provider, 'missing') - } - if (error instanceof ByokBlockedError && error.reason === 'locked') { - this.byok?.request(error.provider, 'locked') - } - this.setError(error) - this.setStatus('error') - this.recordResumeSnapshotError(error) - this.devtoolsBridge.finishRun( - this.devtoolsBridge.getActiveRunId() ?? runId, - 'run:errored', - 'errored', - error.message, - ) - this.callbacksRef.onError?.(error) + this.handleGenerateFailure(err, signal, runId) } finally { if (this.abortController === abortController) { this.abortController = null @@ -358,6 +257,96 @@ export class GenerationClient< } } + private async executeGenerate( + input: TInput, + runId: string, + signal: AbortSignal, + ): Promise { + let headers: Record | undefined + if (this.byok) { + const provider = resolveByokProviderId( + this.byokProvider, + this.body.provider, + ) + headers = await prepareResolvedByokHeaders(this.byok, provider) + } + + if (this.fetcher) { + await this.generateWithFetcher(input, signal, runId, headers) + } else if (this.connection) { + const mergedData = { ...this.body, ...input } + const stream = this.connection.connect( + [], + mergedData, + signal, + this.createRunContext(runId, headers), + ) + await this.processStream(stream, runId, signal) + } else { + throw new Error( + 'GenerationClient requires either a connection or fetcher option', + ) + } + const isSuccessfulRun = !signal.aborted && this.status === 'success' + if (isSuccessfulRun) { + this.progress = completeProgressValue(this.progress) + this.devtoolsBridge.finishRun( + this.devtoolsBridge.getActiveRunId() ?? runId, + 'run:completed', + 'completed', + ) + } + } + + private async generateWithFetcher( + input: TInput, + signal: AbortSignal, + runId: string, + headers?: Record, + ): Promise { + if (!this.fetcher) return + const result = await this.fetcher( + input, + headers === undefined ? { signal } : { signal, headers }, + ) + if (signal.aborted) return + if (result instanceof Response) { + await this.processStream(parseSSEResponse(result, signal), runId, signal) + return + } + this.devtoolsBridge.ensureRunStarted(runId) + this.setResult(result) + this.setStatus('success') + this.completePlainFetcherResumeSnapshot(result) + } + + private handleGenerateFailure( + err: unknown, + signal: AbortSignal, + runId: string, + ): void { + if (signal.aborted) return + const error = err instanceof Error ? err : new Error(String(err)) + if (error instanceof ByokMissingError) { + this.byok?.request(error.provider, 'missing') + } + const isByokLocked = + error instanceof ByokBlockedError && error.reason === 'locked' + if (isByokLocked) { + this.byok?.request(error.provider, 'locked') + } + this.setError(error) + this.setStatus('error') + this.recordResumeSnapshotError(error) + this.devtoolsBridge.finishRun( + this.devtoolsBridge.getActiveRunId() ?? runId, + 'run:errored', + 'errored', + error.message, + ) + this.callbacksRef.onError?.(error) + } + /** * Process a stream of AG-UI events from the streaming connection adapter. * @@ -431,7 +420,8 @@ export class GenerationClient< } // An aborted read is a deliberate stop/dispose, not a truncation. - if (!sawTerminalChunk && !signal.aborted) { + const isTruncatedStream = !sawTerminalChunk && !signal.aborted + if (isTruncatedStream) { throw new Error(GENERATION_STREAM_TRUNCATED_MESSAGE) } } @@ -452,10 +442,9 @@ export class GenerationClient< this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled') } } - // A stopped run is no longer resumable. Without this the in-memory - // snapshot stays `running`, and a remount's `maybeResumeInFlight` would - // rejoin a run the user just cancelled. - if (this.resumeSnapshot && this.resumeSnapshot.status === 'running') { + const hasRunningSnapshot = + this.resumeSnapshot && this.resumeSnapshot.status === 'running' + if (hasRunningSnapshot) { this.resumeSnapshot = { ...this.resumeSnapshot, resumeState: null, @@ -525,14 +514,6 @@ export class GenerationClient< dispose(): void { this.disposed = true - // Teardown, NOT a user cancel: abort in-flight DELIVERY (this reader) but - // do NOT call `stop()` — `stop()` marks the run non-resumable and wipes the - // `running` snapshot, which is correct for a Stop button but wrong for an - // unmount / React StrictMode dispose. Clearing it here would destroy the - // in-memory resume state, so a remount of this same client instance could - // never rejoin. (A real page revisit re-hydrates from the server instead.) - // The run itself survives server-side (durable delivery), so the snapshot - // must stay `running` for the remount to resume it. if (this.abortController) { this.abortController.abort() this.abortController = null @@ -540,17 +521,10 @@ export class GenerationClient< this.setIsLoading(false) this.devtoolsBridge.dispose() this.devtoolsMounted = false - // Re-arm mount hydration + rejoin so a remount resumes from the (preserved) - // snapshot. `mountDevtools` re-runs the hydration entry point and - // `maybeResumeInFlight`, both individually guarded. this.serverHydrationStarted = false this.rejoinedRunId = undefined } - // =========================== - // Getters - // =========================== - getResult(): TOutput | null { return this.result } @@ -594,10 +568,6 @@ export class GenerationClient< : undefined } - // =========================== - // Private state setters - // =========================== - private setResult(rawResult: TResult | null): void { if (rawResult === null) { this.result = null @@ -622,9 +592,6 @@ export class GenerationClient< } } - // No onResult callback, or callback returned void → use raw value as - // TOutput. When the caller did not supply an onResult transform, - // `TOutput` defaults to `TResult`, so the runtime cast is sound. // oxlint-disable-next-line eslint-js/no-restricted-syntax -- TOutput defaults to TResult when no onResult transform is supplied this.result = rawResult as unknown as TOutput this.callbacksRef.onResultChange?.(this.result) @@ -772,11 +739,12 @@ export class GenerationClient< const restored = this.reconstructRestoredResult(snapshot) if (restored !== null) { this.setResult(restored) - } else if ( - this.callbacksRef.reconstructResult && - snapshot.status === 'complete' - ) { - this.reportUnrestorableResult() + } else { + const needsUnrestorableError = + this.callbacksRef.reconstructResult && snapshot.status === 'complete' + if (needsUnrestorableError) { + this.reportUnrestorableResult() + } } } @@ -896,16 +864,11 @@ export class GenerationClient< * run is still in flight. */ private recordResumeSnapshotError(error: Error): void { - // Surface the failure on the OBSERVABLE fields FIRST: a rejoin (or live - // stream) that emits RUN_ERROR has already flipped the snapshot to `error` - // via `observeResumeSnapshot`, so the early-return below would otherwise - // skip this and leave `status` stuck on `generating` — the run would look - // like it is still going forever. The guard avoids a duplicate `error` - // emission on the live `generate()` path, which sets the status itself. if (this.status !== 'error') this.setStatus('error') this.setError(error) if (this.resumeSnapshot?.status === 'error') return - if (!this.resumeSnapshot && !this.serverDriven) return + const hasNoResumeTarget = !this.resumeSnapshot && !this.serverDriven + if (hasNoResumeTarget) return const previous = this.resumeSnapshot this.resumeSnapshot = { schemaVersion: 1, @@ -938,9 +901,12 @@ export class GenerationClient< * re-fire the hydrate GET. */ private maybeHydrateFromServer(): void { - if (!this.serverDriven || this.serverHydrationStarted) return + const shouldHydrate = this.serverDriven && !this.serverHydrationStarted + if (!shouldHydrate) return this.serverHydrationStarted = true - if (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) { + const hydrateHandler = + this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler + if (hydrateHandler) { this.hydrateFromServer() } else { // `persistence: true` without any hydrate source can never restore @@ -969,7 +935,9 @@ export class GenerationClient< this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler if (!hydrate) return // A send that already started owns the client; don't stomp it. - if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + const isClientBusy = + this.resumeSnapshot || this.isLoading || this.status !== 'idle' + if (isClientBusy) return void (async () => { let res: GenerationHydrationResult try { @@ -995,8 +963,9 @@ export class GenerationClient< return } // Re-check: a send may have started while the fetch was in flight. - if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') - return + const isClientBusy = + this.resumeSnapshot || this.isLoading || this.status !== 'idle' + if (isClientBusy) return // A run still generating on the server: re-attach and finish it in place. this.repaintRestoredSnapshot(snapshot, res.activeRun?.runId) })() @@ -1008,7 +977,9 @@ export class GenerationClient< * run's state must win over a stale mount-time failure. */ private failHydration(error: Error): void { - if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + const isClientBusy = + this.resumeSnapshot || this.isLoading || this.status !== 'idle' + if (isClientBusy) return this.setStatus('error') this.setError(error) this.callbacksRef.onError?.(error) @@ -1038,7 +1009,8 @@ export class GenerationClient< if (!joinRun) return if (this.rejoinedRunId === runId) return // A fresh send (or an in-progress rejoin) owns the client. - if (this.isLoading || this.abortController) return + const hasActiveStream = this.isLoading || this.abortController + if (hasActiveStream) return this.rejoinedRunId = runId const controller = new AbortController() this.abortController = controller @@ -1055,16 +1027,10 @@ export class GenerationClient< if (!controller.signal.aborted) { const failure = error instanceof Error ? error : new Error(String(error)) - // Settles `status`/`error` AND rewrites the snapshot to a terminal - // `error` with a null `resumeState`, so the next mount does not - // rejoin this run again. this.recordResumeSnapshotError(failure) this.callbacksRef.onError?.(failure) } } finally { - // Only reset if this rejoin still owns the client: a `stop()` + - // fresh `generate()` may have replaced the controller while the tail - // was settling, and that live run owns `isLoading` now. if (this.abortController === controller) { this.abortController = null this.setIsLoading(false) diff --git a/packages/ai-client/src/generation-reconstruct.ts b/packages/ai-client/src/generation-reconstruct.ts index dd70513ee2..bfe2e05999 100644 --- a/packages/ai-client/src/generation-reconstruct.ts +++ b/packages/ai-client/src/generation-reconstruct.ts @@ -8,18 +8,6 @@ import type { } from '@tanstack/ai' import type { GenerationRestoredResult } from './generation-types' -/** - * Per-activity `reconstructResult` mappers. On mount restore the generic - * `GenerationClient` hands each specialized hook a {@link GenerationRestoredResult} - * (the metadata that survived persistence plus the durable artifact refs, each - * carrying its serve `url`); the mapper rebuilds the concrete typed result so - * `result` repaints as if the run had just finished, with media resolved to the - * durable serve route rather than the provider's expired link. - * - * A mapper returns `null` when the snapshot cannot rebuild a valid result; then - * `result` stays null while `status` / `error` / `resumeState` still repaint. - */ - /** Output artifact refs of a given media type that carry a durable serve URL. */ function mediaUrls( restored: GenerationRestoredResult, diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index bee7670bb0..ddf148caab 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -13,10 +13,6 @@ import type { VideoDevtoolsBridgeFactory, } from './devtools-noop' -// =========================== -// Inference Utilities -// =========================== - /** * Maps an `onResult` transform's raw return type to the stored output type. * @@ -54,10 +50,6 @@ export type InferGenerationOutput = TFn extends ( ? InferGenerationOutputFromReturn : TResult -// =========================== -// State -// =========================== - /** * State machine for generation clients. * Simpler than ChatClientState since generation is a single request/response cycle. @@ -105,6 +97,7 @@ export function createGenerationHydrationError( cause?: unknown, ): Error { const suffix = cause instanceof Error ? `: ${cause.message}` : '' + /** Error message if status is 'failed' */ const error = new Error( `[TanStack AI] Restoring the last generation for this thread failed — ${detail}${suffix}`, ) @@ -138,6 +131,7 @@ export function clientStateFromResumeStatus( /** @internal */ export interface GenerationResumeState { + /** Required by `persistence`. The stable scope runs are filed under. */ threadId: string runId: string /** @@ -152,6 +146,7 @@ export interface GenerationResumeState { export interface GenerationResultSnapshot { id?: string model?: string + /** Current status of the video generation job */ status?: string /** * The provider's async job handle (e.g. a Veo/fal video job id used for @@ -159,6 +154,7 @@ export interface GenerationResultSnapshot { * {@link GenerationResumeState.runId}. */ providerJobId?: string + /** When the URL expires, if applicable */ expiresAt?: string /** * The text output of a text activity (a transcription's `text` or a summary's @@ -169,6 +165,7 @@ export interface GenerationResultSnapshot { text?: string /** Token usage, persisted so a text result that requires it can be rebuilt. */ usage?: TokenUsage + /** Persisted artifact references for generated assets, when available */ artifacts?: Array } @@ -230,6 +227,19 @@ export interface GenerationResumeSnapshot { */ export type GenerationPersistenceOptions = | { + /** + * How this generation persists across reloads. + * + * - Omit or `false`: ephemeral, in-memory only. + * - `true`: server-driven. On mount the client hydrates the last generation + * for its `threadId` from the server (needs a `hydrateGeneration` handler, + * from the connection or the option below) and repaints that snapshot. It + * never auto-starts a run. + * + * The record lives on the server, written by `withGenerationPersistence`. The + * browser caches nothing, so a generation's history is never duplicated into + * client storage. + */ persistence: true /** Required by `persistence`. The stable scope runs are filed under. */ threadId: string @@ -240,10 +250,6 @@ export type GenerationPersistenceOptions = threadId?: string } -// =========================== -// Event Constants -// =========================== - /** * Well-known CUSTOM event names used by generation clients. * These events are emitted by the server-side streaming helpers @@ -262,10 +268,6 @@ export const GENERATION_EVENTS = { VIDEO_STATUS: 'video:status', } as const -// =========================== -// Transport Types -// =========================== - /** * Options passed to a fetcher function by the generation client. */ @@ -299,10 +301,7 @@ export type GenerationTransport = | { connection: ConnectConnectionAdapter; fetcher?: never } | { fetcher: GenerationFetcher; connection?: never } -// =========================== -// Client Options -// =========================== - +// eslint-disable-next-line @typescript-eslint/naming-convention -- _TInput is unused in the interface body but part of the public positional generic API (callers supply it for inference) /** * Options for the GenerationClient. * @@ -310,32 +309,7 @@ export type GenerationTransport = * @template TResult - The result type returned by the generation * @template TOutput - The output type after optional transform (defaults to TResult) */ -// eslint-disable-next-line @typescript-eslint/naming-convention -- _TInput is unused in the interface body but part of the public positional generic API (callers supply it for inference) export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { - /** - * The **scope** this generation belongs to: a stable, app-chosen name for the - * slot successive runs fill, not a link to a chat conversation. This is the - * only identity for the client: wire thread id, DevTools hook id, and - * persistence key. - * - * A generation hook starts empty and produces many runs over its life. Each - * run gets its own `runId`, but they all belong to one scope. Persistence - * keys on this: server-driven hydrates the last run for it on mount. It is - * also sent as the AG-UI thread id on the wire, since the protocol requires - * one. - * - * Derive it from your own domain. It must be meaningful before any media - * exists and identical after a reload: - * - * ```ts - * threadId: `video-${videoId}-start-frame` - * ``` - * - * **Required whenever `persistence` is set.** An app that cannot name the - * scope has nothing to restore *to*, and a generated fallback would key each - * reload differently, silently restoring nothing. Optional only for - * ephemeral runs. If omitted, the client mints a wire id after mount. - */ threadId?: string /** Additional body parameters to send with connect-based adapter requests */ @@ -357,19 +331,6 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> { /** Metadata used to register this generation hook with TanStack AI Devtools */ devtools?: Partial - /** - * How this generation persists across reloads. - * - * - Omit or `false`: ephemeral, in-memory only. - * - `true`: server-driven. On mount the client hydrates the last generation - * for its `threadId` from the server (needs a `hydrateGeneration` handler, - * from the connection or the option below) and repaints that snapshot. It - * never auto-starts a run. - * - * The record lives on the server, written by `withGenerationPersistence`. The - * browser caches nothing, so a generation's history is never duplicated into - * client storage. - */ persistence?: boolean /** @@ -460,34 +421,30 @@ export interface GenerationRestoredResult { providerJobId?: string expiresAt?: string text?: string + /** Token usage, persisted so a text result that requires it can be rebuilt. */ usage?: TokenUsage activity?: PersistedArtifactRef['source']['activity'] artifacts: Array } -/** - * Reduces one observed stream chunk into the lightweight resume snapshot. - * - * A `RUN_STARTED` chunk begins a fresh run, so stale `result` / `error` / - * `pendingArtifacts` from a previous run are dropped rather than carried into - * the new run's snapshot. - * - * @internal - */ -export function updateGenerationResumeSnapshot( +function chunkCorrelationId( + chunk: StreamChunk, + key: 'threadId' | 'runId', +): string | undefined { + const tanstack = tanstackMetadata(chunk) + const fromChunk = stringField(chunk, key) + if (fromChunk) return fromChunk + const fromMeta = tanstack?.[key] + return typeof fromMeta === 'string' ? fromMeta : undefined +} + +function createCarriedResumeSnapshot( previous: GenerationResumeSnapshot | null | undefined, chunk: StreamChunk, ): GenerationResumeSnapshot { - const tanstack = tanstackMetadata(chunk) - const threadId = - stringField(chunk, 'threadId') ?? - (typeof tanstack?.threadId === 'string' ? tanstack.threadId : undefined) - const runId = - stringField(chunk, 'runId') ?? - (typeof tanstack?.runId === 'string' ? tanstack.runId : undefined) const carried = chunk.type === 'RUN_STARTED' ? undefined : previous const previousArtifacts = carried?.pendingArtifacts ?? [] - const next: GenerationResumeSnapshot = { + return { schemaVersion: 1, resumeState: carried?.resumeState ?? null, status: carried?.status ?? 'idle', @@ -499,50 +456,111 @@ export function updateGenerationResumeSnapshot( ...(carried?.error ? { error: { ...carried.error } } : {}), lastEvent: createGenerationEventSnapshot(chunk), } +} +function applyResumeIdentity( + next: GenerationResumeSnapshot, + chunk: StreamChunk, +): void { + const threadId = chunkCorrelationId(chunk, 'threadId') + const runId = chunkCorrelationId(chunk, 'runId') if (threadId && runId) { next.resumeState = { threadId, runId } next.status = 'running' - } else if (chunk.type === 'RUN_STARTED') { + return + } + if (chunk.type === 'RUN_STARTED') { next.status = 'running' } +} + +type CustomStreamChunk = Extract + +function applyArtifactsCustomEvent( + next: GenerationResumeSnapshot, + chunk: CustomStreamChunk, +): void { + const artifacts = collectArtifactRefs(chunk.value) + if (artifacts.length === 0) return + next.pendingArtifacts = artifacts + next.activity = artifacts[0]?.source.activity +} + +function applyResultCustomEvent( + next: GenerationResumeSnapshot, + chunk: CustomStreamChunk, +): void { + const result = createGenerationResultSnapshot(chunk.value) + if (!result) return + next.result = result + if (result.artifacts && result.artifacts.length > 0) { + next.pendingArtifacts = result.artifacts + next.activity = result.artifacts[0]?.source.activity + } +} - if (chunk.type === 'CUSTOM') { - if (chunk.name === GENERATION_EVENTS.ARTIFACTS) { - const artifacts = collectArtifactRefs(chunk.value) - if (artifacts.length > 0) { - next.pendingArtifacts = artifacts - next.activity = artifacts[0]?.source.activity - } - } else if (chunk.name === GENERATION_EVENTS.RESULT) { - const result = createGenerationResultSnapshot(chunk.value) - if (result) { - next.result = result - if (result.artifacts && result.artifacts.length > 0) { - next.pendingArtifacts = result.artifacts - next.activity = result.artifacts[0]?.source.activity - } - } - } else if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) { - // Capture the provider job id as soon as the job exists — for a long - // video run this is the one piece of identity worth having after a - // reload, and the terminal `generation:result` may never arrive. - const providerJobId = isObject(chunk.value) - ? stringField(chunk.value, 'jobId') - : undefined - if (providerJobId) { - next.result = { ...next.result, providerJobId } - } - } - } else if (chunk.type === 'RUN_FINISHED') { +function applyVideoJobCreatedCustomEvent( + next: GenerationResumeSnapshot, + chunk: CustomStreamChunk, +): void { + const providerJobId = isObject(chunk.value) + ? stringField(chunk.value, 'jobId') + : undefined + if (providerJobId) { + next.result = { ...next.result, providerJobId } + } +} + +const generationCustomEventHandlers: Record< + string, + (next: GenerationResumeSnapshot, chunk: CustomStreamChunk) => void +> = { + [GENERATION_EVENTS.ARTIFACTS]: applyArtifactsCustomEvent, + [GENERATION_EVENTS.RESULT]: applyResultCustomEvent, + [GENERATION_EVENTS.VIDEO_JOB_CREATED]: applyVideoJobCreatedCustomEvent, +} + +function applyGenerationCustomEvent( + next: GenerationResumeSnapshot, + chunk: StreamChunk, +): void { + if (chunk.type !== 'CUSTOM') return + generationCustomEventHandlers[chunk.name]?.(next, chunk) +} + +function applyGenerationRunTerminal( + next: GenerationResumeSnapshot, + chunk: StreamChunk, +): void { + if (chunk.type === 'RUN_FINISHED') { next.resumeState = null next.status = 'complete' - } else if (chunk.type === 'RUN_ERROR') { + return + } + if (chunk.type === 'RUN_ERROR') { next.resumeState = null next.status = 'error' next.error = createGenerationErrorSnapshot(chunk) } +} +/** + * Reduces one observed stream chunk into the lightweight resume snapshot. + * + * A `RUN_STARTED` chunk begins a fresh run, so stale `result` / `error` / + * `pendingArtifacts` from a previous run are dropped rather than carried into + * the new run's snapshot. + * + * @internal + */ +export function updateGenerationResumeSnapshot( + previous: GenerationResumeSnapshot | null | undefined, + chunk: StreamChunk, +): GenerationResumeSnapshot { + const next = createCarriedResumeSnapshot(previous, chunk) + applyResumeIdentity(next, chunk) + applyGenerationCustomEvent(next, chunk) + applyGenerationRunTerminal(next, chunk) return next } @@ -564,7 +582,8 @@ export function parseGenerationResumeSnapshot( if (!isObject(value)) return undefined const schemaVersion = Reflect.get(value, 'schemaVersion') - if (schemaVersion !== undefined && schemaVersion !== 1) return undefined + const isUnsupportedSchema = schemaVersion !== undefined && schemaVersion !== 1 + if (isUnsupportedSchema) return undefined const status = generationResumeStatusField(value, 'status') if (!status) return undefined @@ -575,7 +594,8 @@ export function parseGenerationResumeSnapshot( if (!isObject(rawResumeState)) return undefined const threadId = stringField(rawResumeState, 'threadId') const runId = stringField(rawResumeState, 'runId') - if (!threadId || !runId) return undefined + if (!threadId) return undefined + if (!runId) return undefined resumeState = { threadId, runId } } @@ -626,10 +646,6 @@ function generationResumeStatusField( } } -// =========================== -// Video-Specific Options -// =========================== - /** * Video status information returned during job polling. */ @@ -671,10 +687,6 @@ export interface VideoGenerationClientOptions< GenerationClientOptions, 'devtoolsBridgeFactory' > { - /** - * Factory that constructs the video devtools bridge. Default is a no-op - * factory; the real implementation lives in `@tanstack/ai-client/devtools`. - */ devtoolsBridgeFactory?: VideoDevtoolsBridgeFactory /** Callback when a video job is created */ @@ -689,10 +701,6 @@ export interface VideoGenerationClientOptions< onVideoStatusChange?: (status: VideoStatusInfo | null) => void } -// =========================== -// Input Types -// =========================== - /** * Input for image generation. */ @@ -775,11 +783,6 @@ export interface SummarizeGenerateInput { * Input for video generation. */ export interface VideoGenerateInput { - /** - * Description of the desired video: plain text, or an ordered array of - * content parts (text + image) for image-conditioned generation - * (image-to-video, start/end frames). - */ prompt: MediaPrompt /** Video size — format depends on provider (e.g., "16:9", "1280x720") */ size?: string @@ -812,10 +815,6 @@ export function createGenerationResultSnapshot( const id = stringField(value, 'id') const model = stringField(value, 'model') const status = stringField(value, 'status') - // A live provider result carries its job handle as `jobId` (e.g. - // `VideoGenerateResult.jobId`); a persisted snapshot carries it as - // `providerJobId`. Accept both — this narrows raw results AND stored - // snapshots. const providerJobId = stringField(value, 'providerJobId') ?? stringField(value, 'jobId') // A transcription's output is `text`; a summary's is `summary`. Capture either @@ -832,11 +831,10 @@ export function createGenerationResultSnapshot( const expiresAt = Reflect.get(value, 'expiresAt') if (typeof expiresAt === 'string') { snapshot.expiresAt = expiresAt - } else if (expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime())) { - // `toISOString()` throws on an invalid Date. This runs per chunk on live - // provider values, so drop an unusable date like every other bad field - // here rather than throwing out of the stream loop. - snapshot.expiresAt = expiresAt.toISOString() + } else { + if (expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime())) { + snapshot.expiresAt = expiresAt.toISOString() + } } if (artifacts.length > 0) { snapshot.artifacts = artifacts @@ -884,32 +882,35 @@ function createPersistedArtifactRefSnapshot( const runId = stringField(value, 'runId') const name = stringField(value, 'name') const mimeType = stringField(value, 'mimeType') + /** Image size in WIDTHxHEIGHT format (e.g., "1024x1024") */ const size = numberField(value, 'size') const createdAt = stringField(value, 'createdAt') const activity = persistedArtifactActivityField(source, 'activity') const path = stringField(source, 'path') const provider = stringField(source, 'provider') const model = stringField(source, 'model') - if ( - !role || - !artifactId || - !threadId || - !runId || - !name || - !mimeType || - size === undefined || - !createdAt || - !activity || - !path || - !provider || - !model - ) { + const isCompleteArtifact = + role && + artifactId && + threadId && + runId && + name && + mimeType && + size !== undefined && + createdAt && + activity && + path && + provider && + model + if (!isCompleteArtifact) { return undefined } const sourceUrl = durableUrlField(value, 'sourceUrl') + /** URL to the generated video (when completed) */ const url = serveUrlField(value, 'url') const mediaType = persistedArtifactMediaTypeField(source, 'mediaType') + /** Job identifier */ const jobId = stringField(source, 'jobId') const expiresAt = stringField(source, 'expiresAt') @@ -938,7 +939,8 @@ function createPersistedArtifactRefSnapshot( function durableUrlField(value: object, key: string): string | undefined { const field = stringField(value, key) - if (!field || field.length > 2048) return undefined + if (!field) return undefined + if (field.length > 2048) return undefined try { const url = new URL(field) return url.protocol === 'http:' || url.protocol === 'https:' @@ -958,12 +960,11 @@ function durableUrlField(value: object, key: string): string | undefined { */ function serveUrlField(value: object, key: string): string | undefined { const field = stringField(value, key) - if (!field || field.length > 2048) return undefined - // A single leading `/` is a safe same-origin path. Reject protocol-relative - // `//host` AND a backslash bypass (`/\host` — the URL parser treats `\` as `/` - // for http(s), so it would resolve to a foreign origin as an ``). - if (field.startsWith('/') && !field.startsWith('//') && !field.includes('\\')) - return field + if (!field) return undefined + if (field.length > 2048) return undefined + const isRelativeUrl = + field.startsWith('/') && !field.startsWith('//') && !field.includes('\\') + if (isRelativeUrl) return field try { const url = new URL(field) return url.protocol === 'http:' || url.protocol === 'https:' diff --git a/packages/ai-client/src/interrupt-manager.ts b/packages/ai-client/src/interrupt-manager.ts index 44225bd387..9b43c19d69 100644 --- a/packages/ai-client/src/interrupt-manager.ts +++ b/packages/ai-client/src/interrupt-manager.ts @@ -96,7 +96,12 @@ interface ValidationFailure { path?: ReadonlyArray } -type ValidationResult = { valid: true; payload: unknown } | ValidationFailure +type ValidationResult = + | { + valid: true /** Validated display payload for a registered first-party generic item. */ + payload: unknown + } + | ValidationFailure interface TransactionToken { active: boolean @@ -212,12 +217,12 @@ function getDescriptorBinding( function hasReservedFirstPartyBindingMarker(interrupt: Interrupt): boolean { if (!isUnknownObject(interrupt.metadata)) return false + /** `undefined` only for `unbound` items. */ const binding = interrupt.metadata[INTERRUPT_BINDING_METADATA_KEY] if (!isUnknownObject(binding)) return false - if ( - binding['v'] !== undefined && - binding['v'] !== INTERRUPT_BINDING_VERSION - ) { + const isUnsupportedBindingVersion = + binding['v'] !== undefined && binding['v'] !== INTERRUPT_BINDING_VERSION + if (isUnsupportedBindingVersion) { return false } return ( @@ -234,7 +239,9 @@ function hasReservedFirstPartyBindingMarker(interrupt: Interrupt): boolean { function hasFirstPartyGenericMarker(interrupt: Interrupt): boolean { if (!isUnknownObject(interrupt.metadata)) return false const binding = interrupt.metadata[INTERRUPT_BINDING_METADATA_KEY] - if (!isUnknownObject(binding) || binding['kind'] !== 'generic') return false + const isGenericBinding = + isUnknownObject(binding) && binding['kind'] === 'generic' + if (!isGenericBinding) return false return ( 'definitionId' in binding || 'key' in binding || @@ -310,10 +317,6 @@ function validateWithSchema( ? Promise.resolve(result).then(normalize) : normalize(result) } - // A non-Standard-Schema value (a raw JSON Schema arriving over the wire) is - // not validated by the library. The application transforms the schema and - // validates the value itself before resolving; whatever it passes flows - // through as-is. return { valid: true, payload: value } } @@ -361,8 +364,10 @@ function readSubmissionErrors( error: unknown, ): ReadonlyArray { if (isSubmissionError(error)) return [error] - if (!isUnknownObject(error) || !Array.isArray(error['errors'])) return [] - return error['errors'].every(isSubmissionError) ? error['errors'] : [] + if (isUnknownObject(error) && Array.isArray(error['errors'])) { + return error['errors'].every(isSubmissionError) ? error['errors'] : [] + } + return [] } function haveSameInterruptIds( @@ -425,11 +430,11 @@ function submissionErrorMatchesActiveBatch( error: InterruptSubmissionError, submission: InterruptManagerSubmission, ): boolean { - if ( + const isForeignBatch = error.threadId !== submission.threadId || error.interruptedRunId !== submission.interruptedRunId || error.generation !== submission.generation - ) { + if (isForeignBatch) { return false } const interruptIds = submission.resolutions.map( @@ -460,6 +465,53 @@ function genericBinding( }) } +function isCorrelatedBinding( + candidate: InterruptBinding, + interrupt: Interrupt, + hydration: InterruptManagerHydration, +): boolean { + return ( + candidate.interruptId === interrupt.id && + candidate.interruptedRunId === hydration.interruptedRunId && + candidate.generation === hydration.generation && + responseSchemaHash(interrupt) === candidate.responseSchemaHash + ) +} + +function isStructurallyCorrelatedBinding( + candidate: InterruptBinding, + interrupt: Interrupt, + hydration: InterruptManagerHydration, +): boolean { + return ( + candidate.interruptId === interrupt.id && + candidate.interruptedRunId === hydration.interruptedRunId && + candidate.generation === hydration.generation && + candidate.responseSchemaHash === + (responseSchemaHash(interrupt) ?? candidate.responseSchemaHash) + ) +} + +function isGenericFallbackResumable( + legacyResumable: boolean, + candidate: InterruptBinding | undefined, + interrupt: Interrupt, + hydration: InterruptManagerHydration, +): boolean { + if (legacyResumable) return true + if (candidate === undefined) return false + const isMismatchedBinding = + candidate.interruptId !== interrupt.id || + candidate.interruptedRunId !== hydration.interruptedRunId || + candidate.generation !== hydration.generation + if (isMismatchedBinding) { + return false + } + if (candidate.kind !== 'generic') return true + const hash = responseSchemaHash(interrupt) + return hash === undefined || candidate.responseSchemaHash === hash +} + function baseSnapshot( item: RuntimeInterrupt, hydration: InterruptManagerHydration, @@ -723,77 +775,70 @@ export class InterruptManager< const legacyResumable = candidate === undefined && isLegacyInterruptMetadata(interrupt) - // No binding we understand, and nothing else identifying the descriptor as - // ours, means this interrupt was not produced by this package's resume - // path — a workflow engine's durable approval projected onto the same - // AG-UI stream, a third-party agent's pause, or a binding written at a - // protocol version we don't know. - // - // Do not invent a binding for it. Synthesising one would render a - // resolvable form whose answer is submitted against a run that has no - // matching pending descriptor, failing late as `unknown-interrupt` after - // the user has already filled it in. Surface it as unresolvable instead, - // so "someone else owns this pause" is visible rather than silently - // translated into an AI-domain interrupt. - // - // Pre-binding TanStack descriptors are still ours: they carry the legacy - // `metadata.kind` marker, so they keep hydrating through the generic path - // below. - if (candidate === undefined && !legacyResumable) { - if (hasReservedFirstPartyBindingMarker(interrupt)) { - return { - descriptor: interrupt, - binding: genericBinding(interrupt, hydration, undefined), - kind: 'generic', - status: 'error', - canResolve: false, - resumable: false, - error: this.itemError( - interrupt.id, - 'stale', - 'The interrupt binding is invalid or incomplete.', - ), - validationGeneration: 0, - } - } - return { - descriptor: interrupt, - binding: undefined, - kind: 'unbound', - status: 'pending', - canResolve: false, - resumable: false, - validationGeneration: 0, - } + const isUnownedInterrupt = candidate === undefined && !legacyResumable + if (isUnownedInterrupt) { + return this.hydrateUnownedInterrupt(interrupt, hydration) } - const correlated = - candidate !== undefined && - candidate.interruptId === interrupt.id && - candidate.interruptedRunId === hydration.interruptedRunId && - candidate.generation === hydration.generation && - responseSchemaHash(interrupt) === candidate.responseSchemaHash - const structurallyCorrelated = candidate !== undefined && - candidate.interruptId === interrupt.id && - candidate.interruptedRunId === hydration.interruptedRunId && - candidate.generation === hydration.generation && - candidate.responseSchemaHash === - (responseSchemaHash(interrupt) ?? candidate.responseSchemaHash) + isStructurallyCorrelatedBinding(candidate, interrupt, hydration) + const mismatched = this.hydrateMismatchedFirstPartyGeneric( + interrupt, + hydration, + candidate, + structurallyCorrelated, + ) + if (mismatched) return mismatched - if ( + const toolApproval = this.hydrateToolApprovalInterrupt( + interrupt, + candidate, + structurallyCorrelated, + ) + if (toolApproval) return toolApproval + + const clientTool = this.hydrateClientToolInterrupt( + interrupt, + candidate, + structurallyCorrelated, + ) + if (clientTool) return clientTool + + const duplicate = this.hydrateDuplicateGenericBatch( + interrupt, + candidate, + firstPartyIndexes, + ) + if (duplicate) return duplicate + + const correlated = candidate !== undefined && - hasFirstPartyGenericMarker(interrupt) && - (!structurallyCorrelated || - candidate.kind !== 'generic' || - candidate.definitionId === undefined || - candidate.key === undefined || - candidate.batchIndex === undefined) - ) { + isCorrelatedBinding(candidate, interrupt, hydration) + const firstParty = this.hydrateFirstPartyGenericInterrupt( + interrupt, + candidate, + correlated, + firstPartyIndexes, + ) + if (firstParty) return firstParty + + return this.hydrateGenericFallbackInterrupt( + interrupt, + hydration, + candidate, + legacyResumable, + ) + } + + private hydrateUnownedInterrupt( + interrupt: Interrupt, + hydration: InterruptManagerHydration, + ): RuntimeInterrupt { + if (hasReservedFirstPartyBindingMarker(interrupt)) { return { descriptor: interrupt, - binding: genericBinding(interrupt, hydration, candidate), + binding: genericBinding(interrupt, hydration, undefined), kind: 'generic', status: 'error', canResolve: false, @@ -801,64 +846,92 @@ export class InterruptManager< error: this.itemError( interrupt.id, 'stale', - 'The interrupt binding does not match this interrupted run.', + 'The interrupt binding is invalid or incomplete.', ), validationGeneration: 0, } } + return { + descriptor: interrupt, + binding: undefined, + kind: 'unbound', + status: 'pending', + canResolve: false, + resumable: false, + validationGeneration: 0, + } + } - if (structurallyCorrelated && candidate.kind === 'tool-approval') { - const tool = this.tools?.find( - (configured) => configured.name === candidate.toolName, + private hydrateMismatchedFirstPartyGeneric( + interrupt: Interrupt, + hydration: InterruptManagerHydration, + candidate: InterruptBinding | undefined, + structurallyCorrelated: boolean, + ): RuntimeInterrupt | undefined { + const isMismatchedGeneric = + candidate !== undefined && + hasFirstPartyGenericMarker(interrupt) && + !( + structurallyCorrelated && + candidate.kind === 'generic' && + candidate.definitionId !== undefined && + candidate.key !== undefined && + candidate.batchIndex !== undefined ) - // Gated on the binding and the schema hashes below, not on - // `interrupt.reason` — that string is free-form AG-UI text another - // producer can also use, so it cannot be what decides ownership. - if ( - tool?.needsApproval === true && - interrupt.toolCallId === candidate.toolCallId - ) { - try { - const approval = normalizeApprovalSchema( - tool.approvalSchema, - tool.inputSchema, - ) - if ( - hashSchemaInput(tool.inputSchema) === candidate.inputSchemaHash && - approval.approvalSchemaHash === candidate.approvalSchemaHash && - approval.responseSchemaHash === candidate.responseSchemaHash - ) { - return { - descriptor: interrupt, - binding: cloneAndDeepFreezeJson(candidate), - kind: 'tool-approval', - status: 'pending', - canResolve: true, - resumable: true, - tool, - validationGeneration: 0, - } - } - } catch { - // Invalid configured schemas cannot safely grant typed hydration. - } - } + if (!isMismatchedGeneric) { + return undefined + } + return { + descriptor: interrupt, + binding: genericBinding(interrupt, hydration, candidate), + kind: 'generic', + status: 'error', + canResolve: false, + resumable: false, + error: this.itemError( + interrupt.id, + 'stale', + 'The interrupt binding does not match this interrupted run.', + ), + validationGeneration: 0, } + } - if (structurallyCorrelated && candidate.kind === 'client-tool-execution') { - const tool = this.tools?.find( - (configured) => configured.name === candidate.toolName, + private hydrateToolApprovalInterrupt( + interrupt: Interrupt, + candidate: InterruptBinding | undefined, + structurallyCorrelated: boolean, + ): RuntimeInterrupt | undefined { + const isToolApprovalBinding = + structurallyCorrelated && + candidate !== undefined && + candidate.kind === 'tool-approval' + if (!isToolApprovalBinding) { + return undefined + } + const tool = this.tools?.find( + (configured) => configured.name === candidate.toolName, + ) + const isInvalidApprovalTool = + tool?.needsApproval !== true || + interrupt.toolCallId !== candidate.toolCallId + if (isInvalidApprovalTool) { + return undefined + } + try { + const approval = normalizeApprovalSchema( + tool.approvalSchema, + tool.inputSchema, ) - // Binding-gated, for the same reason as tool approvals above. - if ( - tool !== undefined && - interrupt.toolCallId === candidate.toolCallId && - hashSchemaInput(tool.outputSchema) === candidate.outputSchemaHash - ) { + const hasMatchingApprovalSchemas = + hashSchemaInput(tool.inputSchema) === candidate.inputSchemaHash && + approval.approvalSchemaHash === candidate.approvalSchemaHash && + approval.responseSchemaHash === candidate.responseSchemaHash + if (hasMatchingApprovalSchemas) { return { descriptor: interrupt, binding: cloneAndDeepFreezeJson(candidate), - kind: 'client-tool-execution', + kind: 'tool-approval', status: 'pending', canResolve: true, resumable: true, @@ -866,9 +939,53 @@ export class InterruptManager< validationGeneration: 0, } } + } catch { + // Invalid configured schemas cannot safely grant typed hydration. } + return undefined + } - if ( + private hydrateClientToolInterrupt( + interrupt: Interrupt, + candidate: InterruptBinding | undefined, + structurallyCorrelated: boolean, + ): RuntimeInterrupt | undefined { + const isClientToolBinding = + structurallyCorrelated && + candidate !== undefined && + candidate.kind === 'client-tool-execution' + if (!isClientToolBinding) { + return undefined + } + const tool = this.tools?.find( + (configured) => configured.name === candidate.toolName, + ) + // Binding-gated, for the same reason as tool approvals above. + const isInvalidClientTool = + tool === undefined || + interrupt.toolCallId !== candidate.toolCallId || + hashSchemaInput(tool.outputSchema) !== candidate.outputSchemaHash + if (isInvalidClientTool) { + return undefined + } + return { + descriptor: interrupt, + binding: cloneAndDeepFreezeJson(candidate), + kind: 'client-tool-execution', + status: 'pending', + canResolve: true, + resumable: true, + tool, + validationGeneration: 0, + } + } + + private hydrateDuplicateGenericBatch( + interrupt: Interrupt, + candidate: InterruptBinding | undefined, + firstPartyIndexes: ReadonlyMap, + ): RuntimeInterrupt | undefined { + const isDuplicateGeneric = candidate !== undefined && candidate.kind === 'generic' && candidate.definitionId !== undefined && @@ -876,25 +993,34 @@ export class InterruptManager< candidate.batchIndex !== undefined && candidate.key.length > 0 && firstPartyIndexes.get(candidate.batchIndex) !== 1 - ) { - return { - descriptor: interrupt, - binding: cloneAndDeepFreezeJson(candidate), - kind: 'generic', - status: 'error', - canResolve: false, - resumable: false, - error: this.itemError( - interrupt.id, - 'stale', - 'Generic interrupt batch contains a duplicate batchIndex.', - ), - validationGeneration: 0, - } + if (!isDuplicateGeneric) { + return undefined } + return { + descriptor: interrupt, + binding: cloneAndDeepFreezeJson(candidate), + kind: 'generic', + status: 'error', + canResolve: false, + resumable: false, + error: this.itemError( + interrupt.id, + 'stale', + 'Generic interrupt batch contains a duplicate batchIndex.', + ), + validationGeneration: 0, + } + } + private hydrateFirstPartyGenericInterrupt( + interrupt: Interrupt, + candidate: InterruptBinding | undefined, + correlated: boolean, + firstPartyIndexes: ReadonlyMap, + ): RuntimeInterrupt | undefined { if ( correlated && + candidate !== undefined && candidate.kind === 'generic' && candidate.definitionId !== undefined && candidate.key !== undefined && @@ -913,11 +1039,6 @@ export class InterruptManager< definitionSchemaHash(definition.payloadSchema)) ) { const rawPayload = getInterruptPayload(interrupt) - // First-party display payloads are parsed by definition.interrupt() - // before the server emits them. Re-validating here would feed schema - // output back through an input schema and reject transforms such as - // z.string().transform(Number). The checks above still bind this value - // to the exact descriptor, run, generation, definition, and schemas. return { descriptor: interrupt, binding: cloneAndDeepFreezeJson(candidate), @@ -933,25 +1054,27 @@ export class InterruptManager< } } } + return undefined + } - const resumable = - legacyResumable || - (candidate !== undefined && - candidate.interruptId === interrupt.id && - candidate.interruptedRunId === hydration.interruptedRunId && - candidate.generation === hydration.generation && - (candidate.kind !== 'generic' || - responseSchemaHash(interrupt) === undefined || - candidate.responseSchemaHash === responseSchemaHash(interrupt))) + private hydrateGenericFallbackInterrupt( + interrupt: Interrupt, + hydration: InterruptManagerHydration, + candidate: InterruptBinding | undefined, + legacyResumable: boolean, + ): RuntimeInterrupt { + /** This binding is valid and can participate in this chat resume batch. */ + const resumable = isGenericFallbackResumable( + legacyResumable, + candidate, + interrupt, + hydration, + ) return { descriptor: interrupt, binding: genericBinding(interrupt, hydration, candidate), kind: 'generic', status: 'pending', - // A valid raw binding is an explicit request to use this resume path, - // even when this client has no registered first-party definition. Keep - // it untyped, but preserve its existing generic controls. Missing, - // malformed, and unsupported bindings remain display-only. canResolve: resumable, resumable, validationGeneration: 0, @@ -962,16 +1085,6 @@ export class InterruptManager< transaction?: TransactionToken, ): BoundInterrupts { const hydration = this.requireHydration() - // `client-tool-execution` items stay in `this.items` (they usually gate - // batch submission and are resolved internally via auto-execution / - // addToolResult), but they are never surfaced as public bound interrupts. - // A mixed generic batch is the exception: those client tools wait for - // `toolResume` and must not block submit. - // - // Items with status `submitting` are also omitted: the resume stream is - // already in flight, so Approve/Deny is not actionable. Keeping them in - // the public list made UIs look stuck after a successful approve and - // blocked follow-up turns that key off `interrupts.length`. const next = this.items .filter( (item) => @@ -981,7 +1094,7 @@ export class InterruptManager< const base = baseSnapshot(item, hydration) // Not ours to resume: expose the descriptor so a UI can show the run // is paused, with no `resolveInterrupt` to call. - if (item.kind === 'unbound' || item.binding === undefined) { + if (item.kind === 'unbound') { const snapshot: UnboundInterrupt = { ...base, kind: 'unbound', @@ -989,40 +1102,47 @@ export class InterruptManager< } return Object.freeze(snapshot) } - if ( - item.kind === 'tool-approval' && - item.binding.kind === 'tool-approval' - ) { - const binding = cloneAndDeepFreezeJson(item.binding) - const snapshot = { + if (item.binding === undefined) { + const snapshot: UnboundInterrupt = { ...base, - kind: 'tool-approval' as const, - binding, - toolName: item.binding.toolName, - toolCallId: item.binding.toolCallId, - originalArgs: cloneAndDeepFreezeJson(item.binding.originalArgs), - cancel: () => this.cancelItem(item.descriptor.id, transaction), - clearResolution: () => - this.clearItem(item.descriptor.id, transaction), - resolveInterrupt: (approved: boolean, options?: unknown) => { - const details = isUnknownObject(options) ? options : undefined - this.resolveItem( - item.descriptor.id, - { - approved, - ...(approved && details?.['editedArgs'] !== undefined - ? { editedArgs: details['editedArgs'] } - : {}), - ...(details?.['payload'] !== undefined - ? { payload: details['payload'] } - : {}), - }, - transaction, - ) - }, + kind: 'unbound', + canResolve: false, } return Object.freeze(snapshot) } + if (item.kind === 'tool-approval') { + if (item.binding.kind === 'tool-approval') { + const binding = cloneAndDeepFreezeJson(item.binding) + const snapshot = { + ...base, + kind: 'tool-approval' as const, + binding, + toolName: item.binding.toolName, + toolCallId: item.binding.toolCallId, + originalArgs: cloneAndDeepFreezeJson(item.binding.originalArgs), + cancel: () => this.cancelItem(item.descriptor.id, transaction), + clearResolution: () => + this.clearItem(item.descriptor.id, transaction), + resolveInterrupt: (approved: boolean, options?: unknown) => { + const details = isUnknownObject(options) ? options : undefined + this.resolveItem( + item.descriptor.id, + { + approved, + ...(approved && details?.['editedArgs'] !== undefined + ? { editedArgs: details['editedArgs'] } + : {}), + ...(details?.['payload'] !== undefined + ? { payload: details['payload'] } + : {}), + }, + transaction, + ) + }, + } + return Object.freeze(snapshot) + } + } const boundGeneric = item.binding.kind === 'generic' ? cloneAndDeepFreezeJson(item.binding) @@ -1065,10 +1185,6 @@ export class InterruptManager< return Object.freeze(snapshot) }) - // The runtime items are created only from the exact configured TTools entry - // selected by name. TypeScript cannot preserve that per-element lookup - // through Array.map, so this generic return boundary restores the proven - // distributive public union. return Object.freeze(next) as BoundInterrupts } @@ -1172,22 +1288,18 @@ export class InterruptManager< } private maybeSubmit(): void { - // Unbound items can never be resolved through this path — something else - // owns them. Including them in the completeness gate would deadlock the - // batch, so the run's own interrupts could never be answered once a - // foreign one shared the stream. const hasGeneric = this.items.some((item) => item.kind === 'generic') const ours = this.items.filter( (item) => isClientOwnedInterrupt(item) && !(hasGeneric && item.kind === 'client-tool-execution'), ) - if ( + const isBatchIncomplete = ours.length === 0 || ours.some( (item) => item.resolution === undefined || item.status !== 'staged', ) - ) { + if (isBatchIncomplete) { return } const hydration = this.requireHydration() @@ -1300,13 +1412,15 @@ export class InterruptManager< } const approved = payload['approved'] const editedArgs = payload['editedArgs'] - if (!approved && editedArgs !== undefined) { + const hasRejectedEdits = !approved && editedArgs !== undefined + if (hasRejectedEdits) { return { code: 'invalid-edited-args', message: 'Rejected tool approvals cannot edit tool arguments.', } } - if (approved && editedArgs !== undefined) { + const hasEditedArgs = approved && editedArgs !== undefined + if (hasEditedArgs) { const editedValidation = validateWithSchema( item.tool?.inputSchema, editedArgs, @@ -1337,13 +1451,16 @@ export class InterruptManager< const approved = envelope['approved'] === true const schema = this.approvalBranchSchema(item.tool, approved) const branchPayload = envelope['payload'] - if (schema === undefined && branchPayload !== undefined) { + const hasUnexpectedPayload = + schema === undefined && branchPayload !== undefined + if (hasUnexpectedPayload) { return { code: 'invalid-payload', message: 'This approval branch does not accept a payload.', } } - if (schema !== undefined && branchPayload === undefined) { + const needsPayload = schema !== undefined && branchPayload === undefined + if (needsPayload) { return { code: 'invalid-payload', message: 'This approval branch requires a payload.', @@ -1391,9 +1508,6 @@ export class InterruptManager< } private resolveBooleanBulk(approved: boolean): void { - // `client-tool-execution` items resolve out-of-band (auto execution / - // addToolResult); they are transparent to the boolean shorthand. Eligibility - // and resolution consider only the publicly resolvable items. const resolvable = this.items.filter( (item) => isClientOwnedInterrupt(item) && item.kind !== 'client-tool-execution', @@ -1403,7 +1517,8 @@ export class InterruptManager< item.kind === 'tool-approval' && this.approvalBranchSchema(item.tool, approved) === undefined, ) - if (!eligible || resolvable.length === 0) { + const canBulkResolve = eligible && resolvable.length > 0 + if (!canBulkResolve) { this.addRootError( 'unsupported-bulk-operation', 'Boolean bulk resolution requires payloadless tool approvals.', @@ -1458,19 +1573,15 @@ export class InterruptManager< break } } - if ( + const isIncompleteTransaction = failure === undefined && this.items.some( (item) => - // `client-tool-execution` items are resolved out-of-band (auto - // execution / addToolResult), not by this synchronous resolver, so - // they don't count against transaction completeness. `maybeSubmit` - // still waits for them unless a generic interrupt shares the batch. isClientOwnedInterrupt(item) && item.kind !== 'client-tool-execution' && (item.resolution === undefined || item.status !== 'staged'), ) - ) { + if (isIncompleteTransaction) { failure = { code: 'incomplete-batch', message: 'Interrupt transaction did not resolve every item.', @@ -1510,10 +1621,13 @@ export class InterruptManager< } private assertItemMutable(transaction?: TransactionToken): void { - if (transaction && !transaction.active) { + const isInactiveTransaction = transaction && !transaction.active + if (isInactiveTransaction) { throw new Error('Interrupt transaction is inactive.') } - if (this.activeTransaction && transaction !== this.activeTransaction) { + const isForeignTransaction = + this.activeTransaction && transaction !== this.activeTransaction + if (isForeignTransaction) { throw new Error('Interrupt transaction is inactive.') } if (this.resuming) { @@ -1594,11 +1708,11 @@ export class InterruptManager< let retryable = false const batchErrors: Array = [] for (const submissionError of correlatedErrors) { - if ( + const isNonRetryable = submissionError.code === 'stale' || submissionError.code === 'expired' || submissionError.code === 'conflict' - ) { + if (isNonRetryable) { nonRetryable = true } retryable ||= submissionError.retryable diff --git a/packages/ai-client/src/mcp-app-bridge.ts b/packages/ai-client/src/mcp-app-bridge.ts index 7de435c653..fb2a54e4a6 100644 --- a/packages/ai-client/src/mcp-app-bridge.ts +++ b/packages/ai-client/src/mcp-app-bridge.ts @@ -42,9 +42,6 @@ function isToolCallResponse(value: unknown): value is ToolCallResponse { ) } -// Links arrive from an untrusted sandboxed widget. Only hand http(s)/mailto -// URLs to the host's onLink; reject javascript:/data:/file:/etc. so a widget -// can't smuggle a script-executing or local-resource URL through the bridge. const SAFE_LINK_SCHEMES = new Set(['http:', 'https:', 'mailto:']) function isSafeLink(url: string): boolean { try { diff --git a/packages/ai-client/src/realtime-client.ts b/packages/ai-client/src/realtime-client.ts index 7808818745..69bc1c1ca6 100644 --- a/packages/ai-client/src/realtime-client.ts +++ b/packages/ai-client/src/realtime-client.ts @@ -75,10 +75,6 @@ export class RealtimeClient { } } - // ============================================================================ - // Connection Lifecycle - // ============================================================================ - /** * Connect to the realtime session. * Fetches a token and establishes the connection. @@ -121,7 +117,10 @@ export class RealtimeClient { this.updateState({ status: 'connected', mode: 'listening' }) this.options.onConnect?.() - } catch (error) { + } catch ( + /** Get current error, if any */ + error + ) { const err = error instanceof Error ? error : new Error(String(error)) this.updateState({ status: 'error', error: err }) this.options.onError?.(err) @@ -159,18 +158,13 @@ export class RealtimeClient { this.options.onDisconnect?.() } - // ============================================================================ - // Voice Control - // ============================================================================ - /** * Start listening for voice input. * Only needed when vadMode is 'manual'. */ startListening(): void { - if (!this.connection || this.state.status !== 'connected') { - return - } + if (!this.connection) return + if (this.state.status !== 'connected') return void this.connection.startAudioCapture() this.updateState({ mode: 'listening' }) } @@ -197,17 +191,12 @@ export class RealtimeClient { this.connection.interrupt() } - // ============================================================================ - // Text Input - // ============================================================================ - /** * Send a text message instead of voice. */ sendText(text: string): void { - if (!this.connection || this.state.status !== 'connected') { - return - } + if (!this.connection) return + if (this.state.status !== 'connected') return // Add user message const userMessage: RealtimeMessage = { @@ -228,9 +217,8 @@ export class RealtimeClient { * @param mimeType - MIME type of the image (e.g., 'image/png', 'image/jpeg') */ sendImage(imageData: string, mimeType: string): void { - if (!this.connection || this.state.status !== 'connected') { - return - } + if (!this.connection) return + if (this.state.status !== 'connected') return // Add user message with image part const userMessage: RealtimeMessage = { @@ -245,10 +233,6 @@ export class RealtimeClient { this.connection.sendImage(imageData, mimeType) } - // ============================================================================ - // State Access - // ============================================================================ - /** Get current connection status */ get status(): RealtimeStatus { return this.state.status @@ -289,10 +273,6 @@ export class RealtimeClient { * This applies changes to the active connection and persists them for future reconnections. */ updateSession(config: Partial): void { - // Persist type-compatible fields so future (re)connections use the updated - // config. `tools` is intentionally excluded: a session's tool configs are - // serialized descriptors, whereas `options.tools` holds executable client - // tools tracked separately via `clientTools`. const o = this.options if (config.instructions !== undefined) o.instructions = config.instructions if (config.voice !== undefined) o.voice = config.voice @@ -316,10 +296,6 @@ export class RealtimeClient { } } - // ============================================================================ - // State Subscription - // ============================================================================ - /** * Subscribe to state changes. * @returns Unsubscribe function @@ -331,10 +307,6 @@ export class RealtimeClient { } } - // ============================================================================ - // Cleanup - // ============================================================================ - /** * Clean up resources. * Call this when disposing of the client. @@ -344,10 +316,6 @@ export class RealtimeClient { this.stateChangeCallbacks.clear() } - // ============================================================================ - // Private Methods - // ============================================================================ - private updateState(updates: Partial): void { this.state = { ...this.state, ...updates } @@ -414,9 +382,6 @@ export class RealtimeClient { }), ) - // Transcripts (streaming) - // User transcripts are added as messages when final (no separate message_complete for user input) - // Assistant transcripts are streamed, final message comes via message_complete this.unsubscribers.push( this.connection.on('transcript', ({ role, transcript, isFinal }) => { if (role === 'user') { @@ -560,9 +525,6 @@ export class RealtimeClient { semanticEagerness if (!hasConfig) return - // `RealtimeToolConfig.inputSchema`/`outputSchema` are `Record` - // (no `undefined` under `exactOptionalPropertyTypes`). Conditionally spread - // each so we don't pass `undefined` when the tool has no such schema. const toolsConfig = tools ? Array.from(this.clientTools.values()).map((t) => { const inputSchema = t.inputSchema @@ -580,9 +542,6 @@ export class RealtimeClient { }) : undefined - // `RealtimeSessionConfig` declares each field as `field?: T` (no - // `undefined`). Spread each one conditionally so we don't violate - // `exactOptionalPropertyTypes` when the source is `T | undefined`. this.connection.updateSession({ ...(instructions !== undefined ? { instructions } : {}), ...(voice !== undefined ? { voice } : {}), diff --git a/packages/ai-client/src/realtime-types.ts b/packages/ai-client/src/realtime-types.ts index c6f2470a97..c3b0d0eb68 100644 --- a/packages/ai-client/src/realtime-types.ts +++ b/packages/ai-client/src/realtime-types.ts @@ -8,16 +8,8 @@ import type { } from '@tanstack/ai/client' import type { UsageInfo } from '@tanstack/ai' -// The realtime adapter contract lives in `@tanstack/ai` (the shared layer both -// providers and this client depend on) so provider packages don't need to -// depend on `@tanstack/ai-client`. Re-exported here for backwards compatibility -// — `import { RealtimeAdapter } from '@tanstack/ai-client'` keeps working. export type { RealtimeAdapter, RealtimeConnection } from '@tanstack/ai/client' -// ============================================================================ -// Client Options -// ============================================================================ - /** * Options for the RealtimeClient */ @@ -100,10 +92,6 @@ export interface RealtimeClientOptions { onGoAway?: (timeLeft?: string) => void } -// ============================================================================ -// Client State -// ============================================================================ - /** * Internal state of the RealtimeClient */ diff --git a/packages/ai-client/src/sse-parser.ts b/packages/ai-client/src/sse-parser.ts index 5c85a842ae..28200c1bb5 100644 --- a/packages/ai-client/src/sse-parser.ts +++ b/packages/ai-client/src/sse-parser.ts @@ -68,7 +68,8 @@ export async function* parseSSEResponse( const reader = getResponseStreamReader(response) - for await (const line of readStreamLines(reader, abortSignal)) { + const streamLines = readStreamLines(reader, abortSignal) + for await (const line of streamLines) { const data = parseSseDataLine(line) if (data === '[DONE]') { diff --git a/packages/ai-client/src/storage-adapters.ts b/packages/ai-client/src/storage-adapters.ts index e6b84a22cc..f76ca8d725 100644 --- a/packages/ai-client/src/storage-adapters.ts +++ b/packages/ai-client/src/storage-adapters.ts @@ -62,6 +62,7 @@ function createWebStoragePersistence( ): ChatStorageAdapter { const keyPrefix = options.keyPrefix ?? 'tanstack-ai:' const serialize = options.serialize ?? stringifyJson + /** Defaults to `JSON.parse`. */ const deserialize = options.deserialize ?? JSON.parse const key = (id: string) => `${keyPrefix}${id}` diff --git a/packages/ai-client/src/types.ts b/packages/ai-client/src/types.ts index 01373cef02..0be875e0cd 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -47,6 +47,7 @@ export type ChatPendingInterrupt = Interrupt */ export interface ChatResumeSnapshot { resumeState: ChatResumeState + /** @deprecated Use `interrupts`. Same snapshot today. */ pendingInterrupts?: Array } @@ -58,12 +59,27 @@ export type InterruptItemStatus = | 'error' export interface BoundInterruptBase { + /** + * Optional custom ID for the message. + * If not provided, a unique ID will be generated. + */ readonly id: string readonly interruptId: string readonly reason: string readonly message?: string readonly responseSchema?: Readonly> readonly expiresAt?: string + /** + * Optional AG-UI metadata bag copied onto the resulting UIMessage. + * + * @example + * ```ts + * await client.sendMessage({ + * content: 'Show me failed logins', + * metadata: { author: { id: 'user-42', name: 'Dana' } }, + * }) + * ``` + */ readonly metadata?: Readonly> readonly threadId: string readonly interruptedRunId: string @@ -205,15 +221,6 @@ export type ToolApprovalInterrupt = readonly toolName: TTool['name'] readonly toolCallId: string readonly originalArgs: InferToolInput - // A single generic call signature — not two overloads. Overloads break - // editor autocomplete: a half-typed options literal (e.g. - // `resolveInterrupt(true, { payload: {` ) satisfies neither overload, - // so TS resolves no signature and offers no contextual completions. - // Making `approved` a generic discriminant lets TS infer it from the - // first argument and pick the matching branch for the rest params, so - // `payload` / `editedArgs` / the correct schema's fields complete - // per-branch (a plain union-of-tuples would offer both branches' - // fields) while still enforcing the right shape. resolveInterrupt: ( approved: TApproved, ...args: TApproved extends true @@ -232,10 +239,6 @@ type ApprovalInterrupts> = : never : never -// Client tools resolve through their `.client()` implementation (auto-run) or -// `addToolResult` — never as a bound interrupt. The `client-tool-execution` -// pause is handled internally and is intentionally absent from this public -// union. export type ChatInterrupt< TTools extends ReadonlyArray = ReadonlyArray, TInterrupts extends ReadonlyArray> = @@ -266,6 +269,7 @@ export interface ChatInterruptState< TInterrupts extends ReadonlyArray> = readonly [], > { + /** First-party generic interrupts this client can type and resolve. */ readonly interrupts: BoundInterrupts /** @deprecated Use `interrupts`. Same snapshot today. */ readonly pendingInterrupts: BoundInterrupts @@ -286,6 +290,7 @@ export interface ChatFetcherInput { threadId: string runId: string parentRunId?: string + /** Present while a run is in flight or paused on an interrupt; absent otherwise. */ resume?: Array } @@ -343,21 +348,18 @@ export type ChatTransport = * Tool call states - track the lifecycle of a tool call */ export type ToolCallState = - | 'awaiting-input' // Received start but no arguments yet - | 'input-streaming' // Partial arguments received - | 'input-complete' // All arguments received - | 'approval-requested' // Waiting for user approval - | 'approval-responded' // User has approved/denied - | 'complete' // Result is complete - | 'error' // Tool execution failed (terminal) + | 'awaiting-input' + | 'input-streaming' + | 'input-complete' + | 'approval-requested' + | 'approval-responded' + | 'complete' + | 'error' /** * Tool result states - track the lifecycle of a tool result */ -export type ToolResultState = - | 'streaming' // Placeholder for future streamed output - | 'complete' // Result is complete - | 'error' // Error occurred +export type ToolResultState = 'streaming' | 'complete' | 'error' /** * ChatClient state - track the lifecycle of a chat @@ -395,22 +397,7 @@ export interface MultimodalContent { * Can be a simple string or an array of content parts for multimodal messages. */ content: string | Array - /** - * Optional custom ID for the message. - * If not provided, a unique ID will be generated. - */ id?: string - /** - * Optional AG-UI metadata bag copied onto the resulting UIMessage. - * - * @example - * ```ts - * await client.sendMessage({ - * content: 'Show me failed logins', - * metadata: { author: { id: 'user-42', name: 'Dana' } }, - * }) - * ``` - */ metadata?: Record } @@ -541,9 +528,9 @@ type ToolCallPartForTool = T extends AnyClientTool * never satisfies) and strip `undefined` before comparing to `true`. */ approval?: { - id: string // Unique approval ID - needsApproval: boolean // Always true if present - approved?: boolean // User's decision (undefined until responded) + id: string + needsApproval: boolean + approved?: boolean } } : // Tools without `needsApproval: true` never carry an approval field. @@ -559,6 +546,7 @@ type UntypedToolCallPart = { id: string name: string arguments: string + /** Parsed tool input (typed from inputSchema) */ input?: any state: ToolCallState approval?: { @@ -566,6 +554,7 @@ type UntypedToolCallPart = { needsApproval: boolean approved?: boolean } + /** Tool execution output (for client tools or after approval) */ output?: any } @@ -600,6 +589,7 @@ export interface ToolResultPart { toolCallId: string content: string | Array state: ToolResultState + /** @deprecated Use `errors[0]`. */ error?: string // Error message if state is "error" metadata?: Record createdAt?: Date @@ -646,10 +636,6 @@ export interface UIMessage< name?: string parts: Array> createdAt?: Date - /** - * Optional AG-UI metadata bag. TanStack writes the `tanstack` key. - * User keys stay at the top. - */ metadata?: Record } @@ -786,7 +772,15 @@ type UnionToIntersection = [T] extends [never] type DefinedContext = Exclude type ContextFromExecute = T extends (...args: any) => any - ? NonNullable[1]> extends { context: infer TContext } + ? NonNullable[1]> extends { + /** + * Client-local runtime context passed to client tool implementations. + * + * This value is not serialized to the server. Use `forwardedProps` for + * explicit client-to-server handoff of serializable values. + */ + context: infer TContext + } ? KnownContext : never : never @@ -871,13 +865,6 @@ export interface ChatClientBaseOptions< */ forwardedProps?: Record - /** - * @deprecated Use `forwardedProps` instead. `body` continues to work - * unchanged — its values are merged into the AG-UI - * `RunAgentInput.forwardedProps` field on the wire and are also - * mirrored under the legacy `data` field for servers that have not - * migrated yet. Will be removed in a future major release. - */ body?: Record /** @@ -895,12 +882,6 @@ export interface ChatClientBaseOptions< */ byokProvider?: () => string | undefined - /** - * Client-local runtime context passed to client tool implementations. - * - * This value is not serialized to the server. Use `forwardedProps` for - * explicit client-to-server handoff of serializable values. - */ context?: TContext /** diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index 7ae11c2fc1..d5dd8787e0 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -45,9 +45,6 @@ import type { /** * Callbacks stored in a ref so hooks can update them without recreating the client. */ -// All optional fields explicitly allow `| undefined` so callers can spread -// option bags (where each callback may be `undefined`) into the callbacks -// ref under `exactOptionalPropertyTypes`. interface VideoCallbacks { onResult?: | ((result: VideoGenerateResult) => TOutput | null | void) @@ -108,9 +105,6 @@ interface VideoCallbacks { */ export class VideoGenerationClient { private readonly connection: ConnectConnectionAdapter | undefined - // Persistence handlers supplied as options (e.g. alongside a `fetcher`), used - // when the connection doesn't carry its own — the connection's handlers take - // precedence when both exist. private readonly hydrateGenerationHandler: | ConnectConnectionAdapter['hydrateGeneration'] | undefined @@ -190,13 +184,6 @@ export class VideoGenerationClient { this.devtoolsBridge = ( options.devtoolsBridgeFactory ?? createNoOpVideoDevtoolsBridge )(this.buildDevtoolsBridgeOptions()) - - // Mount hydration (`maybeHydrateFromServer`) is deliberately NOT run here. The framework - // hooks build this client inside `useMemo`, so the constructor executes in - // React's render phase; hydrating here would re-fire the hydrate GET on - // every discarded/speculative render, flooding the connection pool when - // several clients mount together. It is kicked off once from - // `mountDevtools`, which the hooks call from a commit-phase mount effect. } private buildDevtoolsBridgeOptions(): VideoDevtoolsBridgeOptions { @@ -227,10 +214,6 @@ export class VideoGenerationClient { mountDevtools(): void { this.ensureThreadId() - // Mounting revives a disposed client. Framework hooks call this from - // their mount effect, so a dispose → remount cycle (e.g. React - // StrictMode's mount → cleanup → mount replay against the same memoized - // client) leaves the client usable again. this.disposed = false this.maybeHydrateFromServer() // Re-attach to an already-loaded `running` snapshot (remount case); see the @@ -293,7 +276,8 @@ export class VideoGenerationClient { 'VideoGenerationClient requires either a connection or fetcher option', ) } - if (!signal.aborted && this.status === 'success') { + const isSuccessfulRun = !signal.aborted && this.status === 'success' + if (isSuccessfulRun) { this.devtoolsBridge.finishRun( this.devtoolsBridge.getActiveRunId() ?? runId, 'run:completed', @@ -306,7 +290,9 @@ export class VideoGenerationClient { if (error instanceof ByokMissingError) { this.byok?.request(error.provider, 'missing') } - if (error instanceof ByokBlockedError && error.reason === 'locked') { + const isByokLocked = + error instanceof ByokBlockedError && error.reason === 'locked' + if (isByokLocked) { this.byok?.request(error.provider, 'locked') } this.setError(error) @@ -370,8 +356,10 @@ export class VideoGenerationClient { fallbackRunId: string, signal: AbortSignal, ): Promise { - let streamRunId: string | undefined - let sawTerminalChunk = false + const state = { + streamRunId: undefined as string | undefined, + sawTerminalChunk: false, + } for await (const raw of source) { if (signal.aborted) break @@ -379,69 +367,83 @@ export class VideoGenerationClient { const chunk = restoreInboundChunk(raw) this.callbacksRef.onChunk?.(chunk) this.observeResumeSnapshot(chunk) - const chunkRunId = - 'runId' in chunk && typeof chunk.runId === 'string' - ? chunk.runId - : undefined - - // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- AG-UI EventType has ~22 variants; this consumer only handles the subset relevant to video generation lifecycle. - switch (chunk.type) { - case 'RUN_STARTED': { - streamRunId = chunk.runId - this.devtoolsBridge.ensureRunStarted(chunk.runId) - break - } - case 'CUSTOM': { - this.devtoolsBridge.ensureRunStarted(streamRunId ?? fallbackRunId) - if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) { - const { jobId } = chunk.value as { jobId: string } - this.setJobId(jobId) - this.callbacksRef.onJobCreated?.(jobId) - } else if (chunk.name === GENERATION_EVENTS.VIDEO_STATUS) { - const statusInfo = chunk.value as VideoStatusInfo - this.setVideoStatus(statusInfo) - this.callbacksRef.onStatusUpdate?.(statusInfo) - if (statusInfo.progress !== undefined) { - this.setProgress(statusInfo.progress) - } - } else if (chunk.name === GENERATION_EVENTS.RESULT) { - this.setResult(chunk.value as VideoGenerateResult) - } else if (chunk.name === GENERATION_EVENTS.PROGRESS) { - const { progress, message } = chunk.value as { - progress: number - message?: string - } - this.setProgress(progress, message) - } - break - } - case 'RUN_FINISHED': { - streamRunId = chunk.runId - sawTerminalChunk = true - this.devtoolsBridge.ensureRunStarted(chunk.runId) - this.setStatus('success') - break - } - case 'RUN_ERROR': { - this.devtoolsBridge.ensureRunStarted( - chunkRunId ?? streamRunId ?? fallbackRunId, - ) - // Spec RUN_ERROR message. Missing message uses this fallback. - const msg = - (chunk.message as string | undefined) || 'An error occurred' - throw new Error(msg) - } - default: - break - } + this.applyVideoStreamChunk(chunk, fallbackRunId, state) } // An aborted read is a deliberate stop/dispose, not a truncation. - if (!sawTerminalChunk && !signal.aborted) { + const isTruncatedStream = !state.sawTerminalChunk && !signal.aborted + if (isTruncatedStream) { throw new Error(GENERATION_STREAM_TRUNCATED_MESSAGE) } } + private applyVideoStreamChunk( + chunk: StreamChunk, + fallbackRunId: string, + state: { streamRunId: string | undefined; sawTerminalChunk: boolean }, + ): void { + if (chunk.type === 'RUN_STARTED') { + state.streamRunId = chunk.runId + this.devtoolsBridge.ensureRunStarted(chunk.runId) + return + } + if (chunk.type === 'CUSTOM') { + this.applyVideoCustomChunk(chunk, state.streamRunId ?? fallbackRunId) + return + } + if (chunk.type === 'RUN_FINISHED') { + state.streamRunId = chunk.runId + state.sawTerminalChunk = true + this.devtoolsBridge.ensureRunStarted(chunk.runId) + this.setStatus('success') + return + } + if (chunk.type !== 'RUN_ERROR') return + const chunkRunId = + 'runId' in chunk && typeof chunk.runId === 'string' + ? chunk.runId + : undefined + this.devtoolsBridge.ensureRunStarted( + chunkRunId ?? state.streamRunId ?? fallbackRunId, + ) + // Spec RUN_ERROR message. Missing message uses this fallback. + const msg = (chunk.message as string | undefined) || 'An error occurred' + throw new Error(msg) + } + + private applyVideoCustomChunk( + chunk: Extract, + runId: string, + ): void { + this.devtoolsBridge.ensureRunStarted(runId) + if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) { + const { jobId } = chunk.value as { jobId: string } + this.setJobId(jobId) + this.callbacksRef.onJobCreated?.(jobId) + return + } + if (chunk.name === GENERATION_EVENTS.VIDEO_STATUS) { + const statusInfo = chunk.value as VideoStatusInfo + this.setVideoStatus(statusInfo) + this.callbacksRef.onStatusUpdate?.(statusInfo) + if (statusInfo.progress !== undefined) { + this.setProgress(statusInfo.progress) + } + return + } + if (chunk.name === GENERATION_EVENTS.RESULT) { + this.setResult(chunk.value as VideoGenerateResult) + return + } + if (chunk.name === GENERATION_EVENTS.PROGRESS) { + const { progress, message } = chunk.value as { + progress: number + message?: string + } + this.setProgress(progress, message) + } + } + /** * Abort any in-flight generation or polling. */ @@ -458,10 +460,9 @@ export class VideoGenerationClient { this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled') } } - // A stopped run is no longer resumable. Without this the in-memory - // snapshot stays `running`, and a remount's `maybeResumeInFlight` would - // rejoin a run the user just cancelled. - if (this.resumeSnapshot && this.resumeSnapshot.status === 'running') { + const hasRunningSnapshot = + this.resumeSnapshot && this.resumeSnapshot.status === 'running' + if (hasRunningSnapshot) { this.resumeSnapshot = { ...this.resumeSnapshot, resumeState: null, @@ -554,10 +555,6 @@ export class VideoGenerationClient { this.rejoinedRunId = undefined } - // =========================== - // Getters - // =========================== - getResult(): TOutput | null { return this.result } @@ -609,10 +606,6 @@ export class VideoGenerationClient { : undefined } - // =========================== - // Private state setters - // =========================== - private setResult(rawResult: VideoGenerateResult | null): void { if (rawResult === null) { this.result = null @@ -644,10 +637,6 @@ export class VideoGenerationClient { } } - // No onResult callback, or callback returned void → use raw value as - // TOutput. When the caller did not supply an onResult transform, - // `TOutput` defaults to `VideoGenerateResult`, so the runtime cast is - // sound. // oxlint-disable-next-line eslint-js/no-restricted-syntax -- TOutput defaults to VideoGenerateResult when no onResult transform is supplied this.result = rawResult as unknown as TOutput this.callbacksRef.onResultChange?.(this.result) @@ -908,15 +897,11 @@ export class VideoGenerationClient { * run is still in flight. */ private recordResumeSnapshotError(error: Error): void { - // Surface the failure on the observable fields FIRST, unconditionally (see - // the note in GenerationClient.recordResumeSnapshotError): a RUN_ERROR - // already flipped the snapshot to `error`, so the early-return would else - // skip this and leave `status` stuck on `generating`. The guard avoids a - // duplicate `error` emission on the live `generate()` path. if (this.status !== 'error') this.setStatus('error') this.setError(error) if (this.resumeSnapshot?.status === 'error') return - if (!this.resumeSnapshot && !this.serverDriven) return + const hasNoResumeTarget = !this.resumeSnapshot && !this.serverDriven + if (hasNoResumeTarget) return const previous = this.resumeSnapshot this.resumeSnapshot = { schemaVersion: 1, @@ -948,9 +933,12 @@ export class VideoGenerationClient { * re-fire the hydrate GET. */ private maybeHydrateFromServer(): void { - if (!this.serverDriven || this.serverHydrationStarted) return + const shouldHydrate = this.serverDriven && !this.serverHydrationStarted + if (!shouldHydrate) return this.serverHydrationStarted = true - if (this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler) { + const hydrateHandler = + this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler + if (hydrateHandler) { this.hydrateFromServer() } else { // `persistence: true` without any hydrate source can never restore @@ -978,7 +966,9 @@ export class VideoGenerationClient { this.connection?.hydrateGeneration ?? this.hydrateGenerationHandler if (!hydrate) return // A send that already started owns the client; don't stomp it. - if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + const isClientBusy = + this.resumeSnapshot || this.isLoading || this.status !== 'idle' + if (isClientBusy) return void (async () => { let res: GenerationHydrationResult try { @@ -1004,8 +994,9 @@ export class VideoGenerationClient { return } // Re-check: a send may have started while the fetch was in flight. - if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') - return + const isClientBusy = + this.resumeSnapshot || this.isLoading || this.status !== 'idle' + if (isClientBusy) return // A run still generating on the server: re-attach and finish it in place. this.repaintRestoredSnapshot(snapshot, res.activeRun?.runId) })() @@ -1016,7 +1007,9 @@ export class VideoGenerationClient { * `generate()` took ownership while the hydrate GET was in flight. */ private failHydration(error: Error): void { - if (this.resumeSnapshot || this.isLoading || this.status !== 'idle') return + const isClientBusy = + this.resumeSnapshot || this.isLoading || this.status !== 'idle' + if (isClientBusy) return this.setStatus('error') this.setError(error) this.callbacksRef.onError?.(error) @@ -1042,7 +1035,8 @@ export class VideoGenerationClient { const joinRun = this.connection?.joinRun ?? this.joinRunHandler if (!joinRun) return if (this.rejoinedRunId === runId) return - if (this.isLoading || this.abortController) return + const hasActiveStream = this.isLoading || this.abortController + if (hasActiveStream) return this.rejoinedRunId = runId const controller = new AbortController() this.abortController = controller @@ -1059,16 +1053,10 @@ export class VideoGenerationClient { if (!controller.signal.aborted) { const failure = error instanceof Error ? error : new Error(String(error)) - // Settles `status`/`error` AND rewrites the snapshot to a terminal - // `error` with a null `resumeState`, so the next mount does not - // rejoin this run again. this.recordResumeSnapshotError(failure) this.callbacksRef.onError?.(failure) } } finally { - // Only reset if this rejoin still owns the client: a `stop()` + - // fresh `generate()` may have replaced the controller while the tail - // was settling, and that live run owns `isLoading` now. if (this.abortController === controller) { this.abortController = null this.setIsLoading(false) diff --git a/packages/ai-code-mode-snippets/src/code-mode-with-snippets.ts b/packages/ai-code-mode-snippets/src/code-mode-with-snippets.ts index 369515249e..7b7b95f52c 100644 --- a/packages/ai-code-mode-snippets/src/code-mode-with-snippets.ts +++ b/packages/ai-code-mode-snippets/src/code-mode-with-snippets.ts @@ -145,7 +145,8 @@ export async function codeModeWithSnippets({ } // 7. Convert selected snippets to direct tools and add to registry (if enabled) - if (snippetsAsTools && selectedSnippets.length > 0) { + const addSnippetTools = snippetsAsTools && selectedSnippets.length > 0 + if (addSnippetTools) { const snippetToolsList = snippetsToTools({ snippets: selectedSnippets, driver: config.driver, diff --git a/packages/ai-code-mode-snippets/src/create-snippets-system-prompt.ts b/packages/ai-code-mode-snippets/src/create-snippets-system-prompt.ts index a974cfc015..bdd4368ec5 100644 --- a/packages/ai-code-mode-snippets/src/create-snippets-system-prompt.ts +++ b/packages/ai-code-mode-snippets/src/create-snippets-system-prompt.ts @@ -27,7 +27,8 @@ function generateExampleFromSchema(schema: Record): string { const props = schema.properties as Record const example: Record = {} - for (const [key, value] of Object.entries(props)) { + const propertyEntries = Object.entries(props) + for (const [key, value] of propertyEntries) { if (value.type === 'string') example[key] = `'example_${key}'` else if (value.type === 'number') example[key] = 0 else if (value.type === 'boolean') example[key] = true diff --git a/packages/ai-code-mode-snippets/src/generate-snippet-types.ts b/packages/ai-code-mode-snippets/src/generate-snippet-types.ts index b33e65e239..350de2c886 100644 --- a/packages/ai-code-mode-snippets/src/generate-snippet-types.ts +++ b/packages/ai-code-mode-snippets/src/generate-snippet-types.ts @@ -12,7 +12,9 @@ function schemaToType(schema: Record): string { // Handle basic types if (schemaType === 'string') return 'string' - if (schemaType === 'number' || schemaType === 'integer') return 'number' + const isNumericSchemaType = + schemaType === 'number' || schemaType === 'integer' + if (isNumericSchemaType) return 'number' if (schemaType === 'boolean') return 'boolean' if (schemaType === 'null') return 'null' @@ -67,7 +69,8 @@ function schemaToType(schema: Record): string { return schemaType .map((t) => { if (t === 'string') return 'string' - if (t === 'number' || t === 'integer') return 'number' + const isNumericUnionMember = t === 'number' || t === 'integer' + if (isNumericUnionMember) return 'number' if (t === 'boolean') return 'boolean' if (t === 'null') return 'null' if (t === 'array') return 'Array' @@ -113,21 +116,21 @@ export function generateSnippetTypes(snippets: Array): string { // Generate input type const inputType = schemaToType(snippet.inputSchema) - if ( + const hasNamedInputObject = snippet.inputSchema.type === 'object' && snippet.inputSchema.properties && Object.keys(snippet.inputSchema.properties).length > 0 - ) { + if (hasNamedInputObject) { declarations.push(`interface ${inputTypeName} ${inputType}`) } // Generate output type const outputType = schemaToType(snippet.outputSchema) - if ( + const hasNamedOutputObject = snippet.outputSchema.type === 'object' && snippet.outputSchema.properties && Object.keys(snippet.outputSchema.properties).length > 0 - ) { + if (hasNamedOutputObject) { declarations.push(`interface ${outputTypeName} ${outputType}`) } diff --git a/packages/ai-code-mode-snippets/src/index.ts b/packages/ai-code-mode-snippets/src/index.ts index f2ad73861f..806e301d99 100644 --- a/packages/ai-code-mode-snippets/src/index.ts +++ b/packages/ai-code-mode-snippets/src/index.ts @@ -39,12 +39,6 @@ export { createSnippetsSystemPrompt } from './create-snippets-system-prompt' // Type generation export { generateSnippetTypes } from './generate-snippet-types' -// Storage implementations -// -// Only the worker/browser-safe in-memory storage is re-exported from the root -// entry. The Node-only file storage (`createFileSnippetStorage`) imports -// `node:fs` / `node:path`, so it lives behind the `@tanstack/ai-code-mode-snippets/storage` -// subpath to keep this root export safe for Cloudflare Workers and browser bundlers. export { createMemorySnippetStorage } from './storage/memory-storage' export type { MemorySnippetStorageOptions } from './storage/memory-storage' diff --git a/packages/ai-code-mode-snippets/src/snippets-to-tools.ts b/packages/ai-code-mode-snippets/src/snippets-to-tools.ts index f65af3754f..81d83c2704 100644 --- a/packages/ai-code-mode-snippets/src/snippets-to-tools.ts +++ b/packages/ai-code-mode-snippets/src/snippets-to-tools.ts @@ -60,9 +60,6 @@ interface SnippetsToToolsOptions { */ snippets: Array - /** - * Isolate driver for executing snippet code - */ driver: IsolateDriver /** @@ -71,21 +68,10 @@ interface SnippetsToToolsOptions { */ tools: Array - /** - * Storage for updating execution stats - */ storage: SnippetStorage - /** - * Timeout for snippet execution in ms - * @default 30000 - */ timeout?: number - /** - * Memory limit in bytes - * @default 128 - */ memoryLimit?: number } @@ -103,7 +89,14 @@ function jsonSchemaToZod(schema: Record): z.ZodType { } return zodString } - if (type === 'number' || type === 'integer') { + if (type === 'number') { + let zodNum = z.number() + if (schema.description) { + zodNum = zodNum.describe(schema.description as string) + } + return zodNum + } + if (type === 'integer') { let zodNum = z.number() if (schema.description) { zodNum = zodNum.describe(schema.description as string) @@ -132,7 +125,8 @@ function jsonSchemaToZod(schema: Record): z.ZodType { if (properties) { const shape: Record = {} - for (const [key, propSchema] of Object.entries(properties)) { + const propertyEntries = Object.entries(properties) + for (const [key, propSchema] of propertyEntries) { let zodProp = jsonSchemaToZod(propSchema) if (!required.includes(key)) { zodProp = zodProp.optional() diff --git a/packages/ai-code-mode-snippets/src/storage/file-storage.ts b/packages/ai-code-mode-snippets/src/storage/file-storage.ts index d0aa288bfe..6dbdd1ada8 100644 --- a/packages/ai-code-mode-snippets/src/storage/file-storage.ts +++ b/packages/ai-code-mode-snippets/src/storage/file-storage.ts @@ -49,10 +49,6 @@ export function createFileSnippetStorage( console.log('[FileSnippetStorage] Initialized with directory:', directory) - // Snippet names are used both as on-disk directory segments and as - // `snippet_` sandbox tool names, so they must be a single safe - // identifier segment. Rejecting anything else keeps an LLM-supplied name - // (e.g. `../../etc`) from escaping `directory` during read/write/delete. const SAFE_SNIPPET_NAME = /^[A-Za-z0-9_-]+$/ function isSafeSnippetName(name: string): boolean { diff --git a/packages/ai-code-mode-snippets/src/trust-strategies.ts b/packages/ai-code-mode-snippets/src/trust-strategies.ts index bcf3235362..920cb290f2 100644 --- a/packages/ai-code-mode-snippets/src/trust-strategies.ts +++ b/packages/ai-code-mode-snippets/src/trust-strategies.ts @@ -32,19 +32,17 @@ export function createDefaultTrustStrategy(): TrustStrategy { calculateTrustLevel: (currentLevel, stats) => { const { executions, successRate } = stats - if ( - currentLevel === 'untrusted' && - executions >= 10 && - successRate >= 0.9 - ) { + const earnedProvisional = + currentLevel === 'untrusted' && executions >= 10 && successRate >= 0.9 + if (earnedProvisional) { return 'provisional' } - if ( + const earnedTrusted = currentLevel === 'provisional' && executions >= 100 && successRate >= 0.95 - ) { + if (earnedTrusted) { return 'trusted' } @@ -79,19 +77,15 @@ export function createRelaxedTrustStrategy(): TrustStrategy { calculateTrustLevel: (currentLevel, stats) => { const { executions, successRate } = stats - if ( - currentLevel === 'untrusted' && - executions >= 3 && - successRate >= 0.8 - ) { + const earnedProvisional = + currentLevel === 'untrusted' && executions >= 3 && successRate >= 0.8 + if (earnedProvisional) { return 'provisional' } - if ( - currentLevel === 'provisional' && - executions >= 10 && - successRate >= 0.9 - ) { + const earnedTrusted = + currentLevel === 'provisional' && executions >= 10 && successRate >= 0.9 + if (earnedTrusted) { return 'trusted' } @@ -120,19 +114,19 @@ export function createCustomTrustStrategy(config: { calculateTrustLevel: (currentLevel, stats) => { const { executions, successRate } = stats - if ( + const earnedProvisional = currentLevel === 'untrusted' && executions >= provisionalThreshold.executions && successRate >= provisionalThreshold.successRate - ) { + if (earnedProvisional) { return 'provisional' } - if ( + const earnedTrusted = currentLevel === 'provisional' && executions >= trustedThreshold.executions && successRate >= trustedThreshold.successRate - ) { + if (earnedTrusted) { return 'trusted' } diff --git a/packages/ai-code-mode-snippets/src/types.ts b/packages/ai-code-mode-snippets/src/types.ts index 1209d9d46e..316eef2a2c 100644 --- a/packages/ai-code-mode-snippets/src/types.ts +++ b/packages/ai-code-mode-snippets/src/types.ts @@ -2,10 +2,6 @@ import type { AnyTextAdapter, ModelMessage, ToolRegistry } from '@tanstack/ai' import type { CodeModeToolConfig } from '@tanstack/ai-code-mode' import type { TrustStrategy } from './trust-strategies' -// ============================================================================ -// Trust Levels -// ============================================================================ - /** * Trust level for a snippet * - untrusted: Newly created, not yet proven @@ -14,10 +10,6 @@ import type { TrustStrategy } from './trust-strategies' */ export type TrustLevel = 'untrusted' | 'provisional' | 'trusted' -// ============================================================================ -// Snippet Statistics -// ============================================================================ - /** * Execution statistics for a snippet */ @@ -33,10 +25,6 @@ export interface SnippetStats { successRate: number } -// ============================================================================ -// Snippet Types -// ============================================================================ - /** * A reusable snippet that can be executed in the Code Mode sandbox */ @@ -108,10 +96,6 @@ export interface Snippet { updatedAt: string } -// ============================================================================ -// Snippet Index Types -// ============================================================================ - /** * Lightweight snippet entry for the index (metadata only, no code) * Used for fast loading and snippet selection @@ -121,10 +105,6 @@ export type SnippetIndexEntry = Pick< 'id' | 'name' | 'description' | 'usageHints' | 'trustLevel' > -// ============================================================================ -// Storage Interface -// ============================================================================ - /** * Options for searching snippets */ @@ -184,10 +164,6 @@ export interface SnippetStorage { trustStrategy?: TrustStrategy } -// ============================================================================ -// Configuration Types -// ============================================================================ - /** * Configuration for the snippets system */ @@ -203,10 +179,6 @@ export interface SnippetsConfig { */ maxSnippetsInContext?: number - /** - * Trust strategy for determining snippet trust levels - * @default createDefaultTrustStrategy() - */ trustStrategy?: TrustStrategy } @@ -264,17 +236,10 @@ export interface CodeModeWithSnippetsResult { selectedSnippets: Array } -// ============================================================================ -// Snippet Binding Types (internal) -// ============================================================================ - /** * A snippet transformed into a format suitable for sandbox injection */ export interface SnippetBinding { - /** - * Function name with snippet_ prefix - */ name: string /** diff --git a/packages/ai-code-mode/src/bindings/tool-to-binding.ts b/packages/ai-code-mode/src/bindings/tool-to-binding.ts index b6c722f517..5e143892f5 100644 --- a/packages/ai-code-mode/src/bindings/tool-to-binding.ts +++ b/packages/ai-code-mode/src/bindings/tool-to-binding.ts @@ -108,7 +108,8 @@ export function createEventAwareBindings( ): Record { const wrapped: Record = {} - for (const [name, binding] of Object.entries(bindings)) { + const toolBindings = Object.entries(bindings) + for (const [name, binding] of toolBindings) { wrapped[name] = { ...binding, execute: async (args: unknown) => { diff --git a/packages/ai-code-mode/src/create-code-mode-tool.ts b/packages/ai-code-mode/src/create-code-mode-tool.ts index 1c1213d5fe..fc6b833276 100644 --- a/packages/ai-code-mode/src/create-code-mode-tool.ts +++ b/packages/ai-code-mode/src/create-code-mode-tool.ts @@ -163,20 +163,31 @@ export function createCodeModeTool( emitCustomEvent('code_mode:execution_finished', payload) if (!result.success) { console.error('[code-mode] execute_typescript failed', payload) - } else if ( - typeof process !== 'undefined' && - process.env?.CODE_MODE_DEBUG === '1' - ) { - console.info('[code-mode] execute_typescript ok', { - durationMs, - phase, - logCount: payload.logCount, - }) + } else if (typeof process !== 'undefined') { + if (process.env?.CODE_MODE_DEBUG === '1') { + console.info('[code-mode] execute_typescript ok', { + durationMs, + phase, + logCount: payload.logCount, + }) + } } return result } - if (!typescriptCode || typeof typescriptCode !== 'string') { + if (!typescriptCode) { + return finish( + { + success: false, + error: { + message: 'typescriptCode must be a non-empty string', + name: 'ValidationError', + }, + }, + 'validate-input', + ) + } + if (typeof typescriptCode !== 'string') { return finish( { success: false, @@ -188,9 +199,6 @@ export function createCodeModeTool( 'validate-input', ) } - - // Create a fresh sandbox context for this execution - let isolateContext: IsolateContext | null = null // Emit execution started event immediately emitCustomEvent('code_mode:execution_started', { @@ -199,152 +207,170 @@ export function createCodeModeTool( }) try { - // Step 1: Strip TypeScript (also serves as syntax validation via the - // transpiler — sucrase by default, or a user-supplied `transpile`) - let strippedCode: string - try { - strippedCode = await transpile(typescriptCode) - } catch (error) { - // Type/syntax error from the transpiler - return finish( - { - success: false, - error: { - message: error instanceof Error ? error.message : String(error), - name: 'TypeScriptError', - ...(error instanceof Error && - error.stack !== undefined && { stack: error.stack }), - }, - }, - 'transpile', - ) - } - - // Step 2: Get dynamic snippet bindings if available - const snippetBindings = getSnippetBindings - ? await getSnippetBindings() - : {} - - // Scan dynamic bindings too — their schemas are equally in-scope for - // the same exfiltration threat. Dedup cache prevents repeat warnings - // when the same binding reappears across executions. - const snippetBindingValues = Object.values(snippetBindings) - if (snippetBindingValues.length > 0) { - warnIfBindingsExposeSecrets(snippetBindingValues, { - handler: onSecretParameter, - dedupCache: secretDedupCache, - }) - } - - // Step 3: Merge static and dynamic bindings, then wrap with event awareness - const allBindings = { ...staticBindings, ...snippetBindings } - const eventAwareBindings = createEventAwareBindings( - allBindings, + return await runCodeModeExecution({ + typescriptCode, + transpile, + getSnippetBindings, + onSecretParameter, + secretDedupCache, + staticBindings, emitCustomEvent, - ) - - // Step 4: Create sandbox context with event-aware bindings - try { - isolateContext = await driver.createContext({ - bindings: eventAwareBindings, - timeout, - memoryLimit, - }) - } catch (error) { - return finish( - { - success: false, - error: { - message: error instanceof Error ? error.message : String(error), - name: - error instanceof Error ? error.name : 'CreateContextError', - ...(error instanceof Error && - error.stack !== undefined && { stack: error.stack }), - }, - }, - 'create-context', - ) - } - - // Step 5: Execute the code in the sandbox - const executionResult = await isolateContext.execute(strippedCode) - - // Emit console logs as custom events - if (executionResult.logs && executionResult.logs.length > 0) { - for (const log of executionResult.logs) { - // Parse log level from prefix (added by sandbox console implementation) - let level: 'log' | 'warn' | 'error' | 'info' = 'log' - let message = log - - if (log.startsWith('ERROR: ')) { - level = 'error' - message = log.slice(7) - } else if (log.startsWith('WARN: ')) { - level = 'warn' - message = log.slice(6) - } else if (log.startsWith('INFO: ')) { - level = 'info' - message = log.slice(6) - } - - emitCustomEvent('code_mode:console', { - level, - message, - timestamp: Date.now(), - }) - } - } - - if (executionResult.success) { - return finish( - { - success: true, - result: executionResult.value, - logs: executionResult.logs, - }, - 'execute', - ) - } - - return finish( - { - success: false, - error: executionResult.error - ? { - message: executionResult.error.message, - name: executionResult.error.name, - ...(executionResult.error.stack !== undefined && { - stack: executionResult.error.stack, - }), - } - : { message: 'Unknown execution error', name: 'UnknownError' }, - logs: executionResult.logs, - }, - 'execute', - ) + driver, + timeout, + memoryLimit, + finish, + }) } catch (error) { return finish( { success: false, - error: { - message: error instanceof Error ? error.message : String(error), - name: error instanceof Error ? error.name : 'Error', - ...(error instanceof Error && - error.stack !== undefined && { stack: error.stack }), - }, + error: codeModeCaughtError(error), }, 'unhandled', ) - } finally { - // Always clean up the sandbox context - if (isolateContext) { - await isolateContext.dispose() - } } }, ) } +function codeModeCaughtError( + error: unknown, +): NonNullable { + return { + message: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : 'Error', + ...(error instanceof Error && + error.stack !== undefined && { stack: error.stack }), + } +} + +function emitCodeModeConsoleLogs( + logs: Array | undefined, + emitCustomEvent: ToolExecutionContext['emitCustomEvent'], +): void { + if (!logs) return + if (logs.length === 0) return + for (const log of logs) { + const parsed = parseCodeModeLog(log) + emitCustomEvent('code_mode:console', { + ...parsed, + timestamp: Date.now(), + }) + } +} + +function parseCodeModeLog(log: string): { + level: 'log' | 'warn' | 'error' | 'info' + message: string +} { + if (log.startsWith('ERROR: ')) + return { level: 'error', message: log.slice(7) } + if (log.startsWith('WARN: ')) return { level: 'warn', message: log.slice(6) } + if (log.startsWith('INFO: ')) return { level: 'info', message: log.slice(6) } + return { level: 'log', message: log } +} + +async function runCodeModeExecution(args: { + typescriptCode: string + transpile: (code: string) => Promise | string + getSnippetBindings: CodeModeToolConfig['getSnippetBindings'] + onSecretParameter: CodeModeToolConfig['onSecretParameter'] + secretDedupCache: Set + staticBindings: ReturnType + emitCustomEvent: ToolExecutionContext['emitCustomEvent'] + driver: CodeModeToolConfig['driver'] + timeout: number + memoryLimit: number + finish: (result: CodeModeToolResult, phase: string) => CodeModeToolResult +}): Promise { + let strippedCode: string + try { + strippedCode = await args.transpile(args.typescriptCode) + } catch (error) { + return args.finish( + { + success: false, + error: { + ...codeModeCaughtError(error), + name: 'TypeScriptError', + }, + }, + 'transpile', + ) + } + + const snippetBindings = args.getSnippetBindings + ? await args.getSnippetBindings() + : {} + const snippetBindingValues = Object.values(snippetBindings) + if (snippetBindingValues.length > 0) { + warnIfBindingsExposeSecrets(snippetBindingValues, { + handler: args.onSecretParameter, + dedupCache: args.secretDedupCache, + }) + } + + const eventAwareBindings = createEventAwareBindings( + { ...args.staticBindings, ...snippetBindings }, + args.emitCustomEvent, + ) + + let isolateContext: IsolateContext + try { + isolateContext = await args.driver.createContext({ + bindings: eventAwareBindings, + timeout: args.timeout, + memoryLimit: args.memoryLimit, + }) + } catch (error) { + const caught = codeModeCaughtError(error) + return args.finish( + { + success: false, + error: { + ...caught, + name: error instanceof Error ? error.name : 'CreateContextError', + }, + }, + 'create-context', + ) + } + try { + const executionResult = await isolateContext.execute(strippedCode) + emitCodeModeConsoleLogs(executionResult.logs, args.emitCustomEvent) + + if (executionResult.success) { + return args.finish( + { + success: true, + result: executionResult.value, + logs: executionResult.logs, + }, + 'execute', + ) + } + + return args.finish( + { + success: false, + error: executionResult.error + ? { + message: executionResult.error.message, + name: executionResult.error.name, + ...(executionResult.error.stack !== undefined && { + stack: executionResult.error.stack, + }), + } + : { message: 'Unknown execution error', name: 'UnknownError' }, + logs: executionResult.logs, + }, + 'execute', + ) + } finally { + await isolateContext.dispose() + } +} + /** * Build the tool description including available external functions */ diff --git a/packages/ai-code-mode/src/strip-typescript.ts b/packages/ai-code-mode/src/strip-typescript.ts index 6373a4d4e0..1767e918e3 100644 --- a/packages/ai-code-mode/src/strip-typescript.ts +++ b/packages/ai-code-mode/src/strip-typescript.ts @@ -4,6 +4,7 @@ import { transform } from 'sucrase' const WRAPPER_START = '___TANSTACK_WRAPPER_START___' const WRAPPER_END = '___TANSTACK_WRAPPER_END___' +// eslint-disable-next-line @typescript-eslint/require-await /** * Strip TypeScript syntax from code, converting it to plain JavaScript. * @@ -47,10 +48,6 @@ const WRAPPER_END = '___TANSTACK_WRAPPER_END___' * @returns Plain JavaScript code with all type syntax removed * @throws Error if sucrase fails (e.g., syntax error) or wrapper extraction fails */ -// sucrase's transform is synchronous, but we keep the published Promise-returning -// signature so existing `await stripTypeScript(...)` callers (and a custom async -// `transpile` hook) stay source-compatible across this swap. -// eslint-disable-next-line @typescript-eslint/require-await export async function stripTypeScript(code: string): Promise { // Wrap the code in an async function to allow top-level return/await. // This is necessary because top-level `return` is invalid outside a function. diff --git a/packages/ai-code-mode/src/type-generator/json-schema-to-ts.ts b/packages/ai-code-mode/src/type-generator/json-schema-to-ts.ts index 9b1bb2f26d..c96e2a67bc 100644 --- a/packages/ai-code-mode/src/type-generator/json-schema-to-ts.ts +++ b/packages/ai-code-mode/src/type-generator/json-schema-to-ts.ts @@ -27,7 +27,8 @@ export function generateTypeStubs( const declarations: Array = [] - for (const [name, binding] of Object.entries(bindings)) { + const toolBindings = Object.entries(bindings) + for (const [name, binding] of toolBindings) { const inputTypeName = `${capitalize(name)}Input` const outputTypeName = `${capitalize(name)}Output` @@ -81,11 +82,11 @@ export function jsonSchemaToTypeScript( const type = schemaToType(schema) // For object schemas with properties, create a named interface - if ( + const hasObjectProperties = schema.type === 'object' && - schema.properties && - Object.keys(schema.properties).length > 0 - ) { + Boolean(schema.properties) && + Object.keys(schema.properties as object).length > 0 + if (hasObjectProperties) { return { name: typeName, declaration: `interface ${typeName} ${type}`, @@ -111,7 +112,8 @@ function schemaToType(schema: Record): string { // Handle basic types if (schemaType === 'string') return 'string' - if (schemaType === 'number' || schemaType === 'integer') return 'number' + if (schemaType === 'number') return 'number' + if (schemaType === 'integer') return 'number' if (schemaType === 'boolean') return 'boolean' if (schemaType === 'null') return 'null' @@ -166,7 +168,8 @@ function schemaToType(schema: Record): string { return schemaType .map((t) => { if (t === 'string') return 'string' - if (t === 'number' || t === 'integer') return 'number' + if (t === 'number') return 'number' + if (t === 'integer') return 'number' if (t === 'boolean') return 'boolean' if (t === 'null') return 'null' if (t === 'array') return 'Array' diff --git a/packages/ai-code-mode/src/types.ts b/packages/ai-code-mode/src/types.ts index f791b2a164..b13f497820 100644 --- a/packages/ai-code-mode/src/types.ts +++ b/packages/ai-code-mode/src/types.ts @@ -6,10 +6,6 @@ import type { } from '@tanstack/ai' import type { SecretParameterHandler } from './validate-bindings' -// ============================================================================ -// Isolate Driver Interfaces -// ============================================================================ - /** * Interface for isolate/sandbox drivers * Each runtime environment implements this to provide sandboxed code execution @@ -106,17 +102,10 @@ export interface NormalizedError { code?: string } -// ============================================================================ -// Tool Binding Interfaces -// ============================================================================ - /** * A tool transformed into a format suitable for sandbox injection */ export interface ToolBinding { - /** - * Unique tool identifier - */ name: string /** @@ -134,20 +123,12 @@ export interface ToolBinding { */ outputSchema?: Record | undefined - /** - * The execute function that will be injected into the sandbox. - * Accepts optional context for emitting custom events. - */ execute: (args: unknown, context?: ToolExecutionContext) => Promise } // Re-export for convenience export type { ToolExecutionContext } -// ============================================================================ -// Code Mode Tool Types -// ============================================================================ - /** * Server-side tool types that can be passed to Code Mode. * @@ -171,14 +152,8 @@ export interface CodeModeToolConfig { */ tools: Array - /** - * Execution timeout in milliseconds (default: 30000) - */ timeout?: number - /** - * Memory limit for isolate in MB (default: 128) - */ memoryLimit?: number /** @@ -262,9 +237,6 @@ export interface CodeModeToolConfig { * Result returned by the execute_typescript tool */ export interface CodeModeToolResult { - /** - * Whether execution completed without errors - */ success: boolean /** @@ -272,14 +244,8 @@ export interface CodeModeToolResult { */ result?: unknown - /** - * Console output captured during execution - */ logs?: Array - /** - * Error details if execution failed - */ error?: | { message: string diff --git a/packages/ai-code-mode/src/validate-bindings.ts b/packages/ai-code-mode/src/validate-bindings.ts index aed7a5b77e..8897f97660 100644 --- a/packages/ai-code-mode/src/validate-bindings.ts +++ b/packages/ai-code-mode/src/validate-bindings.ts @@ -96,11 +96,14 @@ function findSecretParams( path: Array, found: Array<{ path: Array; name: string }>, ): void { - if (!schema || typeof schema !== 'object' || seen.has(schema)) return + if (!schema) return + if (typeof schema !== 'object') return + if (seen.has(schema)) return seen.add(schema) if (schema.properties && typeof schema.properties === 'object') { - for (const [paramName, sub] of Object.entries(schema.properties)) { + const properties = Object.entries(schema.properties) + for (const [paramName, sub] of properties) { if (looksLikeSecret(paramName)) { found.push({ path: [...path, paramName], name: paramName }) } diff --git a/packages/ai-codex/src/adapters/policy-map.ts b/packages/ai-codex/src/adapters/policy-map.ts index 6e85678ef1..039ad85a1c 100644 --- a/packages/ai-codex/src/adapters/policy-map.ts +++ b/packages/ai-codex/src/adapters/policy-map.ts @@ -1,26 +1,3 @@ -/** - * Map a portable {@link SandboxPolicy} onto Codex CLI settings. - * - * **Best-effort, coarse mapping.** `codex exec --experimental-json` runs - * non-interactively: there is no per-action host callback (unlike Claude Code's - * `--permission-prompt-tool`), so the fine-grained, resume-based interactive - * approval flow (`deny` + `approval-requested` + re-run) is NOT available for - * Codex. Instead the policy collapses onto Codex's coarse knobs: - * - * - `capabilities.fileWrite === 'deny'` → `--sandbox read-only` - * (otherwise `workspace-write`). - * - `capabilities.network` → `sandbox_workspace_write.network_access` - * (`'allow'` → true, `'deny'` → false; unset leaves Codex's default). - * - `approval_policy`: a fully-permissive policy (`default: 'allow'` with no - * `ask` rules) → `never`; a `default: 'deny'` policy → `untrusted`; - * `default: 'ask'` or any `commands.ask` rules → `on-request`. A deny list - * alone is a hard block, not a human prompt, so it does not flip - * `on-request`. In `exec` mode Codex will refuse (rather than prompt for) - * actions that need approval. - * - * Returns only the knobs the policy actually constrains; the adapter merges - * these with its own config (config/modelOptions still take precedence). - */ import type { SandboxPolicy } from '@tanstack/ai-sandbox' import type { CodexApprovalMode, CodexSandboxMode } from './text' @@ -46,7 +23,8 @@ export function mapPolicyToCodexFlags( } const hasAsk = (policy.commands?.ask?.length ?? 0) > 0 - if (hasAsk || policy.default === 'ask') { + const needsOnRequest = hasAsk || policy.default === 'ask' + if (needsOnRequest) { flags.approvalPolicy = 'on-request' } else if (policy.default === 'deny') { flags.approvalPolicy = 'untrusted' diff --git a/packages/ai-codex/src/adapters/projection.ts b/packages/ai-codex/src/adapters/projection.ts index 2bf3a6310f..f24cfd5725 100644 --- a/packages/ai-codex/src/adapters/projection.ts +++ b/packages/ai-codex/src/adapters/projection.ts @@ -1,35 +1,3 @@ -/** - * Codex workspace projector — mirrors the claude-code reference - * (`packages/ai-claude-code/src/adapters/projection.ts`). - * - * `withSandbox` surfaces a portable `WorkspaceProjection` (skills, plugins, a - * secret resolver, and a one-time marker path) via a capability. Each harness - * adapter reads it in its `chatStream` setup and projects those inputs into the - * CLI's native format. For Codex that means: - * - * - MCP servers → `[mcp_servers.]` tables in `/.codex/config.toml` - * (TOML), reusing the same `mcp_servers.*` key shape the - * adapter already uses to wire the host tool-bridge. - * - gitSkill repos → linked under codex's skills dir when one exists; Codex - * has no documented project skills dir, so we warn-and-skip. - * - agentSkill → no codex primitive pulls a public skill by bare name, so - * we warn-and-skip rather than invent one. - * - plugins → Codex has no plugin concept, so we warn-and-skip. - * - * The secret-bearing MCP config is (re)written on EVERY call, re-resolving - * secrets each time, so codex always reads current values and a snapshot can - * never serve a stale or rotated secret. Only the safe, idempotent, non-secret - * operations (gitSkill links, agentSkill / plugin handling) are guarded by a - * one-time marker file under the workspace. - * - * Codex specifics (verified against the codex config schema): - * - Codex reads `[mcp_servers.]` from `/.codex/config.toml`, with a - * streamable-HTTP server taking `url` plus optional `http_headers` - * (a literal header table). We write resolved header values directly into - * `http_headers` so a rotated secret re-applies on every projection. - * - AGENTS.md is written universally by bootstrap (codex reads it natively), - * so it is NOT rewritten here. - */ import { discoverSkillDirs, isSecretRef, @@ -105,7 +73,8 @@ function buildMcpServers( count += 1 const headers: Record = {} const rawHeaders = skill.config.headers ?? {} - for (const [name, value] of Object.entries(rawHeaders)) { + const headerEntries = Object.entries(rawHeaders) + for (const [name, value] of headerEntries) { headers[name] = resolveHeaderValue(value, resolveSecret) } const rawUrl = skill.config['url'] @@ -123,7 +92,8 @@ function buildMcpServers( */ function renderMcpToml(servers: Record): string { const blocks: Array = [] - for (const [name, server] of Object.entries(servers)) { + const mcpServers = Object.entries(servers) + for (const [name, server] of mcpServers) { const lines: Array = [ `[mcp_servers.${name}]`, `url = ${tomlString(server.url)}`, diff --git a/packages/ai-codex/src/adapters/text.ts b/packages/ai-codex/src/adapters/text.ts index c5a9a3cac4..3b4225e494 100644 --- a/packages/ai-codex/src/adapters/text.ts +++ b/packages/ai-codex/src/adapters/text.ts @@ -39,6 +39,36 @@ import type { CodexModel } from '../model-meta' import type { CodexTextProviderOptions } from '../provider-options' import type { CodexThreadEvent } from '../stream/sdk-types' +function chatRunErrorChunk(error: unknown, model: string): AdapterYieldChunk { + const err = error as Error & { code?: string } + const rawEvent = toRunErrorRawEvent(error) + const message = err.message || 'Unknown error occurred' + return { + type: EventType.RUN_ERROR, + model, + timestamp: Date.now(), + message, + ...(err.code !== undefined && { code: err.code }), + ...(rawEvent !== undefined && { rawEvent }), + error: { + message, + ...(err.code !== undefined && { code: err.code }), + }, + } +} + +function abortSignalFields( + options: TextOptions, +): { signal: AbortSignal } | Record { + if (options.abortController?.signal) { + return { signal: options.abortController.signal } + } + if (options.request?.signal) { + return { signal: options.request.signal } + } + return {} +} + export type CodexSandboxMode = | 'read-only' | 'workspace-write' @@ -163,6 +193,7 @@ export class CodexTextAdapter< provider: string, outputSchemaPath: string | undefined, ): string { + /** Extra raw `--config key=value` overrides (values passed verbatim as TOML). */ const config = this.adapterConfig const modelOptions = options.modelOptions const exe = config.codexExecutable ?? 'codex' @@ -175,32 +206,65 @@ export class CodexTextAdapter< config.sandboxMode ?? policyFlags.sandboxMode ?? defaultSandboxMode(provider) + /** Codex approval policy (`--config approval_policy=`). Defaults to `'never'`. */ const approvalPolicy = modelOptions?.approvalPolicy ?? config.approvalPolicy ?? policyFlags.approvalPolicy ?? 'never' + /** Allow network in `workspace-write` (`--config sandbox_workspace_write.network_access=`). */ const networkAccessEnabled = config.networkAccessEnabled ?? policyFlags.networkAccessEnabled const reasoning = modelOptions?.modelReasoningEffort ?? config.modelReasoningEffort + /** Skip Codex's git-repo safety check (`--skip-git-repo-check`). Defaults to true. */ const skipGitRepoCheck = modelOptions?.skipGitRepoCheck ?? config.skipGitRepoCheck args.push('--model', q(this.model)) args.push('--sandbox', q(sandboxMode)) - // NOTE: do NOT pass `--cd `. `cwd` is the VIRTUAL `/workspace` root; the - // provider handle already maps it to the sandbox's real workdir and runs the - // process there (e.g. Daytona's `/home/daytona/workspace`). Passing it as a - // literal `--cd` makes codex chdir to a path that doesn't exist on the real - // filesystem → "No such file or directory (os error 2)". Codex inherits the - // handle-set process cwd instead. + this.pushCodexDirFlags(args, skipGitRepoCheck, config.additionalDirectories) + + const cfg = this.codexConfigFlags( + approvalPolicy, + reasoning, + networkAccessEnabled, + bridge, + ) + const configFlags = Object.entries(cfg) + for (const [key, value] of configFlags) { + args.push('--config', q(`${key}=${value}`)) + } + + if (outputSchemaPath !== undefined) { + args.push('--output-schema', q(outputSchemaPath)) + } + + // Resume an existing thread (mirrors the SDK's `resume `). + if (resume !== undefined) args.push('resume', q(resume)) + + return `${exe} ${args.join(' ')}` + } + + private pushCodexDirFlags( + args: Array, + skipGitRepoCheck: boolean | undefined, + additionalDirectories: Array | undefined, + ): void { if (skipGitRepoCheck !== false) args.push('--skip-git-repo-check') - for (const dir of config.additionalDirectories ?? []) { + for (const dir of additionalDirectories ?? []) { args.push('--add-dir', q(dir)) } + } - const cfg: Record = { + private codexConfigFlags( + approvalPolicy: string, + reasoning: string | undefined, + networkAccessEnabled: boolean | undefined, + bridge: HostToolBridge | undefined, + ): Record { + const config = this.adapterConfig + return { approval_policy: `"${approvalPolicy}"`, ...(reasoning ? { model_reasoning_effort: `"${reasoning}"` } : {}), ...(networkAccessEnabled !== undefined @@ -212,12 +276,6 @@ export class CodexTextAdapter< ...(config.webSearchMode ? { web_search: `"${config.webSearchMode}"` } : {}), - // Bridge chat()-provided tools via a streamable-HTTP MCP server. A `url` - // makes codex use its streamable-HTTP transport, which authenticates via an - // `Authorization` header — codex REJECTS inline `bearer_token` for this - // transport ("bearer_token is not supported for streamable_http"; that field - // is only for the stdio transport). Pass the per-run bearer as an HTTP header - // instead; the host tool-bridge checks `Authorization: Bearer `. ...(bridge ? { [`mcp_servers.${bridge.name}.url`]: `"${bridge.url}"`, @@ -226,18 +284,57 @@ export class CodexTextAdapter< : {}), ...config.config, } - for (const [key, value] of Object.entries(cfg)) { - args.push('--config', q(`${key}=${value}`)) - } + } - if (outputSchemaPath !== undefined) { - args.push('--output-schema', q(outputSchemaPath)) - } + private async maybeProvisionCodexBridge( + options: TextOptions, + sandbox: SandboxHandle, + channel: ReturnType, + ): Promise { + if (!options.tools) return undefined + if (options.tools.length === 0) return undefined + const provisioner = + (options.capabilities + ? getToolBridgeProvisioner(options.capabilities, { optional: true }) + : undefined) ?? nodeHttpBridgeProvisioner + return await provisioner.provision(options.tools, { + provider: sandbox.provider, + context: options.context, + emitCustomEvent: channel.emitCustomEvent, + ...(options.abortController?.signal + ? { signal: options.abortController.signal } + : {}), + }) + } - // Resume an existing thread (mirrors the SDK's `resume `). - if (resume !== undefined) args.push('resume', q(resume)) + private codexPrompt( + options: TextOptions, + prompt: string, + ): string { + const systemPrompts = normalizeSystemPrompts(options.systemPrompts) + .map((p) => p.content) + .filter((c) => c.trim() !== '') + if (systemPrompts.length === 0) return prompt + return `${systemPrompts.join('\n\n')}\n\n${prompt}` + } - return `${exe} ${args.join(' ')}` + private async prepareCodexStdin( + sandbox: SandboxHandle, + command: string, + fullPrompt: string, + runId: string, + tempFiles: Array, + ): Promise<{ runCommand: string; stdinInput: string | undefined }> { + if (sandbox.capabilities.writableStdin !== false) { + return { runCommand: command, stdinInput: fullPrompt } + } + const promptPath = `/tmp/tanstack-codex-prompt-${encodeRunId(runId)}` + await sandbox.fs.write(promptPath, fullPrompt) + tempFiles.push(promptPath) + return { + runCommand: `${command} < ${q(promptPath)}`, + stdinInput: undefined, + } } async *chatStream( @@ -247,14 +344,6 @@ export class CodexTextAdapter< let bridge: HostToolBridge | undefined const tempFiles: Array = [] let cleanupSandbox: SandboxHandle | undefined - // Durability caveat: the journaled path below derives its journal file - // path from `runId` alone (see `journalPaths` in `@tanstack/ai-sandbox`), - // and a successor host must recompute that same path to resume this run. - // That is only possible when the caller supplies a stable `runId`. - // `resolveDurableRunId` enforces that when durability is wired (both - // `runs` and `durability.adapter` given to `withSandbox`) and preserves - // the generated fallback — `this.generateId()`, a fresh random id every - // call — when it is not, so a non-durable run's behavior is unchanged. const durability = options.capabilities ? getSandboxDurability(options.capabilities, { optional: true }) : undefined @@ -263,11 +352,6 @@ export class CodexTextAdapter< adapter: 'codex', fallback: () => this.generateId(), }) - // `threadId` is stamped on every chunk `translateThreadEvents` emits, so an - // ATTACHING run that mints a fresh one replays a stream the stored log - // cannot match at index 0. `resolveDurableThreadId` refuses that up front - // instead of letting alignment discover it mid-stream; a durable FRESH run - // and a non-durable run both keep the generated fallback untouched. const threadId = resolveDurableThreadId(options.threadId, { durable: durability !== undefined, attaching: durability?.attach === true, @@ -284,42 +368,21 @@ export class CodexTextAdapter< try { const sandbox = this.sandboxFrom(options) cleanupSandbox = sandbox + /** Working directory inside the sandbox. Defaults to `/workspace`. */ const cwd = this.workdir(options) - // Project declarative workspace inputs (MCP/skills) into codex's native - // format. Re-runs each call so rotated secrets re-apply; idempotent ops - // are marker-gated inside the projector. const projection = options.capabilities ? getWorkspaceProjection(options.capabilities, { optional: true }) : undefined if (projection) await projectCodexWorkspace(sandbox, projection) - if (options.tools && options.tools.length > 0) { - const provisioner = - (options.capabilities - ? getToolBridgeProvisioner(options.capabilities, { optional: true }) - : undefined) ?? nodeHttpBridgeProvisioner - bridge = await provisioner.provision(options.tools, { - provider: sandbox.provider, - context: options.context, - emitCustomEvent: channel.emitCustomEvent, - ...(options.abortController?.signal - ? { signal: options.abortController.signal } - : {}), - }) - } + bridge = await this.maybeProvisionCodexBridge(options, sandbox, channel) const { prompt, resume } = buildPrompt( options.messages, options.modelOptions?.sessionId, ) - const systemPrompts = normalizeSystemPrompts(options.systemPrompts) - .map((p) => p.content) - .filter((c) => c.trim() !== '') - const fullPrompt = - systemPrompts.length > 0 - ? `${systemPrompts.join('\n\n')}\n\n${prompt}` - : prompt + const fullPrompt = this.codexPrompt(options, prompt) const policy = options.capabilities ? getSandboxPolicy(options.capabilities, { optional: true }) @@ -346,52 +409,23 @@ export class CodexTextAdapter< { provider: 'codex', model: this.model }, ) - // Deliver the prompt. Default: over stdin. Providers without a writable - // host→process stdin can't accept that — Docker's hijacked exec severs - // stdout when stdin EOF is signalled (losing the agent's output), and - // Cloudflare can't write stdin at all — so feed the prompt from a file - // (`codex exec … < file`) instead. - let runCommand = command - let stdinInput: string | undefined = fullPrompt - if (sandbox.capabilities.writableStdin === false) { - // Reuse the ALREADY-RESOLVED `runId`, not a fresh `options.runId ?? this.generateId()` - // re-derivation: the latter mints a SECOND random id whenever - // `options.runId` is absent, so the prompt file's suffix would not - // even match the journal path derived from the run's own `runId` - // above (see `resolveDurableRunId`). That mismatch is invisible - // (the prompt still gets read), but it defeats the whole point of a - // stable, caller-supplied `runId` for anything keyed off it. - // `encodeRunId`, because durability makes `runId` CALLER-chosen and this - // interpolates it into a filesystem path. Raw, a `/` would silently turn - // the basename into a nested path (writing outside `/tmp` or failing on a - // missing dir), `..` would climb out of it, and a long id would fail the - // spawn with `ENAMETOOLONG`. The encoder collapses every id to one - // bounded, injective path segment — the same one `journalPaths` uses, so - // the prompt file and the journal agree on how this id spells. - const promptPath = `/tmp/tanstack-codex-prompt-${encodeRunId(runId)}` - await sandbox.fs.write(promptPath, fullPrompt) - tempFiles.push(promptPath) - runCommand = `${command} < ${q(promptPath)}` - stdinInput = undefined - } + const prepared = await this.prepareCodexStdin( + sandbox, + command, + fullPrompt, + runId, + tempFiles, + ) - // `undefined` whenever the run is not durable, so `spawnNdjson` takes its - // original, unjournaled path and behavior stays byte-identical to a - // pre-durability run. When durable, this also carries `attach`, which is - // how `spawnNdjson` decides to tail an EXISTING journal instead of - // starting a new agent — set by the attach route's `drive()` callback, - // never by an application's POST handler (see `SandboxDurabilityOptions.attach`). const journalOptions = journalOptionsFor(durability, runId) - const rawEvents = spawnNdjson(sandbox, runCommand, { + const rawEvents = spawnNdjson(sandbox, prepared.runCommand, { cwd, - ...(stdinInput !== undefined ? { input: stdinInput } : {}), + ...(prepared.stdinInput !== undefined + ? { input: prepared.stdinInput } + : {}), ...(this.adapterConfig.env ? { env: this.adapterConfig.env } : {}), - ...(options.abortController?.signal - ? { signal: options.abortController.signal } - : options.request?.signal - ? { signal: options.request.signal } - : {}), + ...abortSignalFields(options), onNonJsonLine: (line) => logger.provider(`provider=codex non-json line: ${line}`, { chunk: line, @@ -405,29 +439,8 @@ export class CodexTextAdapter< for await (const event of rawEvents) yield event as CodexThreadEvent } - // Deterministic, run-scoped ids: journal replay re-translates the same - // journal bytes, and `this.generateId()` (Date.now() + Math.random()) - // would mint different message ids on every replay. See - // chunk-identity.ts in `@tanstack/ai-sandbox` for why this is required. const genId = createRunScopedIdGen(runId) - // `mergeChunkStreams` below interleaves `translateThreadEvents`'s - // deterministic output with `channel.stream` (host-tool-bridge CUSTOM - // events from LIVE tool execution — see `createBridgeEventChannel` - // above). Those events do not occur on a replay, so a takeover's replay - // is NOT chunk-for-chunk identical to what the log holds. - // `alignedIfAttaching` handles it: alignment skips stored out-of-band - // CUSTOM entries within a bounded window (see `align.ts`), so a - // bridged-tool run can be taken over without a spurious - // `JournalReplayDivergedError`, while a genuine determinism regression - // still throws. It is a no-op (passes the stream through untouched) - // whenever the run is not durable or is not attaching, so a - // non-durable run's output is unaffected byte for byte. - // - // The wrap goes OUTSIDE `mergeChunkStreams`, never around the pre-merge - // translator alone: the stored log holds the previous host's MERGED - // output, so comparing against anything else would compare against a - // stream the log never contained. yield* alignedIfAttaching( mergeChunkStreams( translateThreadEvents(asEvents(), { @@ -450,24 +463,11 @@ export class CodexTextAdapter< logger, ) } catch (error: unknown) { - const err = error as Error & { code?: string } - const rawEvent = toRunErrorRawEvent(error) logger.errors('codex.chatStream fatal', { error, source: 'codex.chatStream', }) - yield { - type: EventType.RUN_ERROR, - model: options.model, - timestamp: Date.now(), - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - ...(rawEvent !== undefined && { rawEvent }), - error: { - message: err.message || 'Unknown error occurred', - ...(err.code !== undefined && { code: err.code }), - }, - } + yield chatRunErrorChunk(error, options.model) } finally { channel.close() await bridge?.close() diff --git a/packages/ai-codex/src/stream/sdk-types.ts b/packages/ai-codex/src/stream/sdk-types.ts index 1e7d367263..f39c7f1c98 100644 --- a/packages/ai-codex/src/stream/sdk-types.ts +++ b/packages/ai-codex/src/stream/sdk-types.ts @@ -7,7 +7,6 @@ * and the package's public types don't depend on the SDK's type exports. * Unknown item or event types fall through every branch at runtime. */ - export interface CodexUsage { input_tokens?: number cached_input_tokens?: number diff --git a/packages/ai-codex/src/stream/translate.ts b/packages/ai-codex/src/stream/translate.ts index 7def8fef9a..490a50b4a0 100644 --- a/packages/ai-codex/src/stream/translate.ts +++ b/packages/ai-codex/src/stream/translate.ts @@ -8,10 +8,12 @@ import type { AdapterYieldChunk, TokenUsage } from '@tanstack/ai' import type { CodexThreadEvent, CodexThreadItem, CodexUsage } from './sdk-types' /** Name of the CUSTOM event carrying the Codex thread (session) id. */ -export const SESSION_ID_EVENT = 'codex.session-id' +export const /** Name of the CUSTOM event carrying the Codex thread (session) id. */ + SESSION_ID_EVENT = 'codex.session-id' /** Server name used for bridged TanStack tools. */ -export const BRIDGED_MCP_SERVER_NAME = 'tanstack' +export const /** Server name used for bridged TanStack tools. */ + BRIDGED_MCP_SERVER_NAME = 'tanstack' export interface TranslateContext { model: string @@ -181,9 +183,11 @@ export async function* translateThreadEvents( let runStarted = false /** Tool calls started but with no result yet. */ - const unresolvedToolCalls = new Set() + const /** Tool calls started but with no result yet. */ + unresolvedToolCalls = new Set() /** Item ids that already emitted TOOL_CALL_START/ARGS/END. */ - const openedToolItems = new Set() + const /** Item ids that already emitted TOOL_CALL_START/ARGS/END. */ + openedToolItems = new Set() function* startRun(): Generator { if (runStarted) return @@ -267,7 +271,8 @@ export async function* translateThreadEvents( ): Generator { yield* startText(messageId) const state = openText.get(messageId) - if (state === undefined || state.ended) return + if (state === undefined) return + if (state.ended) return if (text.length <= state.emitted) return const delta = text.slice(state.emitted) state.emitted = text.length @@ -283,7 +288,8 @@ export async function* translateThreadEvents( function* endText(messageId: string): Generator { const state = openText.get(messageId) - if (state === undefined || state.ended) return + if (state === undefined) return + if (state.ended) return state.ended = true yield { type: EventType.TEXT_MESSAGE_END, @@ -420,12 +426,16 @@ export async function* translateThreadEvents( // needs RUN_STARTED first. yield* startRun() - if (event.type === 'item.started' || event.type === 'item.updated') { + if (event.type === 'item.started') { if (event.item.type === 'agent_message') { yield* handleAgentMessage(event.item, false) - } else if (event.type === 'item.started' && isToolItem(event.item)) { + } else if (isToolItem(event.item)) { yield* openToolCall(event.item) } + } else if (event.type === 'item.updated') { + if (event.item.type === 'agent_message') { + yield* handleAgentMessage(event.item, false) + } } else if (event.type === 'item.completed') { yield* handleItemCompleted(event.item) } else if (event.type === 'turn.completed') { @@ -441,12 +451,19 @@ export async function* translateThreadEvents( finishReason: 'stop', ...(usage !== undefined && { usage }), } - } else if (event.type === 'turn.failed' || event.type === 'error') { + } else if (event.type === 'turn.failed') { + yield* synthesizeUnresolvedResults() + const message = event.error?.message ?? 'Codex turn failed' + yield { + type: EventType.RUN_ERROR, + model, + timestamp: now(), + message, + error: { message }, + } + } else if (event.type === 'error') { yield* synthesizeUnresolvedResults() - const message = - event.type === 'turn.failed' - ? (event.error?.message ?? 'Codex turn failed') - : event.message + const message = event.message yield { type: EventType.RUN_ERROR, model, @@ -460,10 +477,6 @@ export async function* translateThreadEvents( } yield* emitStructuredFromLast() } catch (error) { - // The run is dying (abort or SDK failure). Pair any started tool calls - // with a synthetic result first so the next request's pending-tool-call - // scan doesn't try to execute them, then let the adapter surface the - // error as RUN_ERROR. yield* synthesizeUnresolvedResults() throw error } diff --git a/packages/ai-cohere/src/adapters/embedding.ts b/packages/ai-cohere/src/adapters/embedding.ts index 551736b81e..a9f9fd95bc 100644 --- a/packages/ai-cohere/src/adapters/embedding.ts +++ b/packages/ai-cohere/src/adapters/embedding.ts @@ -36,11 +36,12 @@ function isPrivateOrInternalUrl(url: string): boolean { } catch { return true } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + const notHttp = parsed.protocol !== 'http:' && parsed.protocol !== 'https:' + if (notHttp) { return true } const host = parsed.hostname.toLowerCase() - if ( + const isPrivateHost = host === 'localhost' || host.endsWith('.localhost') || host === '::1' || @@ -50,12 +51,30 @@ function isPrivateOrInternalUrl(url: string): boolean { host.startsWith('192.168.') || host.startsWith('169.254.') || /^172\.(1[6-9]|2\d|3[01])\./.test(host) - ) { + if (isPrivateHost) { return true } return false } +async function readCohereErrorMessage(response: Response): Promise { + const bodyText = await response.text() + try { + const parsed: unknown = JSON.parse(bodyText) + if ( + typeof parsed === 'object' && + parsed !== null && + 'message' in parsed && + typeof parsed.message === 'string' + ) { + return parsed.message + } + } catch { + // Not JSON — fall back to the raw body text. + } + return bodyText +} + async function fetchWithTimeout( url: string, init: RequestInit | undefined, @@ -146,20 +165,8 @@ export class CohereEmbeddingAdapter< ) } - const resolved = resolveEmbeddingInput(options.input) - const inputs = await Promise.all( - resolved.map(async (item) => { - const content: Array = item.texts.map( - (text) => ({ type: 'text', text }), - ) - for (const image of item.images) { - content.push({ - type: 'image_url', - image_url: { url: await this.resolveImageUrl(image) }, - }) - } - return { content } - }), + const inputs = await this.toCohereInputs( + resolveEmbeddingInput(options.input), ) // embedding_types is pinned to ['float'] (overriding any disagreeing @@ -199,22 +206,9 @@ export class CohereEmbeddingAdapter< ) if (!response.ok) { - const bodyText = await response.text() - let message = bodyText - try { - const parsed: unknown = JSON.parse(bodyText) - if ( - typeof parsed === 'object' && - parsed !== null && - 'message' in parsed && - typeof parsed.message === 'string' - ) { - message = parsed.message - } - } catch { - // Not JSON — fall back to the raw body text. - } - throw new Error(`Cohere embed failed (${response.status}): ${message}`) + throw new Error( + `Cohere embed failed (${response.status}): ${await readCohereErrorMessage(response)}`, + ) } const data = (await response.json()) as CohereEmbedResponse @@ -257,6 +251,25 @@ export class CohereEmbeddingAdapter< } } + private async toCohereInputs( + resolved: ReturnType, + ): Promise }>> { + return Promise.all( + resolved.map(async (item) => { + const content: Array = item.texts.map( + (text) => ({ type: 'text', text }), + ) + for (const image of item.images) { + content.push({ + type: 'image_url', + image_url: { url: await this.resolveImageUrl(image) }, + }) + } + return { content } + }), + ) + } + /** * Resolves an image part to a URL Cohere accepts. Cohere does not fetch * remote image URLs, so everything is normalized to a `data:` URI unless diff --git a/packages/ai-cohere/src/adapters/rerank.ts b/packages/ai-cohere/src/adapters/rerank.ts index 3a52e97225..a32f6b0053 100644 --- a/packages/ai-cohere/src/adapters/rerank.ts +++ b/packages/ai-cohere/src/adapters/rerank.ts @@ -22,7 +22,8 @@ interface CohereRerankResponse { } function isCohereRerankResponse(value: unknown): value is CohereRerankResponse { - if (typeof value !== 'object' || value === null) return false + if (typeof value !== 'object') return false + if (value === null) return false const results = (value as { results?: unknown }).results return ( Array.isArray(results) && diff --git a/packages/ai-cohere/src/embedding/embedding-provider-options.ts b/packages/ai-cohere/src/embedding/embedding-provider-options.ts index 35adefd807..4702420d02 100644 --- a/packages/ai-cohere/src/embedding/embedding-provider-options.ts +++ b/packages/ai-cohere/src/embedding/embedding-provider-options.ts @@ -1,11 +1,3 @@ -/** - * Provider options for Cohere embedding models. - * - * `dimensions` is deliberately absent: it's a first-class top-level option on - * `embed()` and is mapped to Cohere's `output_dimension` request field by the - * adapter. - */ - /** * Provider options for `embed-v4.0`. * diff --git a/packages/ai-cohere/src/index.ts b/packages/ai-cohere/src/index.ts index fcfa9efa59..01ac25cfa4 100644 --- a/packages/ai-cohere/src/index.ts +++ b/packages/ai-cohere/src/index.ts @@ -1,16 +1,3 @@ -/** - * @module @tanstack/ai-cohere - * - * Cohere provider adapter for TanStack AI. - * Provides tree-shakeable adapters for Cohere's v2/embed API (multimodal - * embeddings) and v2/rerank API (document reranking) using plain fetch — - * no SDK dependency. - */ - -// ============================================================================ -// Cohere Adapters (tree-shakeable) -// ============================================================================ - // Embedding adapter - for embedding vectors export { CohereEmbeddingAdapter, @@ -30,10 +17,6 @@ export { // Client config + env helpers export { getCohereApiKeyFromEnv, type CohereClientConfig } from './utils/client' -// ============================================================================ -// Type Exports -// ============================================================================ - export type { CohereEmbeddingModel, CohereEmbeddingModelProviderOptionsByName, diff --git a/packages/ai-cohere/src/model-meta.ts b/packages/ai-cohere/src/model-meta.ts index 38700f2bbc..cb4e71001a 100644 --- a/packages/ai-cohere/src/model-meta.ts +++ b/packages/ai-cohere/src/model-meta.ts @@ -26,22 +26,13 @@ export type CohereEmbeddingModelInputModalitiesByName = { 'embed-v4.0': readonly ['text', 'image'] } -/** - * Cohere rerank model metadata. - * - * Provider options are resolved per model at the `cohereRerank('model')` call - * site via {@link CohereRerankModelProviderOptionsByName}. Cohere's rerank - * models currently share the same options, but the per-model map keeps the - * surface symmetric with the other adapters and lets divergent options be - * expressed later without changing the adapter contract. - */ - /** Available Cohere rerank models. */ -export const COHERE_RERANK_MODELS = [ - 'rerank-v3.5', - 'rerank-english-v3.0', - 'rerank-multilingual-v3.0', -] as const +export const /** Available Cohere rerank models. */ + COHERE_RERANK_MODELS = [ + 'rerank-v3.5', + 'rerank-english-v3.0', + 'rerank-multilingual-v3.0', + ] as const /** Union of supported Cohere rerank model names. */ export type CohereRerankModel = (typeof COHERE_RERANK_MODELS)[number] diff --git a/packages/ai-devtools/src/components/Shell.tsx b/packages/ai-devtools/src/components/Shell.tsx index 025d019022..7cf70dd5f1 100644 --- a/packages/ai-devtools/src/components/Shell.tsx +++ b/packages/ai-devtools/src/components/Shell.tsx @@ -89,9 +89,6 @@ function DevtoolsContent() { onCleanup(() => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) - // If the panel unmounts mid-drag, the mouseup handler never fires; - // reset the global drag styles so the host page isn't stuck with - // col-resize cursor / unselectable body. if (isDragging()) { setIsDragging(false) document.body.style.cursor = '' diff --git a/packages/ai-devtools/src/components/conversation/IterationCard.tsx b/packages/ai-devtools/src/components/conversation/IterationCard.tsx index 1654746eb8..3f682b9237 100644 --- a/packages/ai-devtools/src/components/conversation/IterationCard.tsx +++ b/packages/ai-devtools/src/components/conversation/IterationCard.tsx @@ -60,17 +60,21 @@ type IterationStep = // --- Helpers --- function getApprovalStatus(toolCall: ToolCall): string | undefined { - if (toolCall.approvalApproved === true || toolCall.state === 'approved') { + const isApproved = + toolCall.approvalApproved === true || toolCall.state === 'approved' + if (isApproved) { return 'approved' } - if (toolCall.approvalApproved === false || toolCall.state === 'denied') { + const isDenied = + toolCall.approvalApproved === false || toolCall.state === 'denied' + if (isDenied) { return 'denied' } - if ( - toolCall.approvalRequired || - toolCall.approvalId || + const isApprovalRequested = + Boolean(toolCall.approvalRequired) || + Boolean(toolCall.approvalId) || toolCall.state === 'approval-requested' - ) { + if (isApprovalRequested) { return 'approval requested' } if (toolCall.state === 'approval-responded') { @@ -120,9 +124,13 @@ function buildSteps( }) } } - if (msg.toolCalls && msg.toolCalls.length > 0) { - for (const tc of msg.toolCalls) { - steps.push({ kind: 'tool_call', toolCall: tc, message: msg }) + const toolCalls = msg.toolCalls + if (toolCalls) { + const hasToolCalls = toolCalls.length > 0 + if (hasToolCalls) { + for (const tc of toolCalls) { + steps.push({ kind: 'tool_call', toolCall: tc, message: msg }) + } } } if (msg.content) { @@ -167,10 +175,13 @@ const MiddlewareStep: Component<{ const suffix = () => { if (ev().wasDropped) return 'DROP' - if (ev().hookName === 'onChunk' && ev().hasTransform) return 'TRANSFORM' - if (ev().hookName === 'onConfig' && ev().hasTransform) return 'TRANSFORM' - if (ev().hookName === 'onBeforeToolCall' && ev().hasTransform) - return 'DECISION' + const isChunkTransform = ev().hookName === 'onChunk' && ev().hasTransform + if (isChunkTransform) return 'TRANSFORM' + const isConfigTransform = ev().hookName === 'onConfig' && ev().hasTransform + if (isConfigTransform) return 'TRANSFORM' + const isToolCallDecision = + ev().hookName === 'onBeforeToolCall' && ev().hasTransform + if (isToolCallDecision) return 'DECISION' return null } @@ -674,22 +685,27 @@ export const IterationCard: Component = (props) => { const systemPrompts = () => iter().systemPrompts || [] /** Count actual tool invocations in this iteration's messages */ - const toolInvocationCounts = createMemo(() => { - const counts = new Map() - const msgIds = new Set(iter().messageIds) - for (const msg of props.messages) { - if (msgIds.has(msg.id) && msg.toolCalls) { - for (const tc of msg.toolCalls) { - counts.set(tc.name, (counts.get(tc.name) || 0) + 1) + const /** Count actual tool invocations in this iteration's messages */ + toolInvocationCounts = createMemo(() => { + const counts = new Map() + const msgIds = new Set(iter().messageIds) + for (const msg of props.messages) { + if (msgIds.has(msg.id)) { + const toolCalls = msg.toolCalls + if (toolCalls) { + for (const tc of toolCalls) { + counts.set(tc.name, (counts.get(tc.name) || 0) + 1) + } + } } } - } - return counts - }) + return counts + }) const totalToolCalls = createMemo(() => { let count = 0 - for (const v of toolInvocationCounts().values()) count += v + const invocationCounts = toolInvocationCounts().values() + for (const v of invocationCounts) count += v return count }) const modelOptions = () => iter().modelOptions diff --git a/packages/ai-devtools/src/components/conversation/IterationTimeline.tsx b/packages/ai-devtools/src/components/conversation/IterationTimeline.tsx index 280a2873de..96f92e6d99 100644 --- a/packages/ai-devtools/src/components/conversation/IterationTimeline.tsx +++ b/packages/ai-devtools/src/components/conversation/IterationTimeline.tsx @@ -47,12 +47,16 @@ export const IterationTimeline: Component = (props) => { (a, b) => a.timestamp - b.timestamp, ) - for (const [u, currentUser] of sortedUsers.entries()) { + const userEntries = sortedUsers.entries() + for (const [u, currentUser] of userEntries) { const nextUser = sortedUsers[u + 1] const groupIters = iters.filter((it) => { if (it.startedAt < currentUser.timestamp) return false - if (nextUser && it.startedAt >= nextUser.timestamp) return false + if (nextUser) { + const isAfterNextUser = it.startedAt >= nextUser.timestamp + if (isAfterNextUser) return false + } return true }) @@ -99,378 +103,386 @@ export const IterationTimeline: Component = (props) => { } /** Collapsible system prompt with preview */ -export const SystemPromptItem: Component<{ prompt: string; index: number }> = ( - props, -) => { - const styles = useStyles() - const s = () => styles().iterationTimeline - const [expanded, setExpanded] = createSignal(false) - - const isLong = () => props.prompt.length > 120 - const preview = () => - isLong() ? props.prompt.slice(0, 120) + '...' : props.prompt - - return ( -
-
isLong() && setExpanded(!expanded())} - > - #{props.index + 1} - - {expanded() ? '' : preview()} - - - - {expanded() ? 'collapse' : 'expand'} +export const /** Collapsible system prompt with preview */ + SystemPromptItem: Component<{ prompt: string; index: number }> = (props) => { + const styles = useStyles() + const s = () => styles().iterationTimeline + const [expanded, setExpanded] = createSignal(false) + + const isLong = () => props.prompt.length > 120 + const preview = () => + isLong() ? props.prompt.slice(0, 120) + '...' : props.prompt + + return ( +
+
isLong() && setExpanded(!expanded())} + > + #{props.index + 1} + + {expanded() ? '' : preview()} + + + {expanded() ? 'collapse' : 'expand'} + + +
+ +
{props.prompt}
- -
{props.prompt}
-
-
- ) -} + ) + } /** Card wrapping a user message and its child iterations */ -const UserMessageGroupCard: Component<{ - group: UserMessageGroup - allMessages: Array - hoverTarget: HoverTarget | null - onHoverTarget?: (target: HoverTarget | null) => void -}> = (props) => { - const styles = useStyles() - const s = () => styles().iterationTimeline - const [isOpen, setIsOpen] = createSignal(true) - const [configExpanded, setConfigExpanded] = createSignal(false) - - const group = () => props.group - const userMsg = () => group().userMessage - const userMessageId = () => userMsg()?.id - const iters = () => group().iterations - - const totalDuration = createMemo(() => { - let sum = 0 - for (const it of iters()) { - if (it.completedAt) { - sum += it.completedAt - it.startedAt +const /** Card wrapping a user message and its child iterations */ + UserMessageGroupCard: Component<{ + group: UserMessageGroup + allMessages: Array + hoverTarget: HoverTarget | null + onHoverTarget?: (target: HoverTarget | null) => void + }> = (props) => { + const styles = useStyles() + const s = () => styles().iterationTimeline + const [isOpen, setIsOpen] = createSignal(true) + const [configExpanded, setConfigExpanded] = createSignal(false) + + const group = () => props.group + const userMsg = () => group().userMessage + const userMessageId = () => userMsg()?.id + const iters = () => group().iterations + + const totalDuration = createMemo(() => { + let sum = 0 + const iterations = iters() + for (const it of iterations) { + if (it.completedAt) { + sum += it.completedAt - it.startedAt + } } - } - return sum > 0 ? sum : undefined - }) + return sum > 0 ? sum : undefined + }) + + /** Count actual tool invocations across all messages in this group */ + const /** Count actual tool invocations across all messages in this group */ + toolInvocationCounts = createMemo(() => { + const counts = new Map() + const allMsgIds = new Set() + const iterations = iters() + for (const it of iterations) { + for (const id of it.messageIds) allMsgIds.add(id) + } + for (const msg of props.allMessages) { + if (allMsgIds.has(msg.id)) { + const toolCalls = msg.toolCalls + if (toolCalls) { + for (const tc of toolCalls) { + counts.set(tc.name, (counts.get(tc.name) || 0) + 1) + } + } + } + } + return counts + }) - /** Count actual tool invocations across all messages in this group */ - const toolInvocationCounts = createMemo(() => { - const counts = new Map() - const allMsgIds = new Set() - for (const it of iters()) { - for (const id of it.messageIds) allMsgIds.add(id) - } - for (const msg of props.allMessages) { - if (allMsgIds.has(msg.id) && msg.toolCalls) { - for (const tc of msg.toolCalls) { - counts.set(tc.name, (counts.get(tc.name) || 0) + 1) + const totalToolCalls = createMemo(() => { + let count = 0 + const invocationCounts = toolInvocationCounts().values() + for (const v of invocationCounts) count += v + return count + }) + + const totalUsage = createMemo(() => { + const maxByRequest = new Map< + string, + { prompt: number; completion: number; total: number } + >() + const iterations = iters() + for (const it of iterations) { + if (!it.usage) continue + const key = it.requestId || '__default__' + const existing = maxByRequest.get(key) + const shouldReplaceUsage = + !existing || it.usage.totalTokens > existing.total + if (shouldReplaceUsage) { + maxByRequest.set(key, { + prompt: it.usage.promptTokens, + completion: it.usage.completionTokens, + total: it.usage.totalTokens, + }) } } + let prompt = 0 + let completion = 0 + let total = 0 + const requestUsages = maxByRequest.values() + for (const v of requestUsages) { + prompt += v.prompt + completion += v.completion + total += v.total + } + if (total === 0) return undefined + return { + promptTokens: prompt, + completionTokens: completion, + totalTokens: total, + } + }) + + const isActive = () => iters().some((it) => !it.completedAt) + const hasError = () => iters().some((it) => it.finishReason === 'error') + const allCompleted = () => + iters().every((it) => !!it.completedAt) && !hasError() + + const groupAccentClass = () => { + if (isActive()) return s().cardActive + if (hasError()) return s().cardError + if (allCompleted()) return s().cardCompleted + return '' } - return counts - }) - const totalToolCalls = createMemo(() => { - let count = 0 - for (const v of toolInvocationCounts().values()) count += v - return count - }) + const userContent = () => { + const msg = userMsg() + if (!msg) return '(no user message)' + const text = msg.content || '' + return text.length > 120 ? text.slice(0, 120) + '...' : text + } - // Iterations store CUMULATIVE usage per request, so keep the MAX totalTokens - // reading per requestId. Comparing on totalTokens directly (not prompt + - // completion) is necessary because some providers — Anthropic, OpenAI o-series - // — bundle reasoning tokens into totalTokens that are not summed into - // prompt + completion, and a later reading with fewer reasoning tokens would - // otherwise overwrite a larger earlier one. - const totalUsage = createMemo(() => { - const maxByRequest = new Map< - string, - { prompt: number; completion: number; total: number } - >() - for (const it of iters()) { - if (!it.usage) continue - const key = it.requestId || '__default__' - const existing = maxByRequest.get(key) - if (!existing || it.usage.totalTokens > existing.total) { - maxByRequest.set(key, { - prompt: it.usage.promptTokens, - completion: it.usage.completionTokens, - total: it.usage.totalTokens, - }) + const handleUserMouseEnter = () => { + const messageId = userMessageId() + if (messageId) { + props.onHoverTarget?.( + createHoverTarget({ + messageIds: [messageId], + origin: 'timeline', + }), + ) } } - let prompt = 0 - let completion = 0 - let total = 0 - for (const v of maxByRequest.values()) { - prompt += v.prompt - completion += v.completion - total += v.total - } - if (total === 0) return undefined - return { - promptTokens: prompt, - completionTokens: completion, - totalTokens: total, - } - }) - - const isActive = () => iters().some((it) => !it.completedAt) - const hasError = () => iters().some((it) => it.finishReason === 'error') - const allCompleted = () => - iters().every((it) => !!it.completedAt) && !hasError() - - const groupAccentClass = () => { - if (isActive()) return s().cardActive - if (hasError()) return s().cardError - if (allCompleted()) return s().cardCompleted - return '' - } - - const userContent = () => { - const msg = userMsg() - if (!msg) return '(no user message)' - const text = msg.content || '' - return text.length > 120 ? text.slice(0, 120) + '...' : text - } - const handleUserMouseEnter = () => { - const messageId = userMessageId() - if (messageId) { - props.onHoverTarget?.( - createHoverTarget({ - messageIds: [messageId], - origin: 'timeline', - }), - ) + const handleUserMouseLeave = () => { + props.onHoverTarget?.(null) } - } - const handleUserMouseLeave = () => { - props.onHoverTarget?.(null) - } - - const isUserMessageHighlighted = () => { - const messageId = userMessageId() - return messageId - ? isMessageHighlighted(messageId, props.hoverTarget) - : false - } + const isUserMessageHighlighted = () => { + const messageId = userMessageId() + return messageId + ? isMessageHighlighted(messageId, props.hoverTarget) + : false + } - // Config from the first iteration of this group - const firstIter = createMemo(() => iters()[0]) + // Config from the first iteration of this group + const firstIter = createMemo(() => iters()[0]) - const configSubtitle = () => { - const first = firstIter() - if (!first) return null - const parts: Array = [] - if (first.model) parts.push(first.model) - if (first.provider) parts.push(first.provider) - return parts.length > 0 ? parts.join(' \u00B7 ') : null - } + const configSubtitle = () => { + const first = firstIter() + if (!first) return null + const parts: Array = [] + if (first.model) parts.push(first.model) + if (first.provider) parts.push(first.provider) + return parts.length > 0 ? parts.join(' \u00B7 ') : null + } - const toolNames = () => firstIter()?.toolNames || [] - const systemPrompts = () => firstIter()?.systemPrompts || [] - const modelOptions = () => firstIter()?.modelOptions - const hasModelOptions = () => { - const opts = modelOptions() - return opts && Object.keys(opts).length > 0 - } + const toolNames = () => firstIter()?.toolNames || [] + const systemPrompts = () => firstIter()?.systemPrompts || [] + const modelOptions = () => firstIter()?.modelOptions + const hasModelOptions = () => { + const opts = modelOptions() + return opts && Object.keys(opts).length > 0 + } - const middlewareTransformCount = createMemo(() => { - let count = 0 - for (const it of iters()) { - for (const ev of it.middlewareEvents) { - if (ev.hasTransform) count++ + const middlewareTransformCount = createMemo(() => { + let count = 0 + const iterations = iters() + for (const it of iterations) { + for (const ev of it.middlewareEvents) { + if (ev.hasTransform) count++ + } } - } - return count - }) + return count + }) - const hasExpandableConfig = () => - toolNames().length > 0 || systemPrompts().length > 0 || hasModelOptions() + const hasExpandableConfig = () => + toolNames().length > 0 || systemPrompts().length > 0 || hasModelOptions() - return ( -
- {/* User message header */} + return (
setIsOpen(!isOpen())} - onMouseEnter={handleUserMouseEnter} - onMouseLeave={handleUserMouseLeave} + {...getHoverDataAttributes({ + messageIds: userMessageId() ? [userMessageId() ?? ''] : [], + })} + class={`${s().card} ${groupAccentClass()} ${ + isUserMessageHighlighted() ? s().cardHighlighted : '' + }`} > -
U
-
- {userContent()} - {/* Config subtitle — always visible under user message */} -
- - {configSubtitle()} - - 0}> - - {toolNames().length} tool{toolNames().length === 1 ? '' : 's'} - - - 0}> - - {systemPrompts().length} system prompt - {systemPrompts().length === 1 ? '' : 's'} - - - - options - - 0}> - - {middlewareTransformCount()} middleware transform - {middlewareTransformCount() === 1 ? '' : 's'} + {/* User message header */} +
setIsOpen(!isOpen())} + onMouseEnter={handleUserMouseEnter} + onMouseLeave={handleUserMouseLeave} + > +
U
+
+ {userContent()} + {/* Config subtitle — always visible under user message */} +
+ + {configSubtitle()} + + 0}> + + {toolNames().length} tool{toolNames().length === 1 ? '' : 's'} + + + 0}> + + {systemPrompts().length} system prompt + {systemPrompts().length === 1 ? '' : 's'} + + + + options + + 0}> + + {middlewareTransformCount()} middleware transform + {middlewareTransformCount() === 1 ? '' : 's'} + + + + { + e.stopPropagation() + setConfigExpanded(!configExpanded()) + }} + > + {configExpanded() ? 'hide config' : 'show config'} + + +
+
+
+ 0}> + + 🔄 {iters().length} - + 0}> { - e.stopPropagation() - setConfigExpanded(!configExpanded()) - }} + class={`${s().badge} ${s().badgeFinishReasonToolCalls}`} + title={`${totalToolCalls()} tool ${totalToolCalls() === 1 ? 'call' : 'calls'}`} > - {configExpanded() ? 'hide config' : 'show config'} + 🔧 {totalToolCalls()} -
-
-
- 0}> - - 🔄 {iters().length} - - - 0}> - - 🔧 {totalToolCalls()} - - - - - ⏱️ {formatDuration(totalDuration())} - - - - {(usage) => ( + - 🎯 {usage().totalTokens.toLocaleString()} + ⏱️ {formatDuration(totalDuration())} - )} - - - ⟳ streaming - + + + {(usage) => ( + + 🎯 {usage().totalTokens.toLocaleString()} + + )} + + + + ⟳ streaming + + +
+ + {'\u25B6'} +
- - {'\u25B6'} - -
- {/* Expandable config details — sits between header and iterations */} -
-
-
- 0}> -
- Tools -
- - {(name) => ( - - {name} - - {toolInvocationCounts().get(name) || 0} + {/* Expandable config details — sits between header and iterations */} +
+
+
+ 0}> +
+ Tools +
+ + {(name) => ( + + {name} + + {toolInvocationCounts().get(name) || 0} + - + )} + +
+
+
+ 0}> +
+ + System Prompts ({systemPrompts().length}) + + + {(prompt, i) => ( + )}
-
- - 0}> -
- - System Prompts ({systemPrompts().length}) - - - {(prompt, i) => ( - - )} - -
-
- -
- Model Options -
- + + +
+ Model Options +
+ +
-
- + +
-
- {/* Iterations list — full width */} -
-
-
- - {(iteration, index) => ( - 0 ? iters()[index() - 1] : undefined - } - messages={props.allMessages} - index={index()} - isLast={index() === iters().length - 1} - hoverTarget={props.hoverTarget} - onHoverTarget={props.onHoverTarget} - /> - )} - + {/* Iterations list — full width */} +
+
+
+ + {(iteration, index) => ( + 0 ? iters()[index() - 1] : undefined + } + messages={props.allMessages} + index={index()} + isLast={index() === iters().length - 1} + hoverTarget={props.hoverTarget} + onHoverTarget={props.onHoverTarget} + /> + )} + +
-
- ) -} + ) + } diff --git a/packages/ai-devtools/src/components/hooks/GenerationPanel.tsx b/packages/ai-devtools/src/components/hooks/GenerationPanel.tsx index 678aa00a23..f9c56f16f0 100644 --- a/packages/ai-devtools/src/components/hooks/GenerationPanel.tsx +++ b/packages/ai-devtools/src/components/hooks/GenerationPanel.tsx @@ -327,7 +327,8 @@ const GenerationOutputTile: Component<{ }`} onClick={props.onOpen} onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { + const isActivateKey = event.key === 'Enter' || event.key === ' ' + if (isActivateKey) { event.preventDefault() props.onOpen() } @@ -579,7 +580,10 @@ function runSortTime(run: GenerationRunView): number { } function runFromUnknown(value: unknown): GenerationRunView | undefined { - if (!isRecord(value) || typeof value.id !== 'string') { + if (!isRecord(value)) { + return undefined + } + if (typeof value.id !== 'string') { return undefined } @@ -639,67 +643,64 @@ function outputsFromRun( run: GenerationRunView, runLabelValue: string, ): Array { - if (run.preview.kind === 'image' || run.preview.kind === 'audio') { - const kind = run.preview.kind - const items = run.preview.items - - return items.map((item, index) => ({ - id: `${run.id}:${kind}:${index}`, - runId: run.id, - runLabel: runLabelValue, - title: `${titleCase(kind)} ${index + 1}`, - kind, - item, - ...completedAtPatch(run), - })) - } - - if (run.preview.kind === 'video') { - const preview = run.preview - const items = preview.items - const job = preview.job - - return items.map((item, index) => ({ - id: `${run.id}:video:${index}`, - runId: run.id, - runLabel: runLabelValue, - title: `Video ${index + 1}`, - kind: 'video', - item, - ...(job ? { job } : {}), - ...completedAtPatch(run), - })) - } - - if (run.preview.kind === 'text' && run.preview.text.trim().length > 0) { - return [ - { - id: `${run.id}:text`, + const preview = run.preview + switch (preview.kind) { + case 'image': + case 'audio': { + const kind = preview.kind + return preview.items.map((item, index) => ({ + id: `${run.id}:${kind}:${index}`, runId: run.id, runLabel: runLabelValue, - title: 'Text', - kind: 'text', - text: run.preview.text, + title: `${titleCase(kind)} ${index + 1}`, + kind, + item, ...completedAtPatch(run), - }, - ] - } - - if (run.preview.kind === 'structured') { - return [ - { - id: `${run.id}:structured`, + })) + } + case 'video': { + const job = preview.job + return preview.items.map((item, index) => ({ + id: `${run.id}:video:${index}`, runId: run.id, runLabel: runLabelValue, - title: 'Structured', - kind: 'structured', - value: run.preview.value, + title: `Video ${index + 1}`, + kind: 'video', + item, + ...(job ? { job } : {}), ...completedAtPatch(run), - }, - ] + })) + } + case 'text': { + const hasText = preview.text.trim().length > 0 + if (!hasText) return [] + return [ + { + id: `${run.id}:text`, + runId: run.id, + runLabel: runLabelValue, + title: 'Text', + kind: 'text', + text: preview.text, + ...completedAtPatch(run), + }, + ] + } + case 'structured': + return [ + { + id: `${run.id}:structured`, + runId: run.id, + runLabel: runLabelValue, + title: 'Structured', + kind: 'structured', + value: preview.value, + ...completedAtPatch(run), + }, + ] + default: + return [] } - - return [] } function completedAtPatch( @@ -728,35 +729,39 @@ function previewFromUnknown( value: unknown, fallbackResult: unknown, ): GenerationPreviewState { - if (isRecord(value) && typeof value.kind === 'string') { - if (value.kind === 'image') { - return { kind: 'image', items: mediaItemsFromUnknown(value.items) } - } - if (value.kind === 'audio') { - return { kind: 'audio', items: mediaItemsFromUnknown(value.items) } - } - if (value.kind === 'video') { - return { - kind: 'video', - items: mediaItemsFromUnknown(value.items), - ...(isVideoJob(value.job) ? { job: value.job } : {}), + if (isRecord(value)) { + if (typeof value.kind === 'string') { + if (value.kind === 'image') { + return { kind: 'image', items: mediaItemsFromUnknown(value.items) } } - } - if (value.kind === 'text') { - return { - kind: 'text', - text: typeof value.text === 'string' ? value.text : '', + if (value.kind === 'audio') { + return { kind: 'audio', items: mediaItemsFromUnknown(value.items) } + } + if (value.kind === 'video') { + return { + kind: 'video', + items: mediaItemsFromUnknown(value.items), + ...(isVideoJob(value.job) ? { job: value.job } : {}), + } + } + if (value.kind === 'text') { + return { + kind: 'text', + text: typeof value.text === 'string' ? value.text : '', + } + } + if (value.kind === 'structured') { + return { kind: 'structured', value: value.value } + } + if (value.kind === 'empty') { + return { kind: 'empty' } } - } - if (value.kind === 'structured') { - return { kind: 'structured', value: value.value } - } - if (value.kind === 'empty') { - return { kind: 'empty' } } } - if (fallbackResult === null || fallbackResult === undefined) { + const isEmptyFallback = + fallbackResult === null || fallbackResult === undefined + if (isEmptyFallback) { return { kind: 'empty' } } @@ -767,7 +772,10 @@ function previewFromUnknown( } function progressFromUnknown(value: unknown): GenerationProgress | undefined { - if (!isRecord(value) || typeof value.value !== 'number') { + if (!isRecord(value)) { + return undefined + } + if (typeof value.value !== 'number') { return undefined } @@ -783,9 +791,8 @@ function mediaItemsFromUnknown(value: unknown): Array { } function isMediaItem(value: unknown): value is GenerationMediaItem { - if (!isRecord(value) || typeof value.src !== 'string') { - return false - } + if (!isRecord(value)) return false + if (typeof value.src !== 'string') return false return true } @@ -795,10 +802,9 @@ function isVideoJob(value: unknown): value is GenerationVideoJob { function errorTextFromUnknown(value: unknown): string | undefined { if (typeof value === 'string') return value - if (isRecord(value) && typeof value.message === 'string') { - return value.message - } - return undefined + if (!isRecord(value)) return undefined + if (typeof value.message !== 'string') return undefined + return value.message } function stringFromUnknown(value: unknown): string | undefined { @@ -814,7 +820,8 @@ function videoStatusLabel(value: unknown): string | undefined { const status = stringFromUnknown(value.status) const progress = numberFromUnknown(value.progress) - if (!status && progress === undefined) return undefined + const hasNoStatus = !status && progress === undefined + if (hasNoStatus) return undefined return [status, progress !== undefined ? formatProgress(progress) : undefined] .filter((part): part is string => Boolean(part)) diff --git a/packages/ai-devtools/src/components/hooks/HookDetails.tsx b/packages/ai-devtools/src/components/hooks/HookDetails.tsx index 0ca0c48f98..363416d9b8 100644 --- a/packages/ai-devtools/src/components/hooks/HookDetails.tsx +++ b/packages/ai-devtools/src/components/hooks/HookDetails.tsx @@ -153,12 +153,12 @@ export const HookDetails: Component = () => { createEffect(() => { // Tools and Memory are chat-only tabs; if a generation hook becomes active // while one of them is selected, fall back to the conversation view. - if ( - isGenerationHook() && - (activeTab() === 'tools' || - activeTab() === 'memory' || - activeTab() === 'skills') - ) { + const isChatOnlyTab = + activeTab() === 'tools' || + activeTab() === 'memory' || + activeTab() === 'skills' + const shouldResetTab = isGenerationHook() && isChatOnlyTab + if (shouldResetTab) { setActiveTab('conversation') } }) @@ -178,14 +178,12 @@ export const HookDetails: Component = () => { return [] }) const showSecondaryPane = createMemo(() => { - // Tools tab owns its own form/saved-fixtures layout that fills the - // primary pane; the secondary "User View" preview squeezes the tool - // detail column to zero width on narrower hookDetails widths. - if ( + // Tools/skills own the primary pane; the User View preview would + // squeeze the detail column to zero width on narrower layouts. + const hideSecondaryPane = (activeTab() === 'tools' || activeTab() === 'skills') && !isGenerationHook() - ) - return false + if (hideSecondaryPane) return false return isGenerationHook() || !hasStructuredOutputPreview(previewMessages()) }) @@ -1186,7 +1184,8 @@ function findClosestHoverTargetElement( '[data-ai-devtools-hover-message-ids], [data-ai-devtools-hover-part-ids]', ) - if (!element || !container.contains(element)) return undefined + if (!element) return undefined + if (!container.contains(element)) return undefined return element } @@ -1356,10 +1355,9 @@ function findConversationForHook( function messageFromConversation(message: Message): PreviewMessage { const sourceMessage = toolFixtureMessageFromConversation(message) - if ( - message.role === 'tool' && - (!message.parts || message.parts.length === 0) - ) { + const hasNoParts = !message.parts || message.parts.length === 0 + const isBareToolMessage = message.role === 'tool' && hasNoParts + if (isBareToolMessage) { return { id: message.id, role: message.role, @@ -1384,7 +1382,8 @@ function messageFromUnknown(value: unknown): PreviewMessage | undefined { if (!isRecord(value)) return undefined const id = typeof value.id === 'string' ? value.id : undefined const role = typeof value.role === 'string' ? value.role : undefined - if (!id || !role) return undefined + if (!id) return undefined + if (!role) return undefined const sourceMessage = toolFixtureMessageFromUnknown(value) const content = @@ -1425,6 +1424,112 @@ function partsFromUnknown( .filter(isPreviewPart) } +function stringFieldOr( + value: unknown, + fallback: unknown, + defaultValue: string, +): string { + if (typeof value === 'string') return value + if (typeof fallback === 'string') return fallback + return defaultValue +} + +function previewToolCallPart( + part: PreviewPartSource, + index: number, + messageId?: string, +): PreviewPart { + const name = stringFieldOr(part.name, part.toolName, 'tool') + const rawId = stringFieldOr(part.toolCallId, part.id, `${index}:${name}`) + const input = part.input ?? part.arguments + const output = part.output + const parsedInput = input === undefined ? {} : parseJsonishValue(input) + const parsedOutput = + output === undefined ? undefined : parseJsonishValue(output) + const approvalStatus = approvalStatusFromRecord(part.approval, part.state) + const jsonItems: Array = [] + if (input !== undefined) { + jsonItems.push({ + label: 'Input', + value: parsedInput, + }) + } + if (part.approval !== undefined) { + jsonItems.push({ + label: 'Approval', + value: parseJsonishValue(part.approval), + }) + } + if (output !== undefined) { + jsonItems.push({ + label: 'Output', + value: parsedOutput, + }) + } + return { + id: toolCallPartId(rawId), + label: approvalStatus + ? `tool call ${name} - ${approvalStatus}` + : `tool call ${name}`, + content: formatUnknown(input ?? output), + jsonItems, + kind: 'tool-call', + fixture: { + toolName: name, + input: parsedInput, + ...(parsedOutput !== undefined ? { output: parsedOutput } : {}), + toolCallId: rawId, + ...(messageId ? { messageId } : {}), + }, + } +} + +function previewToolResultPart( + part: PreviewPartSource, + index: number, +): PreviewPart { + const name = + typeof part.name === 'string' + ? part.name + : typeof part.toolName === 'string' + ? part.toolName + : undefined + const rawId = stringFieldOr(part.toolCallId, part.id, `${index}`) + const output = part.output ?? part.content ?? part.error + return { + id: toolResultPartId(rawId), + label: name ? `tool result ${name}` : 'tool result', + content: formatUnknown(output), + jsonItems: + output === undefined + ? [] + : [ + { + label: part.error ? 'Error' : 'Output', + value: parseJsonishValue(output), + }, + ], + kind: 'tool-result', + } +} + +function previewStructuredOutputPart( + part: PreviewPartSource, + index: number, + messageId?: string, +): PreviewPart { + const status = typeof part.status === 'string' ? part.status : undefined + const raw = typeof part.raw === 'string' ? part.raw : undefined + + return { + id: structuredOutputPartId(messageId ?? `${index}`), + label: status ? `structured output - ${status}` : 'structured output', + content: raw ?? formatUnknown(part.data ?? part.partial), + jsonItems: structuredOutputJsonItems(part), + kind: 'structured-output', + } +} + function previewPartFromRecord( part: PreviewPartSource, index: number, @@ -1432,101 +1537,13 @@ function previewPartFromRecord( ): PreviewPart { const type = typeof part.type === 'string' ? part.type : 'part' if (type === 'tool-call') { - const name = - typeof part.name === 'string' - ? part.name - : typeof part.toolName === 'string' - ? part.toolName - : 'tool' - const rawId = - typeof part.toolCallId === 'string' - ? part.toolCallId - : typeof part.id === 'string' - ? part.id - : `${index}:${name}` - const input = part.input ?? part.arguments - const output = part.output - const parsedInput = input === undefined ? {} : parseJsonishValue(input) - const parsedOutput = - output === undefined ? undefined : parseJsonishValue(output) - const approvalStatus = approvalStatusFromRecord(part.approval, part.state) - const jsonItems: Array = [] - if (input !== undefined) { - jsonItems.push({ - label: 'Input', - value: parsedInput, - }) - } - if (part.approval !== undefined) { - jsonItems.push({ - label: 'Approval', - value: parseJsonishValue(part.approval), - }) - } - if (output !== undefined) { - jsonItems.push({ - label: 'Output', - value: parsedOutput, - }) - } - return { - id: toolCallPartId(rawId), - label: approvalStatus - ? `tool call ${name} - ${approvalStatus}` - : `tool call ${name}`, - content: formatUnknown(input ?? output), - jsonItems, - kind: 'tool-call', - fixture: { - toolName: name, - input: parsedInput, - ...(parsedOutput !== undefined ? { output: parsedOutput } : {}), - toolCallId: rawId, - ...(messageId ? { messageId } : {}), - }, - } + return previewToolCallPart(part, index, messageId) } if (type === 'tool-result') { - const name = - typeof part.name === 'string' - ? part.name - : typeof part.toolName === 'string' - ? part.toolName - : undefined - const rawId = - typeof part.toolCallId === 'string' - ? part.toolCallId - : typeof part.id === 'string' - ? part.id - : `${index}` - const output = part.output ?? part.content ?? part.error - return { - id: toolResultPartId(rawId), - label: name ? `tool result ${name}` : 'tool result', - content: formatUnknown(output), - jsonItems: - output === undefined - ? [] - : [ - { - label: part.error ? 'Error' : 'Output', - value: parseJsonishValue(output), - }, - ], - kind: 'tool-result', - } + return previewToolResultPart(part, index) } if (type === 'structured-output') { - const status = typeof part.status === 'string' ? part.status : undefined - const raw = typeof part.raw === 'string' ? part.raw : undefined - - return { - id: structuredOutputPartId(messageId ?? `${index}`), - label: status ? `structured output - ${status}` : 'structured output', - content: raw ?? formatUnknown(part.data ?? part.partial), - jsonItems: structuredOutputJsonItems(part), - kind: 'structured-output', - } + return previewStructuredOutputPart(part, index, messageId) } if (type === 'thinking') { return { @@ -1536,7 +1553,8 @@ function previewPartFromRecord( kind: 'thinking', } } - if (type === 'image' || type === 'audio' || type === 'video') { + const isMediaType = type === 'image' || type === 'audio' || type === 'video' + if (isMediaType) { return { id: `${index}:${type}`, label: type, @@ -1634,7 +1652,8 @@ function toolFixtureMessageFromUnknown( ): ToolFixtureMessage | undefined { const id = typeof value.id === 'string' ? value.id : undefined const role = typeof value.role === 'string' ? value.role : undefined - if (!id || !isToolFixtureMessageRole(role)) return undefined + if (!id) return undefined + if (!isToolFixtureMessageRole(role)) return undefined const parts = Array.isArray(value.parts) ? value.parts @@ -1651,17 +1670,19 @@ function toolFixtureMessageFromUnknown( } function approvalStatusFromToolCall(tool: ToolCall): string | undefined { - if (tool.approvalApproved === true || tool.state === 'approved') { + const isApproved = tool.approvalApproved === true || tool.state === 'approved' + if (isApproved) { return 'approved' } - if (tool.approvalApproved === false || tool.state === 'denied') { + const isDenied = tool.approvalApproved === false || tool.state === 'denied' + if (isDenied) { return 'denied' } - if ( - tool.approvalRequired || - tool.approvalId || + const isApprovalRequested = + Boolean(tool.approvalRequired) || + Boolean(tool.approvalId) || tool.state === 'approval-requested' - ) { + if (isApprovalRequested) { return 'approval requested' } if (tool.state === 'approval-responded') { @@ -1749,7 +1770,8 @@ function toolResultPartFromContent( } function formatUnknown(value: unknown): string { - if (value === undefined || value === null) return '' + const isEmptyValue = value === undefined || value === null + if (isEmptyValue) return '' if (typeof value === 'string') return value try { return JSON.stringify(value, null, 2) diff --git a/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx b/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx index b900c95d82..f7e2f9fec7 100644 --- a/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx +++ b/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx @@ -8,17 +8,6 @@ import type { MemoryScopeState, } from '../../store/memory-registry' -/** - * DevTools "Memory" tab. Memory is per composite scope (tenant/user/thread), - * not per-hook, so this panel reads the whole `state.memory` registry and lets - * the user pick a scope (defaulting to the most recently active). It renders - * two things: - * 1. Live contents — the latest `inspect()` records + `listFacts()` facts, - * pushed via `memory:snapshot` (only for adapters that support inspection). - * 2. Operations timeline — the `memory:*` recall/save/error events (always - * available, even when the adapter has no introspection). - */ - /** Shape of a record inside the built-in adapters' `inspect()` payload. */ interface MemoryRecordRow { id: string @@ -86,7 +75,10 @@ export const MemoryPanel: Component = () => { const selectedKey = createMemo(() => { const chosen = override() - if (chosen && state.memory.scopes[chosen]) return chosen + if (chosen) { + const hasChosenScope = Boolean(state.memory.scopes[chosen]) + if (hasChosenScope) return chosen + } return scopeKeys()[0] ?? null }) diff --git a/packages/ai-devtools/src/components/hooks/SkillsPanel.tsx b/packages/ai-devtools/src/components/hooks/SkillsPanel.tsx index dcbc2ef998..b88e5a61c3 100644 --- a/packages/ai-devtools/src/components/hooks/SkillsPanel.tsx +++ b/packages/ai-devtools/src/components/hooks/SkillsPanel.tsx @@ -33,9 +33,11 @@ function loadedFromMessages(messages: Array): Array { } } for (const part of message.parts ?? []) { - if (part.type === 'tool-call' && part.toolName === 'load_skill') { - const name = parseSkillName(part.arguments) - if (name) names.add(name) + if (part.type === 'tool-call') { + if (part.toolName === 'load_skill') { + const name = parseSkillName(part.arguments) + if (name) names.add(name) + } } } } @@ -63,7 +65,8 @@ export const SkillsPanel: Component = () => { const loaded = createMemo(() => { const fromSnap = new Set(snapshot()?.activated ?? []) - for (const name of loadedFromMessages(conversation()?.messages ?? [])) { + const loadedNames = loadedFromMessages(conversation()?.messages ?? []) + for (const name of loadedNames) { fromSnap.add(name) } return fromSnap diff --git a/packages/ai-devtools/src/components/hooks/ToolFixtureForm.tsx b/packages/ai-devtools/src/components/hooks/ToolFixtureForm.tsx index 34bc0a0ce4..99f1657461 100644 --- a/packages/ai-devtools/src/components/hooks/ToolFixtureForm.tsx +++ b/packages/ai-devtools/src/components/hooks/ToolFixtureForm.tsx @@ -214,7 +214,9 @@ const FieldInput: Component<{ ) } - if (props.field.type === 'object' || props.field.type === 'array') { + const isJsonField = + props.field.type === 'object' || props.field.type === 'array' + if (isJsonField) { return (