@tanstack/ai 0.50.0 → 0.52.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 (31) hide show
  1. package/dist/esm/activities/chat/index.js +94 -23
  2. package/dist/esm/activities/chat/index.js.map +1 -1
  3. package/dist/esm/activities/chat/middleware/compose.js +4 -2
  4. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  5. package/dist/esm/activities/chat/middleware/index.d.ts +2 -0
  6. package/dist/esm/activities/chat/middleware/metadata.d.ts +16 -0
  7. package/dist/esm/activities/chat/middleware/metadata.js +8 -0
  8. package/dist/esm/activities/chat/middleware/metadata.js.map +1 -0
  9. package/dist/esm/activities/chat/middleware/types.d.ts +9 -0
  10. package/dist/esm/activities/chat/middleware/types.js.map +1 -1
  11. package/dist/esm/activities/chat/stream/processor.d.ts +2 -0
  12. package/dist/esm/activities/chat/stream/processor.js +39 -12
  13. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  14. package/dist/esm/activities/chat/tools/schema-converter.js +21 -10
  15. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  16. package/dist/esm/index.d.ts +4 -2
  17. package/dist/esm/index.js +3 -1
  18. package/dist/esm/utilities/errors.d.ts +27 -0
  19. package/dist/esm/utilities/errors.js +19 -1
  20. package/dist/esm/utilities/errors.js.map +1 -1
  21. package/package.json +2 -2
  22. package/skills/ai-core/SKILL.md +17 -0
  23. package/src/activities/chat/index.ts +139 -44
  24. package/src/activities/chat/middleware/compose.ts +14 -2
  25. package/src/activities/chat/middleware/index.ts +3 -0
  26. package/src/activities/chat/middleware/metadata.ts +20 -0
  27. package/src/activities/chat/middleware/types.ts +9 -0
  28. package/src/activities/chat/stream/processor.ts +51 -6
  29. package/src/activities/chat/tools/schema-converter.ts +30 -17
  30. package/src/index.ts +6 -0
  31. package/src/utilities/errors.ts +42 -0
@@ -788,6 +788,7 @@ class TextEngine<
788
788
  private readonly effectiveSignal?: AbortSignal
789
789
 
790
790
  private messages: Array<ModelMessage>
791
+ private providerMessages: Array<ModelMessage>
791
792
  private iterationCount = 0
792
793
  /** Cumulative tool calls counted in this run (emitted + pending resume). */
793
794
  private toolCallCount = 0
@@ -845,6 +846,9 @@ class TextEngine<
845
846
  >
846
847
  private readonly middlewareCtx: ChatMiddlewareContext<TContext>
847
848
  private readonly sandboxFileQueue: Array<StreamChunk> = []
849
+ private readonly middlewareCustomQueue: Array<StreamChunk> = []
850
+ private middlewareCustomWaiters: Array<() => void> = []
851
+ private drainingMiddlewareCustom = false
848
852
  private readonly deferredPromises: Array<Promise<unknown>> = []
849
853
  private abortReason?: string
850
854
  private readonly middlewareAbortController?: AbortController
@@ -935,6 +939,7 @@ class TextEngine<
935
939
  // Convert messages to ModelMessage format (handles both UIMessage and ModelMessage input)
936
940
  // This ensures consistent internal format regardless of what the client sends
937
941
  this.messages = convertMessagesToModelMessages(config.params.messages)
942
+ this.providerMessages = this.messages
938
943
 
939
944
  // Initialize lazy tool manager after messages are converted (needs message history for scanning)
940
945
  assertUniqueToolNames(config.params.tools || [])
@@ -993,6 +998,14 @@ class TextEngine<
993
998
  this.abortReason = reason
994
999
  this.middlewareAbortController?.abort(reason)
995
1000
  },
1001
+ emitCustomEvent: (name, value) => {
1002
+ this.middlewareCustomQueue.push(
1003
+ this.createCustomEventChunk(name, value),
1004
+ )
1005
+ const waiters = this.middlewareCustomWaiters
1006
+ this.middlewareCustomWaiters = []
1007
+ for (const waiter of waiters) waiter()
1008
+ },
996
1009
  context: config.context as TContext,
997
1010
  defer: (promise: Promise<unknown>) => {
998
1011
  this.deferredPromises.push(promise)
@@ -1128,21 +1141,24 @@ class TextEngine<
1128
1141
 
1129
1142
  try {
1130
1143
  // Provision capabilities before any consumer (onConfig onward) can read them
1131
- await this.middlewareRunner.runSetup(this.middlewareCtx)
1144
+ yield* this.runWhileYielding(
1145
+ this.middlewareRunner.runSetup(this.middlewareCtx),
1146
+ )
1132
1147
 
1133
1148
  // Run initial onConfig (phase = init)
1134
1149
  this.middlewareCtx.phase = 'init'
1135
1150
  const initialConfig = this.buildMiddlewareConfig()
1136
- const transformedConfig = await this.middlewareRunner.runOnConfig(
1137
- this.middlewareCtx,
1138
- initialConfig,
1151
+ const transformedConfig = yield* this.runWhileYielding(
1152
+ this.middlewareRunner.runOnConfig(this.middlewareCtx, initialConfig),
1139
1153
  )
1140
1154
  this.applyMiddlewareConfig(transformedConfig)
1141
1155
  await this.applyEphemeralInterruptResume(transformedConfig)
1142
1156
  await this.applyDurableGenericInterruptResolution()
1143
1157
 
1144
1158
  // Run onStart (devtools middleware emits text:request:started and initial messages here)
1145
- await this.middlewareRunner.runOnStart(this.middlewareCtx)
1159
+ yield* this.runWhileYielding(
1160
+ this.middlewareRunner.runOnStart(this.middlewareCtx),
1161
+ )
1146
1162
 
1147
1163
  if (this.earlyTermination) {
1148
1164
  yield* this.emitSuccessfulEarlyTermination()
@@ -1189,18 +1205,16 @@ class TextEngine<
1189
1205
  iteration: this.middlewareCtx.iteration,
1190
1206
  })
1191
1207
 
1192
- await this.beginCycle()
1208
+ yield* this.runWhileYielding(this.beginCycle())
1193
1209
 
1194
1210
  if (this.cyclePhase === 'processText') {
1195
1211
  // Run onConfig before each model call (phase = beforeModel)
1196
1212
  this.middlewareCtx.phase = 'beforeModel'
1197
1213
  this.middlewareCtx.iteration = this.iterationCount
1198
1214
  const iterConfig = this.buildMiddlewareConfig()
1199
- const iterTransformedConfig =
1200
- await this.middlewareRunner.runOnConfig(
1201
- this.middlewareCtx,
1202
- iterConfig,
1203
- )
1215
+ const iterTransformedConfig = yield* this.runWhileYielding(
1216
+ this.middlewareRunner.runOnConfig(this.middlewareCtx, iterConfig),
1217
+ )
1204
1218
  this.applyMiddlewareConfig(iterTransformedConfig)
1205
1219
 
1206
1220
  if (
@@ -1239,7 +1253,7 @@ class TextEngine<
1239
1253
  }
1240
1254
 
1241
1255
  this.endCycle()
1242
- } while (await this.shouldContinue())
1256
+ } while (yield* this.runWhileYielding(this.shouldContinue()))
1243
1257
  }
1244
1258
 
1245
1259
  this.logger.agentLoop('run finished', {
@@ -1497,7 +1511,7 @@ class TextEngine<
1497
1511
 
1498
1512
  for await (const raw of this.adapter.chatStream({
1499
1513
  model: this.params.model,
1500
- messages: this.messages,
1514
+ messages: this.providerMessages,
1501
1515
  tools: toolsWithJsonSchemas,
1502
1516
  metadata,
1503
1517
  request: this.effectiveRequest,
@@ -1632,6 +1646,7 @@ class TextEngine<
1632
1646
  continue
1633
1647
  }
1634
1648
  if (spec.type === EventType.RUN_STARTED) {
1649
+ if (this.hasPublicRunStarted) continue
1635
1650
  this.hasPublicRunStarted = true
1636
1651
  }
1637
1652
  this.logger.output(`type=${spec.type}`, { chunk: spec })
@@ -1646,6 +1661,7 @@ class TextEngine<
1646
1661
 
1647
1662
  // Drain any sandbox.file events emitted while processing this chunk.
1648
1663
  yield* this.drainSandboxFileQueue()
1664
+ yield* this.drainMiddlewareCustomQueue()
1649
1665
 
1650
1666
  if (this.earlyTermination) {
1651
1667
  break
@@ -1654,6 +1670,7 @@ class TextEngine<
1654
1670
 
1655
1671
  // Drain any remaining sandbox.file events emitted after the stream ended.
1656
1672
  yield* this.drainSandboxFileQueue()
1673
+ yield* this.drainMiddlewareCustomQueue()
1657
1674
  }
1658
1675
 
1659
1676
  private handleStreamChunk(chunk: AdapterYieldChunk): void {
@@ -2047,12 +2064,14 @@ class TextEngine<
2047
2064
  const allResults = [...executionResult.results, ...deferredErrorResults]
2048
2065
 
2049
2066
  // Notify middleware of tool phase completion (devtools emits aggregate events here)
2050
- await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
2051
- toolCalls: pendingToolCalls,
2052
- results: allResults,
2053
- needsApproval: executionResult.needsApproval,
2054
- needsClientExecution: executionResult.needsClientExecution,
2055
- })
2067
+ yield* this.runWhileYielding(
2068
+ this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
2069
+ toolCalls: pendingToolCalls,
2070
+ results: allResults,
2071
+ needsApproval: executionResult.needsApproval,
2072
+ needsClientExecution: executionResult.needsClientExecution,
2073
+ }),
2074
+ )
2056
2075
 
2057
2076
  if (
2058
2077
  executionResult.needsApproval.length > 0 ||
@@ -2228,23 +2247,26 @@ class TextEngine<
2228
2247
  const allResults = [...executionResult.results, ...deferredErrorResults]
2229
2248
 
2230
2249
  // Notify middleware of tool phase completion (devtools emits aggregate events here)
2231
- await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
2232
- toolCalls,
2233
- results: allResults,
2234
- needsApproval: executionResult.needsApproval,
2235
- needsClientExecution: executionResult.needsClientExecution,
2236
- })
2250
+ yield* this.runWhileYielding(
2251
+ this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
2252
+ toolCalls,
2253
+ results: allResults,
2254
+ needsApproval: executionResult.needsApproval,
2255
+ needsClientExecution: executionResult.needsClientExecution,
2256
+ }),
2257
+ )
2237
2258
 
2238
2259
  const afterToolBoundaryChunks = this.buildToolResultChunks(
2239
2260
  allResults,
2240
2261
  finishEvent,
2241
2262
  )
2242
- const afterToolRequests =
2243
- await this.middlewareRunner.runOnInterruptBoundary(
2263
+ const afterToolRequests = yield* this.runWhileYielding(
2264
+ this.middlewareRunner.runOnInterruptBoundary(
2244
2265
  this.middlewareCtx as ChatMiddlewareContext<TContext> & {
2245
2266
  phase: 'afterTools'
2246
2267
  },
2247
- )
2268
+ ),
2269
+ )
2248
2270
  if (afterToolRequests.length > 0) {
2249
2271
  for (const chunk of afterToolBoundaryChunks) {
2250
2272
  yield* this.pipeThroughMiddleware(chunk)
@@ -2924,10 +2946,12 @@ class TextEngine<
2924
2946
  this.middlewareCtx.phase = phase
2925
2947
  const boundaryRequests =
2926
2948
  requests ??
2927
- (await this.middlewareRunner.runOnInterruptBoundary(
2928
- this.middlewareCtx as ChatMiddlewareContext<TContext> & {
2929
- phase: typeof phase
2930
- },
2949
+ (yield* this.runWhileYielding(
2950
+ this.middlewareRunner.runOnInterruptBoundary(
2951
+ this.middlewareCtx as ChatMiddlewareContext<TContext> & {
2952
+ phase: typeof phase
2953
+ },
2954
+ ),
2931
2955
  ))
2932
2956
  if (boundaryRequests.length === 0) return false
2933
2957
  for (const request of boundaryRequests) {
@@ -3445,9 +3469,11 @@ class TextEngine<
3445
3469
  }
3446
3470
 
3447
3471
  // 1) onStructuredOutputConfig — middleware can transform messages, options, outputSchema
3448
- structuredConfig = await this.middlewareRunner.runOnStructuredOutputConfig(
3449
- this.middlewareCtx,
3450
- structuredConfig,
3472
+ structuredConfig = yield* this.runWhileYielding(
3473
+ this.middlewareRunner.runOnStructuredOutputConfig(
3474
+ this.middlewareCtx,
3475
+ structuredConfig,
3476
+ ),
3451
3477
  )
3452
3478
 
3453
3479
  // 2) onConfig — phase-aware general-purpose middleware re-runs at the
@@ -3456,9 +3482,11 @@ class TextEngine<
3456
3482
  // call — same constraint applies — but the view is consistent with the
3457
3483
  // ChatMiddlewareConfig shape).
3458
3484
  const { outputSchema: pinnedSchema, ...chatConfigSlice } = structuredConfig
3459
- const postOnConfig = await this.middlewareRunner.runOnConfig(
3460
- this.middlewareCtx,
3461
- { ...chatConfigSlice, tools: baseConfig.tools },
3485
+ const postOnConfig = yield* this.runWhileYielding(
3486
+ this.middlewareRunner.runOnConfig(this.middlewareCtx, {
3487
+ ...chatConfigSlice,
3488
+ tools: baseConfig.tools,
3489
+ }),
3462
3490
  )
3463
3491
 
3464
3492
  // Apply merged config back to engine state
@@ -3470,7 +3498,7 @@ class TextEngine<
3470
3498
  const structuredCallOptions = {
3471
3499
  chatOptions: {
3472
3500
  model: this.params.model,
3473
- messages: this.messages,
3501
+ messages: this.providerMessages,
3474
3502
  metadata: postOnConfig.metadata,
3475
3503
  modelOptions: postOnConfig.modelOptions,
3476
3504
  systemPrompts: postOnConfig.systemPrompts,
@@ -3949,6 +3977,7 @@ class TextEngine<
3949
3977
  private buildMiddlewareConfig(): ChatMiddlewareConfig {
3950
3978
  return {
3951
3979
  messages: this.messages,
3980
+ providerMessages: this.messages,
3952
3981
  systemPrompts: [...this.systemPrompts],
3953
3982
  tools: [...this.tools],
3954
3983
  resume: this.params.resume,
@@ -4367,6 +4396,7 @@ class TextEngine<
4367
4396
  private applyMiddlewareConfig(config: ChatMiddlewareConfig): void {
4368
4397
  this.applyResumeToolState(config.resumeToolState)
4369
4398
  this.messages = config.messages
4399
+ this.providerMessages = config.providerMessages ?? config.messages
4370
4400
  this.systemPrompts = config.systemPrompts
4371
4401
  assertUniqueToolNames(config.tools)
4372
4402
  this.tools = config.tools
@@ -4399,6 +4429,7 @@ class TextEngine<
4399
4429
  for (const spec of normalizeStreamChunk(output as AdapterYieldChunk)) {
4400
4430
  restorePublicUsage(spec)
4401
4431
  if (spec.type === EventType.RUN_STARTED) {
4432
+ if (this.hasPublicRunStarted) continue
4402
4433
  this.hasPublicRunStarted = true
4403
4434
  }
4404
4435
  yield spec
@@ -4419,6 +4450,69 @@ class TextEngine<
4419
4450
  chunk,
4420
4451
  )
4421
4452
  yield* this.emitPublicChunks(afterMw)
4453
+ if (!this.drainingMiddlewareCustom) {
4454
+ yield* this.drainMiddlewareCustomQueue()
4455
+ }
4456
+ }
4457
+
4458
+ /**
4459
+ * Drain CUSTOM chunks pushed by `ctx.emitCustomEvent` through middleware
4460
+ * and into the public stream. If the run has not yet sent `RUN_STARTED`,
4461
+ * emit that first so CUSTOM events are not the first wire event.
4462
+ */
4463
+ private async *drainMiddlewareCustomQueue(): AsyncGenerator<StreamChunk> {
4464
+ if (this.drainingMiddlewareCustom) return
4465
+ if (this.middlewareCustomQueue.length === 0) return
4466
+ this.drainingMiddlewareCustom = true
4467
+ try {
4468
+ yield* this.emitSyntheticRunStarted(this.createSyntheticFinishedEvent())
4469
+ while (this.middlewareCustomQueue.length > 0) {
4470
+ const chunk = this.middlewareCustomQueue.shift()
4471
+ if (chunk) yield* this.pipeThroughMiddleware(chunk)
4472
+ }
4473
+ } finally {
4474
+ this.drainingMiddlewareCustom = false
4475
+ }
4476
+ }
4477
+
4478
+ /**
4479
+ * Await `work` while yielding any `emitCustomEvent` chunks as they arrive.
4480
+ */
4481
+ private async *runWhileYielding<T>(
4482
+ work: Promise<T>,
4483
+ ): AsyncGenerator<StreamChunk, T> {
4484
+ let settled = false
4485
+ let result: T | undefined
4486
+ let error: unknown
4487
+ const done = work.then(
4488
+ (value) => {
4489
+ settled = true
4490
+ result = value
4491
+ },
4492
+ (err: unknown) => {
4493
+ settled = true
4494
+ error = err
4495
+ },
4496
+ )
4497
+
4498
+ while (!settled) {
4499
+ yield* this.drainMiddlewareCustomQueue()
4500
+ if (settled) break
4501
+ await Promise.race([
4502
+ done,
4503
+ new Promise<void>((resolve) => {
4504
+ if (this.middlewareCustomQueue.length > 0) {
4505
+ resolve()
4506
+ return
4507
+ }
4508
+ this.middlewareCustomWaiters.push(resolve)
4509
+ }),
4510
+ ])
4511
+ }
4512
+
4513
+ yield* this.drainMiddlewareCustomQueue()
4514
+ if (error !== undefined) throw error
4515
+ return result as T
4422
4516
  }
4423
4517
 
4424
4518
  /**
@@ -4455,17 +4549,18 @@ class TextEngine<
4455
4549
  },
4456
4550
  void
4457
4551
  > {
4458
- let next = await generator.next()
4459
- while (!next.done) {
4552
+ let pending = generator.next()
4553
+ while (true) {
4554
+ const next = yield* this.runWhileYielding(pending)
4555
+ if (next.done) return next.value
4460
4556
  yield* this.pipeThroughMiddleware(next.value)
4461
- next = await generator.next()
4557
+ pending = generator.next()
4462
4558
  }
4463
- return next.value
4464
4559
  }
4465
4560
 
4466
4561
  private createCustomEventChunk(
4467
4562
  eventName: string,
4468
- value: Record<string, any>,
4563
+ value: Record<string, unknown>,
4469
4564
  ): CustomEvent {
4470
4565
  return {
4471
4566
  type: EventType.CUSTOM,
@@ -166,7 +166,13 @@ export class MiddlewareRunner<
166
166
  const result = await mw.onConfig(ctx, current)
167
167
  const hasTransform = result !== undefined && result !== null
168
168
  if (hasTransform) {
169
- current = { ...current, ...result }
169
+ current = {
170
+ ...current,
171
+ ...result,
172
+ ...('messages' in result && !('providerMessages' in result)
173
+ ? { providerMessages: result.messages }
174
+ : {}),
175
+ }
170
176
  if (!skip) {
171
177
  this.logger.config(
172
178
  `middleware=${mw.name ?? 'unnamed'} keys=${Object.keys(result).join(',')}`,
@@ -221,7 +227,13 @@ export class MiddlewareRunner<
221
227
  const result = await mw.onStructuredOutputConfig(ctx, current)
222
228
  const hasTransform = result !== undefined && result !== null
223
229
  if (hasTransform) {
224
- current = { ...current, ...result }
230
+ current = {
231
+ ...current,
232
+ ...result,
233
+ ...('messages' in result && !('providerMessages' in result)
234
+ ? { providerMessages: result.messages }
235
+ : {}),
236
+ }
225
237
  if (!skip) {
226
238
  this.logger.config(
227
239
  `middleware=${mw.name ?? 'unnamed'} keys=${Object.keys(result).join(',')}`,
@@ -67,6 +67,9 @@ export {
67
67
  } from './locks'
68
68
  export type { LockStore } from './locks'
69
69
 
70
+ export { MetadataCapability, getMetadata, provideMetadata } from './metadata'
71
+ export type { MetadataStore } from './metadata'
72
+
70
73
  export {
71
74
  isRunStatus,
72
75
  isTerminalRunStatus,
@@ -0,0 +1,20 @@
1
+ import { createCapability } from './capabilities'
2
+
3
+ /**
4
+ * Namespaced key/value store for app and middleware metadata.
5
+ *
6
+ * `(namespace, key)` is the composite identity. Keep both values separate;
7
+ * joining them with a delimiter can create collisions.
8
+ */
9
+ export interface MetadataStore {
10
+ /** Return the value for `(namespace, key)`, or `null` when it is absent. */
11
+ get: (namespace: string, key: string) => Promise<unknown | null>
12
+ /** Insert or replace the value for `(namespace, key)`. */
13
+ set: (namespace: string, key: string, value: unknown) => Promise<void>
14
+ /** Delete `(namespace, key)`. Do nothing when it is absent. */
15
+ delete: (namespace: string, key: string) => Promise<void>
16
+ }
17
+
18
+ export const MetadataCapability = createCapability<MetadataStore>()('metadata')
19
+
20
+ export const [getMetadata, provideMetadata] = MetadataCapability
@@ -213,6 +213,12 @@ export interface ChatMiddlewareContext<TContext = unknown> {
213
213
  signal?: AbortSignal
214
214
  /** Abort the chat run with a reason */
215
215
  abort: (reason?: string) => void
216
+ /**
217
+ * Push a `CUSTOM` chunk onto the chat stream immediately.
218
+ * The engine yields it as soon as it can (including while `onConfig`
219
+ * is still awaiting work such as a summarize call).
220
+ */
221
+ emitCustomEvent: (name: string, value: Record<string, any>) => void
216
222
  /** Runtime context provided by chat() options */
217
223
  context: TContext
218
224
  /**
@@ -305,7 +311,10 @@ export interface ChatMiddlewareContext<TContext = unknown> {
305
311
  * that middleware is allowed to modify.
306
312
  */
307
313
  export interface ChatMiddlewareConfig {
314
+ /** Canonical conversation history. Middleware and persistence read this. */
308
315
  messages: Array<ModelMessage>
316
+ /** Provider-only context. Defaults to `messages` when it is not set. */
317
+ providerMessages?: Array<ModelMessage> | undefined
309
318
  systemPrompts: Array<SystemPrompt>
310
319
  tools: Array<Tool>
311
320
  resume?: Array<RunAgentResumeItem> | undefined
@@ -212,6 +212,7 @@ export class StreamProcessor {
212
212
  private finishReason: string | null = null
213
213
  private hasError = false
214
214
  private isDone = false
215
+ private streamEndEmitted = false
215
216
 
216
217
  // Recording
217
218
  private recording: ChunkRecording | null = null
@@ -729,9 +730,26 @@ export class StreamProcessor {
729
730
  return id
730
731
  }
731
732
  }
733
+ // finalizeStream() clears activeMessageIds but keeps messageStates.
734
+ // Leftover reasoning after an early RUN_FINISHED must resume that
735
+ // assistant. A new user turn calls prepareAssistantMessage(), which
736
+ // clears messageStates first.
737
+ for (const [id, state] of [...this.messageStates].reverse()) {
738
+ if (state.role === 'assistant') {
739
+ return id
740
+ }
741
+ }
732
742
  return null
733
743
  }
734
744
 
745
+ private resumeAssistantState(id: string, state: MessageStreamState): void {
746
+ this.activeMessageIds.add(id)
747
+ if (state.isComplete || this.isDone) {
748
+ state.isComplete = false
749
+ this.isDone = false
750
+ }
751
+ }
752
+
735
753
  /**
736
754
  * Ensure an active assistant message exists, creating one if needed.
737
755
  * Used for backward compat when events arrive without prior TEXT_MESSAGE_START.
@@ -748,14 +766,20 @@ export class StreamProcessor {
748
766
  // Try to find state by preferred ID
749
767
  if (preferredId) {
750
768
  const state = this.getMessageState(preferredId)
751
- if (state) return { messageId: preferredId, state }
769
+ if (state) {
770
+ this.resumeAssistantState(preferredId, state)
771
+ return { messageId: preferredId, state }
772
+ }
752
773
  }
753
774
 
754
775
  // Try active assistant message
755
776
  const activeId = this.getActiveAssistantMessageId()
756
777
  if (activeId) {
757
778
  const state = this.getMessageState(activeId)
758
- if (state) return { messageId: activeId, state }
779
+ if (state) {
780
+ this.resumeAssistantState(activeId, state)
781
+ return { messageId: activeId, state }
782
+ }
759
783
  }
760
784
 
761
785
  // Check if a message with preferredId already exists (reconnect/resume case).
@@ -900,9 +924,21 @@ export class StreamProcessor {
900
924
  }
901
925
 
902
926
  // Ensure state exists
903
- if (!this.messageStates.has(messageId)) {
904
- this.createMessageState(messageId, uiRole)
927
+ let pendingState = this.messageStates.get(messageId)
928
+ if (!pendingState) {
929
+ pendingState = this.createMessageState(messageId, uiRole)
905
930
  this.activeMessageIds.add(messageId)
931
+ } else if (pendingState.hasToolCallsSinceTextStart) {
932
+ // A tool call (e.g. TOOL_CALL_START with parentMessageId) marked
933
+ // this message before its "real" TEXT_MESSAGE_START arrived — same
934
+ // reset Case 2 performs, so the segment accumulator doesn't carry
935
+ // stale tool-call state into the text that follows.
936
+ if (pendingState.currentSegmentText !== pendingState.lastEmittedText) {
937
+ this.emitTextUpdateForMessage(messageId)
938
+ }
939
+ pendingState.currentSegmentText = ''
940
+ pendingState.lastEmittedText = ''
941
+ pendingState.hasToolCallsSinceTextStart = false
906
942
  }
907
943
 
908
944
  this.mergeMessageMetadata(messageId, chunk.metadata)
@@ -1647,8 +1683,14 @@ export class StreamProcessor {
1647
1683
  }
1648
1684
 
1649
1685
  if (this.activeRuns.size === 0) {
1650
- this.isDone = true
1651
1686
  this.completeAllToolCalls()
1687
+ const isIntermediateToolTurn =
1688
+ this.finishReason === 'tool_calls' &&
1689
+ chunk.outcome?.type !== 'interrupt'
1690
+ if (isIntermediateToolTurn) {
1691
+ return
1692
+ }
1693
+ this.isDone = true
1652
1694
  this.finalizeStream()
1653
1695
  }
1654
1696
  }
@@ -2344,6 +2386,7 @@ export class StreamProcessor {
2344
2386
  * @see docs/chat-architecture.md#single-shot-text-response — Finalization step
2345
2387
  */
2346
2388
  finalizeStream(): void {
2389
+ this.isDone = true
2347
2390
  let lastAssistantMessage: UIMessage | undefined
2348
2391
 
2349
2392
  // Finalize ALL active messages
@@ -2407,7 +2450,8 @@ export class StreamProcessor {
2407
2450
  }
2408
2451
 
2409
2452
  // Emit stream end for the last assistant message
2410
- if (lastAssistantMessage) {
2453
+ if (lastAssistantMessage && !this.streamEndEmitted) {
2454
+ this.streamEndEmitted = true
2411
2455
  this.events.onStreamEnd?.(lastAssistantMessage)
2412
2456
  }
2413
2457
  }
@@ -2526,6 +2570,7 @@ export class StreamProcessor {
2526
2570
  this.finishReason = null
2527
2571
  this.hasError = false
2528
2572
  this.isDone = false
2573
+ this.streamEndEmitted = false
2529
2574
  this.chunkStrategy.reset?.()
2530
2575
  }
2531
2576
 
@@ -99,6 +99,26 @@ function pruneMap(map: NullWideningMap): NullWideningMap | undefined {
99
99
  return Object.keys(map).length > 0 ? map : undefined
100
100
  }
101
101
 
102
+ function coerceArrayItems(items: JSONSchema | Array<JSONSchema>): {
103
+ schema: JSONSchema | Array<JSONSchema>
104
+ itemMap: NullWideningMap | Array<NullWideningMap> | undefined
105
+ } {
106
+ if (Array.isArray(items)) {
107
+ const nested = items.map((item) =>
108
+ makeStructuredOutputCompatible(item, item.required || []),
109
+ )
110
+ const itemMaps = nested.map((entry) => entry.nullWidening ?? {})
111
+ return {
112
+ schema: nested.map((entry) => entry.schema),
113
+ itemMap: itemMaps.some((entry) => Object.keys(entry).length > 0)
114
+ ? itemMaps
115
+ : undefined,
116
+ }
117
+ }
118
+ const nested = makeStructuredOutputCompatible(items, items.required || [])
119
+ return { schema: nested.schema, itemMap: nested.nullWidening }
120
+ }
121
+
102
122
  /**
103
123
  * Transform a JSON schema to be compatible with OpenAI's structured output requirements.
104
124
  * OpenAI requires:
@@ -146,18 +166,15 @@ function makeStructuredOutputCompatible(
146
166
  widenedHere = wasOptional
147
167
  childMap = nested.nullWidening
148
168
  } else if (prop.type === 'array' && prop.items) {
149
- const items = Array.isArray(prop.items) ? prop.items[0] : prop.items
150
- const nestedItems = items
151
- ? makeStructuredOutputCompatible(items, items.required || [])
152
- : undefined
169
+ const nestedItems = coerceArrayItems(prop.items)
153
170
  properties[propName] = {
154
171
  ...prop,
155
- items: nestedItems ? nestedItems.schema : prop.items,
172
+ items: nestedItems.schema,
156
173
  ...(wasOptional ? { type: ['array', 'null'] } : {}),
157
174
  }
158
175
  widenedHere = wasOptional
159
- childMap = nestedItems?.nullWidening
160
- ? { items: nestedItems.nullWidening }
176
+ childMap = nestedItems.itemMap
177
+ ? { items: nestedItems.itemMap }
161
178
  : undefined
162
179
  } else if (wasOptional) {
163
180
  // Make optional fields nullable by adding null to the type. Mark
@@ -188,17 +205,13 @@ function makeStructuredOutputCompatible(
188
205
  if (Object.keys(propertyMaps).length > 0) map.properties = propertyMaps
189
206
  }
190
207
 
191
- // Handle array types with object items
208
+ // Handle array item schemas. A tuple (`items: [a, b, …]`) keeps every
209
+ // position. A homogeneous schema stays a single items map so
210
+ // `undoNullWidening` applies it to every element.
192
211
  if (result.type === 'array' && result.items) {
193
- const items = Array.isArray(result.items) ? result.items[0] : result.items
194
- if (items) {
195
- const nestedItems = makeStructuredOutputCompatible(
196
- items,
197
- items.required || [],
198
- )
199
- result.items = nestedItems.schema
200
- if (nestedItems.nullWidening) map.items = nestedItems.nullWidening
201
- }
212
+ const nestedItems = coerceArrayItems(result.items)
213
+ result.items = nestedItems.schema
214
+ if (nestedItems.itemMap) map.items = nestedItems.itemMap
202
215
  }
203
216
 
204
217
  return { schema: result, nullWidening: pruneMap(map) }
package/src/index.ts CHANGED
@@ -104,6 +104,8 @@ export type {
104
104
  // MCP error classes (value exports — usable with instanceof)
105
105
  export { MCPDuplicateToolNameError } from './activities/chat/mcp/manager'
106
106
  export { DuplicateToolNameError } from './activities/chat/tools/unique-tool-names'
107
+ export { SkillLimitError } from './utilities/errors'
108
+ export type { SkillLimitErrorInit } from './utilities/errors'
107
109
 
108
110
  // Schema conversion (Standard JSON Schema compliant)
109
111
  export {
@@ -281,6 +283,9 @@ export {
281
283
  createCapability,
282
284
  defineChatMiddleware,
283
285
  createChatMiddleware,
286
+ MetadataCapability,
287
+ getMetadata,
288
+ provideMetadata,
284
289
  } from './activities/chat/middleware/index'
285
290
  export type {
286
291
  Capability,
@@ -290,6 +295,7 @@ export type {
290
295
  CapabilityProvider,
291
296
  DefinedChatMiddleware,
292
297
  AnyChatMiddleware,
298
+ MetadataStore,
293
299
  } from './activities/chat/middleware/index'
294
300
  // Locks are a distributed-mutex primitive — coordination, not chat state — and
295
301
  // live behind their own subpath: `@tanstack/ai/locks` (see ./locks.ts).