@tanstack/ai-client 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +15 -1
  2. package/dist/esm/audio-recorder.js +190 -213
  3. package/dist/esm/audio-recorder.js.map +1 -1
  4. package/dist/esm/chat-client.d.ts +172 -3
  5. package/dist/esm/chat-client.js +1656 -1386
  6. package/dist/esm/chat-client.js.map +1 -1
  7. package/dist/esm/cleared-stream-tracker.d.ts +23 -0
  8. package/dist/esm/cleared-stream-tracker.js +97 -0
  9. package/dist/esm/cleared-stream-tracker.js.map +1 -0
  10. package/dist/esm/client-persistor.d.ts +25 -12
  11. package/dist/esm/client-persistor.js +260 -235
  12. package/dist/esm/client-persistor.js.map +1 -1
  13. package/dist/esm/connection-adapters.d.ts +231 -10
  14. package/dist/esm/connection-adapters.js +989 -574
  15. package/dist/esm/connection-adapters.js.map +1 -1
  16. package/dist/esm/devtools-noop.d.ts +1 -0
  17. package/dist/esm/devtools-noop.js +79 -139
  18. package/dist/esm/devtools-noop.js.map +1 -1
  19. package/dist/esm/devtools.d.ts +31 -1
  20. package/dist/esm/devtools.js +977 -1127
  21. package/dist/esm/devtools.js.map +1 -1
  22. package/dist/esm/events.js +224 -226
  23. package/dist/esm/events.js.map +1 -1
  24. package/dist/esm/generation-client.d.ts +145 -2
  25. package/dist/esm/generation-client.js +659 -321
  26. package/dist/esm/generation-client.js.map +1 -1
  27. package/dist/esm/generation-reconstruct.d.ts +21 -0
  28. package/dist/esm/generation-reconstruct.js +85 -0
  29. package/dist/esm/generation-reconstruct.js.map +1 -0
  30. package/dist/esm/generation-types.d.ts +289 -3
  31. package/dist/esm/generation-types.js +356 -13
  32. package/dist/esm/generation-types.js.map +1 -1
  33. package/dist/esm/index.d.ts +9 -4
  34. package/dist/esm/index.js +7 -39
  35. package/dist/esm/interrupt-manager.d.ts +77 -0
  36. package/dist/esm/interrupt-manager.js +787 -0
  37. package/dist/esm/interrupt-manager.js.map +1 -0
  38. package/dist/esm/mcp-app-bridge.js +56 -64
  39. package/dist/esm/mcp-app-bridge.js.map +1 -1
  40. package/dist/esm/realtime-client.js +366 -440
  41. package/dist/esm/realtime-client.js.map +1 -1
  42. package/dist/esm/response-stream.js +19 -26
  43. package/dist/esm/response-stream.js.map +1 -1
  44. package/dist/esm/sse-parser.js +44 -47
  45. package/dist/esm/sse-parser.js.map +1 -1
  46. package/dist/esm/sse-utils.js +8 -9
  47. package/dist/esm/sse-utils.js.map +1 -1
  48. package/dist/esm/storage-adapters.d.ts +62 -0
  49. package/dist/esm/storage-adapters.js +174 -0
  50. package/dist/esm/storage-adapters.js.map +1 -0
  51. package/dist/esm/types.d.ts +212 -10
  52. package/dist/esm/types.js +38 -7
  53. package/dist/esm/types.js.map +1 -1
  54. package/dist/esm/video-generation-client.d.ts +113 -2
  55. package/dist/esm/video-generation-client.js +665 -379
  56. package/dist/esm/video-generation-client.js.map +1 -1
  57. package/package.json +7 -7
  58. package/src/chat-client.ts +1079 -61
  59. package/src/cleared-stream-tracker.ts +151 -0
  60. package/src/client-persistor.ts +102 -33
  61. package/src/connection-adapters.ts +1185 -142
  62. package/src/devtools-noop.ts +4 -3
  63. package/src/devtools.ts +121 -3
  64. package/src/generation-client.ts +563 -13
  65. package/src/generation-reconstruct.ts +121 -0
  66. package/src/generation-types.ts +727 -3
  67. package/src/index.ts +56 -1
  68. package/src/interrupt-manager.ts +1440 -0
  69. package/src/storage-adapters.ts +242 -0
  70. package/src/types.ts +301 -9
  71. package/src/video-generation-client.ts +479 -13
  72. package/dist/esm/index.js.map +0 -1
@@ -0,0 +1,1440 @@
1
+ import {
2
+ INTERRUPT_BINDING_METADATA_KEY,
3
+ INTERRUPT_BINDING_VERSION,
4
+ canonicalInterruptJson,
5
+ canonicalizeInterruptResolutions,
6
+ cloneAndDeepFreezeJson,
7
+ digestInterruptJson,
8
+ hashSchemaInput,
9
+ isStandardSchema,
10
+ normalizeApprovalSchema,
11
+ } from '@tanstack/ai/client'
12
+ import type {
13
+ AnyClientTool,
14
+ BatchInterruptError,
15
+ Interrupt,
16
+ InterruptBinding,
17
+ InterruptSubmissionError,
18
+ ItemInterruptError,
19
+ RunAgentResumeItem,
20
+ } from '@tanstack/ai/client'
21
+ import type {
22
+ BoundInterruptBase,
23
+ BoundInterrupts,
24
+ ChatInterrupt,
25
+ ChatInterruptState,
26
+ GenericAGUIInterrupt,
27
+ InterruptItemStatus,
28
+ UnboundInterrupt,
29
+ } from './types'
30
+
31
+ export interface InterruptManagerHydration {
32
+ threadId: string
33
+ interruptedRunId: string
34
+ generation: number
35
+ interrupts: ReadonlyArray<Interrupt>
36
+ }
37
+
38
+ export interface InterruptManagerSubmission {
39
+ threadId: string
40
+ interruptedRunId: string
41
+ generation: number
42
+ resolutions: ReadonlyArray<RunAgentResumeItem>
43
+ canonicalResolutions: string
44
+ fingerprint: string
45
+ }
46
+
47
+ export interface InterruptManagerOptions<
48
+ TTools extends ReadonlyArray<AnyClientTool>,
49
+ > {
50
+ tools?: TTools
51
+ submit: (submission: InterruptManagerSubmission) => Promise<void>
52
+ onChange?: () => void
53
+ }
54
+
55
+ type UnknownObject = { [key: string]: unknown }
56
+
57
+ type RuntimeKind =
58
+ | 'generic'
59
+ | 'tool-approval'
60
+ | 'client-tool-execution'
61
+ /** Carries no binding we understand — not ours to resume. */
62
+ | 'unbound'
63
+
64
+ interface RuntimeInterrupt {
65
+ descriptor: Interrupt
66
+ /** `undefined` only for `unbound` items. */
67
+ binding: InterruptBinding | undefined
68
+ kind: RuntimeKind
69
+ status: InterruptItemStatus
70
+ canResolve: boolean
71
+ error?: ItemInterruptError
72
+ resolution?: RunAgentResumeItem
73
+ tool?: AnyClientTool
74
+ validationGeneration: number
75
+ }
76
+
77
+ interface ValidationFailure {
78
+ code: ItemInterruptError['code']
79
+ message: string
80
+ path?: ReadonlyArray<string | number>
81
+ }
82
+
83
+ type ValidationResult = { valid: true; payload: unknown } | ValidationFailure
84
+
85
+ interface TransactionToken {
86
+ active: boolean
87
+ }
88
+
89
+ interface RuntimeInterruptCheckpoint {
90
+ status: InterruptItemStatus
91
+ resolution?: RunAgentResumeItem
92
+ error?: ItemInterruptError
93
+ validationGeneration: number
94
+ }
95
+
96
+ const itemErrorCodes = new Set<ItemInterruptError['code']>([
97
+ 'invalid-payload',
98
+ 'invalid-edited-args',
99
+ 'invalid-tool-output',
100
+ 'invalid-response-schema',
101
+ 'unknown-interrupt',
102
+ 'expired',
103
+ 'stale',
104
+ 'conflict',
105
+ 'legacy-unsupported',
106
+ ])
107
+
108
+ const batchErrorCodes = new Set<BatchInterruptError['code']>([
109
+ 'incomplete-batch',
110
+ 'item-validation-failed',
111
+ 'unsupported-bulk-operation',
112
+ 'async-resolver',
113
+ 'inactive-transaction',
114
+ 'mixed-provenance',
115
+ 'transport',
116
+ 'server',
117
+ 'protocol',
118
+ 'invalid-response-schema',
119
+ 'expired',
120
+ 'stale',
121
+ 'conflict',
122
+ 'legacy-submit-failed',
123
+ ])
124
+
125
+ function isUnknownObject(value: unknown): value is UnknownObject {
126
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
127
+ }
128
+
129
+ function isLegacyApprovalMetadata(value: unknown): boolean {
130
+ return (
131
+ isUnknownObject(value) &&
132
+ value['kind'] === 'approval' &&
133
+ typeof value['toolName'] === 'string' &&
134
+ 'input' in value
135
+ )
136
+ }
137
+
138
+ function isLegacyClientToolMetadata(value: unknown): boolean {
139
+ return (
140
+ isUnknownObject(value) &&
141
+ value['kind'] === 'client_tool' &&
142
+ typeof value['toolName'] === 'string' &&
143
+ 'input' in value
144
+ )
145
+ }
146
+
147
+ /**
148
+ * Does this descriptor carry the pre-binding TanStack metadata marker?
149
+ *
150
+ * Descriptors emitted before the resume binding existed are still ours to
151
+ * resume, so they must not be mistaken for another producer's interrupt.
152
+ */
153
+ function isLegacyInterruptMetadata(interrupt: Interrupt): boolean {
154
+ return (
155
+ isLegacyApprovalMetadata(interrupt.metadata) ||
156
+ isLegacyClientToolMetadata(interrupt.metadata)
157
+ )
158
+ }
159
+
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
+ )
177
+ }
178
+
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
+ }
194
+ if (
195
+ value['kind'] === 'client-tool-execution' &&
196
+ typeof value['toolName'] === 'string' &&
197
+ typeof value['toolCallId'] === 'string' &&
198
+ typeof value['outputSchemaHash'] === 'string'
199
+ ) {
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
+ }
235
+ }
236
+ return undefined
237
+ }
238
+
239
+ function getDescriptorBinding(
240
+ interrupt: Interrupt,
241
+ ): InterruptBinding | undefined {
242
+ const candidate: unknown =
243
+ interrupt.metadata?.[INTERRUPT_BINDING_METADATA_KEY]
244
+ return readBinding(candidate)
245
+ }
246
+
247
+ /**
248
+ * Only used to route *legacy* (pre-binding) descriptors, which have no binding
249
+ * to classify off. Current descriptors are classified by their binding alone.
250
+ */
251
+ function isClientToolExecutionReason(reason: string): boolean {
252
+ return (
253
+ reason === 'tanstack:client_tool_execution' ||
254
+ reason === 'client_tool_input'
255
+ )
256
+ }
257
+
258
+ function responseSchemaHash(interrupt: Interrupt): string | undefined {
259
+ if (interrupt.responseSchema === undefined) return undefined
260
+ try {
261
+ return digestInterruptJson(canonicalInterruptJson(interrupt.responseSchema))
262
+ } catch {
263
+ return undefined
264
+ }
265
+ }
266
+
267
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
268
+ return (
269
+ value !== null &&
270
+ (typeof value === 'object' || typeof value === 'function') &&
271
+ 'then' in value &&
272
+ typeof value.then === 'function'
273
+ )
274
+ }
275
+
276
+ function validateWithSchema(
277
+ schema: unknown,
278
+ value: unknown,
279
+ code: ItemInterruptError['code'],
280
+ ): ValidationResult | Promise<ValidationResult> {
281
+ if (schema === undefined) return { valid: true, payload: value }
282
+ if (isStandardSchema(schema)) {
283
+ const result = schema['~standard'].validate(value)
284
+ const normalize = (
285
+ validation: Awaited<typeof result>,
286
+ ): ValidationResult => {
287
+ if (!validation.issues) {
288
+ return { valid: true, payload: validation.value }
289
+ }
290
+ return {
291
+ code,
292
+ message: validation.issues[0]?.message ?? 'Schema validation failed.',
293
+ }
294
+ }
295
+ return isPromiseLike(result)
296
+ ? Promise.resolve(result).then(normalize)
297
+ : normalize(result)
298
+ }
299
+ // A non-Standard-Schema value (a raw JSON Schema arriving over the wire) is
300
+ // not validated by the library. The application transforms the schema and
301
+ // validates the value itself before resolving; whatever it passes flows
302
+ // through as-is.
303
+ return { valid: true, payload: value }
304
+ }
305
+
306
+ function isItemErrorCode(value: string): value is ItemInterruptError['code'] {
307
+ for (const code of itemErrorCodes) if (code === value) return true
308
+ return false
309
+ }
310
+
311
+ function isBatchErrorCode(value: string): value is BatchInterruptError['code'] {
312
+ for (const code of batchErrorCodes) if (code === value) return true
313
+ return false
314
+ }
315
+
316
+ function isSubmissionError(value: unknown): value is InterruptSubmissionError {
317
+ if (!isUnknownObject(value)) return false
318
+ const scope = value['scope']
319
+ const code = value['code']
320
+ const base =
321
+ typeof code === 'string' &&
322
+ typeof value['message'] === 'string' &&
323
+ typeof value['retryable'] === 'boolean' &&
324
+ typeof value['threadId'] === 'string' &&
325
+ typeof value['interruptedRunId'] === 'string' &&
326
+ typeof value['generation'] === 'number'
327
+ if (!base) return false
328
+ if (scope === 'item') {
329
+ return (
330
+ isItemErrorCode(code) &&
331
+ typeof value['interruptId'] === 'string' &&
332
+ (value['source'] === 'client' || value['source'] === 'server')
333
+ )
334
+ }
335
+ return (
336
+ scope === 'batch' &&
337
+ isBatchErrorCode(code) &&
338
+ Array.isArray(value['interruptIds']) &&
339
+ value['interruptIds'].every((id) => typeof id === 'string') &&
340
+ (value['source'] === 'client' ||
341
+ value['source'] === 'server' ||
342
+ value['source'] === 'transport')
343
+ )
344
+ }
345
+
346
+ function readSubmissionErrors(
347
+ error: unknown,
348
+ ): ReadonlyArray<InterruptSubmissionError> {
349
+ if (isSubmissionError(error)) return [error]
350
+ if (!isUnknownObject(error) || !Array.isArray(error['errors'])) return []
351
+ return error['errors'].every(isSubmissionError) ? error['errors'] : []
352
+ }
353
+
354
+ function haveSameInterruptIds(
355
+ left: ReadonlyArray<string>,
356
+ right: ReadonlyArray<string>,
357
+ ): boolean {
358
+ if (left.length !== right.length) return false
359
+ const sortedLeft = [...left].sort()
360
+ const sortedRight = [...right].sort()
361
+ return sortedLeft.every((id, index) => id === sortedRight[index])
362
+ }
363
+
364
+ function haveSameBatchCorrelation(
365
+ left: BatchInterruptError,
366
+ right: BatchInterruptError,
367
+ ): boolean {
368
+ return (
369
+ left.threadId === right.threadId &&
370
+ left.interruptedRunId === right.interruptedRunId &&
371
+ left.generation === right.generation &&
372
+ haveSameInterruptIds(left.interruptIds, right.interruptIds)
373
+ )
374
+ }
375
+
376
+ function mergeSubmissionBatchErrors(
377
+ current: ReadonlyArray<BatchInterruptError>,
378
+ previousSubmission: ReadonlyArray<BatchInterruptError>,
379
+ incoming: ReadonlyArray<BatchInterruptError>,
380
+ ): {
381
+ rootErrors: ReadonlyArray<BatchInterruptError>
382
+ submissionRootErrors: ReadonlyArray<BatchInterruptError>
383
+ } {
384
+ const replaceableIncoming = incoming.filter(
385
+ (error) => error.source !== 'transport',
386
+ )
387
+ const isSuperseded = (candidate: BatchInterruptError): boolean =>
388
+ previousSubmission.includes(candidate) &&
389
+ replaceableIncoming.some((error) =>
390
+ haveSameBatchCorrelation(candidate, error),
391
+ )
392
+ const retainedRootErrors = current.filter(
393
+ (candidate) => !isSuperseded(candidate),
394
+ )
395
+ const retainedSubmissionRootErrors = previousSubmission.filter(
396
+ (candidate) =>
397
+ !replaceableIncoming.some((error) =>
398
+ haveSameBatchCorrelation(candidate, error),
399
+ ),
400
+ )
401
+ return Object.freeze({
402
+ rootErrors: Object.freeze([...retainedRootErrors, ...incoming]),
403
+ submissionRootErrors: Object.freeze([
404
+ ...retainedSubmissionRootErrors,
405
+ ...replaceableIncoming,
406
+ ]),
407
+ })
408
+ }
409
+
410
+ function submissionErrorMatchesActiveBatch(
411
+ error: InterruptSubmissionError,
412
+ submission: InterruptManagerSubmission,
413
+ ): boolean {
414
+ if (
415
+ error.threadId !== submission.threadId ||
416
+ error.interruptedRunId !== submission.interruptedRunId ||
417
+ error.generation !== submission.generation
418
+ ) {
419
+ return false
420
+ }
421
+ const interruptIds = submission.resolutions.map(
422
+ (resolution) => resolution.interruptId,
423
+ )
424
+ return error.scope === 'item'
425
+ ? interruptIds.includes(error.interruptId)
426
+ : haveSameInterruptIds(error.interruptIds, interruptIds)
427
+ }
428
+
429
+ function genericBinding(
430
+ interrupt: Interrupt,
431
+ hydration: InterruptManagerHydration,
432
+ candidate: InterruptBinding | undefined,
433
+ ): InterruptBinding {
434
+ return cloneAndDeepFreezeJson({
435
+ v: INTERRUPT_BINDING_VERSION,
436
+ kind: 'generic',
437
+ interruptId: interrupt.id,
438
+ interruptedRunId: hydration.interruptedRunId,
439
+ generation: hydration.generation,
440
+ responseSchemaHash:
441
+ responseSchemaHash(interrupt) ??
442
+ candidate?.responseSchemaHash ??
443
+ 'invalid',
444
+ ...(interrupt.expiresAt !== undefined
445
+ ? { expiresAt: interrupt.expiresAt }
446
+ : {}),
447
+ })
448
+ }
449
+
450
+ function baseSnapshot(
451
+ item: RuntimeInterrupt,
452
+ hydration: InterruptManagerHydration,
453
+ cancel: () => void,
454
+ clearResolution: () => void,
455
+ ): BoundInterruptBase {
456
+ const descriptor = cloneAndDeepFreezeJson(item.descriptor)
457
+ const errors: ReadonlyArray<ItemInterruptError> =
458
+ item.error === undefined
459
+ ? Object.freeze([])
460
+ : Object.freeze([cloneAndDeepFreezeJson(item.error)])
461
+ const error = errors[0]
462
+ return {
463
+ id: descriptor.id,
464
+ interruptId: descriptor.id,
465
+ reason: descriptor.reason,
466
+ ...(descriptor.message !== undefined
467
+ ? { message: descriptor.message }
468
+ : {}),
469
+ ...(descriptor.responseSchema !== undefined
470
+ ? { responseSchema: descriptor.responseSchema }
471
+ : {}),
472
+ ...(descriptor.expiresAt !== undefined
473
+ ? { expiresAt: descriptor.expiresAt }
474
+ : {}),
475
+ ...(descriptor.metadata !== undefined
476
+ ? { metadata: descriptor.metadata }
477
+ : {}),
478
+ threadId: hydration.threadId,
479
+ interruptedRunId: hydration.interruptedRunId,
480
+ generation: hydration.generation,
481
+ status: item.status,
482
+ errors,
483
+ ...(error !== undefined ? { error } : {}),
484
+ canResolve: item.canResolve,
485
+ cancel,
486
+ clearResolution,
487
+ }
488
+ }
489
+
490
+ export class InterruptManager<
491
+ TTools extends ReadonlyArray<AnyClientTool> = ReadonlyArray<AnyClientTool>,
492
+ > {
493
+ private hydration: InterruptManagerHydration | undefined
494
+ private items: Array<RuntimeInterrupt> = []
495
+ private snapshot: ReadonlyArray<ChatInterrupt<TTools>> = Object.freeze([])
496
+ private rootErrors: ReadonlyArray<BatchInterruptError> = Object.freeze([])
497
+ private submissionRootErrors: ReadonlyArray<BatchInterruptError> =
498
+ Object.freeze([])
499
+ private state: ChatInterruptState<TTools> = Object.freeze({
500
+ interrupts: this.snapshot,
501
+ pendingInterrupts: this.snapshot,
502
+ interruptErrors: this.rootErrors,
503
+ resuming: false,
504
+ })
505
+ private activeTransaction: TransactionToken | undefined
506
+ private retrySubmission: InterruptManagerSubmission | undefined
507
+ private resuming = false
508
+ private tools: TTools | undefined
509
+
510
+ constructor(private readonly options: InterruptManagerOptions<TTools>) {
511
+ this.tools = options.tools
512
+ }
513
+
514
+ updateTools(tools: TTools): void {
515
+ this.tools = tools
516
+ }
517
+
518
+ hydrate(hydration: InterruptManagerHydration): void {
519
+ this.hydration = {
520
+ threadId: hydration.threadId,
521
+ interruptedRunId: hydration.interruptedRunId,
522
+ generation: hydration.generation,
523
+ interrupts: cloneAndDeepFreezeJson(hydration.interrupts),
524
+ }
525
+ this.items = hydration.interrupts.map((interrupt) =>
526
+ this.hydrateInterrupt(interrupt, hydration),
527
+ )
528
+ this.rootErrors = Object.freeze([])
529
+ this.submissionRootErrors = Object.freeze([])
530
+ this.retrySubmission = undefined
531
+ this.resuming = false
532
+ this.publish()
533
+ }
534
+
535
+ getInterrupts(): BoundInterrupts<TTools> {
536
+ return this.snapshot
537
+ }
538
+
539
+ getState(): ChatInterruptState<TTools> {
540
+ return this.state
541
+ }
542
+
543
+ getDescriptors(): ReadonlyArray<Interrupt> {
544
+ return this.hydration?.interrupts ?? Object.freeze([])
545
+ }
546
+
547
+ reset(options?: { preserveRootErrors?: boolean }): void {
548
+ this.hydration = undefined
549
+ this.items = []
550
+ this.snapshot = Object.freeze([])
551
+ if (options?.preserveRootErrors !== true) {
552
+ this.rootErrors = Object.freeze([])
553
+ this.submissionRootErrors = Object.freeze([])
554
+ }
555
+ this.retrySubmission = undefined
556
+ this.resuming = false
557
+ this.state = Object.freeze({
558
+ interrupts: this.snapshot,
559
+ pendingInterrupts: this.snapshot,
560
+ interruptErrors: this.rootErrors,
561
+ resuming: false,
562
+ })
563
+ this.options.onChange?.()
564
+ }
565
+
566
+ getInterruptErrors(): ReadonlyArray<BatchInterruptError> {
567
+ return this.rootErrors
568
+ }
569
+
570
+ getResuming(): boolean {
571
+ return this.resuming
572
+ }
573
+
574
+ resolve(approved: boolean): void
575
+ resolve(resolver: (interrupt: ChatInterrupt<TTools>) => undefined): void
576
+ resolve(
577
+ resolution: boolean | ((interrupt: ChatInterrupt<TTools>) => unknown),
578
+ ): void {
579
+ this.assertRootMutable()
580
+ if (typeof resolution === 'boolean') {
581
+ this.resolveBooleanBulk(resolution)
582
+ return
583
+ }
584
+ this.resolveTransaction(resolution)
585
+ }
586
+
587
+ cancel(): void {
588
+ this.assertRootMutable()
589
+ this.invalidateRetry()
590
+ for (const item of this.items) {
591
+ item.validationGeneration++
592
+ item.resolution = Object.freeze({
593
+ interruptId: item.descriptor.id,
594
+ status: 'cancelled',
595
+ })
596
+ item.status = 'staged'
597
+ item.error = undefined
598
+ }
599
+ this.publish()
600
+ this.maybeSubmit()
601
+ }
602
+
603
+ retry(): void {
604
+ if (this.resuming)
605
+ throw new Error('Interrupt submission is already active.')
606
+ const submission = this.retrySubmission
607
+ if (!submission) {
608
+ this.addRootError(
609
+ 'transport',
610
+ 'There is no retryable interrupt submission.',
611
+ false,
612
+ )
613
+ return
614
+ }
615
+ this.submitBatch(submission)
616
+ }
617
+
618
+ resolveClientToolOutput(toolCallId: string, output: unknown): boolean {
619
+ const item = this.items.find(
620
+ (candidate) =>
621
+ (candidate.kind === 'client-tool-execution' &&
622
+ candidate.binding?.kind === 'client-tool-execution' &&
623
+ candidate.binding.toolCallId === toolCallId) ||
624
+ (candidate.kind === 'generic' &&
625
+ isClientToolExecutionReason(candidate.descriptor.reason) &&
626
+ candidate.descriptor.toolCallId === toolCallId &&
627
+ isLegacyClientToolMetadata(candidate.descriptor.metadata)),
628
+ )
629
+ if (!item) return false
630
+ this.resolveItem(item.descriptor.id, output)
631
+ return true
632
+ }
633
+
634
+ resolveToolApprovalDecision(interruptId: string, approved: boolean): boolean {
635
+ const item = this.items.find(
636
+ (candidate) =>
637
+ candidate.descriptor.id === interruptId &&
638
+ (candidate.kind === 'tool-approval' ||
639
+ (candidate.kind === 'generic' &&
640
+ candidate.descriptor.reason === 'approval_required' &&
641
+ isLegacyApprovalMetadata(candidate.descriptor.metadata))),
642
+ )
643
+ if (!item) return false
644
+ this.resolveItem(item.descriptor.id, { approved })
645
+ return true
646
+ }
647
+
648
+ private hydrateInterrupt(
649
+ descriptor: Interrupt,
650
+ hydration: InterruptManagerHydration,
651
+ ): RuntimeInterrupt {
652
+ const interrupt = cloneAndDeepFreezeJson(descriptor)
653
+ const candidate = getDescriptorBinding(interrupt)
654
+
655
+ // No binding we understand, and nothing else identifying the descriptor as
656
+ // ours, means this interrupt was not produced by this package's resume
657
+ // path — a workflow engine's durable approval projected onto the same
658
+ // AG-UI stream, a third-party agent's pause, or a binding written at a
659
+ // protocol version we don't know.
660
+ //
661
+ // Do not invent a binding for it. Synthesising one would render a
662
+ // resolvable form whose answer is submitted against a run that has no
663
+ // matching pending descriptor, failing late as `unknown-interrupt` after
664
+ // the user has already filled it in. Surface it as unresolvable instead,
665
+ // so "someone else owns this pause" is visible rather than silently
666
+ // translated into an AI-domain interrupt.
667
+ //
668
+ // Pre-binding TanStack descriptors are still ours: they carry the legacy
669
+ // `metadata.kind` marker, so they keep hydrating through the generic path
670
+ // below.
671
+ if (candidate === undefined && !isLegacyInterruptMetadata(interrupt)) {
672
+ return {
673
+ descriptor: interrupt,
674
+ binding: undefined,
675
+ kind: 'unbound',
676
+ status: 'pending',
677
+ canResolve: false,
678
+ validationGeneration: 0,
679
+ }
680
+ }
681
+
682
+ const correlated =
683
+ candidate !== undefined &&
684
+ candidate.interruptId === interrupt.id &&
685
+ candidate.interruptedRunId === hydration.interruptedRunId &&
686
+ candidate.generation === hydration.generation &&
687
+ candidate.responseSchemaHash ===
688
+ (responseSchemaHash(interrupt) ?? candidate.responseSchemaHash)
689
+
690
+ if (correlated && candidate.kind === 'tool-approval') {
691
+ const tool = this.tools?.find(
692
+ (configured) => configured.name === candidate.toolName,
693
+ )
694
+ // Gated on the binding and the schema hashes below, not on
695
+ // `interrupt.reason` — that string is free-form AG-UI text another
696
+ // producer can also use, so it cannot be what decides ownership.
697
+ if (
698
+ tool?.needsApproval === true &&
699
+ interrupt.toolCallId === candidate.toolCallId
700
+ ) {
701
+ try {
702
+ const approval = normalizeApprovalSchema(
703
+ tool.approvalSchema,
704
+ tool.inputSchema,
705
+ )
706
+ if (
707
+ hashSchemaInput(tool.inputSchema) === candidate.inputSchemaHash &&
708
+ approval.approvalSchemaHash === candidate.approvalSchemaHash &&
709
+ approval.responseSchemaHash === candidate.responseSchemaHash
710
+ ) {
711
+ return {
712
+ descriptor: interrupt,
713
+ binding: cloneAndDeepFreezeJson(candidate),
714
+ kind: 'tool-approval',
715
+ status: 'pending',
716
+ canResolve: true,
717
+ tool,
718
+ validationGeneration: 0,
719
+ }
720
+ }
721
+ } catch {
722
+ // Invalid configured schemas cannot safely grant typed hydration.
723
+ }
724
+ }
725
+ }
726
+
727
+ if (correlated && candidate.kind === 'client-tool-execution') {
728
+ const tool = this.tools?.find(
729
+ (configured) => configured.name === candidate.toolName,
730
+ )
731
+ // Binding-gated, for the same reason as tool approvals above.
732
+ if (
733
+ tool !== undefined &&
734
+ interrupt.toolCallId === candidate.toolCallId &&
735
+ hashSchemaInput(tool.outputSchema) === candidate.outputSchemaHash
736
+ ) {
737
+ return {
738
+ descriptor: interrupt,
739
+ binding: cloneAndDeepFreezeJson(candidate),
740
+ kind: 'client-tool-execution',
741
+ status: 'pending',
742
+ canResolve: true,
743
+ tool,
744
+ validationGeneration: 0,
745
+ }
746
+ }
747
+ }
748
+
749
+ return {
750
+ descriptor: interrupt,
751
+ binding: genericBinding(interrupt, hydration, candidate),
752
+ kind: 'generic',
753
+ 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,
757
+ validationGeneration: 0,
758
+ }
759
+ }
760
+
761
+ private buildSnapshot(
762
+ transaction?: TransactionToken,
763
+ ): BoundInterrupts<TTools> {
764
+ 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.
768
+ //
769
+ // Items with status `submitting` are also omitted: the resume stream is
770
+ // already in flight, so Approve/Deny is not actionable. Keeping them in
771
+ // the public list made UIs look stuck after a successful approve and
772
+ // blocked follow-up turns that key off `interrupts.length`.
773
+ const next = this.items
774
+ .filter(
775
+ (item) =>
776
+ item.kind !== 'client-tool-execution' && item.status !== 'submitting',
777
+ )
778
+ .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
+ )
785
+ // Not ours to resume: expose the descriptor so a UI can show the run
786
+ // is paused, with no `resolveInterrupt` to call.
787
+ if (item.kind === 'unbound' || item.binding === undefined) {
788
+ const snapshot: UnboundInterrupt = {
789
+ ...base,
790
+ kind: 'unbound',
791
+ canResolve: false,
792
+ }
793
+ return Object.freeze(snapshot)
794
+ }
795
+ if (
796
+ item.kind === 'tool-approval' &&
797
+ item.binding.kind === 'tool-approval'
798
+ ) {
799
+ const binding = cloneAndDeepFreezeJson(item.binding)
800
+ const snapshot = {
801
+ ...base,
802
+ kind: 'tool-approval' as const,
803
+ binding,
804
+ toolName: item.binding.toolName,
805
+ toolCallId: item.binding.toolCallId,
806
+ originalArgs: cloneAndDeepFreezeJson(item.binding.originalArgs),
807
+ resolveInterrupt: (approved: boolean, options?: unknown) => {
808
+ const details = isUnknownObject(options) ? options : undefined
809
+ this.resolveItem(
810
+ item.descriptor.id,
811
+ {
812
+ approved,
813
+ ...(approved && details?.['editedArgs'] !== undefined
814
+ ? { editedArgs: details['editedArgs'] }
815
+ : {}),
816
+ ...(details?.['payload'] !== undefined
817
+ ? { payload: details['payload'] }
818
+ : {}),
819
+ },
820
+ transaction,
821
+ )
822
+ },
823
+ }
824
+ return Object.freeze(snapshot)
825
+ }
826
+ const boundGeneric =
827
+ item.binding.kind === 'generic'
828
+ ? cloneAndDeepFreezeJson(item.binding)
829
+ : cloneAndDeepFreezeJson({
830
+ v: INTERRUPT_BINDING_VERSION,
831
+ kind: 'generic' as const,
832
+ interruptId: item.descriptor.id,
833
+ interruptedRunId: hydration.interruptedRunId,
834
+ generation: hydration.generation,
835
+ responseSchemaHash:
836
+ typeof item.binding.responseSchemaHash === 'string'
837
+ ? item.binding.responseSchemaHash
838
+ : 'none',
839
+ })
840
+ const snapshot: GenericAGUIInterrupt = {
841
+ ...base,
842
+ kind: 'generic',
843
+ binding: boundGeneric,
844
+ resolveInterrupt: (payload) =>
845
+ this.resolveItem(item.descriptor.id, payload, transaction),
846
+ }
847
+ return Object.freeze(snapshot)
848
+ })
849
+
850
+ // The runtime items are created only from the exact configured TTools entry
851
+ // selected by name. TypeScript cannot preserve that per-element lookup
852
+ // through Array.map, so this generic return boundary restores the proven
853
+ // distributive public union.
854
+ return Object.freeze(next) as BoundInterrupts<TTools>
855
+ }
856
+
857
+ private publish(): void {
858
+ if (!this.hydration) {
859
+ this.snapshot = Object.freeze([])
860
+ this.state = Object.freeze({
861
+ interrupts: this.snapshot,
862
+ pendingInterrupts: this.snapshot,
863
+ interruptErrors: this.rootErrors,
864
+ resuming: this.resuming,
865
+ })
866
+ this.options.onChange?.()
867
+ return
868
+ }
869
+ this.snapshot = this.buildSnapshot()
870
+ this.state = Object.freeze({
871
+ interrupts: this.snapshot,
872
+ pendingInterrupts: this.snapshot,
873
+ interruptErrors: this.rootErrors,
874
+ resuming: this.resuming,
875
+ })
876
+ this.options.onChange?.()
877
+ }
878
+
879
+ private resolveItem(
880
+ interruptId: string,
881
+ payload: unknown,
882
+ transaction?: TransactionToken,
883
+ ): void {
884
+ this.assertItemMutable(transaction)
885
+ const item = this.findItem(interruptId)
886
+ this.invalidateRetry()
887
+ if (!item.canResolve) {
888
+ item.status = 'error'
889
+ item.error = this.itemError(
890
+ interruptId,
891
+ 'invalid-response-schema',
892
+ 'The interrupt response schema is invalid and cannot be resolved.',
893
+ )
894
+ if (!transaction) this.publish()
895
+ return
896
+ }
897
+ const validationGeneration = ++item.validationGeneration
898
+ const validation = this.validateCandidate(item, payload)
899
+ if (isPromiseLike(validation)) {
900
+ item.status = 'validating'
901
+ item.error = undefined
902
+ if (!transaction) this.publish()
903
+ void Promise.resolve(validation)
904
+ .then((result) => {
905
+ if (validationGeneration !== item.validationGeneration) return
906
+ this.applyValidation(item, result, transaction)
907
+ })
908
+ .catch((error: unknown) => {
909
+ if (validationGeneration !== item.validationGeneration) return
910
+ this.applyValidation(
911
+ item,
912
+ {
913
+ code: this.validationCode(item),
914
+ message: error instanceof Error ? error.message : String(error),
915
+ },
916
+ transaction,
917
+ )
918
+ })
919
+ return
920
+ }
921
+ this.applyValidation(item, validation, transaction)
922
+ }
923
+
924
+ private cancelItem(
925
+ interruptId: string,
926
+ transaction?: TransactionToken,
927
+ ): void {
928
+ this.assertItemMutable(transaction)
929
+ const item = this.findItem(interruptId)
930
+ this.invalidateRetry()
931
+ item.validationGeneration++
932
+ item.resolution = Object.freeze({ interruptId, status: 'cancelled' })
933
+ item.status = 'staged'
934
+ item.error = undefined
935
+ if (!transaction) {
936
+ this.publish()
937
+ this.maybeSubmit()
938
+ }
939
+ }
940
+
941
+ private clearItem(interruptId: string, transaction?: TransactionToken): void {
942
+ this.assertItemMutable(transaction)
943
+ const item = this.findItem(interruptId)
944
+ this.invalidateRetry()
945
+ item.validationGeneration++
946
+ item.resolution = undefined
947
+ item.error = undefined
948
+ item.status = 'pending'
949
+ if (!transaction) this.publish()
950
+ }
951
+
952
+ private maybeSubmit(): void {
953
+ // Unbound items can never be resolved through this path — something else
954
+ // owns them. Including them in the completeness gate would deadlock the
955
+ // batch, so the run's own interrupts could never be answered once a
956
+ // foreign one shared the stream.
957
+ const ours = this.items.filter((item) => item.kind !== 'unbound')
958
+ if (
959
+ ours.length === 0 ||
960
+ ours.some(
961
+ (item) => item.resolution === undefined || item.status !== 'staged',
962
+ )
963
+ ) {
964
+ return
965
+ }
966
+ const hydration = this.requireHydration()
967
+ const canonical = canonicalizeInterruptResolutions(
968
+ ours.map((item) => item.resolution).filter((item) => item !== undefined),
969
+ )
970
+ const submission = Object.freeze({
971
+ threadId: hydration.threadId,
972
+ interruptedRunId: hydration.interruptedRunId,
973
+ generation: hydration.generation,
974
+ resolutions: canonical.resolutions,
975
+ canonicalResolutions: canonical.canonicalResolutions,
976
+ fingerprint: canonical.fingerprint,
977
+ })
978
+ this.submitBatch(submission)
979
+ }
980
+
981
+ private applyValidation(
982
+ item: RuntimeInterrupt,
983
+ result: ValidationResult,
984
+ transaction?: TransactionToken,
985
+ ): void {
986
+ if (!('valid' in result)) {
987
+ item.status = 'error'
988
+ const itemError = this.itemError(
989
+ item.descriptor.id,
990
+ result.code,
991
+ result.message,
992
+ result.path,
993
+ )
994
+ item.error = itemError
995
+ // Client-tool-execution items are hidden from the public interrupt list,
996
+ // so promote their validation failures onto interruptErrors for the UI.
997
+ if (item.kind === 'client-tool-execution') {
998
+ this.rootErrors = Object.freeze([
999
+ ...this.rootErrors.filter(
1000
+ (error) =>
1001
+ !(
1002
+ error.code === 'item-validation-failed' &&
1003
+ error.interruptIds.includes(item.descriptor.id)
1004
+ ),
1005
+ ),
1006
+ Object.freeze({
1007
+ scope: 'batch' as const,
1008
+ code: 'item-validation-failed' as const,
1009
+ message: itemError.message,
1010
+ source: 'client' as const,
1011
+ retryable: false,
1012
+ interruptIds: Object.freeze([item.descriptor.id]),
1013
+ threadId: itemError.threadId,
1014
+ interruptedRunId: itemError.interruptedRunId,
1015
+ generation: itemError.generation,
1016
+ }),
1017
+ ])
1018
+ }
1019
+ if (!transaction) this.publish()
1020
+ return
1021
+ }
1022
+ item.resolution = cloneAndDeepFreezeJson({
1023
+ interruptId: item.descriptor.id,
1024
+ status: 'resolved',
1025
+ payload: result.payload,
1026
+ })
1027
+ item.status = 'staged'
1028
+ item.error = undefined
1029
+ if (!transaction) {
1030
+ this.publish()
1031
+ this.maybeSubmit()
1032
+ }
1033
+ }
1034
+
1035
+ private validateCandidate(
1036
+ item: RuntimeInterrupt,
1037
+ payload: unknown,
1038
+ ): ValidationResult | Promise<ValidationResult> {
1039
+ if (item.kind === 'generic') {
1040
+ return validateWithSchema(
1041
+ item.descriptor.responseSchema,
1042
+ payload,
1043
+ 'invalid-payload',
1044
+ )
1045
+ }
1046
+ if (item.kind === 'client-tool-execution') {
1047
+ return validateWithSchema(
1048
+ item.tool?.outputSchema,
1049
+ payload,
1050
+ 'invalid-tool-output',
1051
+ )
1052
+ }
1053
+ return this.validateApprovalCandidate(item, payload)
1054
+ }
1055
+
1056
+ private validateApprovalCandidate(
1057
+ item: RuntimeInterrupt,
1058
+ payload: unknown,
1059
+ ): ValidationResult | Promise<ValidationResult> {
1060
+ if (!isUnknownObject(payload) || typeof payload['approved'] !== 'boolean') {
1061
+ return {
1062
+ code: 'invalid-payload',
1063
+ message: 'Tool approval resolutions require an approved boolean.',
1064
+ }
1065
+ }
1066
+ const approved = payload['approved']
1067
+ const editedArgs = payload['editedArgs']
1068
+ if (!approved && editedArgs !== undefined) {
1069
+ return {
1070
+ code: 'invalid-edited-args',
1071
+ message: 'Rejected tool approvals cannot edit tool arguments.',
1072
+ }
1073
+ }
1074
+ if (approved && editedArgs !== undefined) {
1075
+ const editedValidation = validateWithSchema(
1076
+ item.tool?.inputSchema,
1077
+ editedArgs,
1078
+ 'invalid-edited-args',
1079
+ )
1080
+ if (isPromiseLike(editedValidation)) {
1081
+ return Promise.resolve(editedValidation).then((result) =>
1082
+ 'valid' in result
1083
+ ? this.validateApprovalPayload(item, payload, result.payload)
1084
+ : result,
1085
+ )
1086
+ }
1087
+ if (!('valid' in editedValidation)) return editedValidation
1088
+ return this.validateApprovalPayload(
1089
+ item,
1090
+ payload,
1091
+ editedValidation.payload,
1092
+ )
1093
+ }
1094
+ return this.validateApprovalPayload(item, payload, undefined)
1095
+ }
1096
+
1097
+ private validateApprovalPayload(
1098
+ item: RuntimeInterrupt,
1099
+ envelope: UnknownObject,
1100
+ validatedEditedArgs: unknown,
1101
+ ): ValidationResult | Promise<ValidationResult> {
1102
+ const approved = envelope['approved'] === true
1103
+ const schema = this.approvalBranchSchema(item.tool, approved)
1104
+ const branchPayload = envelope['payload']
1105
+ if (schema === undefined && branchPayload !== undefined) {
1106
+ return {
1107
+ code: 'invalid-payload',
1108
+ message: 'This approval branch does not accept a payload.',
1109
+ }
1110
+ }
1111
+ if (schema !== undefined && branchPayload === undefined) {
1112
+ return {
1113
+ code: 'invalid-payload',
1114
+ message: 'This approval branch requires a payload.',
1115
+ }
1116
+ }
1117
+ const validation = validateWithSchema(
1118
+ schema,
1119
+ branchPayload,
1120
+ 'invalid-payload',
1121
+ )
1122
+ const buildEnvelope = (result: ValidationResult): ValidationResult => {
1123
+ if (!('valid' in result)) return result
1124
+ return {
1125
+ valid: true,
1126
+ payload: {
1127
+ approved,
1128
+ ...(validatedEditedArgs !== undefined
1129
+ ? { editedArgs: validatedEditedArgs }
1130
+ : {}),
1131
+ ...(schema !== undefined ? { payload: result.payload } : {}),
1132
+ },
1133
+ }
1134
+ }
1135
+ return isPromiseLike(validation)
1136
+ ? Promise.resolve(validation).then(buildEnvelope)
1137
+ : buildEnvelope(validation)
1138
+ }
1139
+
1140
+ private approvalBranchSchema(
1141
+ tool: AnyClientTool | undefined,
1142
+ approved: boolean,
1143
+ ): unknown {
1144
+ const approvalSchema: unknown = tool?.approvalSchema
1145
+ if (!isUnknownObject(approvalSchema)) return approvalSchema
1146
+ const hasBranches =
1147
+ 'approve' in approvalSchema || 'reject' in approvalSchema
1148
+ if (!hasBranches) return approvalSchema
1149
+ return approved ? approvalSchema['approve'] : approvalSchema['reject']
1150
+ }
1151
+
1152
+ private validationCode(item: RuntimeInterrupt): ItemInterruptError['code'] {
1153
+ return item.kind === 'client-tool-execution'
1154
+ ? 'invalid-tool-output'
1155
+ : 'invalid-payload'
1156
+ }
1157
+
1158
+ private resolveBooleanBulk(approved: boolean): void {
1159
+ // `client-tool-execution` items resolve out-of-band (auto execution /
1160
+ // addToolResult); they are transparent to the boolean shorthand. Eligibility
1161
+ // and resolution consider only the publicly resolvable items.
1162
+ const resolvable = this.items.filter(
1163
+ (item) => item.kind !== 'client-tool-execution',
1164
+ )
1165
+ const eligible = resolvable.every(
1166
+ (item) =>
1167
+ item.kind === 'tool-approval' &&
1168
+ this.approvalBranchSchema(item.tool, approved) === undefined,
1169
+ )
1170
+ if (!eligible || resolvable.length === 0) {
1171
+ this.addRootError(
1172
+ 'unsupported-bulk-operation',
1173
+ 'Boolean bulk resolution requires payloadless tool approvals.',
1174
+ false,
1175
+ )
1176
+ return
1177
+ }
1178
+ this.invalidateRetry()
1179
+ for (const item of resolvable) {
1180
+ item.validationGeneration++
1181
+ item.resolution = cloneAndDeepFreezeJson({
1182
+ interruptId: item.descriptor.id,
1183
+ status: 'resolved',
1184
+ payload: { approved },
1185
+ })
1186
+ item.status = 'staged'
1187
+ item.error = undefined
1188
+ }
1189
+ this.publish()
1190
+ this.maybeSubmit()
1191
+ }
1192
+
1193
+ private resolveTransaction(
1194
+ resolver: (interrupt: ChatInterrupt<TTools>) => unknown,
1195
+ ): void {
1196
+ const checkpoints = this.items.map<RuntimeInterruptCheckpoint>((item) => ({
1197
+ status: item.status,
1198
+ ...(item.resolution !== undefined ? { resolution: item.resolution } : {}),
1199
+ ...(item.error !== undefined ? { error: item.error } : {}),
1200
+ validationGeneration: item.validationGeneration,
1201
+ }))
1202
+ const token: TransactionToken = { active: true }
1203
+ this.activeTransaction = token
1204
+ const stable = this.buildSnapshot(token)
1205
+ let failure:
1206
+ | { code: BatchInterruptError['code']; message: string }
1207
+ | undefined
1208
+ try {
1209
+ for (const interrupt of stable) {
1210
+ const result = resolver(interrupt)
1211
+ if (result !== undefined) {
1212
+ failure = {
1213
+ code: isPromiseLike(result)
1214
+ ? 'async-resolver'
1215
+ : 'inactive-transaction',
1216
+ message: isPromiseLike(result)
1217
+ ? 'Interrupt transaction resolvers must be synchronous.'
1218
+ : 'Interrupt transaction resolvers must return literal undefined.',
1219
+ }
1220
+ break
1221
+ }
1222
+ }
1223
+ if (
1224
+ failure === undefined &&
1225
+ this.items.some(
1226
+ (item) =>
1227
+ // `client-tool-execution` items are resolved out-of-band (auto
1228
+ // execution / addToolResult), not by this synchronous resolver, so
1229
+ // they don't count against transaction completeness. `maybeSubmit`
1230
+ // still gates the actual submission on them being resolved.
1231
+ item.kind !== 'client-tool-execution' &&
1232
+ (item.resolution === undefined || item.status !== 'staged'),
1233
+ )
1234
+ ) {
1235
+ failure = {
1236
+ code: 'incomplete-batch',
1237
+ message: 'Interrupt transaction did not resolve every item.',
1238
+ }
1239
+ }
1240
+ } catch (error) {
1241
+ failure = {
1242
+ code: 'item-validation-failed',
1243
+ message: error instanceof Error ? error.message : String(error),
1244
+ }
1245
+ } finally {
1246
+ token.active = false
1247
+ this.activeTransaction = undefined
1248
+ }
1249
+
1250
+ if (failure) {
1251
+ this.restoreCheckpoints(checkpoints)
1252
+ this.addRootError(failure.code, failure.message, false)
1253
+ return
1254
+ }
1255
+ this.publish()
1256
+ this.maybeSubmit()
1257
+ }
1258
+
1259
+ private restoreCheckpoints(
1260
+ checkpoints: ReadonlyArray<RuntimeInterruptCheckpoint>,
1261
+ ): void {
1262
+ this.items.forEach((item, index) => {
1263
+ const checkpoint = checkpoints[index]
1264
+ if (!checkpoint) return
1265
+ item.status = checkpoint.status
1266
+ item.resolution = checkpoint.resolution
1267
+ item.error = checkpoint.error
1268
+ item.validationGeneration = checkpoint.validationGeneration + 1
1269
+ })
1270
+ this.publish()
1271
+ }
1272
+
1273
+ private assertItemMutable(transaction?: TransactionToken): void {
1274
+ if (transaction && !transaction.active) {
1275
+ throw new Error('Interrupt transaction is inactive.')
1276
+ }
1277
+ if (this.activeTransaction && transaction !== this.activeTransaction) {
1278
+ throw new Error('Interrupt transaction is inactive.')
1279
+ }
1280
+ if (this.resuming) {
1281
+ throw new Error('Interrupts cannot be mutated while submitting.')
1282
+ }
1283
+ }
1284
+
1285
+ private assertRootMutable(): void {
1286
+ if (this.activeTransaction) {
1287
+ throw new Error('Interrupt transaction is already active.')
1288
+ }
1289
+ if (this.resuming) {
1290
+ throw new Error('Interrupts cannot be mutated while submitting.')
1291
+ }
1292
+ }
1293
+
1294
+ private invalidateRetry(): void {
1295
+ this.retrySubmission = undefined
1296
+ }
1297
+
1298
+ private submitBatch(submission: InterruptManagerSubmission): void {
1299
+ this.resuming = true
1300
+ this.retrySubmission = undefined
1301
+ for (const item of this.items) item.status = 'submitting'
1302
+ this.publish()
1303
+ void this.performSubmission(submission)
1304
+ }
1305
+
1306
+ private async performSubmission(
1307
+ submission: InterruptManagerSubmission,
1308
+ ): Promise<void> {
1309
+ try {
1310
+ await this.options.submit(submission)
1311
+ } catch (error) {
1312
+ this.handleSubmissionFailure(error, submission)
1313
+ } finally {
1314
+ this.resuming = false
1315
+ this.publish()
1316
+ }
1317
+ }
1318
+
1319
+ private handleSubmissionFailure(
1320
+ error: unknown,
1321
+ submission: InterruptManagerSubmission,
1322
+ ): void {
1323
+ const errors = readSubmissionErrors(error)
1324
+ if (errors.length === 0) {
1325
+ const message = error instanceof Error ? error.message : String(error)
1326
+ this.addRootError('transport', message, true, 'transport')
1327
+ this.retrySubmission = submission
1328
+ for (const item of this.items) item.status = 'error'
1329
+ return
1330
+ }
1331
+
1332
+ const correlatedErrors = errors.filter((submissionError) =>
1333
+ submissionErrorMatchesActiveBatch(submissionError, submission),
1334
+ )
1335
+ if (correlatedErrors.length !== errors.length) {
1336
+ this.addRootError(
1337
+ 'protocol',
1338
+ 'Interrupt submission errors did not match the active batch.',
1339
+ false,
1340
+ )
1341
+ }
1342
+
1343
+ let nonRetryable = false
1344
+ let retryable = false
1345
+ const batchErrors: Array<BatchInterruptError> = []
1346
+ for (const submissionError of correlatedErrors) {
1347
+ if (
1348
+ submissionError.code === 'stale' ||
1349
+ submissionError.code === 'expired' ||
1350
+ submissionError.code === 'conflict'
1351
+ ) {
1352
+ nonRetryable = true
1353
+ }
1354
+ retryable ||= submissionError.retryable
1355
+ if (submissionError.scope === 'item') {
1356
+ const item = this.items.find(
1357
+ (candidate) =>
1358
+ candidate.descriptor.id === submissionError.interruptId,
1359
+ )
1360
+ if (item) {
1361
+ item.status = 'error'
1362
+ item.error = cloneAndDeepFreezeJson(submissionError)
1363
+ }
1364
+ } else {
1365
+ batchErrors.push(cloneAndDeepFreezeJson(submissionError))
1366
+ }
1367
+ }
1368
+ const mergedBatchErrors = mergeSubmissionBatchErrors(
1369
+ this.rootErrors,
1370
+ this.submissionRootErrors,
1371
+ batchErrors,
1372
+ )
1373
+ this.rootErrors = mergedBatchErrors.rootErrors
1374
+ this.submissionRootErrors = mergedBatchErrors.submissionRootErrors
1375
+ for (const item of this.items) {
1376
+ if (item.status === 'submitting') item.status = 'error'
1377
+ }
1378
+ this.retrySubmission = retryable && !nonRetryable ? submission : undefined
1379
+ }
1380
+
1381
+ private addRootError(
1382
+ code: BatchInterruptError['code'],
1383
+ message: string,
1384
+ retryable: boolean,
1385
+ source: BatchInterruptError['source'] = 'client',
1386
+ ): void {
1387
+ const hydration = this.requireHydration()
1388
+ this.rootErrors = Object.freeze([
1389
+ ...this.rootErrors,
1390
+ Object.freeze({
1391
+ scope: 'batch' as const,
1392
+ code,
1393
+ message,
1394
+ source,
1395
+ retryable,
1396
+ interruptIds: Object.freeze(
1397
+ this.items.map((item) => item.descriptor.id),
1398
+ ),
1399
+ threadId: hydration.threadId,
1400
+ interruptedRunId: hydration.interruptedRunId,
1401
+ generation: hydration.generation,
1402
+ }),
1403
+ ])
1404
+ this.publish()
1405
+ }
1406
+
1407
+ private findItem(interruptId: string): RuntimeInterrupt {
1408
+ const item = this.items.find(
1409
+ (candidate) => candidate.descriptor.id === interruptId,
1410
+ )
1411
+ if (!item) throw new Error(`Unknown interrupt: ${interruptId}`)
1412
+ return item
1413
+ }
1414
+
1415
+ private requireHydration(): InterruptManagerHydration {
1416
+ if (!this.hydration) throw new Error('InterruptManager is not hydrated.')
1417
+ return this.hydration
1418
+ }
1419
+
1420
+ private itemError(
1421
+ interruptId: string,
1422
+ code: ItemInterruptError['code'],
1423
+ message: string,
1424
+ path?: ReadonlyArray<string | number>,
1425
+ ): ItemInterruptError {
1426
+ const hydration = this.requireHydration()
1427
+ return Object.freeze({
1428
+ scope: 'item',
1429
+ interruptId,
1430
+ code,
1431
+ message,
1432
+ ...(path !== undefined ? { path: Object.freeze([...path]) } : {}),
1433
+ source: 'client',
1434
+ retryable: false,
1435
+ threadId: hydration.threadId,
1436
+ interruptedRunId: hydration.interruptedRunId,
1437
+ generation: hydration.generation,
1438
+ })
1439
+ }
1440
+ }