@jskit-ai/assistant-runtime 0.2.6 → 0.2.8

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.
@@ -121,6 +121,8 @@ onMounted(() => {
121
121
  window.conversationFixture = {
122
122
  socket,
123
123
  acquireConversation,
124
+ // A reader's own visibility, as when an application hides a retained view.
125
+ activity(value) { return ref(value); },
124
126
  releaseFactory() { factoryScope.stop(); },
125
127
  target(id) { conversationId.value = id; },
126
128
  show(value) { visible.value = value; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -13,19 +13,19 @@
13
13
  "./locales/en.json": "./locales/en.json"
14
14
  },
15
15
  "dependencies": {
16
- "@jskit-ai/assistant-core": "0.2.4",
16
+ "@jskit-ai/assistant-core": "0.2.6",
17
17
  "json-rest-schema": "^1.0.17"
18
18
  },
19
19
  "peerDependencies": {
20
- "@jskit-ai/http-runtime": "0.2.1",
21
- "@jskit-ai/http-web": "0.2.5",
22
- "@jskit-ai/kernel": "0.2.1",
23
- "@jskit-ai/realtime": "0.2.4",
24
- "@jskit-ai/shell-web": "0.2.1",
20
+ "@jskit-ai/http-runtime": "0.2.3",
21
+ "@jskit-ai/http-web": "0.2.7",
22
+ "@jskit-ai/kernel": "0.2.3",
23
+ "@jskit-ai/realtime": "0.2.6",
24
+ "@jskit-ai/shell-web": "0.2.3",
25
25
  "@tanstack/vue-query": "^5.90.5",
26
26
  "vue": "^3.5.13",
27
27
  "vuetify": "^4.0.0",
28
- "@jskit-ai/database-runtime": "0.1.250"
28
+ "@jskit-ai/database-runtime": "0.1.252"
29
29
  },
30
30
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
31
31
  "jskit": {
@@ -136,6 +136,11 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
136
136
  const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
137
137
  const editable = computed(() => active.value && !accessDenied.value);
138
138
  const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
139
+ // A deferred message keeps the subscription while its view is hidden, so it
140
+ // is admitted against live state. A failed read leaves that state stale.
141
+ const deferredWaits = ref(0);
142
+ const listening = computed(() => active.value || current.value && deferredWaits.value > 0);
143
+ const admissible = computed(() => listening.value && !loading.value && !accessDenied.value && !error.value && Boolean(snapshot.value));
139
144
  const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
140
145
  const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
141
146
  const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
@@ -305,7 +310,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
305
310
  if (denial !== deniedHistoryRevision) {
306
311
  throw Object.assign(new Error("Access denied."), { status: 403, messageCode: "assistant.runtime.accessDenied" });
307
312
  }
308
- return current.value && active.value && subscriptionCurrent();
313
+ return current.value && listening.value && subscriptionCurrent();
309
314
  };
310
315
  // A concurrent Load older operation extends the desired span. Retry one
311
316
  // refresh in this same read, rather than dropping that newly loaded page.
@@ -354,7 +359,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
354
359
  }
355
360
  return null;
356
361
  }
357
- watch(active, enabled => {
362
+ watch(listening, enabled => {
358
363
  subscription?.();
359
364
  subscription = null;
360
365
  historyRevision += 1;
@@ -450,6 +455,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
450
455
  }
451
456
  // A deferred message waits only while the working turn cannot take it. It
452
457
  // steers as soon as that turn accepts steering, or sends once it is ready.
458
+ // Its view may be hidden. While loading or after a failed read there is no
459
+ // current state to admit it against, so the unsent message keeps waiting;
460
+ // only Stop, Cancel, retirement or lost access end it.
453
461
  function waitForAdmission({ controller }) {
454
462
  return new Promise(resolve => {
455
463
  let stop;
@@ -459,12 +467,13 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
459
467
  resolve(admission);
460
468
  };
461
469
  const check = () => {
462
- if (controller.signal.aborted || !available.value) finish(false);
470
+ if (controller.signal.aborted || accessDenied.value) finish(false);
471
+ else if (!admissible.value) return;
463
472
  else if (snapshot.value.status === "ready") finish("send");
464
473
  else if (steerable.value) finish("steer");
465
474
  else if (snapshot.value.status !== "working") finish(false);
466
475
  };
467
- stop = watch([available, snapshot, steerable], check, { flush: "sync" });
476
+ stop = watch([admissible, snapshot, steerable], check, { flush: "sync" });
468
477
  controller.signal.addEventListener("abort", check, { once: true });
469
478
  check();
470
479
  });
@@ -477,6 +486,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
477
486
  const pending = { controller: new AbortController(), started: false, dispatched: false };
478
487
  const { controller } = pending;
479
488
  pendingMessages.set(messageId, pending);
489
+ if (deferred) deferredWaits.value += 1;
480
490
  try {
481
491
  const result = await delivery.send(payload, {
482
492
  messageId, queue, isCurrent: () => current.value, onAccepted,
@@ -490,13 +500,12 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
490
500
  // Refresh through the same subscription after a queued predecessor;
491
501
  // its receipt may have arrived before the working snapshot.
492
502
  await subscription?.reload();
493
- if (error.value) return false;
494
- if (snapshot.value.status === "working" && !steerable.value) {
503
+ if (snapshot.value?.status === "working" && !steerable.value) {
495
504
  delivery.wait(messageId, t("jskit.assistant.runtime.delivery.waitingForStep"));
496
505
  }
497
506
  admission = await waitForAdmission(pending);
498
507
  delivery.wait(messageId, "");
499
- if (!admission || controller.signal.aborted || !available.value ||
508
+ if (!admission || controller.signal.aborted || !admissible.value ||
500
509
  snapshot.value.status !== (admission === "steer" ? "working" : "ready")) return false;
501
510
  if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
502
511
  return { ok: false, error: t("jskit.assistant.runtime.attachments.noLongerAccepted") };
@@ -566,6 +575,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
566
575
  return false;
567
576
  } finally {
568
577
  if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
578
+ if (deferred) deferredWaits.value -= 1;
569
579
  if (current.value) subscription?.reload();
570
580
  }
571
581
  }
@@ -742,6 +752,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
742
752
  const commands = capabilities.goalCommands || {};
743
753
  const loading = !goalTargetCurrent.value && !goalLoadError.value;
744
754
  const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
755
+ const stopPending = Boolean(goalAction.value) || !goalAvailable || !available.value;
745
756
  return {
746
757
  enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
747
758
  loading,
@@ -751,9 +762,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
751
762
  tokenBudgetSupported: capabilities.goalBudgets === true,
752
763
  goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
753
764
  sampledAt: Date.parse(view.goal.updatedAt) } : null,
754
- pending: Boolean(goalAction.value) || !goalAvailable || !available.value || delivery.state.sending ||
755
- delivery.state.messages.some(message => message.status === "uncertain"),
756
- stopPending: Boolean(goalAction.value) || !goalAvailable || !available.value,
765
+ pending: stopPending || delivery.state.sending || delivery.state.messages.some(message => message.status === "uncertain"),
766
+ stopPending,
757
767
  error: goalError.value || goalLoadError.value || view?.error || "",
758
768
  pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
759
769
  cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
@@ -16,10 +16,10 @@ function patchLoadedHistoryPages(pages = [], patch) {
16
16
  }
17
17
 
18
18
  const SNAPSHOT_EVENTS = new Set(["accepted", "settled", "configuration", "phase", "error", "replaced", "goal"]);
19
- // A connected server may need longer than the normal budget to answer (an
20
- // overloaded host took 15 s). Abandoning it then discards the server's work and
21
- // repeats it, so a slow answer only shows a loading notice; it is treated as
22
- // lost, unsubscribed and retried only after the much longer cap.
19
+ // A connected but busy server may need longer than the notice budget to answer.
20
+ // Abandoning it then would discard the server's work and repeat it, so a slow
21
+ // answer only shows a loading notice; it is treated as lost, unsubscribed and
22
+ // retried only after the much longer cap.
23
23
  const SUBSCRIPTION_NOTICE_MS = 10_000;
24
24
  const SUBSCRIPTION_LOST_MS = 120_000;
25
25
 
@@ -93,7 +93,7 @@ const conversationInputValidator = composeSchemaDefinitions(
93
93
  { mode: "patch", context: "assistant-runtime conversation action input" }
94
94
  );
95
95
 
96
- const conversationInspectInputValidator = composeSchemaDefinitions(
96
+ const conversationDeliveryInputValidator = composeSchemaDefinitions(
97
97
  [conversationInputValidator, assistantConversationMessageIdInputValidator],
98
98
  { mode: "patch", context: "assistant-runtime conversation delivery inspection input" }
99
99
  );
@@ -217,7 +217,7 @@ function createConversationActions({ conversationRuntime, conversationDataSchema
217
217
  channels: ["api", "internal"],
218
218
  surfaces: runtimeSurfaces,
219
219
  permission: { require: "authenticated" },
220
- input: conversationInspectInputValidator,
220
+ input: conversationDeliveryInputValidator,
221
221
  output: null,
222
222
  idempotency: "none",
223
223
  async execute(input, context) {
@@ -232,7 +232,7 @@ function createConversationActions({ conversationRuntime, conversationDataSchema
232
232
  channels: ["api", "internal"],
233
233
  surfaces: runtimeSurfaces,
234
234
  permission: { require: "authenticated" },
235
- input: conversationInspectInputValidator,
235
+ input: conversationDeliveryInputValidator,
236
236
  output: null,
237
237
  idempotency: "none",
238
238
  async execute(input, context) {
@@ -228,8 +228,8 @@ function createRepository(knex) {
228
228
  };
229
229
  }
230
230
 
231
- async function transaction(callback) {
232
- return runInTransaction(knex, callback);
231
+ async function transaction(callback, options) {
232
+ return runInTransaction(knex, callback, options);
233
233
  }
234
234
 
235
235
  return Object.freeze({
@@ -236,9 +236,9 @@ function createChatService({
236
236
  const turns = conversationTurns.get(conversationId);
237
237
  turns.count -= 1;
238
238
  if (turns.count > 0) return;
239
- // A failed disposal keeps the entry, as before: the next turn reuses it and
240
- // runtime close retries its save.
241
- turns.releasing = Promise.resolve(turns.handle?.dispose()).catch(() => {})
239
+ // A failed disposal, thrown or rejected, leaves the runtime entry open: the
240
+ // next turn reuses it and runtime close retries its save.
241
+ turns.releasing = Promise.try(() => turns.handle?.dispose()).catch(() => {})
242
242
  .finally(() => conversationTurns.delete(conversationId));
243
243
  await turns.releasing;
244
244
  }
@@ -167,10 +167,11 @@ function createSqlConversationStorage({ conversationsRepository, messagesReposit
167
167
  });
168
168
  entered = true;
169
169
  return storage[write ? "write" : "read"](id, operation);
170
- });
170
+ }, { retries: 0 });
171
171
  } catch (error) {
172
172
  // SQLite cannot wait asynchronously for another connection's writer.
173
- // Retry acquiring the snapshot/lock only; never replay a caller or save.
173
+ // Retry acquiring the snapshot/lock only; never replay a caller or save,
174
+ // so a deadlock victim is not run again either.
174
175
  if (entered || error.code !== "SQLITE_BUSY" || attempt >= 5) throw error;
175
176
  await delay(10 * 2 ** attempt);
176
177
  }
@@ -887,8 +887,6 @@ for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognise
887
887
  return runtime.canSubmit.value;
888
888
  }), false, "the uncertain request's own words cannot be sent again");
889
889
  await f.page.evaluate(draft => { window.savedBinding.runtime.value.draft.value = draft; }, saved.draft);
890
- }
891
- if (unconfirmed) {
892
890
  const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
893
891
  try { await expect(check).toBeEnabled(); }
894
892
  catch (failure) {
@@ -1137,6 +1135,85 @@ test("a deferred follow-up explains its wait and steers once the working turn ac
1137
1135
  await expect(f.primary.getByText(/^Pending/)).toHaveCount(0);
1138
1136
  });
1139
1137
 
1138
+ // An application keeps a retained view mounted but inactive while another
1139
+ // conversation is shown. A message waiting for admission there was never sent:
1140
+ // hiding its view must not fail it as "Message could not be sent.", and the
1141
+ // person who moved on must not have to return before it is sent.
1142
+ test("a deferred message sends while its view is hidden once the session admits it", options, async t => {
1143
+ const f = await fixture(t);
1144
+ await f.page.evaluate(() => {
1145
+ window.readerActive = window.conversationFixture.activity(true);
1146
+ window.deferredBinding = window.conversationFixture.acquireConversation({
1147
+ conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true, active: window.readerActive
1148
+ });
1149
+ });
1150
+ const deferred = () => f.page.evaluate(() => {
1151
+ const runtime = window.deferredBinding.runtime.value;
1152
+ const { id, status, error, waiting } = runtime.delivery.state.messages.find(message => message.text === "Queued while working") || {};
1153
+ return { available: runtime.available.value, status: runtime.snapshot.value?.status, message: { id, status, error, waiting },
1154
+ subscriptions: window.conversationFixture.socket.inspect().active };
1155
+ });
1156
+ await expect.poll(async () => (await deferred()).available).toBe(true);
1157
+ const { subscriptions } = await deferred();
1158
+ await f.page.evaluate(() => { window.original = window.deferredBinding.runtime.value.send({ message: "Original nonsteerable request" }); });
1159
+ await expect.poll(async () => (await deferred()).status).toBe("working");
1160
+ await f.page.evaluate(() => { window.queued = window.deferredBinding.runtime.value.send({ message: "Queued while working" }); });
1161
+ await expect.poll(async () => (await deferred()).message.waiting).toBe("Waiting for the assistant to finish this step");
1162
+ await f.page.evaluate(() => { window.readerActive.value = false; });
1163
+ await expect.poll(async () => (await deferred()).available).toBe(false);
1164
+ const hidden = await deferred();
1165
+ assert.equal(hidden.message.status, "pending", `hiding the view is not a failed delivery: ${hidden.message.error}`);
1166
+ assert.equal(hidden.subscriptions, subscriptions, "the waiting message keeps its live state");
1167
+ assert.equal((await f.sent()).length, 1, "the working turn still cannot take the message");
1168
+ await f.command("finish", { id: "chat:2" });
1169
+ await f.notify("chat:2");
1170
+ await expect.poll(async () => (await f.sent()).length).toBe(2);
1171
+ const [, followUp] = await f.sent();
1172
+ assert.deepEqual([followUp.input.messageId, followUp.input.text], [hidden.message.id, "Queued while working"]);
1173
+ assert.equal((await f.page.evaluate(() => window.queued)).status, "accepted");
1174
+ const sent = await deferred();
1175
+ assert.equal(sent.available, false, "the message was sent while its view stayed hidden");
1176
+ await expect.poll(async () => (await deferred()).subscriptions, { message: "an empty queue releases the hidden view's subscription" }).toBe(subscriptions - 1);
1177
+ await f.page.evaluate(() => { window.readerActive.value = true; });
1178
+ await expect.poll(async () => (await deferred()).available).toBe(true);
1179
+ assert.equal((await f.sent()).length, 2, "the queued message is sent once");
1180
+ });
1181
+
1182
+ // A lost subscription leaves the last snapshot shown with its error while it
1183
+ // retries. Its refresh then reads nothing, so that stale error says nothing
1184
+ // about the unsent message: it waits for the recovered state and sends once.
1185
+ test("a deferred message waits through a failing resubscription instead of failing", options, async t => {
1186
+ const f = await fixture(t);
1187
+ await f.page.evaluate(() => {
1188
+ window.deferredBinding = window.conversationFixture.acquireConversation({
1189
+ conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true
1190
+ });
1191
+ });
1192
+ const deferred = () => f.page.evaluate(() => {
1193
+ const runtime = window.deferredBinding.runtime.value;
1194
+ const { id, status, error } = runtime.delivery.state.messages.find(message => message.text === "Sent while reconnecting") || {};
1195
+ return { available: runtime.available.value, error: runtime.error.value, message: { id, status, error } };
1196
+ });
1197
+ await expect.poll(async () => (await deferred()).available).toBe(true);
1198
+ let failing = true;
1199
+ await f.page.route(url => url.pathname.endsWith("/conversations/chat%3A2"), route => failing && route.request().method() === "GET"
1200
+ ? route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "Conversation temporarily unavailable." }) })
1201
+ : route.continue());
1202
+ await f.page.evaluate(() => window.conversationFixture.socket.reconnect());
1203
+ await expect.poll(async () => (await deferred()).error).toBe("Conversation temporarily unavailable.");
1204
+ assert.equal((await deferred()).available, true, "the last snapshot stays shown");
1205
+ await f.page.evaluate(() => { window.queued = window.deferredBinding.runtime.value.send({ message: "Sent while reconnecting" }); });
1206
+ await expect.poll(async () => (await deferred()).message.status).toBe("pending");
1207
+ const waiting = await deferred();
1208
+ assert.equal((await f.sent()).length, 0, "nothing is admitted against the stale snapshot");
1209
+ failing = false;
1210
+ // The subscription's own retry, not the message, recovers the state.
1211
+ await expect.poll(async () => (await f.sent()).length, { timeout: 15_000 }).toBe(1);
1212
+ const [sent] = await f.sent();
1213
+ assert.deepEqual([sent.input.messageId, sent.input.text], [waiting.message.id, "Sent while reconnecting"]);
1214
+ assert.equal((await f.page.evaluate(() => window.queued)).status, "accepted");
1215
+ });
1216
+
1140
1217
  for (const steer of [false, true]) {
1141
1218
  test(`Stop cancels locally queued native ${steer ? "steering" : "startup"} follow-ups`, options, async t => {
1142
1219
  const f = await fixture(t);
@@ -342,6 +342,7 @@ test("a real subscription slower than ten seconds keeps its observer, says it is
342
342
  const barrier = f.holdNextRead(id);
343
343
  const view = f.view(socket, id);
344
344
  await until(f.changes, () => barrier.started);
345
+ const readStartedAt = performance.now();
345
346
  const subscriptionEpoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
346
347
  while (!view.errors.length) await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
347
348
  assert.equal(view.errors[0].message, "Chat is still loading. Waiting for the server; Reload chat to try now.");
@@ -350,6 +351,9 @@ test("a real subscription slower than ten seconds keeps its observer, says it is
350
351
  assert.equal(socket.id, socketId);
351
352
  assert.equal(socket.connected, true);
352
353
  assert.equal(view.states.length, 0);
354
+ // The client's ten-second wait starts before the server's clock does, so the
355
+ // read is held until the server has also waited ten seconds.
356
+ await new Promise(resolve => setTimeout(resolve, Math.max(0, 10_001 - (performance.now() - readStartedAt))));
353
357
  barrier.resolve();
354
358
  await until(f.changes, () => view.states.length === 1);
355
359
  assert.equal(view.states[0].conversationLog[0].user.messageId, "survives-wait");
@@ -1113,8 +1117,6 @@ function slowView(socket) {
1113
1117
  }
1114
1118
 
1115
1119
  test("a connected subscription keeps waiting for a slow server instead of abandoning its work at ten seconds", t => {
1116
- // Production: an overloaded daemon answered after 14-15 s; the client gave
1117
- // up at 10 s, unsubscribed and retried with 10 s again, so the chat never loaded.
1118
1120
  const { socket, subscribes, unsubscribes } = slowServerSocket(t);
1119
1121
  const { states, errors, subscription } = slowView(socket);
1120
1122
  t.mock.timers.tick(9_999);
@@ -556,30 +556,30 @@ test("attachment identifiers use the conversation runtime's shape before any fil
556
556
  assert.deepEqual(calls, []);
557
557
  });
558
558
 
559
- function countDisposals(runtime) {
559
+ // Counts the conversations the chat service opens and disposes in the runtime.
560
+ function countDisposals() {
560
561
  const counts = { opened: 0, disposed: 0 };
561
- return { counts, runtime: { ...runtime, async open(input) {
562
+ const wrapRuntime = (runtime) => ({ ...runtime, async open(input) {
562
563
  const handle = await runtime.open(input);
563
564
  counts.opened += 1;
564
565
  return { ...handle, async dispose(options) { counts.disposed += 1; return handle.dispose(options); } };
565
- } } };
566
+ } });
567
+ return { counts, wrapRuntime };
566
568
  }
567
569
 
568
570
  test("each settled turn releases its conversation, and a later turn opens it again", async () => {
569
- const counted = {};
570
- const harness = createHarness([textCompletion("First answer."), textCompletion("Second answer.")], {
571
- wrapRuntime(runtime) { Object.assign(counted, countDisposals(runtime)); return counted.runtime; }
572
- });
571
+ const { counts, wrapRuntime } = countDisposals();
572
+ const harness = createHarness([textCompletion("First answer."), textCompletion("Second answer.")], { wrapRuntime });
573
573
 
574
574
  assert.equal((await harness.run("First question", { messageId: "message_1" })).status, "completed");
575
- assert.deepEqual(counted.counts, { opened: 1, disposed: 1 }, "a finished turn keeps no runtime conversation open");
575
+ assert.deepEqual(counts, { opened: 1, disposed: 1 }, "a finished turn keeps no runtime conversation open");
576
576
  assert.equal((await harness.run("Second question", { messageId: "message_2" })).status, "completed");
577
- assert.deepEqual(counted.counts, { opened: 2, disposed: 2 });
577
+ assert.deepEqual(counts, { opened: 2, disposed: 2 });
578
578
  assert.deepEqual(assistantMessages(harness.streamEvents), ["First answer.", "Second answer."]);
579
579
  });
580
580
 
581
581
  test("a conversation stays open until the last of its overlapping turns settles", async () => {
582
- const counted = {};
582
+ const { counts, wrapRuntime } = countDisposals();
583
583
  const firstChunk = Promise.withResolvers();
584
584
  const continueResponse = Promise.withResolvers();
585
585
  async function* held() {
@@ -588,16 +588,39 @@ test("a conversation stays open until the last of its overlapping turns settles"
588
588
  await continueResponse.promise;
589
589
  yield { choices: [{ delta: {}, finish_reason: "stop" }] };
590
590
  }
591
- const harness = createHarness([held()], {
592
- wrapRuntime(runtime) { Object.assign(counted, countDisposals(runtime)); return counted.runtime; }
593
- });
591
+ const harness = createHarness([held()], { wrapRuntime });
594
592
 
595
593
  const first = harness.run("First question", { messageId: "message_1" });
596
594
  await firstChunk.promise;
597
595
  // A second tab sends while the first answer is still running; the runtime refuses it.
598
596
  assert.equal((await harness.run("Second question", { messageId: "message_2" })).status, "failed");
599
- assert.equal(counted.counts.disposed, 0, "the refused turn must not close the conversation the first turn still uses");
597
+ assert.equal(counts.disposed, 0, "the refused turn must not close the conversation the first turn still uses");
600
598
  continueResponse.resolve();
601
599
  assert.equal((await first).status, "completed");
602
- assert.deepEqual(counted.counts, { opened: 2, disposed: 1 });
600
+ assert.deepEqual(counts, { opened: 2, disposed: 1 });
603
601
  });
602
+
603
+ for (const failure of ["rejects", "throws"]) {
604
+ test(`a conversation whose disposal ${failure} is released like any failed disposal`, async () => {
605
+ const counts = { opened: 0, disposed: 0 };
606
+ let openError = null;
607
+ const wrapRuntime = (runtime) => ({ ...runtime, async open(input) {
608
+ if (openError) throw openError;
609
+ const handle = await runtime.open(input);
610
+ counts.opened += 1;
611
+ return { ...handle, dispose() {
612
+ counts.disposed += 1;
613
+ const error = new Error("The conversation could not be saved.");
614
+ if (failure === "throws") throw error;
615
+ return Promise.reject(error);
616
+ } };
617
+ } });
618
+ const harness = createHarness([textCompletion("First answer.")], { wrapRuntime });
619
+
620
+ assert.equal((await harness.run("First question", { messageId: "message_1" })).status, "completed",
621
+ "a failed disposal does not change the settled turn's outcome");
622
+ openError = new Error("The conversation runtime is closing.");
623
+ await assert.rejects(harness.run("Second question", { messageId: "message_2" }), /runtime is closing/u);
624
+ assert.deepEqual(counts, { opened: 1, disposed: 1 }, "a later turn never reuses or disposes the released turn's handle");
625
+ });
626
+ }
@@ -81,6 +81,12 @@ test("the existing runtime storage contract uses the original SQL rows and rolls
81
81
  throw Object.assign(new Error("Caller failure"), { code: "SQLITE_BUSY" });
82
82
  }), /Caller failure/);
83
83
  assert.equal(invocations, 1, "Lock acquisition retry must never replay a storage callback");
84
+ invocations = 0;
85
+ await assert.rejects(f.storage.write(first.id, async () => {
86
+ invocations += 1;
87
+ throw Object.assign(new Error("Deadlock victim"), { code: "ER_LOCK_DEADLOCK", errno: 1213 });
88
+ }), /Deadlock victim/);
89
+ assert.equal(invocations, 1, "Deadlock retry must never replay a storage callback either");
84
90
  });
85
91
 
86
92
  test("independent SQL connections serialize authored receipt and sequence allocation", async t => {