@tanstack/ai-persistence 0.2.0 → 0.4.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.
package/src/middleware.ts CHANGED
@@ -1,15 +1,30 @@
1
1
  import {
2
2
  defineChatMiddleware,
3
3
  getDetachableRun,
4
+ InterruptResumeValidationError,
5
+ readInterruptBinding,
6
+ validateInterruptResumeBatch,
4
7
  wasCancelRequested,
5
8
  } from '@tanstack/ai'
6
- import { providePendingTurn } from '@tanstack/ai/adapter-internals'
9
+ import {
10
+ createInterruptBinding,
11
+ getGenericInterruptDefinitionRegistry,
12
+ providePendingTurn,
13
+ rehydrateInterruptRequest,
14
+ toRunErrorPayload,
15
+ } from '@tanstack/ai/adapter-internals'
16
+ import type {
17
+ GenericInterruptRequest,
18
+ InterruptDefinition,
19
+ } from '@tanstack/ai/adapter-internals'
7
20
  import { base64ToUint8Array } from '@tanstack/ai-utils'
8
21
  import {
9
22
  InterruptsCapability,
10
23
  PersistenceCapability,
24
+ PersistenceCompletionCapability,
11
25
  provideInterrupts,
12
26
  providePersistence,
27
+ providePersistenceCompletion,
13
28
  } from './capabilities'
14
29
  import {
15
30
  validateChatPersistenceStores,
@@ -28,12 +43,14 @@ import type {
28
43
  GenerationFinishInfo,
29
44
  GenerationMiddleware,
30
45
  GenerationMiddlewareContext,
31
- ModelMessage,
46
+ Interrupt,
47
+ PendingInterruptResumeRecord,
32
48
  PersistedArtifactActivity,
33
49
  PersistedArtifactRef,
34
50
  PersistedArtifactRole,
35
51
  RunAgentResumeItem,
36
52
  StreamChunk,
53
+ Tool,
37
54
  ToolApprovalResolution,
38
55
  BilledUsage,
39
56
  TokenUsage,
@@ -44,6 +61,7 @@ import type {
44
61
  ArtifactRecord,
45
62
  BlobBody,
46
63
  ChatTranscriptStores,
64
+ InterruptCommitEntry,
47
65
  InterruptRecord,
48
66
  RunStore,
49
67
  } from './types'
@@ -269,33 +287,153 @@ interface RunStateEntry {
269
287
  */
270
288
  streamingMessageId?: string
271
289
  streamingMessageCreatedAt?: Date
290
+ completion?: {
291
+ promise: Promise<void>
292
+ resolve: () => void
293
+ reject: (error: unknown) => void
294
+ }
272
295
  }
273
296
 
274
297
  const runState = new WeakMap<object, RunStateEntry>()
275
298
 
276
299
  const validResumeStatuses = new Set(['resolved', 'cancelled'])
277
300
 
301
+ function mergeMaps<K, V>(
302
+ left?: ReadonlyMap<K, V>,
303
+ right?: ReadonlyMap<K, V>,
304
+ ): Map<K, V> | undefined {
305
+ if (!left && !right) return undefined
306
+ return new Map([...(left ?? []), ...(right ?? [])])
307
+ }
308
+
309
+ function mergeSets<T>(
310
+ left?: ReadonlySet<T>,
311
+ right?: ReadonlySet<T>,
312
+ ): Set<T> | undefined {
313
+ if (!left && !right) return undefined
314
+ return new Set([...(left ?? []), ...(right ?? [])])
315
+ }
316
+
317
+ function mergeResumeToolState(
318
+ left: ChatResumeToolState | undefined,
319
+ right: ChatResumeToolState | undefined,
320
+ ): ChatResumeToolState | undefined {
321
+ if (!left) return right
322
+ if (!right) return left
323
+ return {
324
+ approvals: mergeMaps(left.approvals, right.approvals),
325
+ clientToolResults: mergeMaps(
326
+ left.clientToolResults,
327
+ right.clientToolResults,
328
+ ),
329
+ genericInterrupts: mergeMaps(
330
+ left.genericInterrupts,
331
+ right.genericInterrupts,
332
+ ),
333
+ genericInterruptRequests: mergeMaps(
334
+ left.genericInterruptRequests,
335
+ right.genericInterruptRequests,
336
+ ),
337
+ deniedToolResults: mergeMaps(
338
+ left.deniedToolResults,
339
+ right.deniedToolResults,
340
+ ),
341
+ cancelledToolCallIds: mergeSets(
342
+ left.cancelledToolCallIds,
343
+ right.cancelledToolCallIds,
344
+ ),
345
+ }
346
+ }
347
+
348
+ function rejectMixedRunPending(
349
+ pending: Array<InterruptRecord>,
350
+ ctx: Pick<ChatMiddlewareContext, 'threadId' | 'runId'>,
351
+ ): void {
352
+ const runIds = new Set(pending.map((interrupt) => interrupt.runId))
353
+ if (runIds.size <= 1) return
354
+ throw new InterruptResumeValidationError([
355
+ {
356
+ scope: 'batch',
357
+ threadId: ctx.threadId,
358
+ interruptedRunId: ctx.runId,
359
+ generation: 0,
360
+ interruptIds: pending.map((interrupt) => interrupt.interruptId),
361
+ code: 'stale',
362
+ message: 'Thread has pending interrupts from more than one run.',
363
+ source: 'server',
364
+ retryable: false,
365
+ },
366
+ ])
367
+ }
368
+
278
369
  function validatePendingResumes(
279
370
  pending: Array<InterruptRecord>,
280
371
  resume: Array<RunAgentResumeItem> | undefined,
372
+ ctx: Pick<ChatMiddlewareContext, 'threadId' | 'runId'>,
281
373
  ): Map<string, RunAgentResumeItem> {
374
+ const interruptedRunId = pending[0]?.runId ?? ctx.runId
375
+ const failure = (
376
+ interruptId: string,
377
+ code: 'conflict' | 'unknown-interrupt',
378
+ message: string,
379
+ ): never => {
380
+ throw new InterruptResumeValidationError([
381
+ {
382
+ scope: 'item',
383
+ threadId: ctx.threadId,
384
+ interruptedRunId,
385
+ generation: 0,
386
+ interruptId,
387
+ code,
388
+ message,
389
+ source: 'client',
390
+ retryable: false,
391
+ },
392
+ {
393
+ scope: 'batch',
394
+ threadId: ctx.threadId,
395
+ interruptedRunId,
396
+ generation: 0,
397
+ interruptIds: pending.map((interrupt) => interrupt.interruptId),
398
+ code: code === 'conflict' ? 'conflict' : 'incomplete-batch',
399
+ message:
400
+ 'Resume entries must resolve or cancel the complete interrupt batch.',
401
+ source: 'client',
402
+ retryable: false,
403
+ },
404
+ ])
405
+ }
282
406
  const pendingInterruptIds = new Set(
283
407
  pending.map((interrupt) => interrupt.interruptId),
284
408
  )
285
- const resumeByInterruptId = new Map(
286
- (resume ?? []).map((entry) => [entry.interruptId, entry]),
287
- )
409
+ const resumeByInterruptId = new Map<string, RunAgentResumeItem>()
410
+ for (const entry of resume ?? []) {
411
+ if (resumeByInterruptId.has(entry.interruptId)) {
412
+ return failure(
413
+ entry.interruptId,
414
+ 'conflict',
415
+ `Interrupt ${entry.interruptId} has duplicate resume entries.`,
416
+ )
417
+ }
418
+ resumeByInterruptId.set(entry.interruptId, entry)
419
+ }
288
420
  if (pending.length === 0) {
289
421
  const staleEntry = resume?.[0]
290
422
  if (staleEntry) {
291
- throw new Error(
423
+ return failure(
424
+ staleEntry.interruptId,
425
+ 'unknown-interrupt',
292
426
  `Resume entry references non-pending interrupt ${staleEntry.interruptId}.`,
293
427
  )
294
428
  }
295
429
  return resumeByInterruptId
296
430
  }
431
+ const firstPending = pending[0]
432
+ if (firstPending === undefined) return resumeByInterruptId
297
433
  if (!resume || resume.length === 0) {
298
- throw new Error(
434
+ return failure(
435
+ firstPending.interruptId,
436
+ 'unknown-interrupt',
299
437
  `Thread has pending interrupts; resume is required before accepting new input.`,
300
438
  )
301
439
  }
@@ -303,19 +441,25 @@ function validatePendingResumes(
303
441
  for (const interrupt of pending) {
304
442
  const entry = resumeByInterruptId.get(interrupt.interruptId)
305
443
  if (!entry) {
306
- throw new Error(
444
+ return failure(
445
+ interrupt.interruptId,
446
+ 'unknown-interrupt',
307
447
  `Missing resume entry for pending interrupt ${interrupt.interruptId}.`,
308
448
  )
309
449
  }
310
450
  if (!validResumeStatuses.has(entry.status)) {
311
- throw new Error(
451
+ return failure(
452
+ interrupt.interruptId,
453
+ 'unknown-interrupt',
312
454
  `Invalid resume status for pending interrupt ${interrupt.interruptId}: ${entry.status}.`,
313
455
  )
314
456
  }
315
457
  }
316
458
  for (const entry of resume) {
317
459
  if (!pendingInterruptIds.has(entry.interruptId)) {
318
- throw new Error(
460
+ return failure(
461
+ entry.interruptId,
462
+ 'unknown-interrupt',
319
463
  `Resume entry references non-pending interrupt ${entry.interruptId}.`,
320
464
  )
321
465
  }
@@ -328,13 +472,52 @@ async function applyPendingResumes(
328
472
  resumeByInterruptId: Map<string, RunAgentResumeItem>,
329
473
  interrupts: NonNullable<AIPersistence['stores']['interrupts']>,
330
474
  ): Promise<void> {
475
+ const entries: Array<InterruptCommitEntry> = []
331
476
  for (const interrupt of pending) {
332
477
  const entry = resumeByInterruptId.get(interrupt.interruptId)
333
478
  if (!entry) continue
334
479
  if (entry.status === 'resolved') {
335
- await interrupts.resolve(interrupt.interruptId, entry.payload)
480
+ entries.push({
481
+ interruptId: interrupt.interruptId,
482
+ status: 'resolved',
483
+ response: entry.payload,
484
+ })
485
+ } else {
486
+ entries.push({
487
+ interruptId: interrupt.interruptId,
488
+ status: 'cancelled',
489
+ })
490
+ }
491
+ }
492
+ if (interrupts.commitBatch) {
493
+ await interrupts.commitBatch(entries)
494
+ return
495
+ }
496
+ const ids = new Set<string>()
497
+ for (const entry of entries) {
498
+ if (ids.has(entry.interruptId)) {
499
+ throw new Error(
500
+ `Interrupt batch contains duplicate id: ${entry.interruptId}.`,
501
+ )
502
+ }
503
+ ids.add(entry.interruptId)
504
+ const existing = await interrupts.get(entry.interruptId)
505
+ if (!existing) {
506
+ throw new Error(
507
+ `Interrupt batch references missing id: ${entry.interruptId}.`,
508
+ )
509
+ }
510
+ if (existing.status !== 'pending') {
511
+ throw new Error(
512
+ `Interrupt batch references non-pending id: ${entry.interruptId}.`,
513
+ )
514
+ }
515
+ }
516
+ for (const entry of entries) {
517
+ if (entry.status === 'resolved') {
518
+ await interrupts.resolve(entry.interruptId, entry.response)
336
519
  } else {
337
- await interrupts.cancel(interrupt.interruptId)
520
+ await interrupts.cancel(entry.interruptId)
338
521
  }
339
522
  }
340
523
  }
@@ -378,6 +561,267 @@ function interruptKind(interrupt: InterruptRecord): string | undefined {
378
561
  return metadata ? stringField(metadata, 'kind') : undefined
379
562
  }
380
563
 
564
+ function hasReservedInterruptBinding(payload: unknown): boolean {
565
+ const descriptor = objectValue(payload)
566
+ const metadata = objectValue(descriptor?.metadata)
567
+ return !!metadata && 'tanstack:interruptBinding' in metadata
568
+ }
569
+
570
+ function isPersistedInterruptDescriptor(
571
+ value: unknown,
572
+ ): value is Interrupt & { reason: string; message: string } {
573
+ const record = objectValue(value)
574
+ return (
575
+ !!record &&
576
+ typeof record.id === 'string' &&
577
+ typeof record.reason === 'string' &&
578
+ typeof record.message === 'string'
579
+ )
580
+ }
581
+
582
+ /**
583
+ * Does this pending record belong to the TanStack chat resume protocol?
584
+ *
585
+ * An external system can persist an AG-UI descriptor in the same durable
586
+ * thread. A descriptor without a TanStack binding or legacy tool marker stays
587
+ * pending for its owner, but it does not make this resume incomplete. Older
588
+ * opaque records remain owned because their provenance cannot be known.
589
+ */
590
+ function isChatOwnedPendingInterrupt(interrupt: InterruptRecord): boolean {
591
+ const kind = interruptKind(interrupt)
592
+ return (
593
+ !isPersistedInterruptDescriptor(interrupt.payload) ||
594
+ stringField(interrupt.payload, 'toolCallId') !== undefined ||
595
+ kind === 'approval' ||
596
+ kind === 'client_tool' ||
597
+ hasReservedInterruptBinding(interrupt.payload)
598
+ )
599
+ }
600
+
601
+ function durableGenericFailure(
602
+ ctx: Pick<ChatMiddlewareContext, 'threadId' | 'runId'>,
603
+ persisted: InterruptRecord,
604
+ message: string,
605
+ ): InterruptResumeValidationError {
606
+ return new InterruptResumeValidationError([
607
+ {
608
+ scope: 'item',
609
+ threadId: ctx.threadId,
610
+ interruptedRunId: persisted.runId || ctx.runId,
611
+ generation: 0,
612
+ interruptId: persisted.interruptId,
613
+ code: 'stale',
614
+ message,
615
+ source: 'server',
616
+ retryable: false,
617
+ },
618
+ {
619
+ scope: 'batch',
620
+ threadId: ctx.threadId,
621
+ interruptedRunId: persisted.runId || ctx.runId,
622
+ generation: 0,
623
+ interruptIds: [persisted.interruptId],
624
+ code: 'item-validation-failed',
625
+ message: 'One or more persisted interrupt records are invalid.',
626
+ source: 'server',
627
+ retryable: false,
628
+ },
629
+ ])
630
+ }
631
+
632
+ async function durableGenericResumeState(
633
+ ctx: ChatMiddlewareContext,
634
+ pending: Array<InterruptRecord>,
635
+ resume: ReadonlyArray<RunAgentResumeItem>,
636
+ tools: Array<Tool>,
637
+ ): Promise<ChatResumeToolState | undefined> {
638
+ const registry = getGenericInterruptDefinitionRegistry(ctx, {
639
+ optional: true,
640
+ })
641
+ const records: Array<PendingInterruptResumeRecord> = []
642
+
643
+ for (const persisted of pending) {
644
+ if (!isPersistedInterruptDescriptor(persisted.payload)) {
645
+ if (hasReservedInterruptBinding(persisted.payload)) {
646
+ throw durableGenericFailure(
647
+ ctx,
648
+ persisted,
649
+ `Persisted interrupt ${persisted.interruptId} has an invalid binding descriptor.`,
650
+ )
651
+ }
652
+ continue
653
+ }
654
+ const descriptor = persisted.payload
655
+ const binding = readInterruptBinding(descriptor)
656
+ if (!binding) {
657
+ if (hasReservedInterruptBinding(descriptor)) {
658
+ throw durableGenericFailure(
659
+ ctx,
660
+ persisted,
661
+ `Persisted interrupt ${persisted.interruptId} has an invalid or incomplete binding.`,
662
+ )
663
+ }
664
+ continue
665
+ }
666
+ if (
667
+ descriptor.id !== persisted.interruptId ||
668
+ binding.interruptId !== persisted.interruptId ||
669
+ binding.interruptedRunId !== persisted.runId ||
670
+ binding.generation !== 0
671
+ ) {
672
+ throw durableGenericFailure(
673
+ ctx,
674
+ persisted,
675
+ `Persisted interrupt ${persisted.interruptId} has stale correlation metadata.`,
676
+ )
677
+ }
678
+ if (binding.kind !== 'generic') {
679
+ records.push({
680
+ interruptId: persisted.interruptId,
681
+ payload: descriptor,
682
+ binding,
683
+ })
684
+ continue
685
+ }
686
+ if (
687
+ !binding.definitionId ||
688
+ !binding.key ||
689
+ binding.batchIndex === undefined
690
+ ) {
691
+ records.push({
692
+ interruptId: persisted.interruptId,
693
+ payload: descriptor,
694
+ binding,
695
+ })
696
+ continue
697
+ }
698
+ if (!registry) {
699
+ throw durableGenericFailure(
700
+ ctx,
701
+ persisted,
702
+ `Persisted generic interrupt ${persisted.interruptId} cannot be restored because no interrupt registry is available.`,
703
+ )
704
+ }
705
+ const definition = registry.definitions.get(binding.definitionId)
706
+ if (!definition) {
707
+ throw durableGenericFailure(
708
+ ctx,
709
+ persisted,
710
+ `Persisted generic interrupt definition ${binding.definitionId} is unavailable.`,
711
+ )
712
+ }
713
+ const metadata = objectValue(descriptor.metadata)
714
+ const payload = metadata?.['tanstack:interruptPayload']
715
+ let request: GenericInterruptRequest<
716
+ InterruptDefinition<any, any, any, any>
717
+ >
718
+ try {
719
+ request = rehydrateInterruptRequest(definition, {
720
+ key: binding.key,
721
+ reason: descriptor.reason,
722
+ message: descriptor.message,
723
+ ...(descriptor.expiresAt !== undefined
724
+ ? { expiresAt: descriptor.expiresAt }
725
+ : {}),
726
+ ...(payload !== undefined ? { payload } : {}),
727
+ })
728
+ } catch (error) {
729
+ throw durableGenericFailure(
730
+ ctx,
731
+ persisted,
732
+ `Persisted generic interrupt ${persisted.interruptId} is invalid: ${error instanceof Error ? error.message : String(error)}`,
733
+ )
734
+ }
735
+ const emitted = createInterruptBinding(request, {
736
+ batchIndex: binding.batchIndex,
737
+ })
738
+ if (
739
+ emitted.descriptor.responseSchemaHash !== binding.responseSchemaHash ||
740
+ emitted.descriptor.payloadSchemaHash !== binding.payloadSchemaHash ||
741
+ binding.interruptId !== persisted.interruptId
742
+ ) {
743
+ throw durableGenericFailure(
744
+ ctx,
745
+ persisted,
746
+ `Persisted generic interrupt ${persisted.interruptId} is stale.`,
747
+ )
748
+ }
749
+ records.push({
750
+ interruptId: persisted.interruptId,
751
+ payload: descriptor,
752
+ binding,
753
+ genericRequest: request,
754
+ })
755
+ }
756
+
757
+ const firstRecord = records[0]
758
+ if (firstRecord === undefined) return undefined
759
+ const interruptedRunId = firstRecord.binding.interruptedRunId
760
+ const generation = firstRecord.binding.generation
761
+ const validated = await validateInterruptResumeBatch({
762
+ threadId: ctx.threadId,
763
+ interruptedRunId,
764
+ generation,
765
+ pending: records,
766
+ resume: resume.filter((entry) =>
767
+ records.some((record) => record.interruptId === entry.interruptId),
768
+ ),
769
+ tools,
770
+ })
771
+ if (validated.errors.length > 0 || !validated.resumeToolState) {
772
+ throw new InterruptResumeValidationError(validated.errors)
773
+ }
774
+ type GenericRecord = PendingInterruptResumeRecord & {
775
+ binding: Extract<
776
+ PendingInterruptResumeRecord['binding'],
777
+ { kind: 'generic' }
778
+ >
779
+ genericRequest: GenericInterruptRequest<
780
+ InterruptDefinition<any, any, any, any>
781
+ >
782
+ }
783
+ const isGenericRecord = (
784
+ record: PendingInterruptResumeRecord,
785
+ ): record is GenericRecord =>
786
+ record.binding.kind === 'generic' && record.genericRequest !== undefined
787
+ const genericRecords: Array<{ record: GenericRecord; batchIndex: number }> =
788
+ []
789
+ const batchIndexes = new Set<number>()
790
+ for (const record of records) {
791
+ if (!isGenericRecord(record)) continue
792
+ const batchIndex = record.binding.batchIndex
793
+ if (batchIndex === undefined || batchIndexes.has(batchIndex)) {
794
+ throw new InterruptResumeValidationError([
795
+ {
796
+ scope: 'batch',
797
+ threadId: ctx.threadId,
798
+ interruptedRunId,
799
+ generation,
800
+ interruptIds: records.map((item) => item.interruptId),
801
+ code: 'stale',
802
+ message:
803
+ 'Persisted generic interrupts have duplicate or invalid batch indexes.',
804
+ source: 'server',
805
+ retryable: false,
806
+ },
807
+ ])
808
+ }
809
+ batchIndexes.add(batchIndex)
810
+ genericRecords.push({ record, batchIndex })
811
+ }
812
+ genericRecords.sort((left, right) => left.batchIndex - right.batchIndex)
813
+ return {
814
+ ...validated.resumeToolState,
815
+ genericInterruptRequests: new Map(
816
+ genericRecords.flatMap(({ record }) =>
817
+ record.genericRequest
818
+ ? [[record.interruptId, record.genericRequest] as const]
819
+ : [],
820
+ ),
821
+ ),
822
+ }
823
+ }
824
+
381
825
  function resolvedApprovalDecision(entry: RunAgentResumeItem): boolean {
382
826
  if (entry.status === 'cancelled') return false
383
827
  const payload = objectValue(entry.payload)
@@ -438,43 +882,6 @@ function resumeToolStateFromPending(
438
882
  return { approvals, clientToolResults, cancelledToolCallIds }
439
883
  }
440
884
 
441
- /**
442
- * Build the transcript to persist when a run finishes successfully.
443
- *
444
- * The chat engine appends an assistant message to the middleware message list
445
- * only when that turn carries tool calls (to feed the agent loop); a run's
446
- * terminal *text* reply is never appended. So `ctx.messages` at `onFinish` is
447
- * missing the assistant's final answer. Reattach it from the finish info —
448
- * `info.content` is the last turn's accumulated text (reset each cycle) — so
449
- * the stored thread is the complete conversation a server-authoritative client
450
- * hydrates on load. A guard avoids duplicating a terminal assistant turn should
451
- * the engine ever start appending it itself.
452
- */
453
- function finishedTranscript(
454
- messages: ReadonlyArray<ModelMessage>,
455
- info: FinishInfo,
456
- messageId: string | undefined,
457
- createdAt: Date | undefined,
458
- ): Array<ModelMessage> {
459
- const transcript = [...messages]
460
- const last = transcript[transcript.length - 1]
461
- const alreadyPresent =
462
- last?.role === 'assistant' &&
463
- last.toolCalls === undefined &&
464
- last.content === info.content
465
- if (info.content && !alreadyPresent) {
466
- // Stamp the terminal turn with its stream messageId so a hydrated bubble
467
- // keeps the same identity as the live stream (in-place resume on reload).
468
- transcript.push({
469
- role: 'assistant',
470
- content: info.content,
471
- ...(messageId ? { id: messageId } : {}),
472
- ...(createdAt ? { createdAt } : {}),
473
- })
474
- }
475
- return transcript
476
- }
477
-
478
885
  function interruptPayload(interrupt: unknown): Record<string, unknown> {
479
886
  return interrupt && typeof interrupt === 'object'
480
887
  ? { ...(interrupt as Record<string, unknown>) }
@@ -1402,14 +1809,14 @@ async function failRun(
1402
1809
  error: unknown,
1403
1810
  usage?: TokenUsage,
1404
1811
  ): Promise<void> {
1405
- // `RunRecord.error` is a structured `RunError`. Only `message` is filled in
1406
- // here: the middleware sees an opaque thrown value, and inventing a `code`
1407
- // from it would fabricate the stable classification consumers branch on. A
1408
- // provider-supplied code reaches the record through the adapter layer.
1812
+ const runError = toRunErrorPayload(error)
1409
1813
  await runs?.update(runId, {
1410
1814
  status: 'failed',
1411
1815
  finishedAt: Date.now(),
1412
- error: { message: error instanceof Error ? error.message : String(error) },
1816
+ error: {
1817
+ message: runError.message,
1818
+ ...(runError.code !== undefined ? { code: runError.code } : {}),
1819
+ },
1413
1820
  ...(usage ? { usage } : {}),
1414
1821
  })
1415
1822
  }
@@ -1527,6 +1934,7 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
1527
1934
 
1528
1935
  const provides = [
1529
1936
  PersistenceCapability,
1937
+ PersistenceCompletionCapability,
1530
1938
  ...(wantsInterrupts ? [InterruptsCapability] : []),
1531
1939
  ]
1532
1940
 
@@ -1536,9 +1944,27 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
1536
1944
  setup(ctx: ChatMiddlewareContext) {
1537
1945
  providePersistence(ctx, persistence)
1538
1946
 
1947
+ let resolveCompletion: () => void = () => undefined
1948
+ let rejectCompletion: (error: unknown) => void = () => undefined
1949
+ const completion = new Promise<void>((resolve, reject) => {
1950
+ resolveCompletion = resolve
1951
+ rejectCompletion = reject
1952
+ })
1953
+ // Consumers may not need this capability. Mark the rejection handled while
1954
+ // preserving the original promise for callers that do await it.
1955
+ void completion.catch(() => undefined)
1956
+
1539
1957
  runState.set(ctx, {
1540
1958
  merged: false,
1541
1959
  interrupted: false,
1960
+ completion: {
1961
+ promise: completion,
1962
+ resolve: resolveCompletion,
1963
+ reject: rejectCompletion,
1964
+ },
1965
+ })
1966
+ providePersistenceCompletion(ctx, {
1967
+ waitForRunCompletion: () => completion,
1542
1968
  })
1543
1969
 
1544
1970
  if (wantsInterrupts && persistence.stores.interrupts) {
@@ -1575,11 +2001,15 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
1575
2001
  const pending = await persistence.stores.interrupts.listPending(
1576
2002
  ctx.threadId,
1577
2003
  )
1578
- // Gate: a thread with pending interrupts must carry a resume batch that
1579
- // references them.
2004
+ // Gate only records that this chat owns. A foreign AG-UI interrupt can
2005
+ // share the durable thread, but its owner resolves it outside this
2006
+ // resume protocol. Including it would deadlock this chat resume.
2007
+ const ownedPending = pending.filter(isChatOwnedPendingInterrupt)
2008
+ rejectMixedRunPending(ownedPending, ctx)
1580
2009
  const resumeByInterruptId = validatePendingResumes(
1581
- pending,
2010
+ ownedPending,
1582
2011
  config.resume,
2012
+ ctx,
1583
2013
  )
1584
2014
  // Persistence is the server-authoritative resume path: translate the
1585
2015
  // persisted interrupts into the engine's resume tool state and CLEAR
@@ -1588,18 +2018,29 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
1588
2018
  // persistence flow deliberately omits).
1589
2019
  if ((config.resume?.length ?? 0) > 0) {
1590
2020
  const resumeToolState = resumeToolStateFromPending(
1591
- pending,
2021
+ ownedPending,
1592
2022
  resumeByInterruptId,
1593
2023
  )
2024
+ const genericResumeState = await durableGenericResumeState(
2025
+ ctx,
2026
+ ownedPending,
2027
+ config.resume ?? [],
2028
+ config.tools,
2029
+ )
1594
2030
  patch.resume = []
1595
- if (resumeToolState) patch.resumeToolState = resumeToolState
2031
+ if (resumeToolState || genericResumeState) {
2032
+ patch.resumeToolState = mergeResumeToolState(
2033
+ resumeToolState,
2034
+ genericResumeState,
2035
+ )
2036
+ }
1596
2037
  }
1597
2038
  // Defer marking these interrupts resolved/cancelled until the run
1598
2039
  // succeeds (see commitPendingResumes). Committing here would consume the
1599
2040
  // approval even if the run then failed, breaking a retry.
1600
2041
  const state = runState.get(ctx)
1601
- if (state && pending.length > 0) {
1602
- state.pendingResumes = { pending, resumeByInterruptId }
2042
+ if (state && ownedPending.length > 0) {
2043
+ state.pendingResumes = { pending: ownedPending, resumeByInterruptId }
1603
2044
  }
1604
2045
  }
1605
2046
 
@@ -1630,11 +2071,9 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
1630
2071
  },
1631
2072
 
1632
2073
  async onChunk(ctx: ChatMiddlewareContext, chunk: StreamChunk) {
1633
- // Always capture the current assistant turn's stream messageId (cheap),
1634
- // regardless of snapshotStreaming — it's persisted onto the assistant
1635
- // message so its identity survives hydrate and a reload resumes the same
1636
- // bubble in place.
1637
- if (ctx.phase === 'modelStream') {
2074
+ // Capture the current assistant turn's identity for optional in-progress
2075
+ // snapshots. Completed messages already live in `ctx.messages`.
2076
+ if (snapshotStreaming && ctx.phase === 'modelStream') {
1638
2077
  const s = runState.get(ctx)
1639
2078
  if (s && chunk.type === 'TEXT_MESSAGE_START') {
1640
2079
  // An empty/malformed messageId means "no identity" (matching the
@@ -1661,9 +2100,8 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
1661
2100
 
1662
2101
  // (B) Optional throttled snapshot of the in-progress assistant reply, so
1663
2102
  // partial output survives a crash/reload before onFinish. Off unless
1664
- // `snapshotStreaming` is set. We accumulate the terminal turn's text here
1665
- // (the engine only appends assistant turns with tool calls to
1666
- // `ctx.messages`, never a streaming text reply), then persist
2103
+ // `snapshotStreaming` is set. The completed turn enters `ctx.messages`
2104
+ // only after streaming ends, so accumulate its text here and persist
1667
2105
  // `ctx.messages` + that partial assistant message (tagged with its id).
1668
2106
  if (
1669
2107
  snapshotStreaming &&
@@ -1749,21 +2187,30 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
1749
2187
  // resumes stay pending so a retry can re-apply them. Completing the run
1750
2188
  // or consuming approvals before the durable history lands leaves a
1751
2189
  // "finished" run whose transcript is missing the terminal turn.
1752
- await messageStore.saveThread(
1753
- ctx.threadId,
1754
- finishedTranscript(
1755
- ctx.messages,
1756
- info,
1757
- state?.streamingMessageId,
1758
- state?.streamingMessageCreatedAt,
1759
- ),
1760
- )
1761
- await completeRun(runs, ctx.runId, state?.usage ?? info.usage)
1762
- await commitPendingResumes(state, persistence.stores.interrupts)
2190
+ try {
2191
+ await messageStore.saveThread(ctx.threadId, [...ctx.messages])
2192
+ await commitPendingResumes(state, persistence.stores.interrupts)
2193
+ await completeRun(runs, ctx.runId, state?.usage ?? info.usage)
2194
+ state?.completion?.resolve()
2195
+ } catch (error) {
2196
+ // Core has already selected its terminal hook. Persist the failed run
2197
+ // here, so a failed transcript save or batch write does not leave an
2198
+ // interrupted or completed run whose pending records need retrying.
2199
+ try {
2200
+ await failRun(runs, ctx.runId, error, state?.usage)
2201
+ } finally {
2202
+ state?.completion?.reject(error)
2203
+ }
2204
+ throw error
2205
+ }
1763
2206
  },
1764
2207
 
1765
2208
  async onError(ctx: ChatMiddlewareContext, info: ErrorInfo) {
1766
- await failRun(runs, ctx.runId, info.error, runState.get(ctx)?.usage)
2209
+ try {
2210
+ await failRun(runs, ctx.runId, info.error, runState.get(ctx)?.usage)
2211
+ } finally {
2212
+ runState.get(ctx)?.completion?.reject(info.error)
2213
+ }
1767
2214
  },
1768
2215
 
1769
2216
  async onAbort(ctx: ChatMiddlewareContext, info: AbortInfo) {
@@ -1773,10 +2220,6 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
1773
2220
  // (`info.cancelRequested`, set when the cancel aborted this host's signal)
1774
2221
  // and durable (`RunRecord.cancelRequested`, the only channel that reaches
1775
2222
  // a run being driven elsewhere).
1776
- const cancelled =
1777
- info.cancelRequested === true ||
1778
- (runs !== undefined && (await wasCancelRequested(runs, ctx.runId)))
1779
-
1780
2223
  // A run paused at an interrupt boundary is waiting for a HUMAN, not for
1781
2224
  // this socket. `chat()` skips its terminal hook at an actionable-wait
1782
2225
  // boundary, so its `finally` routes the disconnect here — and
@@ -1785,9 +2228,21 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
1785
2228
  // still threw on the next request. An explicit cancel is different: the
1786
2229
  // user gave up on the approval, so the cancel band stays authoritative.
1787
2230
  const state = runState.get(ctx)
1788
- if (cancelled || (!detachableRun(ctx) && state?.interrupted !== true)) {
1789
- await abortRun(runs, ctx.runId, state?.usage)
1790
- return
2231
+ let terminal = false
2232
+ try {
2233
+ // The durable cancel read is best-effort. It must not bypass the
2234
+ // terminal persistence path or prevent the completion promise from
2235
+ // settling when the run store is unavailable.
2236
+ const cancelled =
2237
+ info.cancelRequested === true ||
2238
+ (runs !== undefined && (await wasCancelRequested(runs, ctx.runId)))
2239
+ terminal =
2240
+ cancelled || (!detachableRun(ctx) && state?.interrupted !== true)
2241
+ if (terminal) {
2242
+ await abortRun(runs, ctx.runId, state?.usage)
2243
+ }
2244
+ } finally {
2245
+ if (terminal) state?.completion?.reject(info.reason)
1791
2246
  }
1792
2247
  // A plain disconnect on a detachable or interrupted run: write NOTHING.
1793
2248
  // Either the agent is still running and a later attach can take it over