@tanstack/ai 0.45.1 → 0.47.1

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 (82) hide show
  1. package/dist/esm/activities/chat/index.d.ts +36 -11
  2. package/dist/esm/activities/chat/index.js +462 -66
  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/activities/chat/tools/unique-tool-names.d.ts +20 -0
  24. package/dist/esm/activities/chat/tools/unique-tool-names.js +57 -0
  25. package/dist/esm/activities/chat/tools/unique-tool-names.js.map +1 -0
  26. package/dist/esm/adapter-internals.d.ts +7 -0
  27. package/dist/esm/adapter-internals.js +5 -1
  28. package/dist/esm/client.d.ts +4 -0
  29. package/dist/esm/client.js +3 -1
  30. package/dist/esm/client.js.map +1 -1
  31. package/dist/esm/generic-interrupt-continuation.d.ts +45 -0
  32. package/dist/esm/generic-interrupt-continuation.js +80 -0
  33. package/dist/esm/generic-interrupt-continuation.js.map +1 -0
  34. package/dist/esm/index.d.ts +9 -1
  35. package/dist/esm/index.js +7 -2
  36. package/dist/esm/interrupt-definition.d.ts +113 -0
  37. package/dist/esm/interrupt-definition.js +169 -0
  38. package/dist/esm/interrupt-definition.js.map +1 -0
  39. package/dist/esm/interrupt-resume.d.ts +3 -0
  40. package/dist/esm/interrupt-resume.js +77 -16
  41. package/dist/esm/interrupt-resume.js.map +1 -1
  42. package/dist/esm/interrupts.d.ts +12 -3
  43. package/dist/esm/interrupts.js.map +1 -1
  44. package/dist/esm/middlewares/usage-attributes.d.ts +2 -2
  45. package/dist/esm/middlewares/usage-attributes.js +9 -2
  46. package/dist/esm/middlewares/usage-attributes.js.map +1 -1
  47. package/dist/esm/stream-to-response.d.ts +26 -0
  48. package/dist/esm/stream-to-response.js +1 -1
  49. package/dist/esm/stream-to-response.js.map +1 -1
  50. package/dist/esm/stream-to-websocket.d.ts +123 -0
  51. package/dist/esm/stream-to-websocket.js +249 -0
  52. package/dist/esm/stream-to-websocket.js.map +1 -0
  53. package/dist/esm/types.d.ts +19 -10
  54. package/dist/esm/utilities/chat-params.js +10 -1
  55. package/dist/esm/utilities/chat-params.js.map +1 -1
  56. package/package.json +2 -2
  57. package/skills/ai-core/media-generation/SKILL.md +13 -9
  58. package/skills/ai-core/middleware/SKILL.md +53 -44
  59. package/skills/ai-core/structured-outputs/SKILL.md +59 -55
  60. package/skills/ai-core/tool-calling/SKILL.md +54 -1
  61. package/src/activities/chat/index.ts +1076 -194
  62. package/src/activities/chat/messages.ts +11 -3
  63. package/src/activities/chat/middleware/builder.ts +29 -4
  64. package/src/activities/chat/middleware/compose.ts +95 -5
  65. package/src/activities/chat/middleware/define.ts +13 -3
  66. package/src/activities/chat/middleware/generic-interrupts.ts +26 -0
  67. package/src/activities/chat/middleware/index.ts +15 -0
  68. package/src/activities/chat/middleware/types.ts +127 -2
  69. package/src/activities/chat/stream/processor.ts +21 -0
  70. package/src/activities/chat/tools/unique-tool-names.ts +73 -0
  71. package/src/adapter-internals.ts +24 -0
  72. package/src/client.ts +20 -0
  73. package/src/generic-interrupt-continuation.ts +162 -0
  74. package/src/index.ts +51 -0
  75. package/src/interrupt-definition.ts +581 -0
  76. package/src/interrupt-resume.ts +156 -25
  77. package/src/interrupts.ts +13 -3
  78. package/src/middlewares/usage-attributes.ts +12 -2
  79. package/src/stream-to-response.ts +2 -2
  80. package/src/stream-to-websocket.ts +418 -0
  81. package/src/types.ts +21 -8
  82. 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,
@@ -25,6 +36,7 @@ import {
25
36
  import { normalizeToolResult } from '../../utilities/tool-result'
26
37
  import { isProviderExecutedToolCall } from '../../utilities/provider-executed'
27
38
  import { LazyToolManager } from './tools/lazy-tool-manager'
39
+ import { assertUniqueToolNames } from './tools/unique-tool-names'
28
40
  import {
29
41
  MiddlewareAbortError,
30
42
  ToolCallManager,
@@ -42,7 +54,12 @@ import {
42
54
  } from './tools/approval-schema'
43
55
  import { maxIterations as maxIterationsStrategy } from './agent-loop-strategies'
44
56
  import { isCancelRequestedReason } from './cancel'
45
- import { convertMessagesToModelMessages, generateMessageId } from './messages'
57
+ import {
58
+ convertMessagesToModelMessages,
59
+ generateMessageId,
60
+ modelMessageToUIMessage,
61
+ safeJsonStringify,
62
+ } from './messages'
46
63
  import { MiddlewareRunner } from './middleware/compose'
47
64
  import { getRunDetached } from './middleware/run-store'
48
65
  import { publishRunDetachedSignal } from '../../delivery-detach'
@@ -84,6 +101,7 @@ import type {
84
101
  SchemaInput,
85
102
  StreamChunk,
86
103
  StructuredOutputCompleteEvent,
104
+ StructuredOutputPart,
87
105
  StructuredOutputStream,
88
106
  TextMessageContentEvent,
89
107
  TextOptions,
@@ -99,10 +117,13 @@ import type {
99
117
  ChatMiddleware,
100
118
  ChatMiddlewareConfig,
101
119
  ChatMiddlewareContext,
120
+ ChatResumeGenericResolution,
102
121
  ChatResumeToolState,
122
+ InterruptResolutionCollection,
103
123
  SandboxFileHookEvent,
104
124
  StructuredOutputMiddlewareConfig,
105
125
  } from './middleware/types'
126
+ import { provideGenericInterruptDefinitionRegistry } from './middleware/generic-interrupts'
106
127
  import type { CheckCoverage } from './middleware/builder'
107
128
  import type { SystemPrompt } from '../../system-prompts'
108
129
  import type { InternalLogger } from '../../logger/internal-logger'
@@ -199,74 +220,11 @@ function normalizePublicInterruptBinding(
199
220
  value: unknown,
200
221
  expectedInterruptId: string,
201
222
  ): InterruptBinding | undefined {
202
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
203
- return undefined
204
- }
205
- const binding: Record<string, unknown> = Object.fromEntries(
206
- Object.entries(value),
207
- )
208
- if (
209
- binding.interruptId !== expectedInterruptId ||
210
- // A binding version we don't recognise belongs to another producer. Drop
211
- // it rather than reading our fields out of it.
212
- (binding.v !== undefined && binding.v !== INTERRUPT_BINDING_VERSION) ||
213
- typeof binding.interruptedRunId !== 'string' ||
214
- typeof binding.generation !== 'number' ||
215
- !Number.isInteger(binding.generation) ||
216
- binding.generation < 0 ||
217
- typeof binding.responseSchemaHash !== 'string' ||
218
- (binding.expiresAt !== undefined && typeof binding.expiresAt !== 'string')
219
- ) {
220
- return undefined
221
- }
222
- const base = {
223
- v: INTERRUPT_BINDING_VERSION,
224
- interruptId: binding.interruptId,
225
- interruptedRunId: binding.interruptedRunId,
226
- generation: binding.generation,
227
- responseSchemaHash: binding.responseSchemaHash,
228
- ...(typeof binding.expiresAt === 'string'
229
- ? { expiresAt: binding.expiresAt }
230
- : {}),
231
- }
232
- if (binding.kind === 'generic') {
233
- return { kind: binding.kind, ...base }
234
- }
235
- if (
236
- typeof binding.toolName !== 'string' ||
237
- typeof binding.toolCallId !== 'string'
238
- ) {
239
- return undefined
240
- }
241
- if (
242
- binding.kind === 'client-tool-execution' &&
243
- typeof binding.outputSchemaHash === 'string'
244
- ) {
245
- return {
246
- kind: binding.kind,
247
- ...base,
248
- toolName: binding.toolName,
249
- toolCallId: binding.toolCallId,
250
- outputSchemaHash: binding.outputSchemaHash,
251
- }
252
- }
253
- if (
254
- binding.kind === 'tool-approval' &&
255
- Object.prototype.hasOwnProperty.call(binding, 'originalArgs') &&
256
- typeof binding.inputSchemaHash === 'string' &&
257
- typeof binding.approvalSchemaHash === 'string'
258
- ) {
259
- return {
260
- kind: binding.kind,
261
- ...base,
262
- toolName: binding.toolName,
263
- toolCallId: binding.toolCallId,
264
- originalArgs: binding.originalArgs,
265
- inputSchemaHash: binding.inputSchemaHash,
266
- approvalSchemaHash: binding.approvalSchemaHash,
267
- }
268
- }
269
- return undefined
223
+ return readInterruptBinding({
224
+ id: expectedInterruptId,
225
+ reason: '',
226
+ metadata: { [INTERRUPT_BINDING_METADATA_KEY]: value },
227
+ })
270
228
  }
271
229
 
272
230
  // The leaf context-inference primitives (KnownContext, MergeContext,
@@ -305,33 +263,133 @@ type InferredContext<TTools, TMiddleware> = [
305
263
  ? unknown
306
264
  : ContextFromInputs<TTools, TMiddleware>
307
265
 
308
- type RequiredContextFromInputs<TTools, TMiddleware> = [
309
- 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>,
310
335
  ] extends [never]
311
- ? { context?: unknown }
312
- : undefined extends ContextFromInputs<TTools, TMiddleware>
313
- ? { context?: ContextFromInputs<TTools, TMiddleware> }
314
- : { 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>>>
315
375
 
316
376
  type TextActivityOptionsWithContext<
317
377
  TAdapter extends AnyTextAdapter,
318
378
  TSchema extends SchemaInput | undefined,
319
379
  TStream extends boolean,
320
380
  TTools extends TextActivityOptions<TAdapter, TSchema, TStream, any>['tools'],
321
- TMiddleware extends TextActivityOptions<
322
- TAdapter,
323
- TSchema,
324
- TStream,
325
- any
326
- >['middleware'],
381
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
382
+ [],
383
+ TContext = unknown,
384
+ TMiddleware extends Array<unknown> | undefined = undefined,
327
385
  > = Omit<
328
386
  TextActivityOptions<TAdapter, TSchema, TStream, any>,
329
- 'tools' | 'middleware' | 'context'
387
+ 'tools' | 'middleware' | 'context' | 'interrupts'
330
388
  > & {
331
389
  tools?: TTools
332
- middleware?: TMiddleware &
333
- CheckCoverage<Extract<TMiddleware, ReadonlyArray<AnyChatMiddleware>>>
334
- } & RequiredContextFromInputs<TTools, TMiddleware>
390
+ interrupts?: TInterrupts & CheckUniqueInterruptDefinitions<TInterrupts>
391
+ middleware?: ExactMiddlewareOption<TTools, TContext, TInterrupts, TMiddleware>
392
+ } & RuntimeContextOption<TTools, TMiddleware, TContext>
335
393
 
336
394
  // ===========================
337
395
  // Activity Options Type
@@ -489,6 +547,11 @@ export interface TextActivityOptions<
489
547
  * ```
490
548
  */
491
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>>
492
555
  /**
493
556
  * Runtime context value passed to middleware hooks and server tools.
494
557
  */
@@ -529,28 +592,21 @@ export function createChatOptions<
529
592
  TStream,
530
593
  any
531
594
  >['tools'] = TextActivityOptions<TAdapter, TSchema, TStream, any>['tools'],
532
- const TMiddleware extends TextActivityOptions<
533
- TAdapter,
534
- TSchema,
535
- TStream,
536
- any
537
- >['middleware'] = TextActivityOptions<
538
- TAdapter,
539
- TSchema,
540
- TStream,
541
- any
542
- >['middleware'],
595
+ const TInterrupts extends ReadonlyArray<
596
+ InterruptDefinition<any, any, any, any>
597
+ > = [],
598
+ TContext = unknown,
599
+ const TMiddleware extends Array<unknown> | undefined = undefined,
543
600
  >(
544
601
  options: TextActivityOptionsWithContext<
545
602
  TAdapter,
546
603
  TSchema,
547
604
  TStream,
548
605
  TTools,
606
+ TInterrupts,
607
+ TContext,
549
608
  TMiddleware
550
609
  >,
551
- // Preserve the concrete `tools` tuple on the returned options (so a later
552
- // `chat({ ...opts })` still narrows tool-call events to the tool names)
553
- // while threading the inferred runtime context like the bare options type.
554
610
  ): Omit<
555
611
  TextActivityOptions<
556
612
  TAdapter,
@@ -558,8 +614,12 @@ export function createChatOptions<
558
614
  TStream,
559
615
  InferredContext<TTools, TMiddleware>
560
616
  >,
561
- 'tools'
562
- > & { tools?: TTools } {
617
+ 'tools' | 'middleware' | 'interrupts'
618
+ > & {
619
+ tools?: TTools
620
+ interrupts?: TInterrupts
621
+ middleware?: ExactMiddlewareOption<TTools, TContext, TInterrupts, TMiddleware>
622
+ } {
563
623
  return options
564
624
  }
565
625
 
@@ -623,7 +683,7 @@ interface TextEngineConfig<
623
683
  adapter: TAdapter
624
684
  systemPrompts?: Array<SystemPrompt>
625
685
  params: TParams
626
- middleware?: Array<ChatMiddleware<TContext>>
686
+ middleware?: Array<AnyChatMiddleware>
627
687
  context?: TContext
628
688
  /**
629
689
  * If set, after the agent loop finishes the engine runs a
@@ -707,12 +767,18 @@ class TextEngine<
707
767
  >,
708
768
  > {
709
769
  private readonly adapter: TAdapter
770
+ private readonly interruptDefinitions: ReadonlyMap<
771
+ string,
772
+ InterruptDefinition<any, any, any, any>
773
+ >
710
774
  private params: TParams
711
775
  private systemPrompts: Array<SystemPrompt>
712
776
  private tools: Array<AnyRuntimeTool>
713
777
  private readonly loopStrategy: AgentLoopStrategy
714
778
  private toolCallManager: ToolCallManager<ReadonlyArray<AnyTool>, TContext>
715
779
  private readonly lazyToolManager: LazyToolManager
780
+ /** A public interruption terminal must always have this run's start event. */
781
+ private hasPublicRunStarted = false
716
782
  private readonly initialMessageCount: number
717
783
  private readonly requestId: string
718
784
  private readonly streamId: string
@@ -738,11 +804,15 @@ class TextEngine<
738
804
  []
739
805
  private currentThinkingContent = ''
740
806
  private currentThinkingSignature = ''
807
+ private hasSeenReasoningEvents = false
741
808
  private eventOptions?: Record<string, unknown> | undefined
742
809
  private eventToolNames?: Array<string>
743
810
  private finishedEvent: RunFinishedEvent | null = null
744
811
  private readonly streamedToolErrorResults = new Map<string, ToolResult>()
745
812
  private deferredToolCallRunFinishedChunks: Array<StreamChunk> = []
813
+ /** The model terminal is held until afterModel can choose an interrupt. */
814
+ private deferredModelRunFinishedChunks: Array<StreamChunk> = []
815
+
746
816
  private earlyTermination = false
747
817
  private toolPhase: ToolPhaseResult = 'continue'
748
818
  private cyclePhase: CyclePhase = 'processText'
@@ -753,6 +823,14 @@ class TextEngine<
753
823
  private readonly resumeClientToolResults = new Map<string, any>()
754
824
  private readonly resumeDeniedToolResults = new Map<string, unknown>()
755
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
+ >()
756
834
 
757
835
  // AG-UI protocol IDs
758
836
  private readonly threadId: string
@@ -760,7 +838,10 @@ class TextEngine<
760
838
  private readonly parentRunIdOverride?: string
761
839
 
762
840
  // Middleware support
763
- private readonly middlewareRunner: MiddlewareRunner<TContext>
841
+ private readonly middlewareRunner: MiddlewareRunner<
842
+ TContext,
843
+ InterruptDefinition<any, any, any, any>
844
+ >
764
845
  private readonly middlewareCtx: ChatMiddlewareContext<TContext>
765
846
  private readonly sandboxFileQueue: Array<StreamChunk> = []
766
847
  private readonly deferredPromises: Array<Promise<unknown>> = []
@@ -782,8 +863,13 @@ class TextEngine<
782
863
  private readonly logger: InternalLogger
783
864
 
784
865
  // Structured-output finalization state (populated by runStructuredFinalization)
785
- private structuredOutputResult: { data: unknown; rawText: string } | null =
786
- 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
787
873
  // Native combined mode: tracks whether we've already emitted the synthetic
788
874
  // `structured-output.start` event before the schema-constrained final-turn
789
875
  // text begins streaming. The event must precede the first
@@ -820,6 +906,15 @@ class TextEngine<
820
906
  ) {
821
907
  this.logger = logger
822
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
+ )
823
918
  this.finalStructuredOutput = config.finalStructuredOutput
824
919
  this.params = config.params
825
920
  this.systemPrompts = config.params.systemPrompts || []
@@ -841,6 +936,7 @@ class TextEngine<
841
936
  this.messages = convertMessagesToModelMessages(config.params.messages)
842
937
 
843
938
  // Initialize lazy tool manager after messages are converted (needs message history for scanning)
939
+ assertUniqueToolNames(config.params.tools || [])
844
940
  this.lazyToolManager = new LazyToolManager(
845
941
  config.params.tools || [],
846
942
  this.messages,
@@ -871,7 +967,9 @@ class TextEngine<
871
967
  // handleStreamChunk processes raw chunks BEFORE middleware, so internal
872
968
  // state management sees extended fields (finishReason, delta, toolCallName, etc.).
873
969
  // The strip middleware ensures the yielded public stream is AG-UI spec-compliant.
874
- const allMiddleware: Array<ChatMiddleware<TContext>> = [
970
+ const allMiddleware: Array<
971
+ ChatMiddleware<TContext, InterruptDefinition<any, any, any, any>>
972
+ > = [
875
973
  devtoolsMiddleware(),
876
974
  ...(config.middleware || []),
877
975
  stripToSpecMiddleware(),
@@ -950,6 +1048,10 @@ class TextEngine<
950
1048
  },
951
1049
  })
952
1050
 
1051
+ provideGenericInterruptDefinitionRegistry(this.middlewareCtx, {
1052
+ definitions: this.interruptDefinitions,
1053
+ })
1054
+
953
1055
  // Provide the internal SandboxRuntime capability so harness adapters and
954
1056
  // sandbox middleware can emit file events. The sink logs, fans the event
955
1057
  // out through the middleware `onFile*` hooks (fire-and-forget), and queues
@@ -1041,10 +1143,25 @@ class TextEngine<
1041
1143
  )
1042
1144
  this.applyMiddlewareConfig(transformedConfig)
1043
1145
  await this.applyEphemeralInterruptResume(transformedConfig)
1146
+ await this.applyDurableGenericInterruptResolution()
1044
1147
 
1045
1148
  // Run onStart (devtools middleware emits text:request:started and initial messages here)
1046
1149
  await this.middlewareRunner.runOnStart(this.middlewareCtx)
1047
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
+
1048
1165
  const pendingPhase = yield* this.checkForPendingToolCalls()
1049
1166
  if (pendingPhase === 'wait') {
1050
1167
  return
@@ -1088,7 +1205,35 @@ class TextEngine<
1088
1205
  )
1089
1206
  this.applyMiddlewareConfig(iterTransformedConfig)
1090
1207
 
1208
+ if (
1209
+ yield* this.emitBoundaryInterrupts(
1210
+ 'beforeModel',
1211
+ this.createSyntheticFinishedEvent(),
1212
+ )
1213
+ ) {
1214
+ this.setToolPhase('wait')
1215
+ return
1216
+ }
1217
+
1091
1218
  yield* this.streamModelResponse()
1219
+
1220
+ if (
1221
+ yield* this.emitBoundaryInterrupts(
1222
+ 'afterModel',
1223
+ this.finishedEvent ?? this.createSyntheticFinishedEvent(),
1224
+ )
1225
+ ) {
1226
+ this.setToolPhase('wait')
1227
+ return
1228
+ }
1229
+ if (this.shouldExecuteToolPhase()) {
1230
+ this.deferredToolCallRunFinishedChunks.push(
1231
+ ...this.deferredModelRunFinishedChunks,
1232
+ )
1233
+ this.deferredModelRunFinishedChunks = []
1234
+ } else {
1235
+ yield* this.flushDeferredModelRunFinishedChunks()
1236
+ }
1092
1237
  } else {
1093
1238
  yield* this.processToolCalls()
1094
1239
  }
@@ -1152,6 +1297,7 @@ class TextEngine<
1152
1297
  duration: Date.now() - this.streamStartTime,
1153
1298
  })
1154
1299
  } else {
1300
+ this.addTerminalAssistantMessages()
1155
1301
  this.terminalHookCalled = true
1156
1302
  await this.middlewareRunner.runOnFinish(this.middlewareCtx, {
1157
1303
  finishReason: this.lastFinishReason,
@@ -1282,6 +1428,7 @@ class TextEngine<
1282
1428
  this.accumulatedThinking = []
1283
1429
  this.currentThinkingContent = ''
1284
1430
  this.currentThinkingSignature = ''
1431
+ this.hasSeenReasoningEvents = false
1285
1432
  this.finishedEvent = null
1286
1433
  this.streamedToolErrorResults.clear()
1287
1434
 
@@ -1394,6 +1541,7 @@ class TextEngine<
1394
1541
  typeof startValue.messageId === 'string'
1395
1542
  ) {
1396
1543
  this.combinedStructuredMessageId = startValue.messageId
1544
+ this.captureStructuredOutputMessageIdentity(startValue.messageId)
1397
1545
  }
1398
1546
  }
1399
1547
 
@@ -1411,6 +1559,11 @@ class TextEngine<
1411
1559
  this.structuredOutputResult = { data: object, rawText: parsed.raw }
1412
1560
  this.combinedCompleteEmitted = true
1413
1561
  const value = chunk.value
1562
+ const completeMessageId = readCustomEventMessageId(value)
1563
+ if (completeMessageId) {
1564
+ this.combinedStructuredMessageId = completeMessageId
1565
+ this.captureStructuredOutputMessageIdentity(completeMessageId)
1566
+ }
1414
1567
  if (object !== parsed.object && value && typeof value === 'object') {
1415
1568
  outboundChunk = { ...chunk, value: { ...value, object } }
1416
1569
  }
@@ -1473,10 +1626,17 @@ class TextEngine<
1473
1626
  ) {
1474
1627
  continue
1475
1628
  }
1629
+ if (outputChunk.type === EventType.RUN_FINISHED) {
1630
+ this.deferredModelRunFinishedChunks.push(outputChunk)
1631
+ continue
1632
+ }
1476
1633
  if (this.shouldDeferToolCallRunFinished(outputChunk)) {
1477
1634
  this.deferredToolCallRunFinishedChunks.push(outputChunk)
1478
1635
  continue
1479
1636
  }
1637
+ if (outputChunk.type === EventType.RUN_STARTED) {
1638
+ this.hasPublicRunStarted = true
1639
+ }
1480
1640
  this.logger.output(`type=${outputChunk.type}`, { chunk: outputChunk })
1481
1641
  yield outputChunk
1482
1642
  this.middlewareCtx.chunkIndex++
@@ -1533,16 +1693,19 @@ class TextEngine<
1533
1693
  this.handleStepFinishedEvent(chunk)
1534
1694
  break
1535
1695
 
1696
+ case 'REASONING_MESSAGE_CONTENT':
1697
+ this.handleReasoningMessageContentEvent(chunk)
1698
+ break
1699
+
1536
1700
  case 'TOOL_CALL_RESULT':
1537
1701
  // Tool result is already added to messages in buildToolResultChunks
1538
1702
  break
1539
1703
 
1540
1704
  case 'REASONING_START':
1541
1705
  case 'REASONING_MESSAGE_START':
1542
- case 'REASONING_MESSAGE_CONTENT':
1543
1706
  case 'REASONING_MESSAGE_END':
1544
1707
  case 'REASONING_END':
1545
- // Reasoning events are handled by StreamProcessor
1708
+ // No special handling needed
1546
1709
  break
1547
1710
 
1548
1711
  default:
@@ -1574,6 +1737,11 @@ class TextEngine<
1574
1737
  }
1575
1738
  }
1576
1739
 
1740
+ private captureStructuredOutputMessageIdentity(messageId: string): void {
1741
+ this.structuredOutputMessageId = messageId
1742
+ this.structuredOutputMessageCreatedAt ??= new Date()
1743
+ }
1744
+
1577
1745
  private handleToolCallStartEvent(chunk: ToolCallStartEvent): void {
1578
1746
  if (
1579
1747
  typeof chunk.parentMessageId === 'string' &&
@@ -1651,14 +1819,29 @@ class TextEngine<
1651
1819
  private handleStepFinishedEvent(
1652
1820
  chunk: Extract<StreamChunk, { type: 'STEP_FINISHED' }>,
1653
1821
  ): void {
1654
- if (chunk.delta) {
1655
- this.currentThinkingContent += chunk.delta
1822
+ if (!this.hasSeenReasoningEvents) {
1823
+ if (chunk.delta) {
1824
+ this.currentThinkingContent += chunk.delta
1825
+ } else if (chunk.content) {
1826
+ if (chunk.content.startsWith(this.currentThinkingContent)) {
1827
+ this.currentThinkingContent = chunk.content
1828
+ } else if (!this.currentThinkingContent.startsWith(chunk.content)) {
1829
+ this.currentThinkingContent += chunk.content
1830
+ }
1831
+ }
1656
1832
  }
1657
1833
  if (chunk.signature) {
1658
1834
  this.currentThinkingSignature = chunk.signature
1659
1835
  }
1660
1836
  }
1661
1837
 
1838
+ private handleReasoningMessageContentEvent(
1839
+ chunk: Extract<StreamChunk, { type: 'REASONING_MESSAGE_CONTENT' }>,
1840
+ ): void {
1841
+ this.hasSeenReasoningEvents = true
1842
+ this.currentThinkingContent += chunk.delta
1843
+ }
1844
+
1662
1845
  /**
1663
1846
  * Tools available for execution this turn. The discovery tool is dropped
1664
1847
  * from the advertised set (`this.tools`) once every lazy tool is discovered,
@@ -1736,6 +1919,18 @@ class TextEngine<
1736
1919
  return 'continue'
1737
1920
  }
1738
1921
 
1922
+ this.middlewareCtx.phase = 'beforeTools'
1923
+ if (
1924
+ yield* this.emitBoundaryInterrupts(
1925
+ 'beforeTools',
1926
+ finishEvent,
1927
+ executablePendingCalls,
1928
+ )
1929
+ ) {
1930
+ this.setToolPhase('wait')
1931
+ return 'wait'
1932
+ }
1933
+
1739
1934
  const { approvals, clientToolResults } = this.collectClientState()
1740
1935
 
1741
1936
  const generator = executeToolCalls(
@@ -1903,6 +2098,17 @@ class TextEngine<
1903
2098
  }
1904
2099
  this.middlewareCtx.phase = 'beforeTools'
1905
2100
 
2101
+ if (
2102
+ yield* this.emitBoundaryInterrupts(
2103
+ 'beforeTools',
2104
+ finishEvent,
2105
+ executableToolCalls,
2106
+ )
2107
+ ) {
2108
+ this.setToolPhase('wait')
2109
+ return
2110
+ }
2111
+
1906
2112
  const { approvals, clientToolResults } = this.collectClientState()
1907
2113
 
1908
2114
  const generator = executeToolCalls(
@@ -1970,15 +2176,36 @@ class TextEngine<
1970
2176
  needsClientExecution: executionResult.needsClientExecution,
1971
2177
  })
1972
2178
 
2179
+ const afterToolBoundaryChunks = this.buildToolResultChunks(
2180
+ allResults,
2181
+ finishEvent,
2182
+ )
2183
+ const afterToolRequests =
2184
+ await this.middlewareRunner.runOnInterruptBoundary(
2185
+ this.middlewareCtx as ChatMiddlewareContext<TContext> & {
2186
+ phase: 'afterTools'
2187
+ },
2188
+ )
2189
+ if (afterToolRequests.length > 0) {
2190
+ for (const chunk of afterToolBoundaryChunks) {
2191
+ yield* this.pipeThroughMiddleware(chunk)
2192
+ }
2193
+ yield* this.emitBoundaryInterrupts(
2194
+ 'afterTools',
2195
+ finishEvent,
2196
+ toolCalls,
2197
+ afterToolRequests,
2198
+ )
2199
+ this.setToolPhase('wait')
2200
+ return
2201
+ }
2202
+
1973
2203
  if (
1974
2204
  executionResult.needsApproval.length > 0 ||
1975
2205
  executionResult.needsClientExecution.length > 0
1976
2206
  ) {
1977
2207
  if (allResults.length > 0) {
1978
- for (const chunk of this.buildToolResultChunks(
1979
- allResults,
1980
- finishEvent,
1981
- )) {
2208
+ for (const chunk of afterToolBoundaryChunks) {
1982
2209
  yield* this.pipeThroughMiddleware(chunk)
1983
2210
  }
1984
2211
  }
@@ -1994,7 +2221,7 @@ class TextEngine<
1994
2221
 
1995
2222
  yield* this.flushDeferredToolCallRunFinishedChunks()
1996
2223
 
1997
- const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent)
2224
+ const toolResultChunks = afterToolBoundaryChunks
1998
2225
 
1999
2226
  for (const chunk of toolResultChunks) {
2000
2227
  yield* this.pipeThroughMiddleware(chunk)
@@ -2034,6 +2261,48 @@ class TextEngine<
2034
2261
  this.deferredToolCallRunFinishedChunks = []
2035
2262
  }
2036
2263
 
2264
+ private *flushDeferredModelRunFinishedChunks(): Generator<StreamChunk> {
2265
+ for (const chunk of this.deferredModelRunFinishedChunks) {
2266
+ this.logger.output(`type=${chunk.type}`, { chunk })
2267
+ yield chunk
2268
+ this.middlewareCtx.chunkIndex++
2269
+ }
2270
+ this.deferredModelRunFinishedChunks = []
2271
+ }
2272
+
2273
+ private async *emitSyntheticRunStarted(
2274
+ finishEvent: RunFinishedEvent,
2275
+ ): AsyncGenerator<StreamChunk, void, void> {
2276
+ if (this.hasPublicRunStarted) return
2277
+ yield* this.pipeThroughMiddleware({
2278
+ type: EventType.RUN_STARTED,
2279
+ runId: finishEvent.runId,
2280
+ threadId: finishEvent.threadId,
2281
+ model: finishEvent.model,
2282
+ timestamp: Date.now(),
2283
+ })
2284
+ }
2285
+
2286
+ private async *emitSuccessfulEarlyTermination(): AsyncGenerator<
2287
+ StreamChunk,
2288
+ void,
2289
+ void
2290
+ > {
2291
+ // `stop` is a finished run, not another tool cycle. `tool_calls` here
2292
+ // makes the client auto-send after afterTools, so reject looks stuck.
2293
+ this.lastFinishReason = 'stop'
2294
+ const finishEvent = {
2295
+ ...this.createSyntheticFinishedEvent(),
2296
+ finishReason: 'stop' as const,
2297
+ }
2298
+ yield* this.emitSyntheticRunStarted(finishEvent)
2299
+ yield* this.pipeThroughMiddleware({
2300
+ ...finishEvent,
2301
+ timestamp: Date.now(),
2302
+ outcome: { type: 'success' },
2303
+ })
2304
+ }
2305
+
2037
2306
  private discardDeferredToolCallRunFinishedChunks(): void {
2038
2307
  this.deferredToolCallRunFinishedChunks = []
2039
2308
  }
@@ -2065,6 +2334,107 @@ class TextEngine<
2065
2334
  this.middlewareCtx.messages = this.messages
2066
2335
  }
2067
2336
 
2337
+ private addTerminalAssistantMessages(): void {
2338
+ this.finalizeCurrentThinkingStep()
2339
+
2340
+ const structuredResult = this.structuredOutputResult
2341
+ const raw = structuredResult
2342
+ ? structuredResult.rawText || safeJsonStringify(structuredResult.data)
2343
+ : ''
2344
+ const structuredOutput: StructuredOutputPart | undefined = structuredResult
2345
+ ? {
2346
+ type: 'structured-output',
2347
+ status: 'complete',
2348
+ data: structuredResult.data,
2349
+ partial: structuredResult.data,
2350
+ raw,
2351
+ ...(structuredResult.reasoning !== undefined
2352
+ ? { reasoning: structuredResult.reasoning }
2353
+ : {}),
2354
+ }
2355
+ : undefined
2356
+ const nativeCombined = this.finalStructuredOutput?.nativeCombined === true
2357
+ const eventSourced = this.finalStructuredOutput?.source === 'event'
2358
+ const structuredId =
2359
+ this.structuredOutputMessageId ??
2360
+ this.combinedStructuredMessageId ??
2361
+ this.currentMessageId ??
2362
+ this.createId('msg')
2363
+ // Codex, OpenCode, ACP, and grok-build reuse the last text messageId
2364
+ // on structured-output.complete. Split only when the event uses a
2365
+ // different id (Claude Code). Same-id output stays on one message.
2366
+ const splitStructuredMessage =
2367
+ Boolean(structuredOutput) &&
2368
+ (!nativeCombined || eventSourced) &&
2369
+ this.currentMessageId != null &&
2370
+ structuredId !== this.currentMessageId
2371
+ const messages = [...this.middlewareCtx.messages]
2372
+ const existingStructuredIndex = messages.findIndex(
2373
+ (message) => message.role === 'assistant' && message.id === structuredId,
2374
+ )
2375
+ const currentTurnAlreadyRecorded = messages.some(
2376
+ (message) =>
2377
+ message.role === 'assistant' && message.id === this.currentMessageId,
2378
+ )
2379
+ const thinking =
2380
+ this.accumulatedThinking.length > 0 ? this.accumulatedThinking : undefined
2381
+ const startedLength = messages.length
2382
+
2383
+ if (structuredOutput && existingStructuredIndex >= 0) {
2384
+ const existing = messages[existingStructuredIndex]
2385
+ if (existing) {
2386
+ messages[existingStructuredIndex] = {
2387
+ ...existing,
2388
+ content: raw || existing.content,
2389
+ structuredOutput,
2390
+ }
2391
+ }
2392
+ } else if (structuredOutput && !splitStructuredMessage) {
2393
+ if (!currentTurnAlreadyRecorded) {
2394
+ messages.push({
2395
+ role: 'assistant',
2396
+ content: this.accumulatedContent || raw || null,
2397
+ id: structuredId,
2398
+ createdAt:
2399
+ this.currentMessageCreatedAt ??
2400
+ this.structuredOutputMessageCreatedAt ??
2401
+ new Date(),
2402
+ structuredOutput,
2403
+ ...(thinking ? { thinking } : {}),
2404
+ })
2405
+ }
2406
+ } else {
2407
+ if (
2408
+ !currentTurnAlreadyRecorded &&
2409
+ (this.accumulatedContent !== '' || thinking)
2410
+ ) {
2411
+ messages.push({
2412
+ role: 'assistant',
2413
+ content: this.accumulatedContent || null,
2414
+ id: this.currentMessageId ?? this.createId('msg'),
2415
+ createdAt: this.currentMessageCreatedAt ?? new Date(),
2416
+ ...(thinking ? { thinking } : {}),
2417
+ })
2418
+ }
2419
+ if (structuredOutput) {
2420
+ messages.push({
2421
+ role: 'assistant',
2422
+ content: raw || null,
2423
+ id: structuredId,
2424
+ createdAt: this.structuredOutputMessageCreatedAt ?? new Date(),
2425
+ structuredOutput,
2426
+ })
2427
+ }
2428
+ }
2429
+
2430
+ if (messages.length === startedLength && existingStructuredIndex < 0) {
2431
+ return
2432
+ }
2433
+
2434
+ this.messages = messages
2435
+ this.middlewareCtx.messages = this.messages
2436
+ }
2437
+
2068
2438
  /**
2069
2439
  * Extract client state (approvals and client tool results) from original messages.
2070
2440
  * This is called in the constructor BEFORE converting to ModelMessage format,
@@ -2155,9 +2525,17 @@ class TextEngine<
2155
2525
  return { approvals, clientToolResults }
2156
2526
  }
2157
2527
 
2528
+ private genericInterruptId(): string {
2529
+ return this.createId('interrupt')
2530
+ }
2531
+
2158
2532
  private buildActionableInterrupts(
2159
2533
  approvals: Array<ApprovalRequest>,
2160
2534
  clientRequests: Array<ClientToolRequest>,
2535
+ genericRequests: ReadonlyArray<
2536
+ GenericInterruptRequest<InterruptDefinition<any, any, any, any>>
2537
+ > = [],
2538
+ genericInterruptIds: ReadonlyArray<string> = [],
2161
2539
  ): Array<Interrupt> {
2162
2540
  const interrupts: Array<Interrupt> = []
2163
2541
 
@@ -2227,6 +2605,64 @@ class TextEngine<
2227
2605
  })
2228
2606
  }
2229
2607
 
2608
+ for (const [index, request] of genericRequests.entries()) {
2609
+ const batchIndex = interrupts.length
2610
+ const id = genericInterruptIds[index]
2611
+ if (!id) throw new Error('Generic interrupt id is unavailable.')
2612
+ const preEmission = createInterruptBinding(request, { batchIndex })
2613
+ interrupts.push({
2614
+ id,
2615
+ reason: request.reason,
2616
+ message: request.message,
2617
+ ...(preEmission.descriptor.responseSchemaCanonicalJson !== undefined
2618
+ ? {
2619
+ responseSchema: JSON.parse(
2620
+ preEmission.descriptor.responseSchemaCanonicalJson,
2621
+ ),
2622
+ }
2623
+ : {}),
2624
+ ...(request.expiresAt !== undefined
2625
+ ? { expiresAt: request.expiresAt }
2626
+ : {}),
2627
+ metadata: {
2628
+ [interruptBindingMetadataKey]: {
2629
+ v: INTERRUPT_BINDING_VERSION,
2630
+ kind: 'generic',
2631
+ interruptId: id,
2632
+ definitionId: preEmission.descriptor.definitionId,
2633
+ key: preEmission.descriptor.key,
2634
+ batchIndex,
2635
+ ...(request.expiresAt !== undefined
2636
+ ? { expiresAt: request.expiresAt }
2637
+ : {}),
2638
+ ...(preEmission.descriptor.payloadSchemaHash
2639
+ ? {
2640
+ payloadSchemaHash: preEmission.descriptor.payloadSchemaHash,
2641
+ }
2642
+ : {}),
2643
+ ...(preEmission.descriptor.responseSchemaHash !== undefined
2644
+ ? {
2645
+ responseSchemaHash: preEmission.descriptor.responseSchemaHash,
2646
+ }
2647
+ : {}),
2648
+ },
2649
+ ...(preEmission.payload !== undefined
2650
+ ? { [INTERRUPT_PAYLOAD_METADATA_KEY]: preEmission.payload }
2651
+ : {}),
2652
+ },
2653
+ })
2654
+ }
2655
+
2656
+ const ids = new Set<string>()
2657
+ for (const interrupt of interrupts) {
2658
+ if (ids.has(interrupt.id)) {
2659
+ throw new Error(
2660
+ `Duplicate interrupt id in final batch: ${interrupt.id}`,
2661
+ )
2662
+ }
2663
+ ids.add(interrupt.id)
2664
+ }
2665
+
2230
2666
  return interrupts
2231
2667
  }
2232
2668
 
@@ -2234,13 +2670,22 @@ class TextEngine<
2234
2670
  finishEvent: RunFinishedEvent,
2235
2671
  approvals: Array<ApprovalRequest>,
2236
2672
  clientRequests: Array<ClientToolRequest>,
2673
+ genericRequests: ReadonlyArray<
2674
+ GenericInterruptRequest<InterruptDefinition<any, any, any, any>>
2675
+ > = [],
2676
+ genericInterruptIds?: ReadonlyArray<string>,
2237
2677
  ): StreamChunk {
2238
2678
  return {
2239
2679
  ...finishEvent,
2240
2680
  timestamp: Date.now(),
2241
2681
  outcome: {
2242
2682
  type: 'interrupt',
2243
- interrupts: this.buildActionableInterrupts(approvals, clientRequests),
2683
+ interrupts: this.buildActionableInterrupts(
2684
+ approvals,
2685
+ clientRequests,
2686
+ genericRequests,
2687
+ genericInterruptIds,
2688
+ ),
2244
2689
  },
2245
2690
  }
2246
2691
  }
@@ -2254,12 +2699,19 @@ class TextEngine<
2254
2699
  : message.content === null
2255
2700
  ? undefined
2256
2701
  : JSON.stringify(message.content)
2702
+ const id =
2703
+ message.id ||
2704
+ `snapshot_${this.runIdOverride ?? this.requestId}_${index}`
2705
+ const parts =
2706
+ message.role === 'assistant' &&
2707
+ (message.thinking?.length || message.structuredOutput)
2708
+ ? modelMessageToUIMessage(message, id).parts
2709
+ : undefined
2257
2710
  return {
2258
- id:
2259
- message.id ||
2260
- `snapshot_${this.runIdOverride ?? this.requestId}_${index}`,
2711
+ id,
2261
2712
  role: message.role,
2262
2713
  ...(content !== undefined ? { content } : {}),
2714
+ ...(parts ? { parts } : {}),
2263
2715
  ...('toolCalls' in message && message.toolCalls
2264
2716
  ? { toolCalls: message.toolCalls }
2265
2717
  : {}),
@@ -2383,9 +2835,22 @@ class TextEngine<
2383
2835
  finishEvent: RunFinishedEvent,
2384
2836
  approvals: Array<ApprovalRequest>,
2385
2837
  clientRequests: Array<ClientToolRequest>,
2838
+ genericRequests: ReadonlyArray<
2839
+ GenericInterruptRequest<InterruptDefinition<any, any, any, any>>
2840
+ > = [],
2386
2841
  ): AsyncGenerator<StreamChunk, boolean, void> {
2842
+ yield* this.emitSyntheticRunStarted(finishEvent)
2843
+ const genericInterruptIds = genericRequests.map(() =>
2844
+ this.genericInterruptId(),
2845
+ )
2387
2846
  const terminal = this.completeEphemeralInterruptBindings(
2388
- this.buildInterruptFinishedChunk(finishEvent, approvals, clientRequests),
2847
+ this.buildInterruptFinishedChunk(
2848
+ finishEvent,
2849
+ approvals,
2850
+ clientRequests,
2851
+ genericRequests,
2852
+ genericInterruptIds,
2853
+ ),
2389
2854
  )
2390
2855
  let terminalOutputs: Array<StreamChunk>
2391
2856
  try {
@@ -2414,6 +2879,103 @@ class TextEngine<
2414
2879
  return true
2415
2880
  }
2416
2881
 
2882
+ private async *emitBoundaryInterrupts(
2883
+ phase: 'beforeModel' | 'afterModel' | 'beforeTools' | 'afterTools',
2884
+ finishEvent: RunFinishedEvent,
2885
+ toolCalls: ReadonlyArray<ToolCall> = [],
2886
+ requests?: ReadonlyArray<
2887
+ GenericInterruptRequest<InterruptDefinition<any, any, any, any>>
2888
+ >,
2889
+ ): AsyncGenerator<StreamChunk, boolean, void> {
2890
+ this.middlewareCtx.phase = phase
2891
+ const boundaryRequests =
2892
+ requests ??
2893
+ (await this.middlewareRunner.runOnInterruptBoundary(
2894
+ this.middlewareCtx as ChatMiddlewareContext<TContext> & {
2895
+ phase: typeof phase
2896
+ },
2897
+ ))
2898
+ if (boundaryRequests.length === 0) return false
2899
+ for (const request of boundaryRequests) {
2900
+ if (
2901
+ this.interruptDefinitions.get(request.definition.id) !==
2902
+ request.definition
2903
+ ) {
2904
+ throw new Error(
2905
+ `Generic interrupt definition ${request.definition.id} is not registered on this chat.`,
2906
+ )
2907
+ }
2908
+ }
2909
+ if (phase === 'afterModel') {
2910
+ if (this.toolCallManager.hasToolCalls()) {
2911
+ this.addAssistantToolCallMessage(this.toolCallManager.getToolCalls())
2912
+ } else {
2913
+ this.addAssistantTextMessageForInterrupt()
2914
+ }
2915
+ }
2916
+ const actionable = this.getBoundaryActionableToolRequests(toolCalls)
2917
+ yield* this.emitActionableInterruptBoundary(
2918
+ finishEvent,
2919
+ actionable.approvals,
2920
+ actionable.clientRequests,
2921
+ boundaryRequests,
2922
+ )
2923
+ return true
2924
+ }
2925
+
2926
+ private addAssistantTextMessageForInterrupt(): void {
2927
+ if (this.accumulatedContent.length === 0) return
2928
+ this.messages = [
2929
+ ...this.messages,
2930
+ { role: 'assistant', content: this.accumulatedContent },
2931
+ ]
2932
+ this.middlewareCtx.messages = this.messages
2933
+ }
2934
+
2935
+ private getBoundaryActionableToolRequests(
2936
+ toolCalls: ReadonlyArray<ToolCall>,
2937
+ ): {
2938
+ approvals: Array<ApprovalRequest>
2939
+ clientRequests: Array<ClientToolRequest>
2940
+ } {
2941
+ const { approvals, clientToolResults } = this.collectClientState()
2942
+ const approvalRequests: Array<ApprovalRequest> = []
2943
+ const clientRequests: Array<ClientToolRequest> = []
2944
+ for (const toolCall of toolCalls) {
2945
+ const tool = this.resolveExecutableTools([toolCall]).find(
2946
+ (candidate) => candidate.name === toolCall.function.name,
2947
+ ) as RuntimeToolWithApproval | undefined
2948
+ if (!tool) continue
2949
+ let input: unknown = {}
2950
+ try {
2951
+ const parsed = JSON.parse(toolCall.function.arguments.trim() || '{}')
2952
+ input = parsed && typeof parsed === 'object' ? parsed : {}
2953
+ } catch {
2954
+ input = {}
2955
+ }
2956
+ const approvalId = `approval_${toolCall.id}`
2957
+ if (tool.needsApproval && !approvals.has(approvalId)) {
2958
+ approvalRequests.push({
2959
+ toolCallId: toolCall.id,
2960
+ toolName: toolCall.function.name,
2961
+ input,
2962
+ approvalId,
2963
+ })
2964
+ } else if (
2965
+ !tool.execute &&
2966
+ !clientToolResults.has(toolCall.id) &&
2967
+ !this.resumeCancelledToolCallIds.has(toolCall.id)
2968
+ ) {
2969
+ clientRequests.push({
2970
+ toolCallId: toolCall.id,
2971
+ toolName: toolCall.function.name,
2972
+ input,
2973
+ })
2974
+ }
2975
+ }
2976
+ return { approvals: approvalRequests, clientRequests }
2977
+ }
2978
+
2417
2979
  private completeEphemeralInterruptBindings(chunk: StreamChunk): StreamChunk {
2418
2980
  if (
2419
2981
  chunk.type !== EventType.RUN_FINISHED ||
@@ -2661,7 +3223,7 @@ class TextEngine<
2661
3223
  private createSyntheticFinishedEvent(): RunFinishedEvent {
2662
3224
  return {
2663
3225
  type: 'RUN_FINISHED',
2664
- runId: this.createId('pending'),
3226
+ runId: this.runIdOverride ?? this.requestId,
2665
3227
  threadId: this.threadId,
2666
3228
  model: this.params.model,
2667
3229
  timestamp: Date.now(),
@@ -2940,6 +3502,7 @@ class TextEngine<
2940
3502
  const buildSynthesizedStart = (timestamp = Date.now()): StreamChunk => {
2941
3503
  const idForStart = structuredMessageId ?? generateMessageId()
2942
3504
  structuredMessageId = idForStart
3505
+ this.captureStructuredOutputMessageIdentity(idForStart)
2943
3506
  return {
2944
3507
  type: EventType.CUSTOM,
2945
3508
  name: 'structured-output.start',
@@ -2980,7 +3543,10 @@ class TextEngine<
2980
3543
  // synthesized start (when needed) uses the SAME id the deltas carry
2981
3544
  if (!structuredMessageId) {
2982
3545
  const extracted = extractMessageId(chunk)
2983
- if (extracted) structuredMessageId = extracted
3546
+ if (extracted) {
3547
+ structuredMessageId = extracted
3548
+ this.captureStructuredOutputMessageIdentity(extracted)
3549
+ }
2984
3550
  }
2985
3551
 
2986
3552
  // Synthesis only matters for the streaming client path — the agentic
@@ -3047,7 +3613,13 @@ class TextEngine<
3047
3613
  const object = this.finalStructuredOutput.normalize
3048
3614
  ? this.finalStructuredOutput.normalize(parsed.object)
3049
3615
  : parsed.object
3050
- this.structuredOutputResult = { data: object, rawText: parsed.raw }
3616
+ this.structuredOutputResult = {
3617
+ data: object,
3618
+ rawText: parsed.raw,
3619
+ ...(parsed.reasoning !== undefined
3620
+ ? { reasoning: parsed.reasoning }
3621
+ : {}),
3622
+ }
3051
3623
  // Rewrite the outbound event so the yielded chunk carries the
3052
3624
  // normalized object (the original `chunk.value` still holds the
3053
3625
  // widened one). Preserve every other field — `raw`, `reasoning` —
@@ -3498,7 +4070,15 @@ class TextEngine<
3498
4070
  }
3499
4071
  }
3500
4072
 
3501
- const pending = this.buildActionableInterrupts(
4073
+ const genericPending = this.getGenericContinuationPending(interruptedRunId)
4074
+ const pending: Array<{
4075
+ interruptId: string
4076
+ payload: unknown
4077
+ binding: InterruptBinding
4078
+ genericRequest?: GenericInterruptRequest<
4079
+ InterruptDefinition<any, any, any, any>
4080
+ >
4081
+ }> = this.buildActionableInterrupts(
3502
4082
  approvalRequests,
3503
4083
  clientRequests,
3504
4084
  ).flatMap((descriptor) => {
@@ -3517,6 +4097,7 @@ class TextEngine<
3517
4097
  ]
3518
4098
  : []
3519
4099
  })
4100
+ pending.push(...genericPending)
3520
4101
  const validated = await validateInterruptResumeBatch({
3521
4102
  threadId: this.threadId,
3522
4103
  interruptedRunId,
@@ -3544,6 +4125,185 @@ class TextEngine<
3544
4125
  ...validated.resumeToolState,
3545
4126
  approvals,
3546
4127
  })
4128
+
4129
+ const genericResolutions = validated.resumeToolState.genericInterrupts
4130
+ if (genericPending.length > 0 && genericResolutions) {
4131
+ const resolutions = genericPending
4132
+ .sort((left, right) => {
4133
+ const leftIndex =
4134
+ left.binding.kind === 'generic' ? (left.binding.batchIndex ?? 0) : 0
4135
+ const rightIndex =
4136
+ right.binding.kind === 'generic'
4137
+ ? (right.binding.batchIndex ?? 0)
4138
+ : 0
4139
+ return leftIndex - rightIndex
4140
+ })
4141
+ .flatMap((record) => {
4142
+ const resolution = genericResolutions.get(record.interruptId)
4143
+ if (!resolution || !record.genericRequest) return []
4144
+ return [
4145
+ resolution.status === 'resolved'
4146
+ ? {
4147
+ request: record.genericRequest,
4148
+ status: 'resolved' as const,
4149
+ response: resolution.payload,
4150
+ }
4151
+ : {
4152
+ request: record.genericRequest,
4153
+ status: 'cancelled' as const,
4154
+ },
4155
+ ]
4156
+ })
4157
+ const collection: InterruptResolutionCollection = {
4158
+ for: (definition) =>
4159
+ resolutions.filter(
4160
+ (resolution) => resolution.request.definition === definition,
4161
+ ) as never,
4162
+ all: (
4163
+ ...definitions: Array<InterruptDefinition<any, any, any, any>>
4164
+ ) =>
4165
+ definitions.length === 0
4166
+ ? resolutions
4167
+ : resolutions.filter((resolution) =>
4168
+ definitions.includes(resolution.request.definition),
4169
+ ),
4170
+ }
4171
+ const policy = await this.middlewareRunner.runOnInterruptResolution(
4172
+ this.middlewareCtx,
4173
+ collection,
4174
+ )
4175
+ if (policy.toolResume === 'stop') {
4176
+ this.earlyTermination = true
4177
+ } else if (policy.toolResume === 'cancel') {
4178
+ for (const request of pendingToolCalls) {
4179
+ this.resumeCancelledToolCallIds.add(request.id)
4180
+ }
4181
+ }
4182
+ }
4183
+ }
4184
+
4185
+ private getGenericContinuationPending(interruptedRunId: string): Array<{
4186
+ interruptId: string
4187
+ payload: unknown
4188
+ binding: InterruptBinding
4189
+ genericRequest: GenericInterruptRequest<
4190
+ InterruptDefinition<any, any, any, any>
4191
+ >
4192
+ }> {
4193
+ const fail = (message: string): never => {
4194
+ throw new InterruptResumeValidationError([
4195
+ {
4196
+ scope: 'batch',
4197
+ threadId: this.threadId,
4198
+ interruptedRunId,
4199
+ generation: 0,
4200
+ interruptIds: [],
4201
+ code: 'stale',
4202
+ message,
4203
+ source: 'server',
4204
+ retryable: false,
4205
+ },
4206
+ ])
4207
+ }
4208
+ const pending: Array<{
4209
+ interruptId: string
4210
+ payload: unknown
4211
+ binding: InterruptBinding
4212
+ genericRequest: GenericInterruptRequest<
4213
+ InterruptDefinition<any, any, any, any>
4214
+ >
4215
+ }> = []
4216
+ const ids = new Set<string>()
4217
+ const batchIndexes = new Set<number>()
4218
+ for (const resumeItem of this.params.resume ?? []) {
4219
+ const parsed = readGenericInterruptContinuation(resumeItem.metadata)
4220
+ if (parsed.status === 'absent') continue
4221
+ if (parsed.status === 'invalid') {
4222
+ return fail(parsed.message)
4223
+ }
4224
+ const entry = parsed.value
4225
+ const id = resumeItem.interruptId
4226
+ const definition = this.interruptDefinitions.get(entry.definitionId)
4227
+ if (!definition) {
4228
+ return fail(
4229
+ `Generic interrupt definition ${entry.definitionId} is unavailable.`,
4230
+ )
4231
+ }
4232
+ if (ids.has(id) || batchIndexes.has(entry.batchIndex)) {
4233
+ return fail(
4234
+ 'Generic interrupt continuation contains duplicate entries.',
4235
+ )
4236
+ }
4237
+ ids.add(id)
4238
+ batchIndexes.add(entry.batchIndex)
4239
+ let request: GenericInterruptRequest<
4240
+ InterruptDefinition<any, any, any, any>
4241
+ >
4242
+ try {
4243
+ request = rehydrateInterruptRequest(definition, {
4244
+ key: entry.key,
4245
+ reason: entry.reason,
4246
+ message: entry.message,
4247
+ ...(typeof entry.expiresAt === 'string'
4248
+ ? { expiresAt: entry.expiresAt }
4249
+ : {}),
4250
+ ...(Object.prototype.hasOwnProperty.call(entry, 'payload')
4251
+ ? { payload: entry.payload }
4252
+ : {}),
4253
+ })
4254
+ } catch (error) {
4255
+ return fail(
4256
+ `Generic interrupt continuation ${id} is invalid: ${
4257
+ error instanceof Error ? error.message : String(error)
4258
+ }`,
4259
+ )
4260
+ }
4261
+ const emitted = createInterruptBinding(request, {
4262
+ batchIndex: entry.batchIndex,
4263
+ })
4264
+ if (
4265
+ entry.responseSchemaHash !== emitted.descriptor.responseSchemaHash ||
4266
+ entry.payloadSchemaHash !== emitted.descriptor.payloadSchemaHash
4267
+ ) {
4268
+ return fail(
4269
+ `Generic interrupt continuation ${id} does not match its definition.`,
4270
+ )
4271
+ }
4272
+ pending.push({
4273
+ interruptId: id,
4274
+ payload: {
4275
+ id,
4276
+ ...(emitted.descriptor.responseSchemaCanonicalJson !== undefined
4277
+ ? {
4278
+ responseSchema: JSON.parse(
4279
+ emitted.descriptor.responseSchemaCanonicalJson,
4280
+ ),
4281
+ }
4282
+ : {}),
4283
+ },
4284
+ binding: {
4285
+ v: INTERRUPT_BINDING_VERSION,
4286
+ kind: 'generic',
4287
+ interruptId: id,
4288
+ interruptedRunId,
4289
+ generation: 0,
4290
+ definitionId: entry.definitionId,
4291
+ key: entry.key,
4292
+ batchIndex: entry.batchIndex,
4293
+ ...(typeof entry.expiresAt === 'string'
4294
+ ? { expiresAt: entry.expiresAt }
4295
+ : {}),
4296
+ ...(emitted.descriptor.payloadSchemaHash
4297
+ ? { payloadSchemaHash: emitted.descriptor.payloadSchemaHash }
4298
+ : {}),
4299
+ ...(entry.responseSchemaHash !== undefined
4300
+ ? { responseSchemaHash: entry.responseSchemaHash }
4301
+ : {}),
4302
+ },
4303
+ genericRequest: request,
4304
+ })
4305
+ }
4306
+ return pending
3547
4307
  }
3548
4308
 
3549
4309
  private applyResumeToolState(state: ChatResumeToolState | undefined): void {
@@ -3567,12 +4327,65 @@ class TextEngine<
3567
4327
  this.resumeCancelledToolCallIds.add(toolCallId)
3568
4328
  }
3569
4329
  }
4330
+ if (state?.genericInterrupts) {
4331
+ for (const [interruptId, resolution] of state.genericInterrupts) {
4332
+ this.resumeGenericInterrupts.set(interruptId, resolution)
4333
+ }
4334
+ }
4335
+ if (state?.genericInterruptRequests) {
4336
+ for (const [interruptId, request] of state.genericInterruptRequests) {
4337
+ this.resumeGenericInterruptRequests.set(interruptId, request)
4338
+ }
4339
+ }
4340
+ }
4341
+
4342
+ private async applyDurableGenericInterruptResolution(): Promise<void> {
4343
+ if (this.resumeGenericInterruptRequests.size === 0) return
4344
+ const resolutions = [
4345
+ ...this.resumeGenericInterruptRequests.entries(),
4346
+ ].flatMap(([interruptId, request]) => {
4347
+ const resolution = this.resumeGenericInterrupts.get(interruptId)
4348
+ if (!resolution) return []
4349
+ return [
4350
+ resolution.status === 'resolved'
4351
+ ? {
4352
+ request,
4353
+ status: 'resolved' as const,
4354
+ response: resolution.payload,
4355
+ }
4356
+ : { request, status: 'cancelled' as const },
4357
+ ]
4358
+ })
4359
+ const collection: InterruptResolutionCollection = {
4360
+ for: (definition) =>
4361
+ resolutions.filter(
4362
+ (resolution) => resolution.request.definition === definition,
4363
+ ) as never,
4364
+ all: (...definitions: Array<InterruptDefinition<any, any, any, any>>) =>
4365
+ definitions.length === 0
4366
+ ? resolutions
4367
+ : resolutions.filter((resolution) =>
4368
+ definitions.includes(resolution.request.definition),
4369
+ ),
4370
+ }
4371
+ const policy = await this.middlewareRunner.runOnInterruptResolution(
4372
+ this.middlewareCtx,
4373
+ collection,
4374
+ )
4375
+ if (policy.toolResume === 'stop') {
4376
+ this.earlyTermination = true
4377
+ } else if (policy.toolResume === 'cancel') {
4378
+ for (const toolCall of this.getPendingToolCallsFromMessages()) {
4379
+ this.resumeCancelledToolCallIds.add(toolCall.id)
4380
+ }
4381
+ }
3570
4382
  }
3571
4383
 
3572
4384
  private applyMiddlewareConfig(config: ChatMiddlewareConfig): void {
3573
4385
  this.applyResumeToolState(config.resumeToolState)
3574
4386
  this.messages = config.messages
3575
4387
  this.systemPrompts = config.systemPrompts
4388
+ assertUniqueToolNames(config.tools)
3576
4389
  this.tools = config.tools
3577
4390
  this.params = {
3578
4391
  ...this.params,
@@ -3604,6 +4417,9 @@ class TextEngine<
3604
4417
  chunk,
3605
4418
  )
3606
4419
  for (const outputChunk of outputChunks) {
4420
+ if (outputChunk.type === EventType.RUN_STARTED) {
4421
+ this.hasPublicRunStarted = true
4422
+ }
3607
4423
  yield outputChunk
3608
4424
  this.middlewareCtx.chunkIndex++
3609
4425
  }
@@ -3744,64 +4560,136 @@ export function chat<
3744
4560
  TStream,
3745
4561
  any
3746
4562
  >['tools'] = TextActivityOptions<TAdapter, TSchema, TStream, any>['tools'],
3747
- const TMiddleware extends TextActivityOptions<
3748
- TAdapter,
3749
- TSchema,
3750
- TStream,
3751
- any
3752
- >['middleware'] = TextActivityOptions<
3753
- TAdapter,
3754
- TSchema,
3755
- TStream,
3756
- any
3757
- >['middleware'],
4563
+ const TInterrupts extends ReadonlyArray<
4564
+ InterruptDefinition<any, any, any, any>
4565
+ > = [],
4566
+ TContext = unknown,
4567
+ const TMiddleware extends Array<unknown> | undefined = undefined,
3758
4568
  >(
3759
4569
  options: TextActivityOptionsWithContext<
3760
4570
  TAdapter,
3761
4571
  TSchema,
3762
4572
  TStream,
3763
4573
  TTools,
4574
+ TInterrupts,
4575
+ TContext,
3764
4576
  TMiddleware
3765
4577
  >,
3766
4578
  ): TextActivityResult<TSchema, TStream, TTools> {
3767
- validateCapabilities(options.middleware ?? [], options.adapter)
4579
+ validateInterruptDefinitions(options.interrupts)
4580
+ validateCapabilities(
4581
+ readRuntimeMiddleware(options.middleware) ?? [],
4582
+ options.adapter,
4583
+ )
4584
+ if (options.tools) {
4585
+ assertUniqueToolNames(options.tools)
4586
+ }
3768
4587
 
3769
4588
  const { outputSchema, stream } = options
3770
4589
 
3771
- // outputSchema + stream:true is the only branch that streams structured
3772
- // output. Without an explicit `stream: true`, schema-bearing calls run the
3773
- // agent loop and resolve to a typed Promise<InferSchemaType<TSchema>>.
3774
4590
  if (outputSchema && stream === true) {
3775
- return runStreamingStructuredOutput({
3776
- ...options,
3777
- outputSchema,
3778
- stream,
3779
- }) as TextActivityResult<TSchema, TStream, TTools>
4591
+ return runStreamingStructuredOutput(
4592
+ toRuntimeTextActivityOptions(options, {
4593
+ outputSchema,
4594
+ stream: true,
4595
+ }),
4596
+ ) as TextActivityResult<TSchema, TStream, TTools>
3780
4597
  }
3781
4598
 
3782
- // If outputSchema is provided, run agentic structured output (Promise<T>)
3783
4599
  if (outputSchema) {
3784
- return runAgenticStructuredOutput({
3785
- ...options,
3786
- outputSchema,
3787
- }) as TextActivityResult<TSchema, TStream, TTools>
4600
+ return runAgenticStructuredOutput(
4601
+ toRuntimeTextActivityOptions(options, {
4602
+ outputSchema,
4603
+ stream: false,
4604
+ }),
4605
+ ) as TextActivityResult<TSchema, TStream, TTools>
3788
4606
  }
3789
4607
 
3790
- // If stream is explicitly false, run non-streaming text
3791
4608
  if (stream === false) {
3792
- return runNonStreamingText({
3793
- ...options,
4609
+ return runNonStreamingText(
4610
+ toRuntimeTextActivityOptions(options, {
4611
+ outputSchema: undefined,
4612
+ stream: false,
4613
+ }),
4614
+ ) as TextActivityResult<TSchema, TStream, TTools>
4615
+ }
4616
+
4617
+ return runStreamingText(
4618
+ toRuntimeTextActivityOptions(options, {
3794
4619
  outputSchema: undefined,
3795
- stream,
3796
- }) as TextActivityResult<TSchema, TStream, TTools>
4620
+ stream: true,
4621
+ }),
4622
+ ) as TextActivityResult<TSchema, TStream, TTools>
4623
+ }
4624
+
4625
+ type RuntimeTextActivityOptions<
4626
+ TAdapter extends AnyTextAdapter,
4627
+ TSchema extends SchemaInput | undefined,
4628
+ TStream extends boolean,
4629
+ > = Omit<TextActivityOptions<TAdapter, TSchema, TStream, any>, 'middleware'> & {
4630
+ middleware?: Array<AnyChatMiddleware>
4631
+ }
4632
+
4633
+ function readRuntimeMiddleware(
4634
+ middleware: unknown,
4635
+ ): Array<AnyChatMiddleware> | undefined {
4636
+ if (middleware === undefined) return undefined
4637
+ if (!Array.isArray(middleware)) {
4638
+ throw new TypeError('Chat middleware must be an array.')
3797
4639
  }
4640
+ return middleware
4641
+ }
3798
4642
 
3799
- // Otherwise, run streaming text (default)
3800
- return runStreamingText({
3801
- ...options,
3802
- outputSchema: undefined,
3803
- stream,
3804
- }) as TextActivityResult<TSchema, TStream, TTools>
4643
+ function toRuntimeTextActivityOptions<
4644
+ TAdapter extends AnyTextAdapter,
4645
+ TInputSchema extends SchemaInput | undefined,
4646
+ TInputStream extends boolean,
4647
+ TOutputSchema extends SchemaInput | undefined,
4648
+ TOutputStream extends boolean,
4649
+ TTools extends TextActivityOptions<
4650
+ TAdapter,
4651
+ TInputSchema,
4652
+ TInputStream,
4653
+ any
4654
+ >['tools'],
4655
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
4656
+ TContext,
4657
+ TMiddleware extends Array<unknown> | undefined,
4658
+ >(
4659
+ options: TextActivityOptionsWithContext<
4660
+ TAdapter,
4661
+ TInputSchema,
4662
+ TInputStream,
4663
+ TTools,
4664
+ TInterrupts,
4665
+ TContext,
4666
+ TMiddleware
4667
+ >,
4668
+ overrides: { outputSchema: TOutputSchema; stream: TOutputStream },
4669
+ ): RuntimeTextActivityOptions<TAdapter, TOutputSchema, TOutputStream> {
4670
+ const { middleware, ...rest } = options
4671
+ return {
4672
+ ...rest,
4673
+ ...overrides,
4674
+ ...(middleware === undefined
4675
+ ? {}
4676
+ : { middleware: readRuntimeMiddleware(middleware) }),
4677
+ }
4678
+ }
4679
+
4680
+ function validateInterruptDefinitions(
4681
+ definitions:
4682
+ | ReadonlyArray<InterruptDefinition<any, any, any, any>>
4683
+ | undefined,
4684
+ ): void {
4685
+ if (!definitions) return
4686
+ const seen = new Set<string>()
4687
+ for (const definition of definitions) {
4688
+ if (seen.has(definition.id)) {
4689
+ throw new Error(`Duplicate interrupt definition id: ${definition.id}`)
4690
+ }
4691
+ seen.add(definition.id)
4692
+ }
3805
4693
  }
3806
4694
 
3807
4695
  /**
@@ -3853,8 +4741,8 @@ function publishDeliverySeams(
3853
4741
  * returns, so the identity has to be minted out here and the engine reached back
3854
4742
  * through `engineRef`, which the body fills as soon as its engine exists.
3855
4743
  */
3856
- function runStreamingText<TContext = unknown>(
3857
- options: TextActivityOptions<AnyTextAdapter, undefined, true, TContext>,
4744
+ function runStreamingText(
4745
+ options: RuntimeTextActivityOptions<AnyTextAdapter, undefined, boolean>,
3858
4746
  ): AsyncIterable<StreamChunk> {
3859
4747
  const engineRef: DeliveryEngineRef = {}
3860
4748
  const stream = streamTextChunks(options, engineRef)
@@ -3862,8 +4750,8 @@ function runStreamingText<TContext = unknown>(
3862
4750
  return stream
3863
4751
  }
3864
4752
 
3865
- async function* streamTextChunks<TContext = unknown>(
3866
- options: TextActivityOptions<AnyTextAdapter, undefined, true, TContext>,
4753
+ async function* streamTextChunks(
4754
+ options: RuntimeTextActivityOptions<AnyTextAdapter, undefined, boolean>,
3867
4755
  engineRef: DeliveryEngineRef,
3868
4756
  ): AsyncIterable<StreamChunk> {
3869
4757
  const { adapter, middleware, context, debug, mcp, ...textOptions } = options
@@ -3882,7 +4770,7 @@ async function* streamTextChunks<TContext = unknown>(
3882
4770
  params: { ...textOptions, model, logger } as TextOptions<
3883
4771
  Record<string, any>,
3884
4772
  Record<string, any>,
3885
- TContext
4773
+ any
3886
4774
  >,
3887
4775
  middleware,
3888
4776
  context,
@@ -3904,19 +4792,13 @@ async function* streamTextChunks<TContext = unknown>(
3904
4792
  * Run non-streaming text - collects all content and returns as a string.
3905
4793
  * Runs the full agentic loop (if tools are provided) but returns collected text.
3906
4794
  */
3907
- function runNonStreamingText<TContext = unknown>(
3908
- options: TextActivityOptions<AnyTextAdapter, undefined, false, TContext>,
4795
+ function runNonStreamingText(
4796
+ options: RuntimeTextActivityOptions<AnyTextAdapter, undefined, false>,
3909
4797
  ): Promise<string> {
3910
- // Run the streaming text and collect all text using streamToText.
3911
- const stream = runStreamingText(
3912
- // 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.
3913
- options as unknown as TextActivityOptions<
3914
- AnyTextAdapter,
3915
- undefined,
3916
- true,
3917
- TContext
3918
- >,
3919
- )
4798
+ const stream = runStreamingText({
4799
+ ...options,
4800
+ stream: true,
4801
+ })
3920
4802
 
3921
4803
  return streamToText(stream)
3922
4804
  }
@@ -3927,11 +4809,8 @@ function runNonStreamingText<TContext = unknown>(
3927
4809
  * 2. Once complete, call adapter.structuredOutput with the conversation context
3928
4810
  * 3. Validate and return the structured result
3929
4811
  */
3930
- async function runAgenticStructuredOutput<
3931
- TSchema extends SchemaInput,
3932
- TContext = unknown,
3933
- >(
3934
- options: TextActivityOptions<AnyTextAdapter, TSchema, boolean, TContext>,
4812
+ async function runAgenticStructuredOutput<TSchema extends SchemaInput>(
4813
+ options: RuntimeTextActivityOptions<AnyTextAdapter, TSchema, boolean>,
3935
4814
  ): Promise<InferSchemaType<TSchema>> {
3936
4815
  const {
3937
4816
  adapter,
@@ -4002,7 +4881,7 @@ async function runAgenticStructuredOutput<
4002
4881
  params: { ...textOptions, model, logger } as TextOptions<
4003
4882
  Record<string, unknown>,
4004
4883
  Record<string, unknown>,
4005
- TContext
4884
+ any
4006
4885
  >,
4007
4886
  middleware,
4008
4887
  context,
@@ -4065,6 +4944,15 @@ async function runAgenticStructuredOutput<
4065
4944
  * Uses an `unknown`-input runtime check rather than `as` casts so the engine
4066
4945
  * stays cast-free in its hot path.
4067
4946
  */
4947
+ function readCustomEventMessageId(value: unknown): string | undefined {
4948
+ if (typeof value !== 'object' || value === null) return undefined
4949
+ if (!('messageId' in value)) return undefined
4950
+ const messageId = value.messageId
4951
+ return typeof messageId === 'string' && messageId !== ''
4952
+ ? messageId
4953
+ : undefined
4954
+ }
4955
+
4068
4956
  function readStructuredOutputCompleteValue(
4069
4957
  value: unknown,
4070
4958
  ): { object: unknown; raw: string; reasoning?: string } | null {
@@ -4210,11 +5098,8 @@ async function* fallbackStructuredOutputStream(
4210
5098
  * synchronously at call time rather than as a yielded RUN_ERROR mid-stream —
4211
5099
  * those are programmer errors, not runtime conditions.
4212
5100
  */
4213
- function runStreamingStructuredOutput<
4214
- TSchema extends SchemaInput,
4215
- TContext = unknown,
4216
- >(
4217
- options: TextActivityOptions<AnyTextAdapter, TSchema, true, TContext>,
5101
+ function runStreamingStructuredOutput<TSchema extends SchemaInput>(
5102
+ options: RuntimeTextActivityOptions<AnyTextAdapter, TSchema, true>,
4218
5103
  ): StructuredOutputStream<InferSchemaType<TSchema>> {
4219
5104
  const { outputSchema } = options
4220
5105
 
@@ -4275,11 +5160,8 @@ type StructuredOutputStreamInternal<T> = AsyncIterable<
4275
5160
  StreamChunk | StructuredOutputCompleteEvent<T>
4276
5161
  >
4277
5162
 
4278
- async function* runStreamingStructuredOutputImpl<
4279
- TSchema extends SchemaInput,
4280
- TContext = unknown,
4281
- >(
4282
- options: TextActivityOptions<AnyTextAdapter, TSchema, true, TContext>,
5163
+ async function* runStreamingStructuredOutputImpl<TSchema extends SchemaInput>(
5164
+ options: RuntimeTextActivityOptions<AnyTextAdapter, TSchema, true>,
4283
5165
  jsonSchema: NonNullable<ReturnType<typeof convertSchemaToJsonSchema>>,
4284
5166
  normalize: (data: unknown) => unknown,
4285
5167
  engineRef: DeliveryEngineRef,
@@ -4322,7 +5204,7 @@ async function* runStreamingStructuredOutputImpl<
4322
5204
  params: { ...textOptions, model, logger } as TextOptions<
4323
5205
  Record<string, unknown>,
4324
5206
  Record<string, unknown>,
4325
- TContext
5207
+ any
4326
5208
  >,
4327
5209
  middleware,
4328
5210
  context,