@tanstack/ai-client 0.24.0 → 0.25.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.
@@ -5,15 +5,20 @@ import {
5
5
  canonicalizeInterruptResolutions,
6
6
  cloneAndDeepFreezeJson,
7
7
  digestInterruptJson,
8
+ genericInterruptContinuationFromDescriptor,
9
+ hashInterruptDefinitionSchema,
8
10
  hashSchemaInput,
9
11
  isStandardSchema,
10
12
  normalizeApprovalSchema,
13
+ readInterruptBinding,
14
+ wrapGenericInterruptContinuation,
11
15
  } from '@tanstack/ai/client'
12
16
  import type {
13
17
  AnyClientTool,
14
18
  BatchInterruptError,
15
19
  Interrupt,
16
20
  InterruptBinding,
21
+ InterruptDefinition,
17
22
  InterruptSubmissionError,
18
23
  ItemInterruptError,
19
24
  RunAgentResumeItem,
@@ -25,6 +30,7 @@ import type {
25
30
  ChatInterruptState,
26
31
  GenericAGUIInterrupt,
27
32
  InterruptItemStatus,
33
+ ResolvableChatInterrupt,
28
34
  UnboundInterrupt,
29
35
  } from './types'
30
36
 
@@ -44,12 +50,17 @@ export interface InterruptManagerSubmission {
44
50
  fingerprint: string
45
51
  }
46
52
 
53
+ export type InterruptManagerChangeSource = 'hydrate' | 'live'
54
+
47
55
  export interface InterruptManagerOptions<
48
56
  TTools extends ReadonlyArray<AnyClientTool>,
57
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
58
+ readonly [],
49
59
  > {
50
60
  tools?: TTools
61
+ interrupts?: TInterrupts
51
62
  submit: (submission: InterruptManagerSubmission) => Promise<void>
52
- onChange?: () => void
63
+ onChange?: (source: InterruptManagerChangeSource) => void
53
64
  }
54
65
 
55
66
  type UnknownObject = { [key: string]: unknown }
@@ -71,6 +82,11 @@ interface RuntimeInterrupt {
71
82
  error?: ItemInterruptError
72
83
  resolution?: RunAgentResumeItem
73
84
  tool?: AnyClientTool
85
+ definition?: InterruptDefinition<any, any, any, any>
86
+ /** Validated display payload for a registered first-party generic item. */
87
+ payload?: unknown
88
+ /** This binding is valid and can participate in this chat resume batch. */
89
+ resumable: boolean
74
90
  validationGeneration: number
75
91
  }
76
92
 
@@ -93,6 +109,33 @@ interface RuntimeInterruptCheckpoint {
93
109
  validationGeneration: number
94
110
  }
95
111
 
112
+ function isClientOwnedInterrupt(item: RuntimeInterrupt): boolean {
113
+ return item.resumable
114
+ }
115
+
116
+ function resolutionWithContinuation(
117
+ item: RuntimeInterrupt,
118
+ resolution: RunAgentResumeItem,
119
+ ): RunAgentResumeItem {
120
+ const continuation = genericInterruptContinuationFromDescriptor(
121
+ item.descriptor,
122
+ )
123
+ if (!continuation) return resolution
124
+ return {
125
+ ...resolution,
126
+ metadata: wrapGenericInterruptContinuation(continuation),
127
+ }
128
+ }
129
+
130
+ function isRootResolvableInterrupt<
131
+ TTools extends ReadonlyArray<AnyClientTool>,
132
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>>,
133
+ >(
134
+ interrupt: ChatInterrupt<TTools, TInterrupts>,
135
+ ): interrupt is ResolvableChatInterrupt<TTools, TInterrupts> {
136
+ return 'cancel' in interrupt && 'clearResolution' in interrupt
137
+ }
138
+
96
139
  const itemErrorCodes = new Set<ItemInterruptError['code']>([
97
140
  'invalid-payload',
98
141
  'invalid-edited-args',
@@ -157,91 +200,47 @@ function isLegacyInterruptMetadata(interrupt: Interrupt): boolean {
157
200
  )
158
201
  }
159
202
 
160
- function isBindingBase(value: UnknownObject): boolean {
161
- return (
162
- // A binding stamped with a version we don't know is another producer's.
163
- // Reject it whole; never read our fields out of it. Missing `v` is read as
164
- // the current version so pre-versioning bindings still resume.
165
- (value['v'] === undefined || value['v'] === INTERRUPT_BINDING_VERSION) &&
166
- typeof value['kind'] === 'string' &&
167
- typeof value['interruptId'] === 'string' &&
168
- typeof value['interruptedRunId'] === 'string' &&
169
- typeof value['generation'] === 'number' &&
170
- Number.isInteger(value['generation']) &&
171
- value['generation'] >= 0 &&
172
- typeof value['responseSchemaHash'] === 'string' &&
173
- (value['expiresAt'] === undefined ||
174
- (typeof value['expiresAt'] === 'string' &&
175
- Number.isFinite(Date.parse(value['expiresAt']))))
176
- )
203
+ function getDescriptorBinding(
204
+ interrupt: Interrupt,
205
+ ): InterruptBinding | undefined {
206
+ return readInterruptBinding(interrupt)
177
207
  }
178
208
 
179
- function readBinding(value: unknown): InterruptBinding | undefined {
180
- if (!isUnknownObject(value) || !isBindingBase(value)) return undefined
181
- const expiresAt =
182
- typeof value['expiresAt'] === 'string' ? value['expiresAt'] : undefined
183
- if (value['kind'] === 'generic') {
184
- return {
185
- v: INTERRUPT_BINDING_VERSION,
186
- kind: 'generic',
187
- interruptId: String(value['interruptId']),
188
- interruptedRunId: String(value['interruptedRunId']),
189
- generation: Number(value['generation']),
190
- responseSchemaHash: String(value['responseSchemaHash']),
191
- ...(expiresAt !== undefined ? { expiresAt } : {}),
192
- }
193
- }
209
+ function hasReservedFirstPartyBindingMarker(interrupt: Interrupt): boolean {
210
+ if (!isUnknownObject(interrupt.metadata)) return false
211
+ const binding = interrupt.metadata[INTERRUPT_BINDING_METADATA_KEY]
212
+ if (!isUnknownObject(binding)) return false
194
213
  if (
195
- value['kind'] === 'client-tool-execution' &&
196
- typeof value['toolName'] === 'string' &&
197
- typeof value['toolCallId'] === 'string' &&
198
- typeof value['outputSchemaHash'] === 'string'
214
+ binding['v'] !== undefined &&
215
+ binding['v'] !== INTERRUPT_BINDING_VERSION
199
216
  ) {
200
- return {
201
- v: INTERRUPT_BINDING_VERSION,
202
- kind: 'client-tool-execution',
203
- interruptId: String(value['interruptId']),
204
- interruptedRunId: String(value['interruptedRunId']),
205
- generation: Number(value['generation']),
206
- toolName: value['toolName'],
207
- toolCallId: value['toolCallId'],
208
- outputSchemaHash: value['outputSchemaHash'],
209
- responseSchemaHash: String(value['responseSchemaHash']),
210
- ...(expiresAt !== undefined ? { expiresAt } : {}),
211
- }
212
- }
213
- if (
214
- value['kind'] === 'tool-approval' &&
215
- typeof value['toolName'] === 'string' &&
216
- typeof value['toolCallId'] === 'string' &&
217
- typeof value['inputSchemaHash'] === 'string' &&
218
- typeof value['approvalSchemaHash'] === 'string' &&
219
- 'originalArgs' in value
220
- ) {
221
- return {
222
- v: INTERRUPT_BINDING_VERSION,
223
- kind: 'tool-approval',
224
- interruptId: String(value['interruptId']),
225
- interruptedRunId: String(value['interruptedRunId']),
226
- generation: Number(value['generation']),
227
- toolName: value['toolName'],
228
- toolCallId: value['toolCallId'],
229
- originalArgs: value['originalArgs'],
230
- inputSchemaHash: value['inputSchemaHash'],
231
- approvalSchemaHash: value['approvalSchemaHash'],
232
- responseSchemaHash: String(value['responseSchemaHash']),
233
- ...(expiresAt !== undefined ? { expiresAt } : {}),
234
- }
217
+ return false
235
218
  }
236
- return undefined
219
+ return (
220
+ binding['kind'] === 'generic' ||
221
+ binding['kind'] === 'tool-approval' ||
222
+ binding['kind'] === 'client-tool-execution' ||
223
+ 'definitionId' in binding ||
224
+ 'key' in binding ||
225
+ 'batchIndex' in binding ||
226
+ 'payloadSchemaHash' in binding
227
+ )
237
228
  }
238
229
 
239
- function getDescriptorBinding(
240
- interrupt: Interrupt,
241
- ): InterruptBinding | undefined {
242
- const candidate: unknown =
243
- interrupt.metadata?.[INTERRUPT_BINDING_METADATA_KEY]
244
- return readBinding(candidate)
230
+ function hasFirstPartyGenericMarker(interrupt: Interrupt): boolean {
231
+ if (!isUnknownObject(interrupt.metadata)) return false
232
+ const binding = interrupt.metadata[INTERRUPT_BINDING_METADATA_KEY]
233
+ if (!isUnknownObject(binding) || binding['kind'] !== 'generic') return false
234
+ return (
235
+ 'definitionId' in binding ||
236
+ 'key' in binding ||
237
+ 'batchIndex' in binding ||
238
+ 'payloadSchemaHash' in binding
239
+ )
240
+ }
241
+
242
+ function getInterruptPayload(interrupt: Interrupt): unknown {
243
+ return interrupt.metadata?.['tanstack:interruptPayload']
245
244
  }
246
245
 
247
246
  /**
@@ -264,6 +263,17 @@ function responseSchemaHash(interrupt: Interrupt): string | undefined {
264
263
  }
265
264
  }
266
265
 
266
+ function definitionSchemaHash(
267
+ schema: InterruptDefinition<any, any, any, any>['responseSchema'] | undefined,
268
+ ): string | undefined {
269
+ if (schema === undefined) return undefined
270
+ try {
271
+ return hashInterruptDefinitionSchema(schema)
272
+ } catch {
273
+ return undefined
274
+ }
275
+ }
276
+
267
277
  function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
268
278
  return (
269
279
  value !== null &&
@@ -431,16 +441,15 @@ function genericBinding(
431
441
  hydration: InterruptManagerHydration,
432
442
  candidate: InterruptBinding | undefined,
433
443
  ): InterruptBinding {
444
+ const schemaHash =
445
+ responseSchemaHash(interrupt) ?? candidate?.responseSchemaHash
434
446
  return cloneAndDeepFreezeJson({
435
447
  v: INTERRUPT_BINDING_VERSION,
436
448
  kind: 'generic',
437
449
  interruptId: interrupt.id,
438
450
  interruptedRunId: hydration.interruptedRunId,
439
451
  generation: hydration.generation,
440
- responseSchemaHash:
441
- responseSchemaHash(interrupt) ??
442
- candidate?.responseSchemaHash ??
443
- 'invalid',
452
+ ...(schemaHash !== undefined ? { responseSchemaHash: schemaHash } : {}),
444
453
  ...(interrupt.expiresAt !== undefined
445
454
  ? { expiresAt: interrupt.expiresAt }
446
455
  : {}),
@@ -450,9 +459,7 @@ function genericBinding(
450
459
  function baseSnapshot(
451
460
  item: RuntimeInterrupt,
452
461
  hydration: InterruptManagerHydration,
453
- cancel: () => void,
454
- clearResolution: () => void,
455
- ): BoundInterruptBase {
462
+ ): Omit<BoundInterruptBase, 'cancel' | 'clearResolution'> {
456
463
  const descriptor = cloneAndDeepFreezeJson(item.descriptor)
457
464
  const errors: ReadonlyArray<ItemInterruptError> =
458
465
  item.error === undefined
@@ -482,21 +489,22 @@ function baseSnapshot(
482
489
  errors,
483
490
  ...(error !== undefined ? { error } : {}),
484
491
  canResolve: item.canResolve,
485
- cancel,
486
- clearResolution,
487
492
  }
488
493
  }
489
494
 
490
495
  export class InterruptManager<
491
496
  TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,
497
+ TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
498
+ readonly [],
492
499
  > {
493
500
  private hydration: InterruptManagerHydration | undefined
494
501
  private items: Array<RuntimeInterrupt> = []
495
- private snapshot: ReadonlyArray<ChatInterrupt<TTools>> = Object.freeze([])
502
+ private snapshot: ReadonlyArray<ChatInterrupt<TTools, TInterrupts>> =
503
+ Object.freeze([])
496
504
  private rootErrors: ReadonlyArray<BatchInterruptError> = Object.freeze([])
497
505
  private submissionRootErrors: ReadonlyArray<BatchInterruptError> =
498
506
  Object.freeze([])
499
- private state: ChatInterruptState<TTools> = Object.freeze({
507
+ private state: ChatInterruptState<TTools, TInterrupts> = Object.freeze({
500
508
  interrupts: this.snapshot,
501
509
  pendingInterrupts: this.snapshot,
502
510
  interruptErrors: this.rootErrors,
@@ -506,37 +514,72 @@ export class InterruptManager<
506
514
  private retrySubmission: InterruptManagerSubmission | undefined
507
515
  private resuming = false
508
516
  private tools: TTools | undefined
517
+ private readonly interruptDefinitions: ReadonlyMap<
518
+ string,
519
+ InterruptDefinition<any, any, any, any>
520
+ >
509
521
 
510
- constructor(private readonly options: InterruptManagerOptions<TTools>) {
522
+ constructor(
523
+ private readonly options: InterruptManagerOptions<TTools, TInterrupts>,
524
+ ) {
511
525
  this.tools = options.tools
526
+ const definitions = new Map<
527
+ string,
528
+ InterruptDefinition<any, any, any, any>
529
+ >()
530
+ for (const definition of options.interrupts ?? []) {
531
+ if (definitions.has(definition.id)) {
532
+ throw new Error(`Duplicate interrupt definition id: ${definition.id}`)
533
+ }
534
+ definitions.set(definition.id, definition)
535
+ }
536
+ this.interruptDefinitions = definitions
512
537
  }
513
538
 
514
539
  updateTools(tools: TTools): void {
515
540
  this.tools = tools
516
541
  }
517
542
 
518
- hydrate(hydration: InterruptManagerHydration): void {
543
+ hydrate(
544
+ hydration: InterruptManagerHydration,
545
+ source: InterruptManagerChangeSource = 'live',
546
+ ): void {
519
547
  this.hydration = {
520
548
  threadId: hydration.threadId,
521
549
  interruptedRunId: hydration.interruptedRunId,
522
550
  generation: hydration.generation,
523
551
  interrupts: cloneAndDeepFreezeJson(hydration.interrupts),
524
552
  }
553
+ const firstPartyIndexes = new Map<number, number>()
554
+ for (const interrupt of hydration.interrupts) {
555
+ const binding = getDescriptorBinding(interrupt)
556
+ if (
557
+ binding?.kind === 'generic' &&
558
+ binding.definitionId !== undefined &&
559
+ binding.key !== undefined &&
560
+ binding.batchIndex !== undefined
561
+ ) {
562
+ firstPartyIndexes.set(
563
+ binding.batchIndex,
564
+ (firstPartyIndexes.get(binding.batchIndex) ?? 0) + 1,
565
+ )
566
+ }
567
+ }
525
568
  this.items = hydration.interrupts.map((interrupt) =>
526
- this.hydrateInterrupt(interrupt, hydration),
569
+ this.hydrateInterrupt(interrupt, hydration, firstPartyIndexes),
527
570
  )
528
571
  this.rootErrors = Object.freeze([])
529
572
  this.submissionRootErrors = Object.freeze([])
530
573
  this.retrySubmission = undefined
531
574
  this.resuming = false
532
- this.publish()
575
+ this.publish(source)
533
576
  }
534
577
 
535
- getInterrupts(): BoundInterrupts<TTools> {
578
+ getInterrupts(): BoundInterrupts<TTools, TInterrupts> {
536
579
  return this.snapshot
537
580
  }
538
581
 
539
- getState(): ChatInterruptState<TTools> {
582
+ getState(): ChatInterruptState<TTools, TInterrupts> {
540
583
  return this.state
541
584
  }
542
585
 
@@ -544,7 +587,16 @@ export class InterruptManager<
544
587
  return this.hydration?.interrupts ?? Object.freeze([])
545
588
  }
546
589
 
547
- reset(options?: { preserveRootErrors?: boolean }): void {
590
+ hasValidatedFirstPartyGenericBatch(): boolean {
591
+ return this.items.some(
592
+ (item) => item.kind === 'generic' && item.definition !== undefined,
593
+ )
594
+ }
595
+
596
+ reset(options?: {
597
+ preserveRootErrors?: boolean
598
+ source?: InterruptManagerChangeSource
599
+ }): void {
548
600
  this.hydration = undefined
549
601
  this.items = []
550
602
  this.snapshot = Object.freeze([])
@@ -560,7 +612,7 @@ export class InterruptManager<
560
612
  interruptErrors: this.rootErrors,
561
613
  resuming: false,
562
614
  })
563
- this.options.onChange?.()
615
+ this.options.onChange?.(options?.source ?? 'live')
564
616
  }
565
617
 
566
618
  getInterruptErrors(): ReadonlyArray<BatchInterruptError> {
@@ -572,9 +624,15 @@ export class InterruptManager<
572
624
  }
573
625
 
574
626
  resolve(approved: boolean): void
575
- resolve(resolver: (interrupt: ChatInterrupt<TTools>) => undefined): void
576
627
  resolve(
577
- resolution: boolean | ((interrupt: ChatInterrupt<TTools>) => unknown),
628
+ resolver: (
629
+ interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
630
+ ) => undefined,
631
+ ): void
632
+ resolve(
633
+ resolution:
634
+ | boolean
635
+ | ((interrupt: ResolvableChatInterrupt<TTools, TInterrupts>) => unknown),
578
636
  ): void {
579
637
  this.assertRootMutable()
580
638
  if (typeof resolution === 'boolean') {
@@ -588,11 +646,14 @@ export class InterruptManager<
588
646
  this.assertRootMutable()
589
647
  this.invalidateRetry()
590
648
  for (const item of this.items) {
649
+ if (!isClientOwnedInterrupt(item)) continue
591
650
  item.validationGeneration++
592
- item.resolution = Object.freeze({
593
- interruptId: item.descriptor.id,
594
- status: 'cancelled',
595
- })
651
+ item.resolution = Object.freeze(
652
+ resolutionWithContinuation(item, {
653
+ interruptId: item.descriptor.id,
654
+ status: 'cancelled',
655
+ }),
656
+ )
596
657
  item.status = 'staged'
597
658
  item.error = undefined
598
659
  }
@@ -648,9 +709,12 @@ export class InterruptManager<
648
709
  private hydrateInterrupt(
649
710
  descriptor: Interrupt,
650
711
  hydration: InterruptManagerHydration,
712
+ firstPartyIndexes: ReadonlyMap<number, number>,
651
713
  ): RuntimeInterrupt {
652
714
  const interrupt = cloneAndDeepFreezeJson(descriptor)
653
715
  const candidate = getDescriptorBinding(interrupt)
716
+ const legacyResumable =
717
+ candidate === undefined && isLegacyInterruptMetadata(interrupt)
654
718
 
655
719
  // No binding we understand, and nothing else identifying the descriptor as
656
720
  // ours, means this interrupt was not produced by this package's resume
@@ -668,18 +732,42 @@ export class InterruptManager<
668
732
  // Pre-binding TanStack descriptors are still ours: they carry the legacy
669
733
  // `metadata.kind` marker, so they keep hydrating through the generic path
670
734
  // below.
671
- if (candidate === undefined && !isLegacyInterruptMetadata(interrupt)) {
735
+ if (candidate === undefined && !legacyResumable) {
736
+ if (hasReservedFirstPartyBindingMarker(interrupt)) {
737
+ return {
738
+ descriptor: interrupt,
739
+ binding: genericBinding(interrupt, hydration, undefined),
740
+ kind: 'generic',
741
+ status: 'error',
742
+ canResolve: false,
743
+ resumable: false,
744
+ error: this.itemError(
745
+ interrupt.id,
746
+ 'stale',
747
+ 'The interrupt binding is invalid or incomplete.',
748
+ ),
749
+ validationGeneration: 0,
750
+ }
751
+ }
672
752
  return {
673
753
  descriptor: interrupt,
674
754
  binding: undefined,
675
755
  kind: 'unbound',
676
756
  status: 'pending',
677
757
  canResolve: false,
758
+ resumable: false,
678
759
  validationGeneration: 0,
679
760
  }
680
761
  }
681
762
 
682
763
  const correlated =
764
+ candidate !== undefined &&
765
+ candidate.interruptId === interrupt.id &&
766
+ candidate.interruptedRunId === hydration.interruptedRunId &&
767
+ candidate.generation === hydration.generation &&
768
+ responseSchemaHash(interrupt) === candidate.responseSchemaHash
769
+
770
+ const structurallyCorrelated =
683
771
  candidate !== undefined &&
684
772
  candidate.interruptId === interrupt.id &&
685
773
  candidate.interruptedRunId === hydration.interruptedRunId &&
@@ -687,7 +775,32 @@ export class InterruptManager<
687
775
  candidate.responseSchemaHash ===
688
776
  (responseSchemaHash(interrupt) ?? candidate.responseSchemaHash)
689
777
 
690
- if (correlated && candidate.kind === 'tool-approval') {
778
+ if (
779
+ candidate !== undefined &&
780
+ hasFirstPartyGenericMarker(interrupt) &&
781
+ (!structurallyCorrelated ||
782
+ candidate.kind !== 'generic' ||
783
+ candidate.definitionId === undefined ||
784
+ candidate.key === undefined ||
785
+ candidate.batchIndex === undefined)
786
+ ) {
787
+ return {
788
+ descriptor: interrupt,
789
+ binding: genericBinding(interrupt, hydration, candidate),
790
+ kind: 'generic',
791
+ status: 'error',
792
+ canResolve: false,
793
+ resumable: false,
794
+ error: this.itemError(
795
+ interrupt.id,
796
+ 'stale',
797
+ 'The interrupt binding does not match this interrupted run.',
798
+ ),
799
+ validationGeneration: 0,
800
+ }
801
+ }
802
+
803
+ if (structurallyCorrelated && candidate.kind === 'tool-approval') {
691
804
  const tool = this.tools?.find(
692
805
  (configured) => configured.name === candidate.toolName,
693
806
  )
@@ -714,6 +827,7 @@ export class InterruptManager<
714
827
  kind: 'tool-approval',
715
828
  status: 'pending',
716
829
  canResolve: true,
830
+ resumable: true,
717
831
  tool,
718
832
  validationGeneration: 0,
719
833
  }
@@ -724,7 +838,7 @@ export class InterruptManager<
724
838
  }
725
839
  }
726
840
 
727
- if (correlated && candidate.kind === 'client-tool-execution') {
841
+ if (structurallyCorrelated && candidate.kind === 'client-tool-execution') {
728
842
  const tool = this.tools?.find(
729
843
  (configured) => configured.name === candidate.toolName,
730
844
  )
@@ -740,31 +854,112 @@ export class InterruptManager<
740
854
  kind: 'client-tool-execution',
741
855
  status: 'pending',
742
856
  canResolve: true,
857
+ resumable: true,
743
858
  tool,
744
859
  validationGeneration: 0,
745
860
  }
746
861
  }
747
862
  }
748
863
 
864
+ if (
865
+ candidate !== undefined &&
866
+ candidate.kind === 'generic' &&
867
+ candidate.definitionId !== undefined &&
868
+ candidate.key !== undefined &&
869
+ candidate.batchIndex !== undefined &&
870
+ candidate.key.length > 0 &&
871
+ firstPartyIndexes.get(candidate.batchIndex) !== 1
872
+ ) {
873
+ return {
874
+ descriptor: interrupt,
875
+ binding: cloneAndDeepFreezeJson(candidate),
876
+ kind: 'generic',
877
+ status: 'error',
878
+ canResolve: false,
879
+ resumable: false,
880
+ error: this.itemError(
881
+ interrupt.id,
882
+ 'stale',
883
+ 'Generic interrupt batch contains a duplicate batchIndex.',
884
+ ),
885
+ validationGeneration: 0,
886
+ }
887
+ }
888
+
889
+ if (
890
+ correlated &&
891
+ candidate.kind === 'generic' &&
892
+ candidate.definitionId !== undefined &&
893
+ candidate.key !== undefined &&
894
+ candidate.batchIndex !== undefined &&
895
+ candidate.key.length > 0 &&
896
+ firstPartyIndexes.get(candidate.batchIndex) === 1
897
+ ) {
898
+ const definition = this.interruptDefinitions.get(candidate.definitionId)
899
+ if (
900
+ definition !== undefined &&
901
+ definitionSchemaHash(definition.responseSchema) ===
902
+ candidate.responseSchemaHash &&
903
+ (definition.payloadSchema === undefined
904
+ ? candidate.payloadSchemaHash === undefined
905
+ : candidate.payloadSchemaHash ===
906
+ definitionSchemaHash(definition.payloadSchema))
907
+ ) {
908
+ const rawPayload = getInterruptPayload(interrupt)
909
+ // First-party display payloads are parsed by definition.interrupt()
910
+ // before the server emits them. Re-validating here would feed schema
911
+ // output back through an input schema and reject transforms such as
912
+ // z.string().transform(Number). The checks above still bind this value
913
+ // to the exact descriptor, run, generation, definition, and schemas.
914
+ return {
915
+ descriptor: interrupt,
916
+ binding: cloneAndDeepFreezeJson(candidate),
917
+ definition,
918
+ kind: 'generic',
919
+ status: 'pending',
920
+ canResolve: true,
921
+ resumable: true,
922
+ ...(rawPayload === undefined
923
+ ? {}
924
+ : { payload: cloneAndDeepFreezeJson(rawPayload) }),
925
+ validationGeneration: 0,
926
+ }
927
+ }
928
+ }
929
+
930
+ const resumable =
931
+ legacyResumable ||
932
+ (candidate !== undefined &&
933
+ candidate.interruptId === interrupt.id &&
934
+ candidate.interruptedRunId === hydration.interruptedRunId &&
935
+ candidate.generation === hydration.generation &&
936
+ (candidate.kind !== 'generic' ||
937
+ responseSchemaHash(interrupt) === undefined ||
938
+ candidate.responseSchemaHash === responseSchemaHash(interrupt)))
749
939
  return {
750
940
  descriptor: interrupt,
751
941
  binding: genericBinding(interrupt, hydration, candidate),
752
942
  kind: 'generic',
753
943
  status: 'pending',
754
- // The library no longer validates the wire response schema, so a generic
755
- // item is always resolvable. The application validates the value itself.
756
- canResolve: true,
944
+ // A valid raw binding is an explicit request to use this resume path,
945
+ // even when this client has no registered first-party definition. Keep
946
+ // it untyped, but preserve its existing generic controls. Missing,
947
+ // malformed, and unsupported bindings remain display-only.
948
+ canResolve: resumable,
949
+ resumable,
757
950
  validationGeneration: 0,
758
951
  }
759
952
  }
760
953
 
761
954
  private buildSnapshot(
762
955
  transaction?: TransactionToken,
763
- ): BoundInterrupts<TTools> {
956
+ ): BoundInterrupts<TTools, TInterrupts> {
764
957
  const hydration = this.requireHydration()
765
- // `client-tool-execution` items stay in `this.items` (they gate batch
766
- // submission and are resolved internally via auto-execution / addToolResult),
767
- // but they are never surfaced as public bound interrupts.
958
+ // `client-tool-execution` items stay in `this.items` (they usually gate
959
+ // batch submission and are resolved internally via auto-execution /
960
+ // addToolResult), but they are never surfaced as public bound interrupts.
961
+ // A mixed generic batch is the exception: those client tools wait for
962
+ // `toolResume` and must not block submit.
768
963
  //
769
964
  // Items with status `submitting` are also omitted: the resume stream is
770
965
  // already in flight, so Approve/Deny is not actionable. Keeping them in
@@ -776,12 +971,7 @@ export class InterruptManager<
776
971
  item.kind !== 'client-tool-execution' && item.status !== 'submitting',
777
972
  )
778
973
  .map((item) => {
779
- const base = baseSnapshot(
780
- item,
781
- hydration,
782
- () => this.cancelItem(item.descriptor.id, transaction),
783
- () => this.clearItem(item.descriptor.id, transaction),
784
- )
974
+ const base = baseSnapshot(item, hydration)
785
975
  // Not ours to resume: expose the descriptor so a UI can show the run
786
976
  // is paused, with no `resolveInterrupt` to call.
787
977
  if (item.kind === 'unbound' || item.binding === undefined) {
@@ -804,6 +994,9 @@ export class InterruptManager<
804
994
  toolName: item.binding.toolName,
805
995
  toolCallId: item.binding.toolCallId,
806
996
  originalArgs: cloneAndDeepFreezeJson(item.binding.originalArgs),
997
+ cancel: () => this.cancelItem(item.descriptor.id, transaction),
998
+ clearResolution: () =>
999
+ this.clearItem(item.descriptor.id, transaction),
807
1000
  resolveInterrupt: (approved: boolean, options?: unknown) => {
808
1001
  const details = isUnknownObject(options) ? options : undefined
809
1002
  this.resolveItem(
@@ -832,15 +1025,33 @@ export class InterruptManager<
832
1025
  interruptId: item.descriptor.id,
833
1026
  interruptedRunId: hydration.interruptedRunId,
834
1027
  generation: hydration.generation,
835
- responseSchemaHash:
836
- typeof item.binding.responseSchemaHash === 'string'
837
- ? item.binding.responseSchemaHash
838
- : 'none',
1028
+ ...(typeof item.binding.responseSchemaHash === 'string'
1029
+ ? { responseSchemaHash: item.binding.responseSchemaHash }
1030
+ : {}),
839
1031
  })
1032
+ if (item.definition !== undefined && item.binding.kind === 'generic') {
1033
+ const snapshot = {
1034
+ ...base,
1035
+ kind: 'generic',
1036
+ definitionId: item.definition.id,
1037
+ key: item.binding.key ?? '',
1038
+ payload: item.payload,
1039
+ binding: boundGeneric,
1040
+ cancel: () => this.cancelItem(item.descriptor.id, transaction),
1041
+ clearResolution: () =>
1042
+ this.clearItem(item.descriptor.id, transaction),
1043
+ resolveInterrupt: (response: unknown) =>
1044
+ this.resolveItem(item.descriptor.id, response, transaction),
1045
+ }
1046
+ return Object.freeze(snapshot)
1047
+ }
840
1048
  const snapshot: GenericAGUIInterrupt = {
841
1049
  ...base,
842
1050
  kind: 'generic',
843
1051
  binding: boundGeneric,
1052
+ cancel: () => this.cancelItem(item.descriptor.id, transaction),
1053
+ clearResolution: () =>
1054
+ this.clearItem(item.descriptor.id, transaction),
844
1055
  resolveInterrupt: (payload) =>
845
1056
  this.resolveItem(item.descriptor.id, payload, transaction),
846
1057
  }
@@ -851,10 +1062,12 @@ export class InterruptManager<
851
1062
  // selected by name. TypeScript cannot preserve that per-element lookup
852
1063
  // through Array.map, so this generic return boundary restores the proven
853
1064
  // distributive public union.
854
- return Object.freeze(next) as BoundInterrupts<TTools>
1065
+ return Object.freeze(next) as BoundInterrupts<TTools, TInterrupts>
855
1066
  }
856
1067
 
857
- private publish(): void {
1068
+ // Provenance belongs to each publication because `onChange` may synchronously
1069
+ // mutate the manager and publish again before an outer callback returns.
1070
+ private publish(source: InterruptManagerChangeSource = 'live'): void {
858
1071
  if (!this.hydration) {
859
1072
  this.snapshot = Object.freeze([])
860
1073
  this.state = Object.freeze({
@@ -863,7 +1076,7 @@ export class InterruptManager<
863
1076
  interruptErrors: this.rootErrors,
864
1077
  resuming: this.resuming,
865
1078
  })
866
- this.options.onChange?.()
1079
+ this.options.onChange?.(source)
867
1080
  return
868
1081
  }
869
1082
  this.snapshot = this.buildSnapshot()
@@ -873,7 +1086,7 @@ export class InterruptManager<
873
1086
  interruptErrors: this.rootErrors,
874
1087
  resuming: this.resuming,
875
1088
  })
876
- this.options.onChange?.()
1089
+ this.options.onChange?.(source)
877
1090
  }
878
1091
 
879
1092
  private resolveItem(
@@ -929,7 +1142,9 @@ export class InterruptManager<
929
1142
  const item = this.findItem(interruptId)
930
1143
  this.invalidateRetry()
931
1144
  item.validationGeneration++
932
- item.resolution = Object.freeze({ interruptId, status: 'cancelled' })
1145
+ item.resolution = Object.freeze(
1146
+ resolutionWithContinuation(item, { interruptId, status: 'cancelled' }),
1147
+ )
933
1148
  item.status = 'staged'
934
1149
  item.error = undefined
935
1150
  if (!transaction) {
@@ -954,7 +1169,12 @@ export class InterruptManager<
954
1169
  // owns them. Including them in the completeness gate would deadlock the
955
1170
  // batch, so the run's own interrupts could never be answered once a
956
1171
  // foreign one shared the stream.
957
- const ours = this.items.filter((item) => item.kind !== 'unbound')
1172
+ const hasGeneric = this.items.some((item) => item.kind === 'generic')
1173
+ const ours = this.items.filter(
1174
+ (item) =>
1175
+ isClientOwnedInterrupt(item) &&
1176
+ !(hasGeneric && item.kind === 'client-tool-execution'),
1177
+ )
958
1178
  if (
959
1179
  ours.length === 0 ||
960
1180
  ours.some(
@@ -1019,11 +1239,13 @@ export class InterruptManager<
1019
1239
  if (!transaction) this.publish()
1020
1240
  return
1021
1241
  }
1022
- item.resolution = cloneAndDeepFreezeJson({
1023
- interruptId: item.descriptor.id,
1024
- status: 'resolved',
1025
- payload: result.payload,
1026
- })
1242
+ item.resolution = cloneAndDeepFreezeJson(
1243
+ resolutionWithContinuation(item, {
1244
+ interruptId: item.descriptor.id,
1245
+ status: 'resolved',
1246
+ payload: result.payload,
1247
+ }),
1248
+ )
1027
1249
  item.status = 'staged'
1028
1250
  item.error = undefined
1029
1251
  if (!transaction) {
@@ -1037,11 +1259,17 @@ export class InterruptManager<
1037
1259
  payload: unknown,
1038
1260
  ): ValidationResult | Promise<ValidationResult> {
1039
1261
  if (item.kind === 'generic') {
1040
- return validateWithSchema(
1041
- item.descriptor.responseSchema,
1262
+ const validation = validateWithSchema(
1263
+ item.definition?.responseSchema ?? item.descriptor.responseSchema,
1042
1264
  payload,
1043
1265
  'invalid-payload',
1044
1266
  )
1267
+ if (item.definition === undefined) return validation
1268
+ const preserveInput = (result: ValidationResult): ValidationResult =>
1269
+ 'valid' in result ? { valid: true, payload } : result
1270
+ return isPromiseLike(validation)
1271
+ ? Promise.resolve(validation).then(preserveInput)
1272
+ : preserveInput(validation)
1045
1273
  }
1046
1274
  if (item.kind === 'client-tool-execution') {
1047
1275
  return validateWithSchema(
@@ -1160,7 +1388,8 @@ export class InterruptManager<
1160
1388
  // addToolResult); they are transparent to the boolean shorthand. Eligibility
1161
1389
  // and resolution consider only the publicly resolvable items.
1162
1390
  const resolvable = this.items.filter(
1163
- (item) => item.kind !== 'client-tool-execution',
1391
+ (item) =>
1392
+ isClientOwnedInterrupt(item) && item.kind !== 'client-tool-execution',
1164
1393
  )
1165
1394
  const eligible = resolvable.every(
1166
1395
  (item) =>
@@ -1191,7 +1420,9 @@ export class InterruptManager<
1191
1420
  }
1192
1421
 
1193
1422
  private resolveTransaction(
1194
- resolver: (interrupt: ChatInterrupt<TTools>) => unknown,
1423
+ resolver: (
1424
+ interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
1425
+ ) => unknown,
1195
1426
  ): void {
1196
1427
  const checkpoints = this.items.map<RuntimeInterruptCheckpoint>((item) => ({
1197
1428
  status: item.status,
@@ -1201,7 +1432,7 @@ export class InterruptManager<
1201
1432
  }))
1202
1433
  const token: TransactionToken = { active: true }
1203
1434
  this.activeTransaction = token
1204
- const stable = this.buildSnapshot(token)
1435
+ const stable = this.buildSnapshot(token).filter(isRootResolvableInterrupt)
1205
1436
  let failure:
1206
1437
  | { code: BatchInterruptError['code']; message: string }
1207
1438
  | undefined
@@ -1227,7 +1458,8 @@ export class InterruptManager<
1227
1458
  // `client-tool-execution` items are resolved out-of-band (auto
1228
1459
  // execution / addToolResult), not by this synchronous resolver, so
1229
1460
  // they don't count against transaction completeness. `maybeSubmit`
1230
- // still gates the actual submission on them being resolved.
1461
+ // still waits for them unless a generic interrupt shares the batch.
1462
+ isClientOwnedInterrupt(item) &&
1231
1463
  item.kind !== 'client-tool-execution' &&
1232
1464
  (item.resolution === undefined || item.status !== 'staged'),
1233
1465
  )
@@ -1298,7 +1530,9 @@ export class InterruptManager<
1298
1530
  private submitBatch(submission: InterruptManagerSubmission): void {
1299
1531
  this.resuming = true
1300
1532
  this.retrySubmission = undefined
1301
- for (const item of this.items) item.status = 'submitting'
1533
+ for (const item of this.items) {
1534
+ if (isClientOwnedInterrupt(item)) item.status = 'submitting'
1535
+ }
1302
1536
  this.publish()
1303
1537
  void this.performSubmission(submission)
1304
1538
  }
@@ -1325,7 +1559,9 @@ export class InterruptManager<
1325
1559
  const message = error instanceof Error ? error.message : String(error)
1326
1560
  this.addRootError('transport', message, true, 'transport')
1327
1561
  this.retrySubmission = submission
1328
- for (const item of this.items) item.status = 'error'
1562
+ for (const item of this.items) {
1563
+ if (isClientOwnedInterrupt(item)) item.status = 'error'
1564
+ }
1329
1565
  return
1330
1566
  }
1331
1567
 
@@ -1355,6 +1591,7 @@ export class InterruptManager<
1355
1591
  if (submissionError.scope === 'item') {
1356
1592
  const item = this.items.find(
1357
1593
  (candidate) =>
1594
+ isClientOwnedInterrupt(candidate) &&
1358
1595
  candidate.descriptor.id === submissionError.interruptId,
1359
1596
  )
1360
1597
  if (item) {
@@ -1373,7 +1610,9 @@ export class InterruptManager<
1373
1610
  this.rootErrors = mergedBatchErrors.rootErrors
1374
1611
  this.submissionRootErrors = mergedBatchErrors.submissionRootErrors
1375
1612
  for (const item of this.items) {
1376
- if (item.status === 'submitting') item.status = 'error'
1613
+ if (isClientOwnedInterrupt(item) && item.status === 'submitting') {
1614
+ item.status = 'error'
1615
+ }
1377
1616
  }
1378
1617
  this.retrySubmission = retryable && !nonRetryable ? submission : undefined
1379
1618
  }
@@ -1394,7 +1633,9 @@ export class InterruptManager<
1394
1633
  source,
1395
1634
  retryable,
1396
1635
  interruptIds: Object.freeze(
1397
- this.items.map((item) => item.descriptor.id),
1636
+ this.items
1637
+ .filter(isClientOwnedInterrupt)
1638
+ .map((item) => item.descriptor.id),
1398
1639
  ),
1399
1640
  threadId: hydration.threadId,
1400
1641
  interruptedRunId: hydration.interruptedRunId,