@tanstack/ai 0.46.0 → 0.47.2

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 (66) hide show
  1. package/dist/esm/activities/chat/index.d.ts +36 -11
  2. package/dist/esm/activities/chat/index.js +440 -79
  3. package/dist/esm/activities/chat/index.js.map +1 -1
  4. package/dist/esm/activities/chat/messages.d.ts +1 -0
  5. package/dist/esm/activities/chat/messages.js +12 -7
  6. package/dist/esm/activities/chat/messages.js.map +1 -1
  7. package/dist/esm/activities/chat/middleware/builder.d.ts +7 -2
  8. package/dist/esm/activities/chat/middleware/builder.js.map +1 -1
  9. package/dist/esm/activities/chat/middleware/compose.d.ts +10 -3
  10. package/dist/esm/activities/chat/middleware/compose.js +55 -0
  11. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  12. package/dist/esm/activities/chat/middleware/define.d.ts +6 -3
  13. package/dist/esm/activities/chat/middleware/define.js.map +1 -1
  14. package/dist/esm/activities/chat/middleware/generic-interrupts.d.ts +13 -0
  15. package/dist/esm/activities/chat/middleware/generic-interrupts.js +8 -0
  16. package/dist/esm/activities/chat/middleware/generic-interrupts.js.map +1 -0
  17. package/dist/esm/activities/chat/middleware/index.d.ts +4 -1
  18. package/dist/esm/activities/chat/middleware/types.d.ts +54 -3
  19. package/dist/esm/activities/chat/middleware/types.js +16 -0
  20. package/dist/esm/activities/chat/middleware/types.js.map +1 -0
  21. package/dist/esm/activities/chat/stream/processor.js +18 -5
  22. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  23. package/dist/esm/adapter-internals.d.ts +6 -0
  24. package/dist/esm/adapter-internals.js +4 -1
  25. package/dist/esm/client.d.ts +4 -0
  26. package/dist/esm/client.js +3 -1
  27. package/dist/esm/client.js.map +1 -1
  28. package/dist/esm/generic-interrupt-continuation.d.ts +45 -0
  29. package/dist/esm/generic-interrupt-continuation.js +80 -0
  30. package/dist/esm/generic-interrupt-continuation.js.map +1 -0
  31. package/dist/esm/index.d.ts +6 -1
  32. package/dist/esm/index.js +4 -1
  33. package/dist/esm/interrupt-definition.d.ts +113 -0
  34. package/dist/esm/interrupt-definition.js +169 -0
  35. package/dist/esm/interrupt-definition.js.map +1 -0
  36. package/dist/esm/interrupt-resume.d.ts +3 -0
  37. package/dist/esm/interrupt-resume.js +77 -16
  38. package/dist/esm/interrupt-resume.js.map +1 -1
  39. package/dist/esm/interrupts.d.ts +12 -3
  40. package/dist/esm/interrupts.js.map +1 -1
  41. package/dist/esm/types.d.ts +11 -3
  42. package/dist/esm/utilities/chat-params.js +10 -1
  43. package/dist/esm/utilities/chat-params.js.map +1 -1
  44. package/package.json +3 -3
  45. package/skills/ai-core/media-generation/SKILL.md +4 -1
  46. package/skills/ai-core/middleware/SKILL.md +53 -44
  47. package/skills/ai-core/structured-outputs/SKILL.md +59 -55
  48. package/skills/ai-core/tool-calling/SKILL.md +54 -1
  49. package/src/activities/chat/index.ts +1030 -211
  50. package/src/activities/chat/messages.ts +11 -3
  51. package/src/activities/chat/middleware/builder.ts +29 -4
  52. package/src/activities/chat/middleware/compose.ts +95 -5
  53. package/src/activities/chat/middleware/define.ts +13 -3
  54. package/src/activities/chat/middleware/generic-interrupts.ts +26 -0
  55. package/src/activities/chat/middleware/index.ts +15 -0
  56. package/src/activities/chat/middleware/types.ts +127 -2
  57. package/src/activities/chat/stream/processor.ts +21 -0
  58. package/src/adapter-internals.ts +20 -0
  59. package/src/client.ts +20 -0
  60. package/src/generic-interrupt-continuation.ts +162 -0
  61. package/src/index.ts +34 -0
  62. package/src/interrupt-definition.ts +581 -0
  63. package/src/interrupt-resume.ts +156 -25
  64. package/src/interrupts.ts +13 -3
  65. package/src/types.ts +11 -3
  66. package/src/utilities/chat-params.ts +16 -3
@@ -14,10 +14,21 @@ import { EventType } from '../../types'
14
14
  import {
15
15
  INTERRUPT_BINDING_METADATA_KEY,
16
16
  InterruptResumeValidationError,
17
+ readInterruptBinding,
17
18
  readUnopenedInterruptBinding,
18
19
  validateInterruptResumeBatch,
19
20
  } from '../../interrupt-resume'
20
21
  import { INTERRUPT_BINDING_VERSION } from '../../interrupts'
22
+ import {
23
+ INTERRUPT_PAYLOAD_METADATA_KEY,
24
+ createInterruptBinding,
25
+ rehydrateInterruptRequest,
26
+ } from '../../interrupt-definition'
27
+ import { readGenericInterruptContinuation } from '../../generic-interrupt-continuation'
28
+ import type {
29
+ GenericInterruptRequest,
30
+ InterruptDefinition,
31
+ } from '../../interrupt-definition'
21
32
  import {
22
33
  canonicalInterruptJson,
23
34
  digestInterruptJson,
@@ -47,6 +58,7 @@ import {
47
58
  convertMessagesToModelMessages,
48
59
  generateMessageId,
49
60
  modelMessageToUIMessage,
61
+ safeJsonStringify,
50
62
  } from './messages'
51
63
  import { MiddlewareRunner } from './middleware/compose'
52
64
  import { getRunDetached } from './middleware/run-store'
@@ -89,6 +101,7 @@ import type {
89
101
  SchemaInput,
90
102
  StreamChunk,
91
103
  StructuredOutputCompleteEvent,
104
+ StructuredOutputPart,
92
105
  StructuredOutputStream,
93
106
  TextMessageContentEvent,
94
107
  TextOptions,
@@ -104,10 +117,13 @@ import type {
104
117
  ChatMiddleware,
105
118
  ChatMiddlewareConfig,
106
119
  ChatMiddlewareContext,
120
+ ChatResumeGenericResolution,
107
121
  ChatResumeToolState,
122
+ InterruptResolutionCollection,
108
123
  SandboxFileHookEvent,
109
124
  StructuredOutputMiddlewareConfig,
110
125
  } from './middleware/types'
126
+ import { provideGenericInterruptDefinitionRegistry } from './middleware/generic-interrupts'
111
127
  import type { CheckCoverage } from './middleware/builder'
112
128
  import type { SystemPrompt } from '../../system-prompts'
113
129
  import type { InternalLogger } from '../../logger/internal-logger'
@@ -204,74 +220,11 @@ function normalizePublicInterruptBinding(
204
220
  value: unknown,
205
221
  expectedInterruptId: string,
206
222
  ): InterruptBinding | undefined {
207
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
208
- return undefined
209
- }
210
- const binding: Record<string, unknown> = Object.fromEntries(
211
- Object.entries(value),
212
- )
213
- if (
214
- binding.interruptId !== expectedInterruptId ||
215
- // A binding version we don't recognise belongs to another producer. Drop
216
- // it rather than reading our fields out of it.
217
- (binding.v !== undefined && binding.v !== INTERRUPT_BINDING_VERSION) ||
218
- typeof binding.interruptedRunId !== 'string' ||
219
- typeof binding.generation !== 'number' ||
220
- !Number.isInteger(binding.generation) ||
221
- binding.generation < 0 ||
222
- typeof binding.responseSchemaHash !== 'string' ||
223
- (binding.expiresAt !== undefined && typeof binding.expiresAt !== 'string')
224
- ) {
225
- return undefined
226
- }
227
- const base = {
228
- v: INTERRUPT_BINDING_VERSION,
229
- interruptId: binding.interruptId,
230
- interruptedRunId: binding.interruptedRunId,
231
- generation: binding.generation,
232
- responseSchemaHash: binding.responseSchemaHash,
233
- ...(typeof binding.expiresAt === 'string'
234
- ? { expiresAt: binding.expiresAt }
235
- : {}),
236
- }
237
- if (binding.kind === 'generic') {
238
- return { kind: binding.kind, ...base }
239
- }
240
- if (
241
- typeof binding.toolName !== 'string' ||
242
- typeof binding.toolCallId !== 'string'
243
- ) {
244
- return undefined
245
- }
246
- if (
247
- binding.kind === 'client-tool-execution' &&
248
- typeof binding.outputSchemaHash === 'string'
249
- ) {
250
- return {
251
- kind: binding.kind,
252
- ...base,
253
- toolName: binding.toolName,
254
- toolCallId: binding.toolCallId,
255
- outputSchemaHash: binding.outputSchemaHash,
256
- }
257
- }
258
- if (
259
- binding.kind === 'tool-approval' &&
260
- Object.prototype.hasOwnProperty.call(binding, 'originalArgs') &&
261
- typeof binding.inputSchemaHash === 'string' &&
262
- typeof binding.approvalSchemaHash === 'string'
263
- ) {
264
- return {
265
- kind: binding.kind,
266
- ...base,
267
- toolName: binding.toolName,
268
- toolCallId: binding.toolCallId,
269
- originalArgs: binding.originalArgs,
270
- inputSchemaHash: binding.inputSchemaHash,
271
- approvalSchemaHash: binding.approvalSchemaHash,
272
- }
273
- }
274
- return undefined
223
+ return readInterruptBinding({
224
+ id: expectedInterruptId,
225
+ reason: '',
226
+ metadata: { [INTERRUPT_BINDING_METADATA_KEY]: value },
227
+ })
275
228
  }
276
229
 
277
230
  // The leaf context-inference primitives (KnownContext, MergeContext,
@@ -310,33 +263,133 @@ type InferredContext<TTools, TMiddleware> = [
310
263
  ? unknown
311
264
  : ContextFromInputs<TTools, TMiddleware>
312
265
 
313
- type RequiredContextFromInputs<TTools, TMiddleware> = [
314
- ContextFromInputs<TTools, TMiddleware>,
266
+ type RegistryInterrupt<
267
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
268
+ > = [TInterrupts[number]] extends [never] ? never : TInterrupts[number]
269
+
270
+ type DuplicateInterruptDefinitionId<
271
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
272
+ TSeenIds extends string = never,
273
+ > = TInterrupts extends readonly [infer THead, ...infer TTail]
274
+ ? THead extends InterruptDefinition<infer TId, any, any, any>
275
+ ? string extends TId
276
+ ? TTail extends ReadonlyArray<InterruptDefinition<any, any, any, any>>
277
+ ? DuplicateInterruptDefinitionId<TTail, TSeenIds>
278
+ : never
279
+ : TId extends TSeenIds
280
+ ? TId
281
+ : TTail extends ReadonlyArray<InterruptDefinition<any, any, any, any>>
282
+ ? DuplicateInterruptDefinitionId<TTail, TSeenIds | TId>
283
+ : never
284
+ : never
285
+ : never
286
+
287
+ type CheckUniqueInterruptDefinitions<
288
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
289
+ > = [DuplicateInterruptDefinitionId<TInterrupts>] extends [never]
290
+ ? unknown
291
+ : {
292
+ readonly '✖ Duplicate interrupt definition id in chat({ interrupts }).': never
293
+ }
294
+
295
+ type InlineChatContext<TTools, TContext> = MergeContext<
296
+ ContextFromArray<NonNullable<TTools>>,
297
+ TContext
298
+ >
299
+
300
+ type RegistryChatMiddleware<
301
+ TContext,
302
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
303
+ > = ChatMiddleware<TContext, RegistryInterrupt<TInterrupts>>
304
+
305
+ type MiddlewareInterruptDefinitions<TMiddleware> =
306
+ TMiddleware extends ReadonlyArray<infer TMiddlewareItem>
307
+ ? TMiddlewareItem extends ChatMiddleware<any, infer TDefinitions>
308
+ ? TDefinitions
309
+ : never
310
+ : never
311
+
312
+ type IsAny<TValue> = 0 extends 1 & TValue ? true : false
313
+
314
+ type CheckInterruptRegistry<
315
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
316
+ TMiddleware,
317
+ > =
318
+ IsAny<MiddlewareInterruptDefinitions<TMiddleware>> extends true
319
+ ? unknown
320
+ : [MiddlewareInterruptDefinitions<TMiddleware>] extends [never]
321
+ ? unknown
322
+ : [
323
+ Exclude<
324
+ MiddlewareInterruptDefinitions<TMiddleware>,
325
+ RegistryInterrupt<TInterrupts>
326
+ >,
327
+ ] extends [never]
328
+ ? unknown
329
+ : {
330
+ readonly '✖ Middleware emits an interrupt definition that is not registered in chat({ interrupts }).': never
331
+ }
332
+
333
+ type RuntimeContextOption<TTools, TMiddleware, TContext> = [
334
+ MergeContext<ContextFromInputs<TTools, TMiddleware>, TContext>,
315
335
  ] extends [never]
316
- ? { context?: unknown }
317
- : undefined extends ContextFromInputs<TTools, TMiddleware>
318
- ? { context?: ContextFromInputs<TTools, TMiddleware> }
319
- : { context: ContextFromInputs<TTools, TMiddleware> }
336
+ ? { context?: TContext }
337
+ : undefined extends MergeContext<
338
+ ContextFromInputs<TTools, TMiddleware>,
339
+ TContext
340
+ >
341
+ ? {
342
+ context?: MergeContext<ContextFromInputs<TTools, TMiddleware>, TContext>
343
+ }
344
+ : {
345
+ context: MergeContext<ContextFromInputs<TTools, TMiddleware>, TContext>
346
+ }
347
+
348
+ type ExactMiddlewareOption<
349
+ TTools,
350
+ TContext,
351
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
352
+ TMiddleware extends Array<unknown> | undefined,
353
+ > = [TMiddleware] extends [undefined]
354
+ ? Array<
355
+ RegistryChatMiddleware<
356
+ InlineChatContext<TTools, TContext>,
357
+ NoInfer<TInterrupts>
358
+ >
359
+ >
360
+ : TMiddleware &
361
+ (TMiddleware extends Array<
362
+ RegistryChatMiddleware<
363
+ InlineChatContext<TTools, NoInfer<TContext>>,
364
+ NoInfer<TInterrupts>
365
+ >
366
+ >
367
+ ? Array<
368
+ RegistryChatMiddleware<
369
+ InlineChatContext<TTools, TContext>,
370
+ NoInfer<TInterrupts>
371
+ >
372
+ >
373
+ : CheckInterruptRegistry<TInterrupts, TMiddleware>) &
374
+ CheckCoverage<Extract<TMiddleware, ReadonlyArray<AnyChatMiddleware>>>
320
375
 
321
376
  type TextActivityOptionsWithContext<
322
377
  TAdapter extends AnyTextAdapter,
323
378
  TSchema extends SchemaInput | undefined,
324
379
  TStream extends boolean,
325
380
  TTools extends TextActivityOptions<TAdapter, TSchema, TStream, any>['tools'],
326
- TMiddleware extends TextActivityOptions<
327
- TAdapter,
328
- TSchema,
329
- TStream,
330
- any
331
- >['middleware'],
381
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
382
+ [],
383
+ TContext = unknown,
384
+ TMiddleware extends Array<unknown> | undefined = undefined,
332
385
  > = Omit<
333
386
  TextActivityOptions<TAdapter, TSchema, TStream, any>,
334
- 'tools' | 'middleware' | 'context'
387
+ 'tools' | 'middleware' | 'context' | 'interrupts'
335
388
  > & {
336
389
  tools?: TTools
337
- middleware?: TMiddleware &
338
- CheckCoverage<Extract<TMiddleware, ReadonlyArray<AnyChatMiddleware>>>
339
- } & RequiredContextFromInputs<TTools, TMiddleware>
390
+ interrupts?: TInterrupts & CheckUniqueInterruptDefinitions<TInterrupts>
391
+ middleware?: ExactMiddlewareOption<TTools, TContext, TInterrupts, TMiddleware>
392
+ } & RuntimeContextOption<TTools, TMiddleware, TContext>
340
393
 
341
394
  // ===========================
342
395
  // Activity Options Type
@@ -494,6 +547,11 @@ export interface TextActivityOptions<
494
547
  * ```
495
548
  */
496
549
  middleware?: Array<ChatMiddleware<TContext>>
550
+ /**
551
+ * First-party generic interrupt definitions for this chat call.
552
+ * Register the same definitions on the client to type payloads and answers.
553
+ */
554
+ interrupts?: ReadonlyArray<InterruptDefinition<any, any, any, any>>
497
555
  /**
498
556
  * Runtime context value passed to middleware hooks and server tools.
499
557
  */
@@ -534,28 +592,21 @@ export function createChatOptions<
534
592
  TStream,
535
593
  any
536
594
  >['tools'] = TextActivityOptions<TAdapter, TSchema, TStream, any>['tools'],
537
- const TMiddleware extends TextActivityOptions<
538
- TAdapter,
539
- TSchema,
540
- TStream,
541
- any
542
- >['middleware'] = TextActivityOptions<
543
- TAdapter,
544
- TSchema,
545
- TStream,
546
- any
547
- >['middleware'],
595
+ const TInterrupts extends ReadonlyArray<
596
+ InterruptDefinition<any, any, any, any>
597
+ > = [],
598
+ TContext = unknown,
599
+ const TMiddleware extends Array<unknown> | undefined = undefined,
548
600
  >(
549
601
  options: TextActivityOptionsWithContext<
550
602
  TAdapter,
551
603
  TSchema,
552
604
  TStream,
553
605
  TTools,
606
+ TInterrupts,
607
+ TContext,
554
608
  TMiddleware
555
609
  >,
556
- // Preserve the concrete `tools` tuple on the returned options (so a later
557
- // `chat({ ...opts })` still narrows tool-call events to the tool names)
558
- // while threading the inferred runtime context like the bare options type.
559
610
  ): Omit<
560
611
  TextActivityOptions<
561
612
  TAdapter,
@@ -563,8 +614,12 @@ export function createChatOptions<
563
614
  TStream,
564
615
  InferredContext<TTools, TMiddleware>
565
616
  >,
566
- 'tools'
567
- > & { tools?: TTools } {
617
+ 'tools' | 'middleware' | 'interrupts'
618
+ > & {
619
+ tools?: TTools
620
+ interrupts?: TInterrupts
621
+ middleware?: ExactMiddlewareOption<TTools, TContext, TInterrupts, TMiddleware>
622
+ } {
568
623
  return options
569
624
  }
570
625
 
@@ -628,7 +683,7 @@ interface TextEngineConfig<
628
683
  adapter: TAdapter
629
684
  systemPrompts?: Array<SystemPrompt>
630
685
  params: TParams
631
- middleware?: Array<ChatMiddleware<TContext>>
686
+ middleware?: Array<AnyChatMiddleware>
632
687
  context?: TContext
633
688
  /**
634
689
  * If set, after the agent loop finishes the engine runs a
@@ -712,12 +767,18 @@ class TextEngine<
712
767
  >,
713
768
  > {
714
769
  private readonly adapter: TAdapter
770
+ private readonly interruptDefinitions: ReadonlyMap<
771
+ string,
772
+ InterruptDefinition<any, any, any, any>
773
+ >
715
774
  private params: TParams
716
775
  private systemPrompts: Array<SystemPrompt>
717
776
  private tools: Array<AnyRuntimeTool>
718
777
  private readonly loopStrategy: AgentLoopStrategy
719
778
  private toolCallManager: ToolCallManager<ReadonlyArray<AnyTool>, TContext>
720
779
  private readonly lazyToolManager: LazyToolManager
780
+ /** A public interruption terminal must always have this run's start event. */
781
+ private hasPublicRunStarted = false
721
782
  private readonly initialMessageCount: number
722
783
  private readonly requestId: string
723
784
  private readonly streamId: string
@@ -749,6 +810,9 @@ class TextEngine<
749
810
  private finishedEvent: RunFinishedEvent | null = null
750
811
  private readonly streamedToolErrorResults = new Map<string, ToolResult>()
751
812
  private deferredToolCallRunFinishedChunks: Array<StreamChunk> = []
813
+ /** The model terminal is held until afterModel can choose an interrupt. */
814
+ private deferredModelRunFinishedChunks: Array<StreamChunk> = []
815
+
752
816
  private earlyTermination = false
753
817
  private toolPhase: ToolPhaseResult = 'continue'
754
818
  private cyclePhase: CyclePhase = 'processText'
@@ -759,6 +823,14 @@ class TextEngine<
759
823
  private readonly resumeClientToolResults = new Map<string, any>()
760
824
  private readonly resumeDeniedToolResults = new Map<string, unknown>()
761
825
  private readonly resumeCancelledToolCallIds = new Set<string>()
826
+ private readonly resumeGenericInterrupts = new Map<
827
+ string,
828
+ ChatResumeGenericResolution
829
+ >()
830
+ private readonly resumeGenericInterruptRequests = new Map<
831
+ string,
832
+ GenericInterruptRequest<InterruptDefinition<any, any, any, any>>
833
+ >()
762
834
 
763
835
  // AG-UI protocol IDs
764
836
  private readonly threadId: string
@@ -766,7 +838,10 @@ class TextEngine<
766
838
  private readonly parentRunIdOverride?: string
767
839
 
768
840
  // Middleware support
769
- private readonly middlewareRunner: MiddlewareRunner<TContext>
841
+ private readonly middlewareRunner: MiddlewareRunner<
842
+ TContext,
843
+ InterruptDefinition<any, any, any, any>
844
+ >
770
845
  private readonly middlewareCtx: ChatMiddlewareContext<TContext>
771
846
  private readonly sandboxFileQueue: Array<StreamChunk> = []
772
847
  private readonly deferredPromises: Array<Promise<unknown>> = []
@@ -788,8 +863,13 @@ class TextEngine<
788
863
  private readonly logger: InternalLogger
789
864
 
790
865
  // Structured-output finalization state (populated by runStructuredFinalization)
791
- private structuredOutputResult: { data: unknown; rawText: string } | null =
792
- null
866
+ private structuredOutputResult: {
867
+ data: unknown
868
+ rawText: string
869
+ reasoning?: string
870
+ } | null = null
871
+ private structuredOutputMessageId: string | null = null
872
+ private structuredOutputMessageCreatedAt: Date | null = null
793
873
  // Native combined mode: tracks whether we've already emitted the synthetic
794
874
  // `structured-output.start` event before the schema-constrained final-turn
795
875
  // text begins streaming. The event must precede the first
@@ -826,6 +906,15 @@ class TextEngine<
826
906
  ) {
827
907
  this.logger = logger
828
908
  this.adapter = config.adapter
909
+ this.interruptDefinitions = new Map(
910
+ (
911
+ (
912
+ config.params as TParams & {
913
+ interrupts?: ReadonlyArray<InterruptDefinition<any, any, any, any>>
914
+ }
915
+ ).interrupts ?? []
916
+ ).map((definition) => [definition.id, definition]),
917
+ )
829
918
  this.finalStructuredOutput = config.finalStructuredOutput
830
919
  this.params = config.params
831
920
  this.systemPrompts = config.params.systemPrompts || []
@@ -878,7 +967,9 @@ class TextEngine<
878
967
  // handleStreamChunk processes raw chunks BEFORE middleware, so internal
879
968
  // state management sees extended fields (finishReason, delta, toolCallName, etc.).
880
969
  // The strip middleware ensures the yielded public stream is AG-UI spec-compliant.
881
- const allMiddleware: Array<ChatMiddleware<TContext>> = [
970
+ const allMiddleware: Array<
971
+ ChatMiddleware<TContext, InterruptDefinition<any, any, any, any>>
972
+ > = [
882
973
  devtoolsMiddleware(),
883
974
  ...(config.middleware || []),
884
975
  stripToSpecMiddleware(),
@@ -957,6 +1048,10 @@ class TextEngine<
957
1048
  },
958
1049
  })
959
1050
 
1051
+ provideGenericInterruptDefinitionRegistry(this.middlewareCtx, {
1052
+ definitions: this.interruptDefinitions,
1053
+ })
1054
+
960
1055
  // Provide the internal SandboxRuntime capability so harness adapters and
961
1056
  // sandbox middleware can emit file events. The sink logs, fans the event
962
1057
  // out through the middleware `onFile*` hooks (fire-and-forget), and queues
@@ -1048,10 +1143,25 @@ class TextEngine<
1048
1143
  )
1049
1144
  this.applyMiddlewareConfig(transformedConfig)
1050
1145
  await this.applyEphemeralInterruptResume(transformedConfig)
1146
+ await this.applyDurableGenericInterruptResolution()
1051
1147
 
1052
1148
  // Run onStart (devtools middleware emits text:request:started and initial messages here)
1053
1149
  await this.middlewareRunner.runOnStart(this.middlewareCtx)
1054
1150
 
1151
+ if (this.earlyTermination) {
1152
+ yield* this.emitSuccessfulEarlyTermination()
1153
+ if (!this.terminalHookCalled) {
1154
+ this.terminalHookCalled = true
1155
+ await this.middlewareRunner.runOnFinish(this.middlewareCtx, {
1156
+ finishReason: this.lastFinishReason,
1157
+ duration: Date.now() - this.streamStartTime,
1158
+ content: this.accumulatedContent,
1159
+ usage: this.finishedEvent?.usage,
1160
+ })
1161
+ }
1162
+ return
1163
+ }
1164
+
1055
1165
  const pendingPhase = yield* this.checkForPendingToolCalls()
1056
1166
  if (pendingPhase === 'wait') {
1057
1167
  return
@@ -1073,9 +1183,8 @@ class TextEngine<
1073
1183
 
1074
1184
  if (!skipAgentLoop) {
1075
1185
  do {
1076
- if (this.earlyTermination || this.isCancelled()) {
1077
- return
1078
- }
1186
+ if (this.earlyTermination) break
1187
+ if (this.isCancelled()) return
1079
1188
 
1080
1189
  this.logger.agentLoop(`iteration=${this.middlewareCtx.iteration}`, {
1081
1190
  iteration: this.middlewareCtx.iteration,
@@ -1095,7 +1204,37 @@ class TextEngine<
1095
1204
  )
1096
1205
  this.applyMiddlewareConfig(iterTransformedConfig)
1097
1206
 
1207
+ if (
1208
+ yield* this.emitBoundaryInterrupts(
1209
+ 'beforeModel',
1210
+ this.createSyntheticFinishedEvent(),
1211
+ )
1212
+ ) {
1213
+ this.setToolPhase('wait')
1214
+ return
1215
+ }
1216
+
1098
1217
  yield* this.streamModelResponse()
1218
+
1219
+ if (this.earlyTermination) break
1220
+
1221
+ if (
1222
+ yield* this.emitBoundaryInterrupts(
1223
+ 'afterModel',
1224
+ this.finishedEvent ?? this.createSyntheticFinishedEvent(),
1225
+ )
1226
+ ) {
1227
+ this.setToolPhase('wait')
1228
+ return
1229
+ }
1230
+ if (this.shouldExecuteToolPhase()) {
1231
+ this.deferredToolCallRunFinishedChunks.push(
1232
+ ...this.deferredModelRunFinishedChunks,
1233
+ )
1234
+ this.deferredModelRunFinishedChunks = []
1235
+ } else {
1236
+ yield* this.flushDeferredModelRunFinishedChunks()
1237
+ }
1099
1238
  } else {
1100
1239
  yield* this.processToolCalls()
1101
1240
  }
@@ -1159,7 +1298,7 @@ class TextEngine<
1159
1298
  duration: Date.now() - this.streamStartTime,
1160
1299
  })
1161
1300
  } else {
1162
- this.addTerminalReasoningMessage()
1301
+ this.addTerminalAssistantMessages()
1163
1302
  this.terminalHookCalled = true
1164
1303
  await this.middlewareRunner.runOnFinish(this.middlewareCtx, {
1165
1304
  finishReason: this.lastFinishReason,
@@ -1403,6 +1542,7 @@ class TextEngine<
1403
1542
  typeof startValue.messageId === 'string'
1404
1543
  ) {
1405
1544
  this.combinedStructuredMessageId = startValue.messageId
1545
+ this.captureStructuredOutputMessageIdentity(startValue.messageId)
1406
1546
  }
1407
1547
  }
1408
1548
 
@@ -1420,6 +1560,11 @@ class TextEngine<
1420
1560
  this.structuredOutputResult = { data: object, rawText: parsed.raw }
1421
1561
  this.combinedCompleteEmitted = true
1422
1562
  const value = chunk.value
1563
+ const completeMessageId = readCustomEventMessageId(value)
1564
+ if (completeMessageId) {
1565
+ this.combinedStructuredMessageId = completeMessageId
1566
+ this.captureStructuredOutputMessageIdentity(completeMessageId)
1567
+ }
1423
1568
  if (object !== parsed.object && value && typeof value === 'object') {
1424
1569
  outboundChunk = { ...chunk, value: { ...value, object } }
1425
1570
  }
@@ -1482,10 +1627,17 @@ class TextEngine<
1482
1627
  ) {
1483
1628
  continue
1484
1629
  }
1630
+ if (outputChunk.type === EventType.RUN_FINISHED) {
1631
+ this.deferredModelRunFinishedChunks.push(outputChunk)
1632
+ continue
1633
+ }
1485
1634
  if (this.shouldDeferToolCallRunFinished(outputChunk)) {
1486
1635
  this.deferredToolCallRunFinishedChunks.push(outputChunk)
1487
1636
  continue
1488
1637
  }
1638
+ if (outputChunk.type === EventType.RUN_STARTED) {
1639
+ this.hasPublicRunStarted = true
1640
+ }
1489
1641
  this.logger.output(`type=${outputChunk.type}`, { chunk: outputChunk })
1490
1642
  yield outputChunk
1491
1643
  this.middlewareCtx.chunkIndex++
@@ -1586,6 +1738,11 @@ class TextEngine<
1586
1738
  }
1587
1739
  }
1588
1740
 
1741
+ private captureStructuredOutputMessageIdentity(messageId: string): void {
1742
+ this.structuredOutputMessageId = messageId
1743
+ this.structuredOutputMessageCreatedAt ??= new Date()
1744
+ }
1745
+
1589
1746
  private handleToolCallStartEvent(chunk: ToolCallStartEvent): void {
1590
1747
  if (
1591
1748
  typeof chunk.parentMessageId === 'string' &&
@@ -1627,11 +1784,8 @@ class TextEngine<
1627
1784
  chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
1628
1785
  ): void {
1629
1786
  this.earlyTermination = true
1630
- if (this.finalStructuredOutput && this.finalizationError === null) {
1631
- const message =
1632
- chunk.message ||
1633
- chunk.error?.message ||
1634
- 'Run failed before structured output completed'
1787
+ if (this.finalizationError === null) {
1788
+ const message = chunk.message || chunk.error?.message || 'Run failed'
1635
1789
  this.finalizationError = {
1636
1790
  message,
1637
1791
  ...(chunk.code !== undefined
@@ -1763,6 +1917,18 @@ class TextEngine<
1763
1917
  return 'continue'
1764
1918
  }
1765
1919
 
1920
+ this.middlewareCtx.phase = 'beforeTools'
1921
+ if (
1922
+ yield* this.emitBoundaryInterrupts(
1923
+ 'beforeTools',
1924
+ finishEvent,
1925
+ executablePendingCalls,
1926
+ )
1927
+ ) {
1928
+ this.setToolPhase('wait')
1929
+ return 'wait'
1930
+ }
1931
+
1766
1932
  const { approvals, clientToolResults } = this.collectClientState()
1767
1933
 
1768
1934
  const generator = executeToolCalls(
@@ -1930,6 +2096,17 @@ class TextEngine<
1930
2096
  }
1931
2097
  this.middlewareCtx.phase = 'beforeTools'
1932
2098
 
2099
+ if (
2100
+ yield* this.emitBoundaryInterrupts(
2101
+ 'beforeTools',
2102
+ finishEvent,
2103
+ executableToolCalls,
2104
+ )
2105
+ ) {
2106
+ this.setToolPhase('wait')
2107
+ return
2108
+ }
2109
+
1933
2110
  const { approvals, clientToolResults } = this.collectClientState()
1934
2111
 
1935
2112
  const generator = executeToolCalls(
@@ -1997,15 +2174,36 @@ class TextEngine<
1997
2174
  needsClientExecution: executionResult.needsClientExecution,
1998
2175
  })
1999
2176
 
2177
+ const afterToolBoundaryChunks = this.buildToolResultChunks(
2178
+ allResults,
2179
+ finishEvent,
2180
+ )
2181
+ const afterToolRequests =
2182
+ await this.middlewareRunner.runOnInterruptBoundary(
2183
+ this.middlewareCtx as ChatMiddlewareContext<TContext> & {
2184
+ phase: 'afterTools'
2185
+ },
2186
+ )
2187
+ if (afterToolRequests.length > 0) {
2188
+ for (const chunk of afterToolBoundaryChunks) {
2189
+ yield* this.pipeThroughMiddleware(chunk)
2190
+ }
2191
+ yield* this.emitBoundaryInterrupts(
2192
+ 'afterTools',
2193
+ finishEvent,
2194
+ toolCalls,
2195
+ afterToolRequests,
2196
+ )
2197
+ this.setToolPhase('wait')
2198
+ return
2199
+ }
2200
+
2000
2201
  if (
2001
2202
  executionResult.needsApproval.length > 0 ||
2002
2203
  executionResult.needsClientExecution.length > 0
2003
2204
  ) {
2004
2205
  if (allResults.length > 0) {
2005
- for (const chunk of this.buildToolResultChunks(
2006
- allResults,
2007
- finishEvent,
2008
- )) {
2206
+ for (const chunk of afterToolBoundaryChunks) {
2009
2207
  yield* this.pipeThroughMiddleware(chunk)
2010
2208
  }
2011
2209
  }
@@ -2021,7 +2219,7 @@ class TextEngine<
2021
2219
 
2022
2220
  yield* this.flushDeferredToolCallRunFinishedChunks()
2023
2221
 
2024
- const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent)
2222
+ const toolResultChunks = afterToolBoundaryChunks
2025
2223
 
2026
2224
  for (const chunk of toolResultChunks) {
2027
2225
  yield* this.pipeThroughMiddleware(chunk)
@@ -2061,6 +2259,48 @@ class TextEngine<
2061
2259
  this.deferredToolCallRunFinishedChunks = []
2062
2260
  }
2063
2261
 
2262
+ private *flushDeferredModelRunFinishedChunks(): Generator<StreamChunk> {
2263
+ for (const chunk of this.deferredModelRunFinishedChunks) {
2264
+ this.logger.output(`type=${chunk.type}`, { chunk })
2265
+ yield chunk
2266
+ this.middlewareCtx.chunkIndex++
2267
+ }
2268
+ this.deferredModelRunFinishedChunks = []
2269
+ }
2270
+
2271
+ private async *emitSyntheticRunStarted(
2272
+ finishEvent: RunFinishedEvent,
2273
+ ): AsyncGenerator<StreamChunk, void, void> {
2274
+ if (this.hasPublicRunStarted) return
2275
+ yield* this.pipeThroughMiddleware({
2276
+ type: EventType.RUN_STARTED,
2277
+ runId: finishEvent.runId,
2278
+ threadId: finishEvent.threadId,
2279
+ model: finishEvent.model,
2280
+ timestamp: Date.now(),
2281
+ })
2282
+ }
2283
+
2284
+ private async *emitSuccessfulEarlyTermination(): AsyncGenerator<
2285
+ StreamChunk,
2286
+ void,
2287
+ void
2288
+ > {
2289
+ // `stop` is a finished run, not another tool cycle. `tool_calls` here
2290
+ // makes the client auto-send after afterTools, so reject looks stuck.
2291
+ this.lastFinishReason = 'stop'
2292
+ const finishEvent = {
2293
+ ...this.createSyntheticFinishedEvent(),
2294
+ finishReason: 'stop' as const,
2295
+ }
2296
+ yield* this.emitSyntheticRunStarted(finishEvent)
2297
+ yield* this.pipeThroughMiddleware({
2298
+ ...finishEvent,
2299
+ timestamp: Date.now(),
2300
+ outcome: { type: 'success' },
2301
+ })
2302
+ }
2303
+
2064
2304
  private discardDeferredToolCallRunFinishedChunks(): void {
2065
2305
  this.deferredToolCallRunFinishedChunks = []
2066
2306
  }
@@ -2092,27 +2332,104 @@ class TextEngine<
2092
2332
  this.middlewareCtx.messages = this.messages
2093
2333
  }
2094
2334
 
2095
- private addTerminalReasoningMessage(): void {
2335
+ private addTerminalAssistantMessages(): void {
2096
2336
  this.finalizeCurrentThinkingStep()
2097
- if (this.accumulatedThinking.length === 0) return
2098
2337
 
2099
- const messages = this.middlewareCtx.messages
2100
- const alreadyPresent = messages.some(
2338
+ const structuredResult = this.structuredOutputResult
2339
+ const raw = structuredResult
2340
+ ? structuredResult.rawText || safeJsonStringify(structuredResult.data)
2341
+ : ''
2342
+ const structuredOutput: StructuredOutputPart | undefined = structuredResult
2343
+ ? {
2344
+ type: 'structured-output',
2345
+ status: 'complete',
2346
+ data: structuredResult.data,
2347
+ partial: structuredResult.data,
2348
+ raw,
2349
+ ...(structuredResult.reasoning !== undefined
2350
+ ? { reasoning: structuredResult.reasoning }
2351
+ : {}),
2352
+ }
2353
+ : undefined
2354
+ const nativeCombined = this.finalStructuredOutput?.nativeCombined === true
2355
+ const eventSourced = this.finalStructuredOutput?.source === 'event'
2356
+ const structuredId =
2357
+ this.structuredOutputMessageId ??
2358
+ this.combinedStructuredMessageId ??
2359
+ this.currentMessageId ??
2360
+ this.createId('msg')
2361
+ // Codex, OpenCode, ACP, and grok-build reuse the last text messageId
2362
+ // on structured-output.complete. Split only when the event uses a
2363
+ // different id (Claude Code). Same-id output stays on one message.
2364
+ const splitStructuredMessage =
2365
+ Boolean(structuredOutput) &&
2366
+ (!nativeCombined || eventSourced) &&
2367
+ this.currentMessageId != null &&
2368
+ structuredId !== this.currentMessageId
2369
+ const messages = [...this.middlewareCtx.messages]
2370
+ const existingStructuredIndex = messages.findIndex(
2371
+ (message) => message.role === 'assistant' && message.id === structuredId,
2372
+ )
2373
+ const currentTurnAlreadyRecorded = messages.some(
2101
2374
  (message) =>
2102
2375
  message.role === 'assistant' && message.id === this.currentMessageId,
2103
2376
  )
2104
- if (alreadyPresent) return
2377
+ const thinking =
2378
+ this.accumulatedThinking.length > 0 ? this.accumulatedThinking : undefined
2379
+ const startedLength = messages.length
2380
+
2381
+ if (structuredOutput && existingStructuredIndex >= 0) {
2382
+ const existing = messages[existingStructuredIndex]
2383
+ if (existing) {
2384
+ messages[existingStructuredIndex] = {
2385
+ ...existing,
2386
+ content: raw || existing.content,
2387
+ structuredOutput,
2388
+ }
2389
+ }
2390
+ } else if (structuredOutput && !splitStructuredMessage) {
2391
+ if (!currentTurnAlreadyRecorded) {
2392
+ messages.push({
2393
+ role: 'assistant',
2394
+ content: this.accumulatedContent || raw || null,
2395
+ id: structuredId,
2396
+ createdAt:
2397
+ this.currentMessageCreatedAt ??
2398
+ this.structuredOutputMessageCreatedAt ??
2399
+ new Date(),
2400
+ structuredOutput,
2401
+ ...(thinking ? { thinking } : {}),
2402
+ })
2403
+ }
2404
+ } else {
2405
+ if (
2406
+ !currentTurnAlreadyRecorded &&
2407
+ (this.accumulatedContent !== '' || thinking)
2408
+ ) {
2409
+ messages.push({
2410
+ role: 'assistant',
2411
+ content: this.accumulatedContent || null,
2412
+ id: this.currentMessageId ?? this.createId('msg'),
2413
+ createdAt: this.currentMessageCreatedAt ?? new Date(),
2414
+ ...(thinking ? { thinking } : {}),
2415
+ })
2416
+ }
2417
+ if (structuredOutput) {
2418
+ messages.push({
2419
+ role: 'assistant',
2420
+ content: raw || null,
2421
+ id: structuredId,
2422
+ createdAt: this.structuredOutputMessageCreatedAt ?? new Date(),
2423
+ structuredOutput,
2424
+ })
2425
+ }
2426
+ }
2105
2427
 
2106
- this.messages = [
2107
- ...messages,
2108
- {
2109
- role: 'assistant',
2110
- content: this.accumulatedContent || null,
2111
- id: this.currentMessageId ?? undefined,
2112
- createdAt: this.currentMessageCreatedAt ?? undefined,
2113
- thinking: this.accumulatedThinking,
2114
- },
2115
- ]
2428
+ if (messages.length === startedLength && existingStructuredIndex < 0) {
2429
+ return
2430
+ }
2431
+
2432
+ this.messages = messages
2116
2433
  this.middlewareCtx.messages = this.messages
2117
2434
  }
2118
2435
 
@@ -2206,9 +2523,17 @@ class TextEngine<
2206
2523
  return { approvals, clientToolResults }
2207
2524
  }
2208
2525
 
2526
+ private genericInterruptId(): string {
2527
+ return this.createId('interrupt')
2528
+ }
2529
+
2209
2530
  private buildActionableInterrupts(
2210
2531
  approvals: Array<ApprovalRequest>,
2211
2532
  clientRequests: Array<ClientToolRequest>,
2533
+ genericRequests: ReadonlyArray<
2534
+ GenericInterruptRequest<InterruptDefinition<any, any, any, any>>
2535
+ > = [],
2536
+ genericInterruptIds: ReadonlyArray<string> = [],
2212
2537
  ): Array<Interrupt> {
2213
2538
  const interrupts: Array<Interrupt> = []
2214
2539
 
@@ -2278,6 +2603,64 @@ class TextEngine<
2278
2603
  })
2279
2604
  }
2280
2605
 
2606
+ for (const [index, request] of genericRequests.entries()) {
2607
+ const batchIndex = interrupts.length
2608
+ const id = genericInterruptIds[index]
2609
+ if (!id) throw new Error('Generic interrupt id is unavailable.')
2610
+ const preEmission = createInterruptBinding(request, { batchIndex })
2611
+ interrupts.push({
2612
+ id,
2613
+ reason: request.reason,
2614
+ message: request.message,
2615
+ ...(preEmission.descriptor.responseSchemaCanonicalJson !== undefined
2616
+ ? {
2617
+ responseSchema: JSON.parse(
2618
+ preEmission.descriptor.responseSchemaCanonicalJson,
2619
+ ),
2620
+ }
2621
+ : {}),
2622
+ ...(request.expiresAt !== undefined
2623
+ ? { expiresAt: request.expiresAt }
2624
+ : {}),
2625
+ metadata: {
2626
+ [interruptBindingMetadataKey]: {
2627
+ v: INTERRUPT_BINDING_VERSION,
2628
+ kind: 'generic',
2629
+ interruptId: id,
2630
+ definitionId: preEmission.descriptor.definitionId,
2631
+ key: preEmission.descriptor.key,
2632
+ batchIndex,
2633
+ ...(request.expiresAt !== undefined
2634
+ ? { expiresAt: request.expiresAt }
2635
+ : {}),
2636
+ ...(preEmission.descriptor.payloadSchemaHash
2637
+ ? {
2638
+ payloadSchemaHash: preEmission.descriptor.payloadSchemaHash,
2639
+ }
2640
+ : {}),
2641
+ ...(preEmission.descriptor.responseSchemaHash !== undefined
2642
+ ? {
2643
+ responseSchemaHash: preEmission.descriptor.responseSchemaHash,
2644
+ }
2645
+ : {}),
2646
+ },
2647
+ ...(preEmission.payload !== undefined
2648
+ ? { [INTERRUPT_PAYLOAD_METADATA_KEY]: preEmission.payload }
2649
+ : {}),
2650
+ },
2651
+ })
2652
+ }
2653
+
2654
+ const ids = new Set<string>()
2655
+ for (const interrupt of interrupts) {
2656
+ if (ids.has(interrupt.id)) {
2657
+ throw new Error(
2658
+ `Duplicate interrupt id in final batch: ${interrupt.id}`,
2659
+ )
2660
+ }
2661
+ ids.add(interrupt.id)
2662
+ }
2663
+
2281
2664
  return interrupts
2282
2665
  }
2283
2666
 
@@ -2285,13 +2668,22 @@ class TextEngine<
2285
2668
  finishEvent: RunFinishedEvent,
2286
2669
  approvals: Array<ApprovalRequest>,
2287
2670
  clientRequests: Array<ClientToolRequest>,
2671
+ genericRequests: ReadonlyArray<
2672
+ GenericInterruptRequest<InterruptDefinition<any, any, any, any>>
2673
+ > = [],
2674
+ genericInterruptIds?: ReadonlyArray<string>,
2288
2675
  ): StreamChunk {
2289
2676
  return {
2290
2677
  ...finishEvent,
2291
2678
  timestamp: Date.now(),
2292
2679
  outcome: {
2293
2680
  type: 'interrupt',
2294
- interrupts: this.buildActionableInterrupts(approvals, clientRequests),
2681
+ interrupts: this.buildActionableInterrupts(
2682
+ approvals,
2683
+ clientRequests,
2684
+ genericRequests,
2685
+ genericInterruptIds,
2686
+ ),
2295
2687
  },
2296
2688
  }
2297
2689
  }
@@ -2309,7 +2701,8 @@ class TextEngine<
2309
2701
  message.id ||
2310
2702
  `snapshot_${this.runIdOverride ?? this.requestId}_${index}`
2311
2703
  const parts =
2312
- message.role === 'assistant' && message.thinking?.length
2704
+ message.role === 'assistant' &&
2705
+ (message.thinking?.length || message.structuredOutput)
2313
2706
  ? modelMessageToUIMessage(message, id).parts
2314
2707
  : undefined
2315
2708
  return {
@@ -2440,9 +2833,22 @@ class TextEngine<
2440
2833
  finishEvent: RunFinishedEvent,
2441
2834
  approvals: Array<ApprovalRequest>,
2442
2835
  clientRequests: Array<ClientToolRequest>,
2836
+ genericRequests: ReadonlyArray<
2837
+ GenericInterruptRequest<InterruptDefinition<any, any, any, any>>
2838
+ > = [],
2443
2839
  ): AsyncGenerator<StreamChunk, boolean, void> {
2840
+ yield* this.emitSyntheticRunStarted(finishEvent)
2841
+ const genericInterruptIds = genericRequests.map(() =>
2842
+ this.genericInterruptId(),
2843
+ )
2444
2844
  const terminal = this.completeEphemeralInterruptBindings(
2445
- this.buildInterruptFinishedChunk(finishEvent, approvals, clientRequests),
2845
+ this.buildInterruptFinishedChunk(
2846
+ finishEvent,
2847
+ approvals,
2848
+ clientRequests,
2849
+ genericRequests,
2850
+ genericInterruptIds,
2851
+ ),
2446
2852
  )
2447
2853
  let terminalOutputs: Array<StreamChunk>
2448
2854
  try {
@@ -2471,6 +2877,103 @@ class TextEngine<
2471
2877
  return true
2472
2878
  }
2473
2879
 
2880
+ private async *emitBoundaryInterrupts(
2881
+ phase: 'beforeModel' | 'afterModel' | 'beforeTools' | 'afterTools',
2882
+ finishEvent: RunFinishedEvent,
2883
+ toolCalls: ReadonlyArray<ToolCall> = [],
2884
+ requests?: ReadonlyArray<
2885
+ GenericInterruptRequest<InterruptDefinition<any, any, any, any>>
2886
+ >,
2887
+ ): AsyncGenerator<StreamChunk, boolean, void> {
2888
+ this.middlewareCtx.phase = phase
2889
+ const boundaryRequests =
2890
+ requests ??
2891
+ (await this.middlewareRunner.runOnInterruptBoundary(
2892
+ this.middlewareCtx as ChatMiddlewareContext<TContext> & {
2893
+ phase: typeof phase
2894
+ },
2895
+ ))
2896
+ if (boundaryRequests.length === 0) return false
2897
+ for (const request of boundaryRequests) {
2898
+ if (
2899
+ this.interruptDefinitions.get(request.definition.id) !==
2900
+ request.definition
2901
+ ) {
2902
+ throw new Error(
2903
+ `Generic interrupt definition ${request.definition.id} is not registered on this chat.`,
2904
+ )
2905
+ }
2906
+ }
2907
+ if (phase === 'afterModel') {
2908
+ if (this.toolCallManager.hasToolCalls()) {
2909
+ this.addAssistantToolCallMessage(this.toolCallManager.getToolCalls())
2910
+ } else {
2911
+ this.addAssistantTextMessageForInterrupt()
2912
+ }
2913
+ }
2914
+ const actionable = this.getBoundaryActionableToolRequests(toolCalls)
2915
+ yield* this.emitActionableInterruptBoundary(
2916
+ finishEvent,
2917
+ actionable.approvals,
2918
+ actionable.clientRequests,
2919
+ boundaryRequests,
2920
+ )
2921
+ return true
2922
+ }
2923
+
2924
+ private addAssistantTextMessageForInterrupt(): void {
2925
+ if (this.accumulatedContent.length === 0) return
2926
+ this.messages = [
2927
+ ...this.messages,
2928
+ { role: 'assistant', content: this.accumulatedContent },
2929
+ ]
2930
+ this.middlewareCtx.messages = this.messages
2931
+ }
2932
+
2933
+ private getBoundaryActionableToolRequests(
2934
+ toolCalls: ReadonlyArray<ToolCall>,
2935
+ ): {
2936
+ approvals: Array<ApprovalRequest>
2937
+ clientRequests: Array<ClientToolRequest>
2938
+ } {
2939
+ const { approvals, clientToolResults } = this.collectClientState()
2940
+ const approvalRequests: Array<ApprovalRequest> = []
2941
+ const clientRequests: Array<ClientToolRequest> = []
2942
+ for (const toolCall of toolCalls) {
2943
+ const tool = this.resolveExecutableTools([toolCall]).find(
2944
+ (candidate) => candidate.name === toolCall.function.name,
2945
+ ) as RuntimeToolWithApproval | undefined
2946
+ if (!tool) continue
2947
+ let input: unknown = {}
2948
+ try {
2949
+ const parsed = JSON.parse(toolCall.function.arguments.trim() || '{}')
2950
+ input = parsed && typeof parsed === 'object' ? parsed : {}
2951
+ } catch {
2952
+ input = {}
2953
+ }
2954
+ const approvalId = `approval_${toolCall.id}`
2955
+ if (tool.needsApproval && !approvals.has(approvalId)) {
2956
+ approvalRequests.push({
2957
+ toolCallId: toolCall.id,
2958
+ toolName: toolCall.function.name,
2959
+ input,
2960
+ approvalId,
2961
+ })
2962
+ } else if (
2963
+ !tool.execute &&
2964
+ !clientToolResults.has(toolCall.id) &&
2965
+ !this.resumeCancelledToolCallIds.has(toolCall.id)
2966
+ ) {
2967
+ clientRequests.push({
2968
+ toolCallId: toolCall.id,
2969
+ toolName: toolCall.function.name,
2970
+ input,
2971
+ })
2972
+ }
2973
+ }
2974
+ return { approvals: approvalRequests, clientRequests }
2975
+ }
2976
+
2474
2977
  private completeEphemeralInterruptBindings(chunk: StreamChunk): StreamChunk {
2475
2978
  if (
2476
2979
  chunk.type !== EventType.RUN_FINISHED ||
@@ -2718,7 +3221,7 @@ class TextEngine<
2718
3221
  private createSyntheticFinishedEvent(): RunFinishedEvent {
2719
3222
  return {
2720
3223
  type: 'RUN_FINISHED',
2721
- runId: this.createId('pending'),
3224
+ runId: this.runIdOverride ?? this.requestId,
2722
3225
  threadId: this.threadId,
2723
3226
  model: this.params.model,
2724
3227
  timestamp: Date.now(),
@@ -2997,6 +3500,7 @@ class TextEngine<
2997
3500
  const buildSynthesizedStart = (timestamp = Date.now()): StreamChunk => {
2998
3501
  const idForStart = structuredMessageId ?? generateMessageId()
2999
3502
  structuredMessageId = idForStart
3503
+ this.captureStructuredOutputMessageIdentity(idForStart)
3000
3504
  return {
3001
3505
  type: EventType.CUSTOM,
3002
3506
  name: 'structured-output.start',
@@ -3037,7 +3541,10 @@ class TextEngine<
3037
3541
  // synthesized start (when needed) uses the SAME id the deltas carry
3038
3542
  if (!structuredMessageId) {
3039
3543
  const extracted = extractMessageId(chunk)
3040
- if (extracted) structuredMessageId = extracted
3544
+ if (extracted) {
3545
+ structuredMessageId = extracted
3546
+ this.captureStructuredOutputMessageIdentity(extracted)
3547
+ }
3041
3548
  }
3042
3549
 
3043
3550
  // Synthesis only matters for the streaming client path — the agentic
@@ -3104,7 +3611,13 @@ class TextEngine<
3104
3611
  const object = this.finalStructuredOutput.normalize
3105
3612
  ? this.finalStructuredOutput.normalize(parsed.object)
3106
3613
  : parsed.object
3107
- this.structuredOutputResult = { data: object, rawText: parsed.raw }
3614
+ this.structuredOutputResult = {
3615
+ data: object,
3616
+ rawText: parsed.raw,
3617
+ ...(parsed.reasoning !== undefined
3618
+ ? { reasoning: parsed.reasoning }
3619
+ : {}),
3620
+ }
3108
3621
  // Rewrite the outbound event so the yielded chunk carries the
3109
3622
  // normalized object (the original `chunk.value` still holds the
3110
3623
  // widened one). Preserve every other field — `raw`, `reasoning` —
@@ -3555,7 +4068,15 @@ class TextEngine<
3555
4068
  }
3556
4069
  }
3557
4070
 
3558
- const pending = this.buildActionableInterrupts(
4071
+ const genericPending = this.getGenericContinuationPending(interruptedRunId)
4072
+ const pending: Array<{
4073
+ interruptId: string
4074
+ payload: unknown
4075
+ binding: InterruptBinding
4076
+ genericRequest?: GenericInterruptRequest<
4077
+ InterruptDefinition<any, any, any, any>
4078
+ >
4079
+ }> = this.buildActionableInterrupts(
3559
4080
  approvalRequests,
3560
4081
  clientRequests,
3561
4082
  ).flatMap((descriptor) => {
@@ -3574,6 +4095,7 @@ class TextEngine<
3574
4095
  ]
3575
4096
  : []
3576
4097
  })
4098
+ pending.push(...genericPending)
3577
4099
  const validated = await validateInterruptResumeBatch({
3578
4100
  threadId: this.threadId,
3579
4101
  interruptedRunId,
@@ -3601,6 +4123,185 @@ class TextEngine<
3601
4123
  ...validated.resumeToolState,
3602
4124
  approvals,
3603
4125
  })
4126
+
4127
+ const genericResolutions = validated.resumeToolState.genericInterrupts
4128
+ if (genericPending.length > 0 && genericResolutions) {
4129
+ const resolutions = genericPending
4130
+ .sort((left, right) => {
4131
+ const leftIndex =
4132
+ left.binding.kind === 'generic' ? (left.binding.batchIndex ?? 0) : 0
4133
+ const rightIndex =
4134
+ right.binding.kind === 'generic'
4135
+ ? (right.binding.batchIndex ?? 0)
4136
+ : 0
4137
+ return leftIndex - rightIndex
4138
+ })
4139
+ .flatMap((record) => {
4140
+ const resolution = genericResolutions.get(record.interruptId)
4141
+ if (!resolution || !record.genericRequest) return []
4142
+ return [
4143
+ resolution.status === 'resolved'
4144
+ ? {
4145
+ request: record.genericRequest,
4146
+ status: 'resolved' as const,
4147
+ response: resolution.payload,
4148
+ }
4149
+ : {
4150
+ request: record.genericRequest,
4151
+ status: 'cancelled' as const,
4152
+ },
4153
+ ]
4154
+ })
4155
+ const collection: InterruptResolutionCollection = {
4156
+ for: (definition) =>
4157
+ resolutions.filter(
4158
+ (resolution) => resolution.request.definition === definition,
4159
+ ) as never,
4160
+ all: (
4161
+ ...definitions: Array<InterruptDefinition<any, any, any, any>>
4162
+ ) =>
4163
+ definitions.length === 0
4164
+ ? resolutions
4165
+ : resolutions.filter((resolution) =>
4166
+ definitions.includes(resolution.request.definition),
4167
+ ),
4168
+ }
4169
+ const policy = await this.middlewareRunner.runOnInterruptResolution(
4170
+ this.middlewareCtx,
4171
+ collection,
4172
+ )
4173
+ if (policy.toolResume === 'stop') {
4174
+ this.earlyTermination = true
4175
+ } else if (policy.toolResume === 'cancel') {
4176
+ for (const request of pendingToolCalls) {
4177
+ this.resumeCancelledToolCallIds.add(request.id)
4178
+ }
4179
+ }
4180
+ }
4181
+ }
4182
+
4183
+ private getGenericContinuationPending(interruptedRunId: string): Array<{
4184
+ interruptId: string
4185
+ payload: unknown
4186
+ binding: InterruptBinding
4187
+ genericRequest: GenericInterruptRequest<
4188
+ InterruptDefinition<any, any, any, any>
4189
+ >
4190
+ }> {
4191
+ const fail = (message: string): never => {
4192
+ throw new InterruptResumeValidationError([
4193
+ {
4194
+ scope: 'batch',
4195
+ threadId: this.threadId,
4196
+ interruptedRunId,
4197
+ generation: 0,
4198
+ interruptIds: [],
4199
+ code: 'stale',
4200
+ message,
4201
+ source: 'server',
4202
+ retryable: false,
4203
+ },
4204
+ ])
4205
+ }
4206
+ const pending: Array<{
4207
+ interruptId: string
4208
+ payload: unknown
4209
+ binding: InterruptBinding
4210
+ genericRequest: GenericInterruptRequest<
4211
+ InterruptDefinition<any, any, any, any>
4212
+ >
4213
+ }> = []
4214
+ const ids = new Set<string>()
4215
+ const batchIndexes = new Set<number>()
4216
+ for (const resumeItem of this.params.resume ?? []) {
4217
+ const parsed = readGenericInterruptContinuation(resumeItem.metadata)
4218
+ if (parsed.status === 'absent') continue
4219
+ if (parsed.status === 'invalid') {
4220
+ return fail(parsed.message)
4221
+ }
4222
+ const entry = parsed.value
4223
+ const id = resumeItem.interruptId
4224
+ const definition = this.interruptDefinitions.get(entry.definitionId)
4225
+ if (!definition) {
4226
+ return fail(
4227
+ `Generic interrupt definition ${entry.definitionId} is unavailable.`,
4228
+ )
4229
+ }
4230
+ if (ids.has(id) || batchIndexes.has(entry.batchIndex)) {
4231
+ return fail(
4232
+ 'Generic interrupt continuation contains duplicate entries.',
4233
+ )
4234
+ }
4235
+ ids.add(id)
4236
+ batchIndexes.add(entry.batchIndex)
4237
+ let request: GenericInterruptRequest<
4238
+ InterruptDefinition<any, any, any, any>
4239
+ >
4240
+ try {
4241
+ request = rehydrateInterruptRequest(definition, {
4242
+ key: entry.key,
4243
+ reason: entry.reason,
4244
+ message: entry.message,
4245
+ ...(typeof entry.expiresAt === 'string'
4246
+ ? { expiresAt: entry.expiresAt }
4247
+ : {}),
4248
+ ...(Object.prototype.hasOwnProperty.call(entry, 'payload')
4249
+ ? { payload: entry.payload }
4250
+ : {}),
4251
+ })
4252
+ } catch (error) {
4253
+ return fail(
4254
+ `Generic interrupt continuation ${id} is invalid: ${
4255
+ error instanceof Error ? error.message : String(error)
4256
+ }`,
4257
+ )
4258
+ }
4259
+ const emitted = createInterruptBinding(request, {
4260
+ batchIndex: entry.batchIndex,
4261
+ })
4262
+ if (
4263
+ entry.responseSchemaHash !== emitted.descriptor.responseSchemaHash ||
4264
+ entry.payloadSchemaHash !== emitted.descriptor.payloadSchemaHash
4265
+ ) {
4266
+ return fail(
4267
+ `Generic interrupt continuation ${id} does not match its definition.`,
4268
+ )
4269
+ }
4270
+ pending.push({
4271
+ interruptId: id,
4272
+ payload: {
4273
+ id,
4274
+ ...(emitted.descriptor.responseSchemaCanonicalJson !== undefined
4275
+ ? {
4276
+ responseSchema: JSON.parse(
4277
+ emitted.descriptor.responseSchemaCanonicalJson,
4278
+ ),
4279
+ }
4280
+ : {}),
4281
+ },
4282
+ binding: {
4283
+ v: INTERRUPT_BINDING_VERSION,
4284
+ kind: 'generic',
4285
+ interruptId: id,
4286
+ interruptedRunId,
4287
+ generation: 0,
4288
+ definitionId: entry.definitionId,
4289
+ key: entry.key,
4290
+ batchIndex: entry.batchIndex,
4291
+ ...(typeof entry.expiresAt === 'string'
4292
+ ? { expiresAt: entry.expiresAt }
4293
+ : {}),
4294
+ ...(emitted.descriptor.payloadSchemaHash
4295
+ ? { payloadSchemaHash: emitted.descriptor.payloadSchemaHash }
4296
+ : {}),
4297
+ ...(entry.responseSchemaHash !== undefined
4298
+ ? { responseSchemaHash: entry.responseSchemaHash }
4299
+ : {}),
4300
+ },
4301
+ genericRequest: request,
4302
+ })
4303
+ }
4304
+ return pending
3604
4305
  }
3605
4306
 
3606
4307
  private applyResumeToolState(state: ChatResumeToolState | undefined): void {
@@ -3624,6 +4325,58 @@ class TextEngine<
3624
4325
  this.resumeCancelledToolCallIds.add(toolCallId)
3625
4326
  }
3626
4327
  }
4328
+ if (state?.genericInterrupts) {
4329
+ for (const [interruptId, resolution] of state.genericInterrupts) {
4330
+ this.resumeGenericInterrupts.set(interruptId, resolution)
4331
+ }
4332
+ }
4333
+ if (state?.genericInterruptRequests) {
4334
+ for (const [interruptId, request] of state.genericInterruptRequests) {
4335
+ this.resumeGenericInterruptRequests.set(interruptId, request)
4336
+ }
4337
+ }
4338
+ }
4339
+
4340
+ private async applyDurableGenericInterruptResolution(): Promise<void> {
4341
+ if (this.resumeGenericInterruptRequests.size === 0) return
4342
+ const resolutions = [
4343
+ ...this.resumeGenericInterruptRequests.entries(),
4344
+ ].flatMap(([interruptId, request]) => {
4345
+ const resolution = this.resumeGenericInterrupts.get(interruptId)
4346
+ if (!resolution) return []
4347
+ return [
4348
+ resolution.status === 'resolved'
4349
+ ? {
4350
+ request,
4351
+ status: 'resolved' as const,
4352
+ response: resolution.payload,
4353
+ }
4354
+ : { request, status: 'cancelled' as const },
4355
+ ]
4356
+ })
4357
+ const collection: InterruptResolutionCollection = {
4358
+ for: (definition) =>
4359
+ resolutions.filter(
4360
+ (resolution) => resolution.request.definition === definition,
4361
+ ) as never,
4362
+ all: (...definitions: Array<InterruptDefinition<any, any, any, any>>) =>
4363
+ definitions.length === 0
4364
+ ? resolutions
4365
+ : resolutions.filter((resolution) =>
4366
+ definitions.includes(resolution.request.definition),
4367
+ ),
4368
+ }
4369
+ const policy = await this.middlewareRunner.runOnInterruptResolution(
4370
+ this.middlewareCtx,
4371
+ collection,
4372
+ )
4373
+ if (policy.toolResume === 'stop') {
4374
+ this.earlyTermination = true
4375
+ } else if (policy.toolResume === 'cancel') {
4376
+ for (const toolCall of this.getPendingToolCallsFromMessages()) {
4377
+ this.resumeCancelledToolCallIds.add(toolCall.id)
4378
+ }
4379
+ }
3627
4380
  }
3628
4381
 
3629
4382
  private applyMiddlewareConfig(config: ChatMiddlewareConfig): void {
@@ -3662,6 +4415,9 @@ class TextEngine<
3662
4415
  chunk,
3663
4416
  )
3664
4417
  for (const outputChunk of outputChunks) {
4418
+ if (outputChunk.type === EventType.RUN_STARTED) {
4419
+ this.hasPublicRunStarted = true
4420
+ }
3665
4421
  yield outputChunk
3666
4422
  this.middlewareCtx.chunkIndex++
3667
4423
  }
@@ -3802,67 +4558,136 @@ export function chat<
3802
4558
  TStream,
3803
4559
  any
3804
4560
  >['tools'] = TextActivityOptions<TAdapter, TSchema, TStream, any>['tools'],
3805
- const TMiddleware extends TextActivityOptions<
3806
- TAdapter,
3807
- TSchema,
3808
- TStream,
3809
- any
3810
- >['middleware'] = TextActivityOptions<
3811
- TAdapter,
3812
- TSchema,
3813
- TStream,
3814
- any
3815
- >['middleware'],
4561
+ const TInterrupts extends ReadonlyArray<
4562
+ InterruptDefinition<any, any, any, any>
4563
+ > = [],
4564
+ TContext = unknown,
4565
+ const TMiddleware extends Array<unknown> | undefined = undefined,
3816
4566
  >(
3817
4567
  options: TextActivityOptionsWithContext<
3818
4568
  TAdapter,
3819
4569
  TSchema,
3820
4570
  TStream,
3821
4571
  TTools,
4572
+ TInterrupts,
4573
+ TContext,
3822
4574
  TMiddleware
3823
4575
  >,
3824
4576
  ): TextActivityResult<TSchema, TStream, TTools> {
3825
- validateCapabilities(options.middleware ?? [], options.adapter)
4577
+ validateInterruptDefinitions(options.interrupts)
4578
+ validateCapabilities(
4579
+ readRuntimeMiddleware(options.middleware) ?? [],
4580
+ options.adapter,
4581
+ )
3826
4582
  if (options.tools) {
3827
4583
  assertUniqueToolNames(options.tools)
3828
4584
  }
3829
4585
 
3830
4586
  const { outputSchema, stream } = options
3831
4587
 
3832
- // outputSchema + stream:true is the only branch that streams structured
3833
- // output. Without an explicit `stream: true`, schema-bearing calls run the
3834
- // agent loop and resolve to a typed Promise<InferSchemaType<TSchema>>.
3835
4588
  if (outputSchema && stream === true) {
3836
- return runStreamingStructuredOutput({
3837
- ...options,
3838
- outputSchema,
3839
- stream,
3840
- }) as TextActivityResult<TSchema, TStream, TTools>
4589
+ return runStreamingStructuredOutput(
4590
+ toRuntimeTextActivityOptions(options, {
4591
+ outputSchema,
4592
+ stream: true,
4593
+ }),
4594
+ ) as TextActivityResult<TSchema, TStream, TTools>
3841
4595
  }
3842
4596
 
3843
- // If outputSchema is provided, run agentic structured output (Promise<T>)
3844
4597
  if (outputSchema) {
3845
- return runAgenticStructuredOutput({
3846
- ...options,
3847
- outputSchema,
3848
- }) as TextActivityResult<TSchema, TStream, TTools>
4598
+ return runAgenticStructuredOutput(
4599
+ toRuntimeTextActivityOptions(options, {
4600
+ outputSchema,
4601
+ stream: false,
4602
+ }),
4603
+ ) as TextActivityResult<TSchema, TStream, TTools>
3849
4604
  }
3850
4605
 
3851
- // If stream is explicitly false, run non-streaming text
3852
4606
  if (stream === false) {
3853
- return runNonStreamingText({
3854
- ...options,
4607
+ return runNonStreamingText(
4608
+ toRuntimeTextActivityOptions(options, {
4609
+ outputSchema: undefined,
4610
+ stream: false,
4611
+ }),
4612
+ ) as TextActivityResult<TSchema, TStream, TTools>
4613
+ }
4614
+
4615
+ return runStreamingText(
4616
+ toRuntimeTextActivityOptions(options, {
3855
4617
  outputSchema: undefined,
3856
- stream,
3857
- }) as TextActivityResult<TSchema, TStream, TTools>
4618
+ stream: true,
4619
+ }),
4620
+ ) as TextActivityResult<TSchema, TStream, TTools>
4621
+ }
4622
+
4623
+ type RuntimeTextActivityOptions<
4624
+ TAdapter extends AnyTextAdapter,
4625
+ TSchema extends SchemaInput | undefined,
4626
+ TStream extends boolean,
4627
+ > = Omit<TextActivityOptions<TAdapter, TSchema, TStream, any>, 'middleware'> & {
4628
+ middleware?: Array<AnyChatMiddleware>
4629
+ }
4630
+
4631
+ function readRuntimeMiddleware(
4632
+ middleware: unknown,
4633
+ ): Array<AnyChatMiddleware> | undefined {
4634
+ if (middleware === undefined) return undefined
4635
+ if (!Array.isArray(middleware)) {
4636
+ throw new TypeError('Chat middleware must be an array.')
3858
4637
  }
4638
+ return middleware
4639
+ }
3859
4640
 
3860
- // Otherwise, run streaming text (default)
3861
- return runStreamingText({
3862
- ...options,
3863
- outputSchema: undefined,
3864
- stream,
3865
- }) as TextActivityResult<TSchema, TStream, TTools>
4641
+ function toRuntimeTextActivityOptions<
4642
+ TAdapter extends AnyTextAdapter,
4643
+ TInputSchema extends SchemaInput | undefined,
4644
+ TInputStream extends boolean,
4645
+ TOutputSchema extends SchemaInput | undefined,
4646
+ TOutputStream extends boolean,
4647
+ TTools extends TextActivityOptions<
4648
+ TAdapter,
4649
+ TInputSchema,
4650
+ TInputStream,
4651
+ any
4652
+ >['tools'],
4653
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
4654
+ TContext,
4655
+ TMiddleware extends Array<unknown> | undefined,
4656
+ >(
4657
+ options: TextActivityOptionsWithContext<
4658
+ TAdapter,
4659
+ TInputSchema,
4660
+ TInputStream,
4661
+ TTools,
4662
+ TInterrupts,
4663
+ TContext,
4664
+ TMiddleware
4665
+ >,
4666
+ overrides: { outputSchema: TOutputSchema; stream: TOutputStream },
4667
+ ): RuntimeTextActivityOptions<TAdapter, TOutputSchema, TOutputStream> {
4668
+ const { middleware, ...rest } = options
4669
+ return {
4670
+ ...rest,
4671
+ ...overrides,
4672
+ ...(middleware === undefined
4673
+ ? {}
4674
+ : { middleware: readRuntimeMiddleware(middleware) }),
4675
+ }
4676
+ }
4677
+
4678
+ function validateInterruptDefinitions(
4679
+ definitions:
4680
+ | ReadonlyArray<InterruptDefinition<any, any, any, any>>
4681
+ | undefined,
4682
+ ): void {
4683
+ if (!definitions) return
4684
+ const seen = new Set<string>()
4685
+ for (const definition of definitions) {
4686
+ if (seen.has(definition.id)) {
4687
+ throw new Error(`Duplicate interrupt definition id: ${definition.id}`)
4688
+ }
4689
+ seen.add(definition.id)
4690
+ }
3866
4691
  }
3867
4692
 
3868
4693
  /**
@@ -3914,8 +4739,8 @@ function publishDeliverySeams(
3914
4739
  * returns, so the identity has to be minted out here and the engine reached back
3915
4740
  * through `engineRef`, which the body fills as soon as its engine exists.
3916
4741
  */
3917
- function runStreamingText<TContext = unknown>(
3918
- options: TextActivityOptions<AnyTextAdapter, undefined, true, TContext>,
4742
+ function runStreamingText(
4743
+ options: RuntimeTextActivityOptions<AnyTextAdapter, undefined, boolean>,
3919
4744
  ): AsyncIterable<StreamChunk> {
3920
4745
  const engineRef: DeliveryEngineRef = {}
3921
4746
  const stream = streamTextChunks(options, engineRef)
@@ -3923,8 +4748,8 @@ function runStreamingText<TContext = unknown>(
3923
4748
  return stream
3924
4749
  }
3925
4750
 
3926
- async function* streamTextChunks<TContext = unknown>(
3927
- options: TextActivityOptions<AnyTextAdapter, undefined, true, TContext>,
4751
+ async function* streamTextChunks(
4752
+ options: RuntimeTextActivityOptions<AnyTextAdapter, undefined, boolean>,
3928
4753
  engineRef: DeliveryEngineRef,
3929
4754
  ): AsyncIterable<StreamChunk> {
3930
4755
  const { adapter, middleware, context, debug, mcp, ...textOptions } = options
@@ -3943,7 +4768,7 @@ async function* streamTextChunks<TContext = unknown>(
3943
4768
  params: { ...textOptions, model, logger } as TextOptions<
3944
4769
  Record<string, any>,
3945
4770
  Record<string, any>,
3946
- TContext
4771
+ any
3947
4772
  >,
3948
4773
  middleware,
3949
4774
  context,
@@ -3965,19 +4790,13 @@ async function* streamTextChunks<TContext = unknown>(
3965
4790
  * Run non-streaming text - collects all content and returns as a string.
3966
4791
  * Runs the full agentic loop (if tools are provided) but returns collected text.
3967
4792
  */
3968
- function runNonStreamingText<TContext = unknown>(
3969
- options: TextActivityOptions<AnyTextAdapter, undefined, false, TContext>,
4793
+ function runNonStreamingText(
4794
+ options: RuntimeTextActivityOptions<AnyTextAdapter, undefined, false>,
3970
4795
  ): Promise<string> {
3971
- // Run the streaming text and collect all text using streamToText.
3972
- const stream = runStreamingText(
3973
- // oxlint-disable-next-line eslint-js/no-restricted-syntax -- generic-stream remap: caller is non-streaming (false), but runStreamingText is invoked internally to collect text; concrete `false`→`true` literals don't structurally overlap.
3974
- options as unknown as TextActivityOptions<
3975
- AnyTextAdapter,
3976
- undefined,
3977
- true,
3978
- TContext
3979
- >,
3980
- )
4796
+ const stream = runStreamingText({
4797
+ ...options,
4798
+ stream: true,
4799
+ })
3981
4800
 
3982
4801
  return streamToText(stream)
3983
4802
  }
@@ -3988,11 +4807,8 @@ function runNonStreamingText<TContext = unknown>(
3988
4807
  * 2. Once complete, call adapter.structuredOutput with the conversation context
3989
4808
  * 3. Validate and return the structured result
3990
4809
  */
3991
- async function runAgenticStructuredOutput<
3992
- TSchema extends SchemaInput,
3993
- TContext = unknown,
3994
- >(
3995
- options: TextActivityOptions<AnyTextAdapter, TSchema, boolean, TContext>,
4810
+ async function runAgenticStructuredOutput<TSchema extends SchemaInput>(
4811
+ options: RuntimeTextActivityOptions<AnyTextAdapter, TSchema, boolean>,
3996
4812
  ): Promise<InferSchemaType<TSchema>> {
3997
4813
  const {
3998
4814
  adapter,
@@ -4063,7 +4879,7 @@ async function runAgenticStructuredOutput<
4063
4879
  params: { ...textOptions, model, logger } as TextOptions<
4064
4880
  Record<string, unknown>,
4065
4881
  Record<string, unknown>,
4066
- TContext
4882
+ any
4067
4883
  >,
4068
4884
  middleware,
4069
4885
  context,
@@ -4126,6 +4942,15 @@ async function runAgenticStructuredOutput<
4126
4942
  * Uses an `unknown`-input runtime check rather than `as` casts so the engine
4127
4943
  * stays cast-free in its hot path.
4128
4944
  */
4945
+ function readCustomEventMessageId(value: unknown): string | undefined {
4946
+ if (typeof value !== 'object' || value === null) return undefined
4947
+ if (!('messageId' in value)) return undefined
4948
+ const messageId = value.messageId
4949
+ return typeof messageId === 'string' && messageId !== ''
4950
+ ? messageId
4951
+ : undefined
4952
+ }
4953
+
4129
4954
  function readStructuredOutputCompleteValue(
4130
4955
  value: unknown,
4131
4956
  ): { object: unknown; raw: string; reasoning?: string } | null {
@@ -4271,11 +5096,8 @@ async function* fallbackStructuredOutputStream(
4271
5096
  * synchronously at call time rather than as a yielded RUN_ERROR mid-stream —
4272
5097
  * those are programmer errors, not runtime conditions.
4273
5098
  */
4274
- function runStreamingStructuredOutput<
4275
- TSchema extends SchemaInput,
4276
- TContext = unknown,
4277
- >(
4278
- options: TextActivityOptions<AnyTextAdapter, TSchema, true, TContext>,
5099
+ function runStreamingStructuredOutput<TSchema extends SchemaInput>(
5100
+ options: RuntimeTextActivityOptions<AnyTextAdapter, TSchema, true>,
4279
5101
  ): StructuredOutputStream<InferSchemaType<TSchema>> {
4280
5102
  const { outputSchema } = options
4281
5103
 
@@ -4336,11 +5158,8 @@ type StructuredOutputStreamInternal<T> = AsyncIterable<
4336
5158
  StreamChunk | StructuredOutputCompleteEvent<T>
4337
5159
  >
4338
5160
 
4339
- async function* runStreamingStructuredOutputImpl<
4340
- TSchema extends SchemaInput,
4341
- TContext = unknown,
4342
- >(
4343
- options: TextActivityOptions<AnyTextAdapter, TSchema, true, TContext>,
5161
+ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
5162
+ options: RuntimeTextActivityOptions<AnyTextAdapter, TSchema, true>,
4344
5163
  jsonSchema: NonNullable<ReturnType<typeof convertSchemaToJsonSchema>>,
4345
5164
  normalize: (data: unknown) => unknown,
4346
5165
  engineRef: DeliveryEngineRef,
@@ -4383,7 +5202,7 @@ async function* runStreamingStructuredOutputImpl<
4383
5202
  params: { ...textOptions, model, logger } as TextOptions<
4384
5203
  Record<string, unknown>,
4385
5204
  Record<string, unknown>,
4386
- TContext
5205
+ any
4387
5206
  >,
4388
5207
  middleware,
4389
5208
  context,