@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.
@@ -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, toRunErrorPayload } 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
  }
@@ -703,10 +906,14 @@ async function completeRun(runs, runId, usage) {
703
906
  });
704
907
  }
705
908
  async function failRun(runs, runId, error, usage) {
909
+ const runError = toRunErrorPayload(error);
706
910
  await runs?.update(runId, {
707
911
  status: "failed",
708
912
  finishedAt: Date.now(),
709
- error: { message: error instanceof Error ? error.message : String(error) },
913
+ error: {
914
+ message: runError.message,
915
+ ...runError.code !== void 0 ? { code: runError.code } : {}
916
+ },
710
917
  ...usage ? { usage } : {}
711
918
  });
712
919
  }
@@ -760,16 +967,33 @@ function withPersistence(persistence, options = {}) {
760
967
  const { wantsInterrupts, runs } = resolvePersistencePlan(persistence);
761
968
  const messageStore = persistence.stores.messages;
762
969
  if (!messageStore) throw new Error("Chat persistence requires stores.messages.");
763
- const provides = [PersistenceCapability, ...wantsInterrupts ? [InterruptsCapability] : []];
970
+ const provides = [
971
+ PersistenceCapability,
972
+ PersistenceCompletionCapability,
973
+ ...wantsInterrupts ? [InterruptsCapability] : []
974
+ ];
764
975
  return defineChatMiddleware({
765
976
  name: "chat-persistence",
766
977
  provides,
767
978
  setup(ctx) {
768
979
  providePersistence(ctx, persistence);
980
+ let resolveCompletion = () => void 0;
981
+ let rejectCompletion = () => void 0;
982
+ const completion = new Promise((resolve, reject) => {
983
+ resolveCompletion = resolve;
984
+ rejectCompletion = reject;
985
+ });
986
+ completion.catch(() => void 0);
769
987
  runState.set(ctx, {
770
988
  merged: false,
771
- interrupted: false
989
+ interrupted: false,
990
+ completion: {
991
+ promise: completion,
992
+ resolve: resolveCompletion,
993
+ reject: rejectCompletion
994
+ }
772
995
  });
996
+ providePersistenceCompletion(ctx, { waitForRunCompletion: () => completion });
773
997
  if (wantsInterrupts && persistence.stores.interrupts) provideInterrupts(ctx, persistence.stores.interrupts);
774
998
  providePendingTurn(ctx, { snapshot: async () => {
775
999
  const stored = await messageStore.loadThread(ctx.threadId);
@@ -781,16 +1005,18 @@ function withPersistence(persistence, options = {}) {
781
1005
  if (ctx.phase !== "init") return;
782
1006
  const patch = {};
783
1007
  if (wantsInterrupts && persistence.stores.interrupts) {
784
- const pending = await persistence.stores.interrupts.listPending(ctx.threadId);
785
- const resumeByInterruptId = validatePendingResumes(pending, config.resume);
1008
+ const ownedPending = (await persistence.stores.interrupts.listPending(ctx.threadId)).filter(isChatOwnedPendingInterrupt);
1009
+ rejectMixedRunPending(ownedPending, ctx);
1010
+ const resumeByInterruptId = validatePendingResumes(ownedPending, config.resume, ctx);
786
1011
  if ((config.resume?.length ?? 0) > 0) {
787
- const resumeToolState = resumeToolStateFromPending(pending, resumeByInterruptId);
1012
+ const resumeToolState = resumeToolStateFromPending(ownedPending, resumeByInterruptId);
1013
+ const genericResumeState = await durableGenericResumeState(ctx, ownedPending, config.resume ?? [], config.tools);
788
1014
  patch.resume = [];
789
- if (resumeToolState) patch.resumeToolState = resumeToolState;
1015
+ if (resumeToolState || genericResumeState) patch.resumeToolState = mergeResumeToolState(resumeToolState, genericResumeState);
790
1016
  }
791
1017
  const state = runState.get(ctx);
792
- if (state && pending.length > 0) state.pendingResumes = {
793
- pending,
1018
+ if (state && ownedPending.length > 0) state.pendingResumes = {
1019
+ pending: ownedPending,
794
1020
  resumeByInterruptId
795
1021
  };
796
1022
  }
@@ -810,7 +1036,7 @@ function withPersistence(persistence, options = {}) {
810
1036
  } catch {}
811
1037
  },
812
1038
  async onChunk(ctx, chunk) {
813
- if (ctx.phase === "modelStream") {
1039
+ if (snapshotStreaming && ctx.phase === "modelStream") {
814
1040
  const s = runState.get(ctx);
815
1041
  if (s && chunk.type === "TEXT_MESSAGE_START") {
816
1042
  s.streamingMessageId = typeof chunk.messageId === "string" && chunk.messageId !== "" ? chunk.messageId : void 0;
@@ -866,19 +1092,35 @@ function withPersistence(persistence, options = {}) {
866
1092
  async onFinish(ctx, info) {
867
1093
  const state = runState.get(ctx);
868
1094
  if (state?.interrupted) return;
869
- await messageStore.saveThread(ctx.threadId, finishedTranscript(ctx.messages, info, state?.streamingMessageId, state?.streamingMessageCreatedAt));
870
- await completeRun(runs, ctx.runId, state?.usage ?? info.usage);
871
- await commitPendingResumes(state, persistence.stores.interrupts);
1095
+ try {
1096
+ await messageStore.saveThread(ctx.threadId, [...ctx.messages]);
1097
+ await commitPendingResumes(state, persistence.stores.interrupts);
1098
+ await completeRun(runs, ctx.runId, state?.usage ?? info.usage);
1099
+ state?.completion?.resolve();
1100
+ } catch (error) {
1101
+ try {
1102
+ await failRun(runs, ctx.runId, error, state?.usage);
1103
+ } finally {
1104
+ state?.completion?.reject(error);
1105
+ }
1106
+ throw error;
1107
+ }
872
1108
  },
873
1109
  async onError(ctx, info) {
874
- await failRun(runs, ctx.runId, info.error, runState.get(ctx)?.usage);
1110
+ try {
1111
+ await failRun(runs, ctx.runId, info.error, runState.get(ctx)?.usage);
1112
+ } finally {
1113
+ runState.get(ctx)?.completion?.reject(info.error);
1114
+ }
875
1115
  },
876
1116
  async onAbort(ctx, info) {
877
- const cancelled = info.cancelRequested === true || runs !== void 0 && await wasCancelRequested(runs, ctx.runId);
878
1117
  const state = runState.get(ctx);
879
- if (cancelled || !detachableRun(ctx) && state?.interrupted !== true) {
880
- await abortRun(runs, ctx.runId, state?.usage);
881
- return;
1118
+ let terminal = false;
1119
+ try {
1120
+ terminal = info.cancelRequested === true || runs !== void 0 && await wasCancelRequested(runs, ctx.runId) || !detachableRun(ctx) && state?.interrupted !== true;
1121
+ if (terminal) await abortRun(runs, ctx.runId, state?.usage);
1122
+ } finally {
1123
+ if (terminal) state?.completion?.reject(info.reason);
882
1124
  }
883
1125
  }
884
1126
  });