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