@tanstack/ai-persistence 0.1.5 → 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.
@@ -1,8 +1,8 @@
1
1
  import { validateChatPersistenceStores, validateGenerationPersistenceStores } from "./types.js";
2
- import { InterruptsCapability, PersistenceCapability, provideInterrupts, providePersistence } from "./capabilities.js";
2
+ import { InterruptsCapability, PersistenceCapability, PersistenceCompletionCapability, provideInterrupts, providePersistence, providePersistenceCompletion } from "./capabilities.js";
3
3
  import { artifactBlobKey } from "./retrieve.js";
4
- import { defineChatMiddleware, getDetachableRun, wasCancelRequested } from "@tanstack/ai";
5
- import { providePendingTurn } from "@tanstack/ai/adapter-internals";
4
+ import { InterruptResumeValidationError, defineChatMiddleware, getDetachableRun, readInterruptBinding, validateInterruptResumeBatch, wasCancelRequested } from "@tanstack/ai";
5
+ import { createInterruptBinding, getGenericInterruptDefinitionRegistry, providePendingTurn, rehydrateInterruptRequest } from "@tanstack/ai/adapter-internals";
6
6
  import { base64ToUint8Array } from "@tanstack/ai-utils";
7
7
  //#region src/middleware.ts
8
8
  /**
@@ -23,30 +23,116 @@ var DEFAULT_ARTIFACT_FETCH_TIMEOUT_MS = 3e4;
23
23
  var DEFAULT_MAX_ARTIFACT_BYTES = 1073741824;
24
24
  var runState = /* @__PURE__ */ new WeakMap();
25
25
  var validResumeStatuses = /* @__PURE__ */ new Set(["resolved", "cancelled"]);
26
- function validatePendingResumes(pending, resume) {
26
+ function mergeMaps(left, right) {
27
+ if (!left && !right) return void 0;
28
+ return new Map([...left ?? [], ...right ?? []]);
29
+ }
30
+ function mergeSets(left, right) {
31
+ if (!left && !right) return void 0;
32
+ return /* @__PURE__ */ new Set([...left ?? [], ...right ?? []]);
33
+ }
34
+ function mergeResumeToolState(left, right) {
35
+ if (!left) return right;
36
+ if (!right) return left;
37
+ return {
38
+ approvals: mergeMaps(left.approvals, right.approvals),
39
+ clientToolResults: mergeMaps(left.clientToolResults, right.clientToolResults),
40
+ genericInterrupts: mergeMaps(left.genericInterrupts, right.genericInterrupts),
41
+ genericInterruptRequests: mergeMaps(left.genericInterruptRequests, right.genericInterruptRequests),
42
+ deniedToolResults: mergeMaps(left.deniedToolResults, right.deniedToolResults),
43
+ cancelledToolCallIds: mergeSets(left.cancelledToolCallIds, right.cancelledToolCallIds)
44
+ };
45
+ }
46
+ function rejectMixedRunPending(pending, ctx) {
47
+ if (new Set(pending.map((interrupt) => interrupt.runId)).size <= 1) return;
48
+ throw new InterruptResumeValidationError([{
49
+ scope: "batch",
50
+ threadId: ctx.threadId,
51
+ interruptedRunId: ctx.runId,
52
+ generation: 0,
53
+ interruptIds: pending.map((interrupt) => interrupt.interruptId),
54
+ code: "stale",
55
+ message: "Thread has pending interrupts from more than one run.",
56
+ source: "server",
57
+ retryable: false
58
+ }]);
59
+ }
60
+ function validatePendingResumes(pending, resume, ctx) {
61
+ const interruptedRunId = pending[0]?.runId ?? ctx.runId;
62
+ const failure = (interruptId, code, message) => {
63
+ throw new InterruptResumeValidationError([{
64
+ scope: "item",
65
+ threadId: ctx.threadId,
66
+ interruptedRunId,
67
+ generation: 0,
68
+ interruptId,
69
+ code,
70
+ message,
71
+ source: "client",
72
+ retryable: false
73
+ }, {
74
+ scope: "batch",
75
+ threadId: ctx.threadId,
76
+ interruptedRunId,
77
+ generation: 0,
78
+ interruptIds: pending.map((interrupt) => interrupt.interruptId),
79
+ code: code === "conflict" ? "conflict" : "incomplete-batch",
80
+ message: "Resume entries must resolve or cancel the complete interrupt batch.",
81
+ source: "client",
82
+ retryable: false
83
+ }]);
84
+ };
27
85
  const pendingInterruptIds = new Set(pending.map((interrupt) => interrupt.interruptId));
28
- const resumeByInterruptId = new Map((resume ?? []).map((entry) => [entry.interruptId, entry]));
86
+ const resumeByInterruptId = /* @__PURE__ */ new Map();
87
+ for (const entry of resume ?? []) {
88
+ if (resumeByInterruptId.has(entry.interruptId)) return failure(entry.interruptId, "conflict", `Interrupt ${entry.interruptId} has duplicate resume entries.`);
89
+ resumeByInterruptId.set(entry.interruptId, entry);
90
+ }
29
91
  if (pending.length === 0) {
30
92
  const staleEntry = resume?.[0];
31
- if (staleEntry) throw new Error(`Resume entry references non-pending interrupt ${staleEntry.interruptId}.`);
93
+ if (staleEntry) return failure(staleEntry.interruptId, "unknown-interrupt", `Resume entry references non-pending interrupt ${staleEntry.interruptId}.`);
32
94
  return resumeByInterruptId;
33
95
  }
34
- if (!resume || resume.length === 0) throw new Error(`Thread has pending interrupts; resume is required before accepting new input.`);
96
+ const firstPending = pending[0];
97
+ if (firstPending === void 0) return resumeByInterruptId;
98
+ if (!resume || resume.length === 0) return failure(firstPending.interruptId, "unknown-interrupt", `Thread has pending interrupts; resume is required before accepting new input.`);
35
99
  for (const interrupt of pending) {
36
100
  const entry = resumeByInterruptId.get(interrupt.interruptId);
37
- if (!entry) throw new Error(`Missing resume entry for pending interrupt ${interrupt.interruptId}.`);
38
- if (!validResumeStatuses.has(entry.status)) throw new Error(`Invalid resume status for pending interrupt ${interrupt.interruptId}: ${entry.status}.`);
101
+ if (!entry) return failure(interrupt.interruptId, "unknown-interrupt", `Missing resume entry for pending interrupt ${interrupt.interruptId}.`);
102
+ if (!validResumeStatuses.has(entry.status)) return failure(interrupt.interruptId, "unknown-interrupt", `Invalid resume status for pending interrupt ${interrupt.interruptId}: ${entry.status}.`);
39
103
  }
40
- for (const entry of resume) if (!pendingInterruptIds.has(entry.interruptId)) throw new Error(`Resume entry references non-pending interrupt ${entry.interruptId}.`);
104
+ for (const entry of resume) if (!pendingInterruptIds.has(entry.interruptId)) return failure(entry.interruptId, "unknown-interrupt", `Resume entry references non-pending interrupt ${entry.interruptId}.`);
41
105
  return resumeByInterruptId;
42
106
  }
43
107
  async function applyPendingResumes(pending, resumeByInterruptId, interrupts) {
108
+ const entries = [];
44
109
  for (const interrupt of pending) {
45
110
  const entry = resumeByInterruptId.get(interrupt.interruptId);
46
111
  if (!entry) continue;
47
- if (entry.status === "resolved") await interrupts.resolve(interrupt.interruptId, entry.payload);
48
- else await interrupts.cancel(interrupt.interruptId);
112
+ if (entry.status === "resolved") entries.push({
113
+ interruptId: interrupt.interruptId,
114
+ status: "resolved",
115
+ response: entry.payload
116
+ });
117
+ else entries.push({
118
+ interruptId: interrupt.interruptId,
119
+ status: "cancelled"
120
+ });
121
+ }
122
+ if (interrupts.commitBatch) {
123
+ await interrupts.commitBatch(entries);
124
+ return;
125
+ }
126
+ const ids = /* @__PURE__ */ new Set();
127
+ for (const entry of entries) {
128
+ if (ids.has(entry.interruptId)) throw new Error(`Interrupt batch contains duplicate id: ${entry.interruptId}.`);
129
+ ids.add(entry.interruptId);
130
+ const existing = await interrupts.get(entry.interruptId);
131
+ if (!existing) throw new Error(`Interrupt batch references missing id: ${entry.interruptId}.`);
132
+ if (existing.status !== "pending") throw new Error(`Interrupt batch references non-pending id: ${entry.interruptId}.`);
49
133
  }
134
+ for (const entry of entries) if (entry.status === "resolved") await interrupts.resolve(entry.interruptId, entry.response);
135
+ else await interrupts.cancel(entry.interruptId);
50
136
  }
51
137
  /**
52
138
  * Commit the resumes stashed in `onConfig`, marking each resumed interrupt
@@ -72,6 +158,147 @@ function interruptKind(interrupt) {
72
158
  const metadata = objectValue(interrupt.payload.metadata);
73
159
  return metadata ? stringField(metadata, "kind") : void 0;
74
160
  }
161
+ function hasReservedInterruptBinding(payload) {
162
+ const metadata = objectValue(objectValue(payload)?.metadata);
163
+ return !!metadata && "tanstack:interruptBinding" in metadata;
164
+ }
165
+ function isPersistedInterruptDescriptor(value) {
166
+ const record = objectValue(value);
167
+ return !!record && typeof record.id === "string" && typeof record.reason === "string" && typeof record.message === "string";
168
+ }
169
+ /**
170
+ * Does this pending record belong to the TanStack chat resume protocol?
171
+ *
172
+ * An external system can persist an AG-UI descriptor in the same durable
173
+ * thread. A descriptor without a TanStack binding or legacy tool marker stays
174
+ * pending for its owner, but it does not make this resume incomplete. Older
175
+ * opaque records remain owned because their provenance cannot be known.
176
+ */
177
+ function isChatOwnedPendingInterrupt(interrupt) {
178
+ const kind = interruptKind(interrupt);
179
+ return !isPersistedInterruptDescriptor(interrupt.payload) || stringField(interrupt.payload, "toolCallId") !== void 0 || kind === "approval" || kind === "client_tool" || hasReservedInterruptBinding(interrupt.payload);
180
+ }
181
+ function durableGenericFailure(ctx, persisted, message) {
182
+ return new InterruptResumeValidationError([{
183
+ scope: "item",
184
+ threadId: ctx.threadId,
185
+ interruptedRunId: persisted.runId || ctx.runId,
186
+ generation: 0,
187
+ interruptId: persisted.interruptId,
188
+ code: "stale",
189
+ message,
190
+ source: "server",
191
+ retryable: false
192
+ }, {
193
+ scope: "batch",
194
+ threadId: ctx.threadId,
195
+ interruptedRunId: persisted.runId || ctx.runId,
196
+ generation: 0,
197
+ interruptIds: [persisted.interruptId],
198
+ code: "item-validation-failed",
199
+ message: "One or more persisted interrupt records are invalid.",
200
+ source: "server",
201
+ retryable: false
202
+ }]);
203
+ }
204
+ async function durableGenericResumeState(ctx, pending, resume, tools) {
205
+ const registry = getGenericInterruptDefinitionRegistry(ctx, { optional: true });
206
+ const records = [];
207
+ for (const persisted of pending) {
208
+ if (!isPersistedInterruptDescriptor(persisted.payload)) {
209
+ if (hasReservedInterruptBinding(persisted.payload)) throw durableGenericFailure(ctx, persisted, `Persisted interrupt ${persisted.interruptId} has an invalid binding descriptor.`);
210
+ continue;
211
+ }
212
+ const descriptor = persisted.payload;
213
+ const binding = readInterruptBinding(descriptor);
214
+ if (!binding) {
215
+ if (hasReservedInterruptBinding(descriptor)) throw durableGenericFailure(ctx, persisted, `Persisted interrupt ${persisted.interruptId} has an invalid or incomplete binding.`);
216
+ continue;
217
+ }
218
+ if (descriptor.id !== persisted.interruptId || binding.interruptId !== persisted.interruptId || binding.interruptedRunId !== persisted.runId || binding.generation !== 0) throw durableGenericFailure(ctx, persisted, `Persisted interrupt ${persisted.interruptId} has stale correlation metadata.`);
219
+ if (binding.kind !== "generic") {
220
+ records.push({
221
+ interruptId: persisted.interruptId,
222
+ payload: descriptor,
223
+ binding
224
+ });
225
+ continue;
226
+ }
227
+ if (!binding.definitionId || !binding.key || binding.batchIndex === void 0) {
228
+ records.push({
229
+ interruptId: persisted.interruptId,
230
+ payload: descriptor,
231
+ binding
232
+ });
233
+ continue;
234
+ }
235
+ if (!registry) throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt ${persisted.interruptId} cannot be restored because no interrupt registry is available.`);
236
+ const definition = registry.definitions.get(binding.definitionId);
237
+ if (!definition) throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt definition ${binding.definitionId} is unavailable.`);
238
+ const payload = objectValue(descriptor.metadata)?.["tanstack:interruptPayload"];
239
+ let request;
240
+ try {
241
+ request = rehydrateInterruptRequest(definition, {
242
+ key: binding.key,
243
+ reason: descriptor.reason,
244
+ message: descriptor.message,
245
+ ...descriptor.expiresAt !== void 0 ? { expiresAt: descriptor.expiresAt } : {},
246
+ ...payload !== void 0 ? { payload } : {}
247
+ });
248
+ } catch (error) {
249
+ throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt ${persisted.interruptId} is invalid: ${error instanceof Error ? error.message : String(error)}`);
250
+ }
251
+ const emitted = createInterruptBinding(request, { batchIndex: binding.batchIndex });
252
+ if (emitted.descriptor.responseSchemaHash !== binding.responseSchemaHash || emitted.descriptor.payloadSchemaHash !== binding.payloadSchemaHash || binding.interruptId !== persisted.interruptId) throw durableGenericFailure(ctx, persisted, `Persisted generic interrupt ${persisted.interruptId} is stale.`);
253
+ records.push({
254
+ interruptId: persisted.interruptId,
255
+ payload: descriptor,
256
+ binding,
257
+ genericRequest: request
258
+ });
259
+ }
260
+ const firstRecord = records[0];
261
+ if (firstRecord === void 0) return void 0;
262
+ const interruptedRunId = firstRecord.binding.interruptedRunId;
263
+ const generation = firstRecord.binding.generation;
264
+ const validated = await validateInterruptResumeBatch({
265
+ threadId: ctx.threadId,
266
+ interruptedRunId,
267
+ generation,
268
+ pending: records,
269
+ resume: resume.filter((entry) => records.some((record) => record.interruptId === entry.interruptId)),
270
+ tools
271
+ });
272
+ if (validated.errors.length > 0 || !validated.resumeToolState) throw new InterruptResumeValidationError(validated.errors);
273
+ const isGenericRecord = (record) => record.binding.kind === "generic" && record.genericRequest !== void 0;
274
+ const genericRecords = [];
275
+ const batchIndexes = /* @__PURE__ */ new Set();
276
+ for (const record of records) {
277
+ if (!isGenericRecord(record)) continue;
278
+ const batchIndex = record.binding.batchIndex;
279
+ if (batchIndex === void 0 || batchIndexes.has(batchIndex)) throw new InterruptResumeValidationError([{
280
+ scope: "batch",
281
+ threadId: ctx.threadId,
282
+ interruptedRunId,
283
+ generation,
284
+ interruptIds: records.map((item) => item.interruptId),
285
+ code: "stale",
286
+ message: "Persisted generic interrupts have duplicate or invalid batch indexes.",
287
+ source: "server",
288
+ retryable: false
289
+ }]);
290
+ batchIndexes.add(batchIndex);
291
+ genericRecords.push({
292
+ record,
293
+ batchIndex
294
+ });
295
+ }
296
+ genericRecords.sort((left, right) => left.batchIndex - right.batchIndex);
297
+ return {
298
+ ...validated.resumeToolState,
299
+ genericInterruptRequests: new Map(genericRecords.flatMap(({ record }) => record.genericRequest ? [[record.interruptId, record.genericRequest]] : []))
300
+ };
301
+ }
75
302
  function resolvedApprovalDecision(entry) {
76
303
  if (entry.status === "cancelled") return false;
77
304
  const payload = objectValue(entry.payload);
@@ -109,30 +336,6 @@ function resumeToolStateFromPending(pending, resumeByInterruptId) {
109
336
  cancelledToolCallIds
110
337
  };
111
338
  }
112
- /**
113
- * Build the transcript to persist when a run finishes successfully.
114
- *
115
- * The chat engine appends an assistant message to the middleware message list
116
- * only when that turn carries tool calls (to feed the agent loop); a run's
117
- * terminal *text* reply is never appended. So `ctx.messages` at `onFinish` is
118
- * missing the assistant's final answer. Reattach it from the finish info —
119
- * `info.content` is the last turn's accumulated text (reset each cycle) — so
120
- * the stored thread is the complete conversation a server-authoritative client
121
- * hydrates on load. A guard avoids duplicating a terminal assistant turn should
122
- * the engine ever start appending it itself.
123
- */
124
- function finishedTranscript(messages, info, messageId, createdAt) {
125
- const transcript = [...messages];
126
- const last = transcript[transcript.length - 1];
127
- const alreadyPresent = last?.role === "assistant" && last.toolCalls === void 0 && last.content === info.content;
128
- if (info.content && !alreadyPresent) transcript.push({
129
- role: "assistant",
130
- content: info.content,
131
- ...messageId ? { id: messageId } : {},
132
- ...createdAt ? { createdAt } : {}
133
- });
134
- return transcript;
135
- }
136
339
  function interruptPayload(interrupt) {
137
340
  return interrupt && typeof interrupt === "object" ? { ...interrupt } : { value: interrupt };
138
341
  }
@@ -664,6 +867,7 @@ function accumulateTokenUsage(current, next) {
664
867
  const providerUsageDetails = next.providerUsageDetails ?? current.providerUsageDetails;
665
868
  const durationSeconds = sumOptionalNumber(current.durationSeconds, next.durationSeconds);
666
869
  const unitsBilled = sumOptionalNumber(current.unitsBilled, next.unitsBilled);
870
+ const billed = accumulateBilled(current.billed, next.billed);
667
871
  const cost = sumOptionalNumber(current.cost, next.cost);
668
872
  return {
669
873
  ...current,
@@ -675,11 +879,25 @@ function accumulateTokenUsage(current, next) {
675
879
  ...completionTokensDetails ? { completionTokensDetails } : {},
676
880
  ...durationSeconds !== void 0 ? { durationSeconds } : {},
677
881
  ...unitsBilled !== void 0 ? { unitsBilled } : {},
882
+ ...billed !== void 0 ? { billed } : {},
678
883
  ...cost !== void 0 ? { cost } : {},
679
884
  ...costDetails ? { costDetails } : {},
680
885
  ...providerUsageDetails ? { providerUsageDetails } : {}
681
886
  };
682
887
  }
888
+ /**
889
+ * Sum billed quantities when both reports use the same unit. Different units
890
+ * cannot be added, so the later report wins.
891
+ */
892
+ function accumulateBilled(current, next) {
893
+ if (!current) return next;
894
+ if (!next) return current;
895
+ if (current.unit !== next.unit) return next;
896
+ return {
897
+ quantity: current.quantity + next.quantity,
898
+ unit: current.unit
899
+ };
900
+ }
683
901
  async function completeRun(runs, runId, usage) {
684
902
  await runs?.update(runId, {
685
903
  status: "completed",
@@ -745,16 +963,33 @@ function withPersistence(persistence, options = {}) {
745
963
  const { wantsInterrupts, runs } = resolvePersistencePlan(persistence);
746
964
  const messageStore = persistence.stores.messages;
747
965
  if (!messageStore) throw new Error("Chat persistence requires stores.messages.");
748
- const provides = [PersistenceCapability, ...wantsInterrupts ? [InterruptsCapability] : []];
966
+ const provides = [
967
+ PersistenceCapability,
968
+ PersistenceCompletionCapability,
969
+ ...wantsInterrupts ? [InterruptsCapability] : []
970
+ ];
749
971
  return defineChatMiddleware({
750
972
  name: "chat-persistence",
751
973
  provides,
752
974
  setup(ctx) {
753
975
  providePersistence(ctx, persistence);
976
+ let resolveCompletion = () => void 0;
977
+ let rejectCompletion = () => void 0;
978
+ const completion = new Promise((resolve, reject) => {
979
+ resolveCompletion = resolve;
980
+ rejectCompletion = reject;
981
+ });
982
+ completion.catch(() => void 0);
754
983
  runState.set(ctx, {
755
984
  merged: false,
756
- interrupted: false
985
+ interrupted: false,
986
+ completion: {
987
+ promise: completion,
988
+ resolve: resolveCompletion,
989
+ reject: rejectCompletion
990
+ }
757
991
  });
992
+ providePersistenceCompletion(ctx, { waitForRunCompletion: () => completion });
758
993
  if (wantsInterrupts && persistence.stores.interrupts) provideInterrupts(ctx, persistence.stores.interrupts);
759
994
  providePendingTurn(ctx, { snapshot: async () => {
760
995
  const stored = await messageStore.loadThread(ctx.threadId);
@@ -766,16 +1001,18 @@ function withPersistence(persistence, options = {}) {
766
1001
  if (ctx.phase !== "init") return;
767
1002
  const patch = {};
768
1003
  if (wantsInterrupts && persistence.stores.interrupts) {
769
- const pending = await persistence.stores.interrupts.listPending(ctx.threadId);
770
- const resumeByInterruptId = validatePendingResumes(pending, config.resume);
1004
+ const ownedPending = (await persistence.stores.interrupts.listPending(ctx.threadId)).filter(isChatOwnedPendingInterrupt);
1005
+ rejectMixedRunPending(ownedPending, ctx);
1006
+ const resumeByInterruptId = validatePendingResumes(ownedPending, config.resume, ctx);
771
1007
  if ((config.resume?.length ?? 0) > 0) {
772
- const resumeToolState = resumeToolStateFromPending(pending, resumeByInterruptId);
1008
+ const resumeToolState = resumeToolStateFromPending(ownedPending, resumeByInterruptId);
1009
+ const genericResumeState = await durableGenericResumeState(ctx, ownedPending, config.resume ?? [], config.tools);
773
1010
  patch.resume = [];
774
- if (resumeToolState) patch.resumeToolState = resumeToolState;
1011
+ if (resumeToolState || genericResumeState) patch.resumeToolState = mergeResumeToolState(resumeToolState, genericResumeState);
775
1012
  }
776
1013
  const state = runState.get(ctx);
777
- if (state && pending.length > 0) state.pendingResumes = {
778
- pending,
1014
+ if (state && ownedPending.length > 0) state.pendingResumes = {
1015
+ pending: ownedPending,
779
1016
  resumeByInterruptId
780
1017
  };
781
1018
  }
@@ -795,7 +1032,7 @@ function withPersistence(persistence, options = {}) {
795
1032
  } catch {}
796
1033
  },
797
1034
  async onChunk(ctx, chunk) {
798
- if (ctx.phase === "modelStream") {
1035
+ if (snapshotStreaming && ctx.phase === "modelStream") {
799
1036
  const s = runState.get(ctx);
800
1037
  if (s && chunk.type === "TEXT_MESSAGE_START") {
801
1038
  s.streamingMessageId = typeof chunk.messageId === "string" && chunk.messageId !== "" ? chunk.messageId : void 0;
@@ -851,19 +1088,35 @@ function withPersistence(persistence, options = {}) {
851
1088
  async onFinish(ctx, info) {
852
1089
  const state = runState.get(ctx);
853
1090
  if (state?.interrupted) return;
854
- await messageStore.saveThread(ctx.threadId, finishedTranscript(ctx.messages, info, state?.streamingMessageId, state?.streamingMessageCreatedAt));
855
- await completeRun(runs, ctx.runId, state?.usage ?? info.usage);
856
- await commitPendingResumes(state, persistence.stores.interrupts);
1091
+ try {
1092
+ await messageStore.saveThread(ctx.threadId, [...ctx.messages]);
1093
+ await commitPendingResumes(state, persistence.stores.interrupts);
1094
+ await completeRun(runs, ctx.runId, state?.usage ?? info.usage);
1095
+ state?.completion?.resolve();
1096
+ } catch (error) {
1097
+ try {
1098
+ await failRun(runs, ctx.runId, error, state?.usage);
1099
+ } finally {
1100
+ state?.completion?.reject(error);
1101
+ }
1102
+ throw error;
1103
+ }
857
1104
  },
858
1105
  async onError(ctx, info) {
859
- await failRun(runs, ctx.runId, info.error, runState.get(ctx)?.usage);
1106
+ try {
1107
+ await failRun(runs, ctx.runId, info.error, runState.get(ctx)?.usage);
1108
+ } finally {
1109
+ runState.get(ctx)?.completion?.reject(info.error);
1110
+ }
860
1111
  },
861
1112
  async onAbort(ctx, info) {
862
- const cancelled = info.cancelRequested === true || runs !== void 0 && await wasCancelRequested(runs, ctx.runId);
863
1113
  const state = runState.get(ctx);
864
- if (cancelled || !detachableRun(ctx) && state?.interrupted !== true) {
865
- await abortRun(runs, ctx.runId, state?.usage);
866
- return;
1114
+ let terminal = false;
1115
+ try {
1116
+ terminal = info.cancelRequested === true || runs !== void 0 && await wasCancelRequested(runs, ctx.runId) || !detachableRun(ctx) && state?.interrupted !== true;
1117
+ if (terminal) await abortRun(runs, ctx.runId, state?.usage);
1118
+ } finally {
1119
+ if (terminal) state?.completion?.reject(info.reason);
867
1120
  }
868
1121
  }
869
1122
  });