@tanstack/ai 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/esm/activities/chat/index.d.ts +7 -0
  2. package/dist/esm/activities/chat/index.js +86 -20
  3. package/dist/esm/activities/chat/index.js.map +1 -1
  4. package/dist/esm/activities/chat/mcp/manager.d.ts +25 -0
  5. package/dist/esm/activities/chat/mcp/manager.js +71 -0
  6. package/dist/esm/activities/chat/mcp/manager.js.map +1 -0
  7. package/dist/esm/activities/chat/mcp/types.d.ts +56 -0
  8. package/dist/esm/activities/chat/messages.js +1 -1
  9. package/dist/esm/activities/chat/messages.js.map +1 -1
  10. package/dist/esm/activities/chat/stream/message-updaters.js +20 -8
  11. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  12. package/dist/esm/activities/chat/stream/processor.d.ts +6 -0
  13. package/dist/esm/activities/chat/stream/processor.js +18 -3
  14. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  15. package/dist/esm/activities/chat/tools/tool-calls.d.ts +1 -1
  16. package/dist/esm/activities/chat/tools/tool-calls.js +2 -1
  17. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  18. package/dist/esm/activities/generateVideo/index.d.ts +2 -1
  19. package/dist/esm/activities/generateVideo/index.js +12 -2
  20. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  21. package/dist/esm/extend-adapter.d.ts +22 -6
  22. package/dist/esm/extend-adapter.js.map +1 -1
  23. package/dist/esm/index.d.ts +2 -0
  24. package/dist/esm/index.js +2 -0
  25. package/dist/esm/index.js.map +1 -1
  26. package/dist/esm/logger/console-logger.d.ts +18 -0
  27. package/dist/esm/logger/console-logger.js +64 -8
  28. package/dist/esm/logger/console-logger.js.map +1 -1
  29. package/dist/esm/logger/types.d.ts +4 -4
  30. package/dist/esm/realtime/index.d.ts +1 -3
  31. package/dist/esm/realtime/index.js.map +1 -1
  32. package/dist/esm/types.d.ts +13 -1
  33. package/package.json +2 -2
  34. package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +2 -2
  35. package/skills/ai-core/chat-experience/SKILL.md +71 -0
  36. package/skills/ai-core/media-generation/SKILL.md +29 -1
  37. package/skills/ai-core/tool-calling/SKILL.md +287 -0
  38. package/src/activities/chat/index.ts +109 -25
  39. package/src/activities/chat/mcp/manager.ts +85 -0
  40. package/src/activities/chat/mcp/types.ts +66 -0
  41. package/src/activities/chat/messages.ts +1 -0
  42. package/src/activities/chat/stream/message-updaters.ts +22 -9
  43. package/src/activities/chat/stream/processor.ts +30 -3
  44. package/src/activities/chat/tools/tool-calls.ts +2 -0
  45. package/src/activities/generateVideo/index.ts +12 -0
  46. package/src/extend-adapter.ts +42 -24
  47. package/src/index.ts +10 -0
  48. package/src/logger/console-logger.ts +112 -17
  49. package/src/logger/types.ts +4 -4
  50. package/src/realtime/index.ts +1 -3
  51. package/src/types.ts +13 -0
@@ -25,6 +25,7 @@ import {
25
25
  import { maxIterations as maxIterationsStrategy } from './agent-loop-strategies'
26
26
  import { convertMessagesToModelMessages, generateMessageId } from './messages'
27
27
  import { MiddlewareRunner } from './middleware/compose'
28
+ import { MCPManager } from './mcp/manager'
28
29
  import type {
29
30
  ApprovalRequest,
30
31
  ClientToolRequest,
@@ -69,6 +70,7 @@ import type {
69
70
  MergeContext,
70
71
  UnionToIntersection,
71
72
  } from './runtime-context-types'
73
+ import type { ChatMCPOptions } from './mcp/types'
72
74
 
73
75
  // ===========================
74
76
  // Activity Kind
@@ -208,6 +210,12 @@ export interface TextActivityOptions<
208
210
  | ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
209
211
  >
210
212
  | undefined
213
+ /**
214
+ * Hand MCP clients/pools to chat(): their tools are discovered at run start
215
+ * and merged into the run; `connection` controls whether chat() closes them
216
+ * when the run ends. See docs/tools/mcp.md "Managing MCP clients with chat()".
217
+ */
218
+ mcp?: ChatMCPOptions
211
219
  /** Additional metadata to attach to the request. */
212
220
  metadata?: TextOptions['metadata']
213
221
  /** Model-specific provider options (type comes from adapter) */
@@ -432,6 +440,28 @@ interface TextEngineConfig<
432
440
  type ToolPhaseResult = 'continue' | 'stop' | 'wait'
433
441
  type CyclePhase = 'processText' | 'executeToolCalls'
434
442
 
443
+ /**
444
+ * Combine two optional AbortSignals into one that aborts when either does.
445
+ * Returns the other signal directly when one is absent or already aborted.
446
+ * (Manual implementation — `AbortSignal.any` requires Node >= 20.3.)
447
+ */
448
+ function combineAbortSignals(
449
+ a: AbortSignal | undefined,
450
+ b: AbortSignal | undefined,
451
+ ): AbortSignal | undefined {
452
+ if (!a) return b
453
+ if (!b) return a
454
+ if (a.aborted) return a
455
+ if (b.aborted) return b
456
+ const controller = new AbortController()
457
+ const onAbort = (source: AbortSignal) => () => {
458
+ controller.abort(source.reason)
459
+ }
460
+ a.addEventListener('abort', onAbort(a), { once: true })
461
+ b.addEventListener('abort', onAbort(b), { once: true })
462
+ return controller.signal
463
+ }
464
+
435
465
  class TextEngine<
436
466
  TAdapter extends AnyTextAdapter,
437
467
  TContext = unknown,
@@ -486,6 +516,9 @@ class TextEngine<
486
516
  private readonly deferredPromises: Array<Promise<unknown>> = []
487
517
  private abortReason?: string
488
518
  private readonly middlewareAbortController?: AbortController
519
+ // Combines the caller's signal with middleware abort() so running tools
520
+ // observe both cancellation sources via ctx.abortSignal.
521
+ private readonly toolAbortSignal?: AbortSignal
489
522
  private terminalHookCalled = false
490
523
 
491
524
  private readonly logger: InternalLogger
@@ -583,6 +616,10 @@ class TextEngine<
583
616
  ]
584
617
  this.middlewareRunner = new MiddlewareRunner(allMiddleware, logger)
585
618
  this.middlewareAbortController = new AbortController()
619
+ this.toolAbortSignal = combineAbortSignals(
620
+ this.effectiveSignal,
621
+ this.middlewareAbortController.signal,
622
+ )
586
623
  this.middlewareCtx = {
587
624
  requestId: this.requestId,
588
625
  streamId: this.streamId,
@@ -1225,6 +1262,7 @@ class TextEngine<
1225
1262
  },
1226
1263
  },
1227
1264
  this.middlewareCtx.context,
1265
+ this.toolAbortSignal,
1228
1266
  )
1229
1267
 
1230
1268
  // Consume the async generator, yielding custom events and collecting the return value
@@ -1386,6 +1424,7 @@ class TextEngine<
1386
1424
  },
1387
1425
  },
1388
1426
  this.middlewareCtx.context,
1427
+ this.toolAbortSignal,
1389
1428
  )
1390
1429
 
1391
1430
  // Consume the async generator, yielding custom events and collecting the return value
@@ -1639,8 +1678,9 @@ class TextEngine<
1639
1678
  const wireContent =
1640
1679
  typeof content === 'string' ? content : JSON.stringify(content)
1641
1680
 
1642
- // Emit TOOL_CALL_START + TOOL_CALL_ARGS before TOOL_CALL_END so that
1643
- // the client can reconstruct the full tool call during continuations.
1681
+ // argsMap is set only on continuation re-executions, where the adapter
1682
+ // never streamed these calls. Otherwise it already emitted END, so a
1683
+ // second one here would be an orphan that fails verifyEvents (#519).
1644
1684
  if (argsMap) {
1645
1685
  chunks.push({
1646
1686
  type: 'TOOL_CALL_START',
@@ -1660,18 +1700,18 @@ class TextEngine<
1660
1700
  delta: args,
1661
1701
  args,
1662
1702
  } as StreamChunk)
1663
- }
1664
1703
 
1665
- chunks.push({
1666
- type: 'TOOL_CALL_END',
1667
- timestamp: Date.now(),
1668
- model: finishEvent.model,
1669
- toolCallId: result.toolCallId,
1670
- toolCallName: result.toolName,
1671
- toolName: result.toolName,
1672
- result: wireContent,
1673
- ...(result.state !== undefined && { state: result.state }),
1674
- } as StreamChunk)
1704
+ chunks.push({
1705
+ type: 'TOOL_CALL_END',
1706
+ timestamp: Date.now(),
1707
+ model: finishEvent.model,
1708
+ toolCallId: result.toolCallId,
1709
+ toolCallName: result.toolName,
1710
+ toolName: result.toolName,
1711
+ result: wireContent,
1712
+ ...(result.state !== undefined && { state: result.state }),
1713
+ } as StreamChunk)
1714
+ }
1675
1715
 
1676
1716
  // AG-UI spec TOOL_CALL_RESULT event (content is string-only per spec)
1677
1717
  chunks.push({
@@ -2568,10 +2608,16 @@ export function chat<
2568
2608
  async function* runStreamingText<TContext = unknown>(
2569
2609
  options: TextActivityOptions<AnyTextAdapter, undefined, true, TContext>,
2570
2610
  ): AsyncIterable<StreamChunk> {
2571
- const { adapter, middleware, context, debug, ...textOptions } = options
2611
+ const { adapter, middleware, context, debug, mcp, ...textOptions } = options
2572
2612
  const model = adapter.model
2573
2613
  const logger = resolveDebugOption(debug)
2574
2614
 
2615
+ const mcpManager = MCPManager.from(mcp)
2616
+ const mcpTools = await mcpManager.discover()
2617
+ if (mcpTools.length > 0) {
2618
+ textOptions.tools = [...(textOptions.tools ?? []), ...mcpTools]
2619
+ }
2620
+
2575
2621
  const engine = new TextEngine(
2576
2622
  {
2577
2623
  adapter,
@@ -2586,8 +2632,12 @@ async function* runStreamingText<TContext = unknown>(
2586
2632
  logger,
2587
2633
  )
2588
2634
 
2589
- for await (const chunk of engine.run()) {
2590
- yield chunk
2635
+ try {
2636
+ for await (const chunk of engine.run()) {
2637
+ yield chunk
2638
+ }
2639
+ } finally {
2640
+ await mcpManager.dispose()
2591
2641
  }
2592
2642
  }
2593
2643
 
@@ -2624,8 +2674,15 @@ async function runAgenticStructuredOutput<
2624
2674
  >(
2625
2675
  options: TextActivityOptions<AnyTextAdapter, TSchema, boolean, TContext>,
2626
2676
  ): Promise<InferSchemaType<TSchema>> {
2627
- const { adapter, outputSchema, middleware, context, debug, ...textOptions } =
2628
- options
2677
+ const {
2678
+ adapter,
2679
+ outputSchema,
2680
+ middleware,
2681
+ context,
2682
+ debug,
2683
+ mcp,
2684
+ ...textOptions
2685
+ } = options
2629
2686
  const model = adapter.model
2630
2687
  const logger = resolveDebugOption(debug)
2631
2688
 
@@ -2659,6 +2716,12 @@ async function runAgenticStructuredOutput<
2659
2716
  const nativeCombined =
2660
2717
  adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true
2661
2718
 
2719
+ const mcpManager = MCPManager.from(mcp)
2720
+ const mcpTools = await mcpManager.discover()
2721
+ if (mcpTools.length > 0) {
2722
+ textOptions.tools = [...(textOptions.tools ?? []), ...mcpTools]
2723
+ }
2724
+
2662
2725
  const engine = new TextEngine(
2663
2726
  {
2664
2727
  adapter,
@@ -2679,9 +2742,13 @@ async function runAgenticStructuredOutput<
2679
2742
  logger,
2680
2743
  )
2681
2744
 
2682
- // Consume the stream — chunks pipe through middleware but are not yielded externally
2683
- for await (const _chunk of engine.run()) {
2684
- // intentionally empty
2745
+ try {
2746
+ // Consume the stream — chunks pipe through middleware but are not yielded externally
2747
+ for await (const _chunk of engine.run()) {
2748
+ // intentionally empty
2749
+ }
2750
+ } finally {
2751
+ await mcpManager.dispose()
2685
2752
  }
2686
2753
 
2687
2754
  const finalizationError = engine.getFinalizationError()
@@ -2912,8 +2979,15 @@ async function* runStreamingStructuredOutputImpl<
2912
2979
  options: TextActivityOptions<AnyTextAdapter, TSchema, true, TContext>,
2913
2980
  jsonSchema: NonNullable<ReturnType<typeof convertSchemaToJsonSchema>>,
2914
2981
  ): StructuredOutputStreamInternal<InferSchemaType<TSchema>> {
2915
- const { adapter, outputSchema, middleware, context, debug, ...textOptions } =
2916
- options
2982
+ const {
2983
+ adapter,
2984
+ outputSchema,
2985
+ middleware,
2986
+ context,
2987
+ debug,
2988
+ mcp,
2989
+ ...textOptions
2990
+ } = options
2917
2991
  const model = adapter.model
2918
2992
  const logger = resolveDebugOption(debug)
2919
2993
 
@@ -2927,6 +3001,12 @@ async function* runStreamingStructuredOutputImpl<
2927
3001
  const nativeCombined =
2928
3002
  adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true
2929
3003
 
3004
+ const mcpManager = MCPManager.from(mcp)
3005
+ const mcpTools = await mcpManager.discover()
3006
+ if (mcpTools.length > 0) {
3007
+ textOptions.tools = [...(textOptions.tools ?? []), ...mcpTools]
3008
+ }
3009
+
2930
3010
  // Inputs may be UIMessages (from useChat) or ModelMessages (from server-side
2931
3011
  // callers). TextEngine handles the conversion uniformly.
2932
3012
  const engine = new TextEngine(
@@ -2948,8 +3028,12 @@ async function* runStreamingStructuredOutputImpl<
2948
3028
  logger,
2949
3029
  )
2950
3030
 
2951
- for await (const chunk of engine.run()) {
2952
- yield chunk
3031
+ try {
3032
+ for await (const chunk of engine.run()) {
3033
+ yield chunk
3034
+ }
3035
+ } finally {
3036
+ await mcpManager.dispose()
2953
3037
  }
2954
3038
 
2955
3039
  // Schema validation for the streaming variant remains the consumer's
@@ -0,0 +1,85 @@
1
+ import type { ServerTool } from '../tools/tool-definition'
2
+ import type { ChatMCPOptions, MCPToolSource } from './types'
3
+
4
+ export class MCPDuplicateToolNameError extends Error {
5
+ constructor(public readonly toolName: string) {
6
+ super(
7
+ `Duplicate MCP tool name "${toolName}" in chat({ mcp.clients }). ` +
8
+ `Set a unique \`prefix\` on one of the MCP clients (or use a pool, ` +
9
+ `which auto-prefixes) to disambiguate.`,
10
+ )
11
+ this.name = 'MCPDuplicateToolNameError'
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Encapsulates MCP tool discovery + connection lifecycle for chat().
17
+ * Built from chat()'s `mcp` option; runners only call `discover()` then
18
+ * `dispose()`. A manager built from `undefined` is an inert no-op
19
+ * (`discover()` → `[]`, `dispose()` → no-op), so runners need no branching.
20
+ */
21
+ export class MCPManager {
22
+ static from(options: ChatMCPOptions | undefined): MCPManager {
23
+ return new MCPManager(options)
24
+ }
25
+
26
+ readonly #sources: ReadonlyArray<MCPToolSource>
27
+ readonly #shouldClose: boolean
28
+ readonly #lazyTools: boolean
29
+ readonly #onDiscoveryError?: (
30
+ error: unknown,
31
+ source: MCPToolSource,
32
+ ) => void | Promise<void>
33
+
34
+ private constructor(options: ChatMCPOptions | undefined) {
35
+ this.#sources = options?.clients ?? []
36
+ // default 'close'; only 'keep-alive' disables closing
37
+ this.#shouldClose = options ? options.connection !== 'keep-alive' : false
38
+ this.#lazyTools = options?.lazyTools ?? false
39
+ this.#onDiscoveryError = options?.onDiscoveryError
40
+ }
41
+
42
+ /**
43
+ * Discover + merge tools from all sources. Throws on a fatal discovery error
44
+ * (no `onDiscoveryError`, or it re-threw) or a duplicate tool name; in that
45
+ * case it first closes any connected sources when the policy is 'close'.
46
+ */
47
+ async discover(): Promise<Array<ServerTool>> {
48
+ if (this.#sources.length === 0) return []
49
+ try {
50
+ const settled = await Promise.allSettled(
51
+ this.#sources.map((s) => s.tools({ lazy: this.#lazyTools })),
52
+ )
53
+ const tools: Array<ServerTool> = []
54
+ const zipped = this.#sources.map(
55
+ (source, i) => [source, settled[i]] as const,
56
+ )
57
+ for (const [source, result] of zipped) {
58
+ if (result === undefined) continue
59
+ if (result.status === 'fulfilled') {
60
+ tools.push(...result.value)
61
+ } else if (this.#onDiscoveryError) {
62
+ // throw/reject inside handler ⇒ propagate (fail-fast); return ⇒ skip
63
+ await this.#onDiscoveryError(result.reason, source)
64
+ } else {
65
+ throw result.reason
66
+ }
67
+ }
68
+ const seen = new Set<string>()
69
+ for (const t of tools) {
70
+ if (seen.has(t.name)) throw new MCPDuplicateToolNameError(t.name)
71
+ seen.add(t.name)
72
+ }
73
+ return tools
74
+ } catch (err) {
75
+ await this.dispose() // cleanup-on-failure (no-op if keep-alive)
76
+ throw err
77
+ }
78
+ }
79
+
80
+ /** Close sources iff policy is 'close'. Idempotent; never throws. */
81
+ async dispose(): Promise<void> {
82
+ if (!this.#shouldClose || this.#sources.length === 0) return
83
+ await Promise.allSettled(this.#sources.map((s) => s.close()))
84
+ }
85
+ }
@@ -0,0 +1,66 @@
1
+ import type { ServerTool } from '../tools/tool-definition'
2
+
3
+ /**
4
+ * Minimal structural shape that `chat({ mcp })` needs from an MCP client.
5
+ *
6
+ * `@tanstack/ai-mcp`'s `MCPClient` and `MCPClients` satisfy this interface by
7
+ * shape — the core `@tanstack/ai` package does NOT import `@tanstack/ai-mcp`
8
+ * (ai-mcp depends on ai, not the reverse).
9
+ */
10
+ export interface MCPToolSource {
11
+ // Keep the options shape in sync with ai-mcp's `ToolsOptions` — extra
12
+ // optional fields added there still match structurally, but chat() only
13
+ // forwards what is declared here.
14
+ tools: (options?: { lazy?: boolean }) => Promise<Array<ServerTool>>
15
+ close: () => Promise<void>
16
+ }
17
+
18
+ /**
19
+ * Controls what happens to MCP connections when the chat run ends.
20
+ *
21
+ * - `'close'` (default) — `chat()` closes each connection when the run ends
22
+ * (after the agent loop completes and the stream is drained), so tools can
23
+ * still execute throughout the run.
24
+ * - `'keep-alive'` — `chat()` never closes the connections; the caller owns
25
+ * their lifecycle (e.g. keep them warm across requests).
26
+ */
27
+ export type MCPConnectionPolicy = 'close' | 'keep-alive'
28
+
29
+ /**
30
+ * Options controlling MCP tool discovery and lifecycle for a `chat()` call.
31
+ */
32
+ export interface ChatMCPOptions {
33
+ /**
34
+ * The MCP clients or client pools to discover tools from and manage.
35
+ */
36
+ clients: Array<MCPToolSource>
37
+
38
+ /**
39
+ * Connection lifecycle policy applied to all clients when the run ends.
40
+ *
41
+ * Defaults to `'close'`.
42
+ */
43
+ connection?: MCPConnectionPolicy
44
+
45
+ /**
46
+ * When `true`, tool schemas are fetched lazily (forwarded to
47
+ * `tools({ lazy: true })`).
48
+ *
49
+ * Defaults to `false`.
50
+ */
51
+ lazyTools?: boolean
52
+
53
+ /**
54
+ * Called when tool discovery fails for a single source.
55
+ *
56
+ * - Throw (or re-throw) from this handler to fail the entire chat call fast.
57
+ * - Return normally to skip that source and continue with remaining clients.
58
+ * - Omit this handler entirely to rethrow the error (fail-fast by default).
59
+ *
60
+ * Async handlers are awaited, so a rejected promise also fails fast.
61
+ */
62
+ onDiscoveryError?: (
63
+ error: unknown,
64
+ source: MCPToolSource,
65
+ ) => void | Promise<void>
66
+ }
@@ -211,6 +211,7 @@ function isToolCallIncluded(part: ToolCallPart): boolean {
211
211
  part.state === 'input-complete' ||
212
212
  part.state === 'complete' ||
213
213
  part.state === 'approval-responded' ||
214
+ part.state === 'error' ||
214
215
  part.output !== undefined
215
216
  )
216
217
  }
@@ -161,10 +161,14 @@ export function updateToolCallApproval(
161
161
  )
162
162
 
163
163
  if (toolCallPart) {
164
- toolCallPart.state = 'approval-requested'
165
- toolCallPart.approval = {
166
- id: approvalId,
167
- needsApproval: true,
164
+ const index = parts.indexOf(toolCallPart)
165
+ parts[index] = {
166
+ ...toolCallPart,
167
+ state: 'approval-requested',
168
+ approval: {
169
+ id: approvalId,
170
+ needsApproval: true,
171
+ },
168
172
  }
169
173
  }
170
174
 
@@ -192,7 +196,8 @@ export function updateToolCallState(
192
196
  )
193
197
 
194
198
  if (toolCallPart) {
195
- toolCallPart.state = state
199
+ const index = parts.indexOf(toolCallPart)
200
+ parts[index] = { ...toolCallPart, state }
196
201
  }
197
202
 
198
203
  return { ...msg, parts }
@@ -217,8 +222,12 @@ export function updateToolCallWithOutput(
217
222
  )
218
223
 
219
224
  if (toolCallPart) {
220
- toolCallPart.output = errorText ? { error: errorText } : output
221
- toolCallPart.state = state ?? (errorText ? 'input-complete' : 'complete')
225
+ const index = parts.indexOf(toolCallPart)
226
+ parts[index] = {
227
+ ...toolCallPart,
228
+ output: errorText ? { error: errorText } : output,
229
+ state: state ?? (errorText ? 'error' : 'complete'),
230
+ }
222
231
  }
223
232
 
224
233
  return { ...msg, parts }
@@ -242,8 +251,12 @@ export function updateToolCallApprovalResponse(
242
251
  )
243
252
 
244
253
  if (toolCallPart && toolCallPart.approval) {
245
- toolCallPart.approval.approved = approved
246
- toolCallPart.state = 'approval-responded'
254
+ const index = parts.indexOf(toolCallPart)
255
+ parts[index] = {
256
+ ...toolCallPart,
257
+ approval: { ...toolCallPart.approval, approved },
258
+ state: 'approval-responded',
259
+ }
247
260
  }
248
261
 
249
262
  return { ...msg, parts }
@@ -317,7 +317,7 @@ export class StreamProcessor {
317
317
  this.messages,
318
318
  toolCallId,
319
319
  output,
320
- error ? 'input-complete' : undefined,
320
+ error ? 'error' : undefined,
321
321
  error,
322
322
  )
323
323
 
@@ -1184,7 +1184,7 @@ export class StreamProcessor {
1184
1184
  this.messages,
1185
1185
  chunk.toolCallId,
1186
1186
  output,
1187
- chunk.state === 'output-error' ? 'input-complete' : undefined,
1187
+ chunk.state === 'output-error' ? 'error' : undefined,
1188
1188
  )
1189
1189
 
1190
1190
  // Step 2: Create/update the tool-result part (for LLM conversation history)
@@ -1240,7 +1240,7 @@ export class StreamProcessor {
1240
1240
  this.messages,
1241
1241
  chunk.toolCallId,
1242
1242
  output,
1243
- chunk.state === 'output-error' ? 'input-complete' : undefined,
1243
+ chunk.state === 'output-error' ? 'error' : undefined,
1244
1244
  )
1245
1245
 
1246
1246
  // Step 2: Create/update the tool-result part
@@ -1690,11 +1690,22 @@ export class StreamProcessor {
1690
1690
  _index: number,
1691
1691
  toolCall: InternalToolCallState,
1692
1692
  ): void {
1693
+ // Finalize the internal bookkeeping: the call's input arguments ARE
1694
+ // complete regardless of whether execution later failed, so the call still
1695
+ // counts as a completed tool call in getCompletedToolCalls()/getState().
1693
1696
  toolCall.state = 'input-complete'
1694
1697
 
1695
1698
  // Try final parse
1696
1699
  toolCall.parsedArguments = this.jsonParser.parse(toolCall.arguments)
1697
1700
 
1701
+ // Don't downgrade the rendered part of a call that already reached the
1702
+ // terminal 'error' state (e.g. an output-error TOOL_CALL_RESULT arrived
1703
+ // without a preceding TOOL_CALL_END). The RUN_FINISHED / finalizeStream
1704
+ // safety net must not clobber a failed call back to 'input-complete'.
1705
+ if (this.isToolCallPartErrored(toolCall.id)) {
1706
+ return
1707
+ }
1708
+
1698
1709
  // Update UIMessage
1699
1710
  this.messages = updateToolCallPart(this.messages, messageId, {
1700
1711
  id: toolCall.id,
@@ -1714,6 +1725,22 @@ export class StreamProcessor {
1714
1725
  )
1715
1726
  }
1716
1727
 
1728
+ /**
1729
+ * Whether the rendered tool-call part for the given id has reached the
1730
+ * terminal 'error' state. Used to prevent the completion safety net from
1731
+ * downgrading a failed call back to 'input-complete'.
1732
+ */
1733
+ private isToolCallPartErrored(toolCallId: string): boolean {
1734
+ return this.messages.some((msg) =>
1735
+ msg.parts.some(
1736
+ (part) =>
1737
+ part.type === 'tool-call' &&
1738
+ part.id === toolCallId &&
1739
+ part.state === 'error',
1740
+ ),
1741
+ )
1742
+ }
1743
+
1717
1744
  /**
1718
1745
  * Emit pending text update for a specific message.
1719
1746
  *
@@ -599,6 +599,7 @@ export async function* executeToolCalls<TContext = unknown>(
599
599
  ) => CustomEvent,
600
600
  middlewareHooks?: ToolExecutionMiddlewareHooks,
601
601
  userContext?: TContext,
602
+ abortSignal?: AbortSignal,
602
603
  ): AsyncGenerator<CustomEvent, ExecuteToolCallsResult, void> {
603
604
  const results: Array<ToolResult> = []
604
605
  const needsApproval: Array<ApprovalRequest> = []
@@ -679,6 +680,7 @@ export async function* executeToolCalls<TContext = unknown>(
679
680
  const context = {
680
681
  toolCallId: toolCall.id,
681
682
  context: userContext,
683
+ abortSignal,
682
684
  emitCustomEvent: (eventName: string, value: Record<string, any>) => {
683
685
  if (createCustomEventChunk) {
684
686
  pendingEvents.push(
@@ -15,6 +15,7 @@ import type { DebugOption } from '../../logger/types'
15
15
  import type { VideoAdapter } from './adapter'
16
16
  import type {
17
17
  StreamChunk,
18
+ TokenUsage,
18
19
  VideoJobResult,
19
20
  VideoStatusResult,
20
21
  VideoUrlResult,
@@ -380,6 +381,7 @@ async function* runStreamingVideoGeneration<
380
381
  status: 'completed',
381
382
  url: urlResult.url,
382
383
  expiresAt: urlResult.expiresAt,
384
+ ...(urlResult.usage ? { usage: urlResult.usage } : {}),
383
385
  },
384
386
  timestamp: Date.now(),
385
387
  } as StreamChunk
@@ -454,6 +456,7 @@ export async function getVideoJobStatus<
454
456
  progress?: number
455
457
  url?: string
456
458
  error?: string
459
+ usage?: TokenUsage
457
460
  }> {
458
461
  const { adapter, jobId } = options
459
462
  const requestId = createId('video-status')
@@ -487,10 +490,19 @@ export async function getVideoJobStatus<
487
490
  duration: Date.now() - startTime,
488
491
  timestamp: Date.now(),
489
492
  })
493
+ if (urlResult.usage) {
494
+ aiEventClient.emit('video:usage', {
495
+ requestId,
496
+ model: adapter.model,
497
+ usage: urlResult.usage,
498
+ timestamp: Date.now(),
499
+ })
500
+ }
490
501
  return {
491
502
  status: statusResult.status,
492
503
  progress: statusResult.progress,
493
504
  url: urlResult.url,
505
+ ...(urlResult.usage ? { usage: urlResult.usage } : {}),
494
506
  }
495
507
  } catch (error) {
496
508
  const errorMessage =