@jskit-ai/assistant-runtime 0.2.1 → 0.2.3

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.
@@ -36,12 +36,18 @@ async function fixture(t, { workspace = false, conversationDataSchema = null, ap
36
36
  const dispatched = [Promise.withResolvers(), Promise.withResolvers()];
37
37
  const scopeInputs = [];
38
38
  const knownIds = new Set(["chat:one", "chat:two"]);
39
- const access = { allowed: true };
39
+ const access = { allowed: true, authorized: [], hold: null };
40
40
  const storage = createFileConversationStorage({ directory });
41
41
  const runtime = createConversationRuntime({
42
42
  storage,
43
- authorize: ({ context: actor, conversationId }) => access.allowed && actor?.actor?.id === "owner" && knownIds.has(conversationId) &&
44
- (!workspace || actor.workspace?.id === trustedWorkspace.id),
43
+ async authorize({ context: actor, conversationId, operation }) {
44
+ access.authorized.push(operation);
45
+ // A test can hold one operation's authorization, and with it the conversation queue.
46
+ const hold = access.hold?.operation === operation && access.hold;
47
+ if (hold) { access.hold = null; hold.entered.resolve(); await hold.release; }
48
+ return access.allowed && actor?.actor?.id === "owner" && knownIds.has(conversationId) &&
49
+ (!workspace || actor.workspace?.id === trustedWorkspace.id);
50
+ },
45
51
  connections: {
46
52
  async resolve({ context: actor }) {
47
53
  assert.equal(actor.actor.id, "owner");
@@ -215,9 +221,9 @@ test("file-backed conversation routes return admission receipts and cancel one c
215
221
  const f = await fixture(t);
216
222
  assert.deepEqual(f.catalogue.listDefinitions().map(({ id }) => id), [
217
223
  actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, actionIds.conversationSubscribe,
218
- actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate
224
+ actionIds.conversationInspectDelivery, actionIds.conversationCancelDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate
219
225
  ]);
220
- assert.equal(f.routes.length, 6, "A supplied runtime does not register the old chat or optional settings routes");
226
+ assert.equal(f.routes.length, 7, "A supplied runtime does not register the old chat or optional settings routes");
221
227
  const initial = await f.invoke("GET");
222
228
  assert.equal(initial.status, 200);
223
229
  assert.equal(initial.payload.id, "chat:one");
@@ -255,11 +261,62 @@ test("file-backed conversation routes return admission receipts and cancel one c
255
261
  assert.equal((await readdir(f.directory)).length, 2, "Only the host's two conversation records are persisted");
256
262
  });
257
263
 
264
+ test("Cancel of one message stops its own submission and always answers", async t => {
265
+ const f = await fixture(t);
266
+ const first = await f.invoke("POST", "/messages", { body: { messageId: "first", text: "First question" } });
267
+ assert.equal(first.payload.status, "accepted");
268
+ await f.dispatched[0].promise;
269
+ const cancelled = await f.invoke("POST", "/deliveries/:messageId/cancel", { messageId: "first" });
270
+ assert.equal(cancelled.status, 200);
271
+ assert.deepEqual(cancelled.payload, { ok: true, cancelled: true, messageId: "first", interrupted: true });
272
+ assert.equal(f.requests[0].signal.aborted, true, "the turn this message started stops");
273
+ const never = await f.invoke("POST", "/deliveries/:messageId/cancel", { messageId: "never-sent" });
274
+ assert.deepEqual(never.payload, { ok: true, cancelled: true, messageId: "never-sent", interrupted: false });
275
+ assert.equal((await f.invoke("GET")).payload.status, "ready");
276
+ assert.equal(f.requests.length, 1, "Cancel never submits a model request");
277
+ });
278
+
279
+ test("Cancel answers while earlier work holds the conversation queue and the queued send is refused as not sent", async t => {
280
+ const f = await fixture(t);
281
+ const entered = Promise.withResolvers();
282
+ const release = Promise.withResolvers();
283
+ f.access.hold = { operation: "inspectDelivery", entered, release: release.promise };
284
+ const inspecting = f.invoke("POST", "/deliveries/:messageId/inspect", { messageId: "earlier" });
285
+ inspecting.catch(() => {});
286
+ let sending;
287
+ let cancelling;
288
+ let timer;
289
+ try {
290
+ await entered.promise;
291
+ sending = f.invoke("POST", "/messages", { body: { messageId: "queued", text: "Queued question" } });
292
+ sending.catch(() => {});
293
+ // The send is authorized and waits in the queue behind the held inspection.
294
+ while (!f.access.authorized.includes("send")) await new Promise(resolve => setImmediate(resolve));
295
+ await new Promise(resolve => setImmediate(resolve));
296
+ cancelling = f.invoke("POST", "/deliveries/:messageId/cancel", { messageId: "queued" });
297
+ const answer = await Promise.race([cancelling,
298
+ new Promise(resolve => { timer = setTimeout(() => resolve(null), 2_000); })]);
299
+ assert.notEqual(answer, null, "Cancel answered while earlier work held the conversation queue");
300
+ assert.deepEqual(answer.payload, { ok: true, cancelled: true, messageId: "queued", interrupted: false });
301
+ } finally {
302
+ clearTimeout(timer);
303
+ release.resolve();
304
+ }
305
+ await inspecting.catch(() => {});
306
+ await cancelling;
307
+ await assert.rejects(sending, { code: "conversation_message_cancelled", statusCode: 409 });
308
+ await assert.rejects(f.invoke("POST", "/messages", { body: { messageId: "queued", text: "Queued question" } }),
309
+ { code: "conversation_message_cancelled" }, "a later send of the cancelled message is refused too");
310
+ assert.equal(f.requests.length, 0, "the cancelled message never reaches the model");
311
+ const next = await f.invoke("POST", "/messages", { body: { messageId: "next", text: "Next question" } });
312
+ assert.equal(next.payload.status, "accepted", "the conversation stays usable");
313
+ });
314
+
258
315
  test("conversation routes keep authenticated ownership and reject unknown identities", async t => {
259
316
  const f = await fixture(t);
260
317
  const other = { ...f.context, actor: { id: "other-user" } };
261
318
  for (const [method, suffix] of [["GET", ""], ["POST", "/messages"], ["POST", "/cancel"],
262
- ["POST", "/deliveries/:messageId/inspect"], ["GET", "/goal"], ["POST", "/goal"]]) {
319
+ ["POST", "/deliveries/:messageId/inspect"], ["POST", "/deliveries/:messageId/cancel"], ["GET", "/goal"], ["POST", "/goal"]]) {
263
320
  await assert.rejects(f.invoke(method, suffix, { actor: other, body: {
264
321
  action: "set", expectedSegmentId: "segment", objective: "A goal", messageId: "forged", text: "A forged request", actor: f.context.actor, context: f.context,
265
322
  host: { nativeTools: true }, configuration: { systemPrompt: "Override" }, engine: "codex"
@@ -296,7 +353,7 @@ test("workspace conversation actions use the host's resolved workspace and optio
296
353
  const options = { ...f.options, assistantConfigService: { getSettings() {}, updateSettings() {} } };
297
354
  assert.deepEqual(createAssistantActions(options).map(({ id }) => id), [
298
355
  actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, actionIds.conversationSubscribe,
299
- actionIds.conversationInspectDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate,
356
+ actionIds.conversationInspectDelivery, actionIds.conversationCancelDelivery, actionIds.conversationGoalRead, actionIds.conversationGoalUpdate,
300
357
  actionIds.settingsRead, actionIds.settingsUpdate
301
358
  ]);
302
359
  const router = createRouter();
@@ -414,10 +471,10 @@ test("the supplied runtime route/action path imports without SQL or the old chat
414
471
  const { registerRoutes } = await import(${JSON.stringify(new URL("../src/server/registerRoutes.js", import.meta.url).href)});
415
472
  const conversationRuntime = { async open() { throw new Error("Registration cannot open conversations."); } };
416
473
  const options = { config: ${JSON.stringify(config)}, conversationRuntime };
417
- assert.equal(createAssistantActions(options).length, 7);
474
+ assert.equal(createAssistantActions(options).length, 8);
418
475
  const routes = [];
419
476
  registerRoutes({ register(...args) { routes.push(args); } }, options);
420
- assert.equal(routes.length, 6);
477
+ assert.equal(routes.length, 7);
421
478
  `], { encoding: "utf8" });
422
479
  assert.equal(result.status, 0, result.stderr);
423
480
  });
@@ -162,6 +162,9 @@ async function fixture(t, { diagnostics = false, onSetup } = {}) {
162
162
  stopSubscriptions();
163
163
  stopProbe();
164
164
  await transport.shutdown();
165
+ // Realtime closes only Socket.IO; the fixture owns its HTTP server.
166
+ http.closeAllConnections();
167
+ await new Promise((resolve) => http.close(resolve));
165
168
  await runtime.close();
166
169
  await rm(directory, { recursive: true, force: true });
167
170
  });
@@ -329,45 +332,42 @@ test("slow successful subscriptions warn once with the blocked stage and no conv
329
332
  assert.equal(f.fetches.length, 0);
330
333
  });
331
334
 
332
- test("the real ten-second subscription deadline releases its late observer and Reload never repeats work", { timeout: 25_000 }, async t => {
335
+ test("a real subscription slower than ten seconds keeps its observer, says it is still loading and delivers the late snapshot", { timeout: 25_000 }, async t => {
333
336
  const f = await fixture(t);
334
337
  const socket = await f.connect();
335
338
  const socketId = socket.id;
336
339
  const id = f.ids[0];
337
- await f.conversations.get(id).send({ messageId: "survives-timeout", text: "Keep working through a slow snapshot" });
340
+ await f.conversations.get(id).send({ messageId: "survives-wait", text: "Keep working through a slow snapshot" });
338
341
  await until(f.changes, () => f.fetches.length === 1);
339
342
  const barrier = f.holdNextRead(id);
340
343
  const view = f.view(socket, id);
341
344
  await until(f.changes, () => barrier.started);
342
- const firstSubscriptionEpoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
343
- while (!view.errors.length || f.active() !== 0) {
344
- await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
345
- }
346
- assert.equal(view.errors[0].message, "Chat updates could not reconnect. Retrying automatically; Reload chat to try now.");
347
- assert.equal(view.errors[0].cause.message, "operation has timed out");
345
+ const subscriptionEpoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
346
+ while (!view.errors.length) await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
347
+ assert.equal(view.errors[0].message, "Chat is still loading. Waiting for the server; Reload chat to try now.");
348
+ assert.equal(view.errors[0].code, "assistant_subscription_waiting");
349
+ assert.equal(f.active(), 1, "The slow observer stays attached while its snapshot is read");
348
350
  assert.equal(socket.id, socketId);
349
351
  assert.equal(socket.connected, true);
350
352
  assert.equal(view.states.length, 0);
351
- assert.equal(f.fetches[0].signal.aborted, false);
352
- view.dispose.reload();
353
- await until(f.changes, () => view.states.length === 1 && f.active() === 1);
354
- assert.equal(view.states[0].conversationLog[0].user.messageId, "survives-timeout");
355
- const statesBeforeLateRead = view.states.length;
356
353
  barrier.resolve();
357
- await until(f.changes, () => f.subscriptionTimings.some(record =>
358
- record.subscriptionEpoch === firstSubscriptionEpoch && record.stage === "abandoned"));
359
- const abandoned = f.subscriptionTimings.find(record =>
360
- record.subscriptionEpoch === firstSubscriptionEpoch && record.stage === "abandoned");
361
- assert.equal(abandoned.level, "warn", "A timed-out snapshot remains visible at the production warning level");
362
- assert.equal(abandoned.previousStage, "snapshot-read");
363
- assert.ok(abandoned.durationMs >= 5_000);
364
- assert.equal(view.states.length, statesBeforeLateRead, "The obsolete server read never acknowledges into the recovered view");
365
- assert.equal(f.active(), 1, "The late read cannot release the recovered observer");
354
+ await until(f.changes, () => view.states.length === 1);
355
+ assert.equal(view.states[0].conversationLog[0].user.messageId, "survives-wait");
356
+ const records = f.subscriptionTimings.filter(record => record.subscriptionEpoch === subscriptionEpoch);
357
+ assert.equal(records.some(record => record.stage === "abandoned"), false, "Slow work is never abandoned");
358
+ const acknowledged = records.find(record => record.stage === "acknowledgement");
359
+ assert.equal(acknowledged.level, "warn", "A slow snapshot remains visible at the production warning level");
360
+ assert.equal(acknowledged.previousStage, "snapshot-read");
361
+ assert.ok(acknowledged.elapsedMs >= 10_000);
362
+ assert.equal(f.subscriptionTimings.filter(record => record.stage === "authentication").length, 1,
363
+ "Waiting never sends another subscription");
364
+ assert.equal(view.errors.length, 1);
365
+ assert.equal(f.active(), 1);
366
366
  assert.equal(f.fetches.length, 1);
367
367
  assert.equal(f.fetches[0].signal.aborted, false);
368
368
  assert.equal(socket.listeners(ASSISTANT_CONVERSATION_EVENT).length, 1);
369
- f.fetches[0].text("Still running after the timed-out observation.");
370
- await until(f.changes, () => view.received.some(event => event.text?.includes("timed-out observation")));
369
+ f.fetches[0].text("Still running after the slow snapshot.");
370
+ await until(f.changes, () => view.received.some(event => event.text?.includes("slow snapshot")));
371
371
  view.dispose();
372
372
  await until(f.changes, () => f.active() === 0);
373
373
  });
@@ -474,7 +474,7 @@ for (const failure of ["rejection", "timeout"]) {
474
474
  const requests = [], states = [], errors = [];
475
475
  let reads = 0;
476
476
  socket.timeout = timeout => ({ emit(name, input, acknowledge) {
477
- assert.equal(timeout, 10_000);
477
+ assert.equal(timeout, 120_000, "Only a much longer silence counts as a lost answer");
478
478
  assert.equal(name, ASSISTANT_CONVERSATION_SUBSCRIBE);
479
479
  requests.push({ input, acknowledge });
480
480
  } });
@@ -911,6 +911,8 @@ async function hostSubscriptionFixture(t, authService = null, conversationRuntim
911
911
  for (const client of clients) client.disconnect();
912
912
  release();
913
913
  await transport.shutdown();
914
+ http.closeAllConnections();
915
+ await new Promise((resolve) => http.close(resolve));
914
916
  });
915
917
  http.listen(0, "127.0.0.1");
916
918
  await once(http, "listening");
@@ -1076,6 +1078,134 @@ test("a retried resubscribe keeps the loaded history its caller preserved", t =>
1076
1078
  subscription();
1077
1079
  });
1078
1080
 
1081
+ // Socket.IO's acknowledgement deadline and disconnect handling, on mocked timers.
1082
+ function slowServerSocket(t) {
1083
+ t.mock.timers.enable({ apis: ["setTimeout"] });
1084
+ const socket = new EventEmitter();
1085
+ socket.connected = true;
1086
+ const subscribes = [], unsubscribes = [];
1087
+ socket.on(ASSISTANT_CONVERSATION_UNSUBSCRIBE, input => unsubscribes.push(input));
1088
+ socket.timeout = milliseconds => ({ emit(name, input, acknowledge) {
1089
+ let settled = false;
1090
+ const settle = (...args) => {
1091
+ if (settled) return;
1092
+ settled = true;
1093
+ clearTimeout(timer);
1094
+ acknowledge(...args);
1095
+ };
1096
+ const timer = setTimeout(() => settle(new Error("operation has timed out")), milliseconds);
1097
+ subscribes.push({ name, input, answer: response => settle(null, response),
1098
+ drop: () => settle(new Error("socket has been disconnected")) });
1099
+ } });
1100
+ function disconnect() {
1101
+ socket.connected = false;
1102
+ socket.emit("disconnect");
1103
+ for (const request of subscribes) request.drop();
1104
+ }
1105
+ return { socket, subscribes, unsubscribes, disconnect };
1106
+ }
1107
+ const slowState = { id: "chat", conversationLog: [], streaming: { revision: 0, messages: [] } };
1108
+ function slowView(socket) {
1109
+ const states = [], errors = [];
1110
+ const subscription = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
1111
+ read: () => slowState, onState: state => states.push(state), onError: error => errors.push(error) });
1112
+ return { states, errors, subscription };
1113
+ }
1114
+
1115
+ 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
+ const { socket, subscribes, unsubscribes } = slowServerSocket(t);
1119
+ const { states, errors, subscription } = slowView(socket);
1120
+ t.mock.timers.tick(9_999);
1121
+ assert.equal(errors.length, 0);
1122
+ t.mock.timers.tick(1);
1123
+ assert.equal(errors.length, 1, "Past the normal budget the user is told the chat is still loading");
1124
+ assert.equal(errors[0].code, "assistant_subscription_waiting");
1125
+ assert.equal(errors[0].message, "Chat is still loading. Waiting for the server; Reload chat to try now.");
1126
+ t.mock.timers.tick(5_000);
1127
+ subscribes[0].answer({ ok: true, streamEpoch: "slow", state: slowState });
1128
+ assert.equal(states.length, 1, "The fifteen-second answer is delivered");
1129
+ assert.equal(subscribes.length, 1, "Waiting never starts another subscription");
1130
+ assert.deepEqual(unsubscribes, [], "Waiting never abandons the server's work");
1131
+ t.mock.timers.tick(300_000);
1132
+ assert.equal(subscribes.length, 1);
1133
+ assert.equal(errors.length, 1);
1134
+ subscription();
1135
+ assert.equal(unsubscribes.length, 1, "Disposal still releases the observer");
1136
+ });
1137
+
1138
+ test("a subscription answer lost past the two-minute cap still unsubscribes and retries with backoff", t => {
1139
+ const { socket, subscribes, unsubscribes } = slowServerSocket(t);
1140
+ const { states, errors, subscription } = slowView(socket);
1141
+ t.mock.timers.tick(119_999);
1142
+ assert.deepEqual(errors.map(error => error.code), ["assistant_subscription_waiting"]);
1143
+ assert.deepEqual(unsubscribes, []);
1144
+ t.mock.timers.tick(1);
1145
+ assert.deepEqual(unsubscribes, [{ subscriptionId: subscribes[0].input.subscriptionId }]);
1146
+ assert.equal(errors[1].code, "assistant_subscription_transport_failed");
1147
+ assert.equal(errors[1].cause.message, "operation has timed out");
1148
+ t.mock.timers.tick(999);
1149
+ assert.equal(subscribes.length, 1);
1150
+ t.mock.timers.tick(1);
1151
+ assert.equal(subscribes.length, 2, "The lost answer retries after the existing backoff");
1152
+ assert.deepEqual(subscribes[1].input, subscribes[0].input);
1153
+ t.mock.timers.tick(10_000);
1154
+ assert.equal(errors[2].code, "assistant_subscription_waiting", "Each slow attempt says it is still loading");
1155
+ subscribes[1].answer({ ok: true, streamEpoch: "recovered", state: slowState });
1156
+ assert.equal(states.length, 1);
1157
+ assert.equal(unsubscribes.length, 1);
1158
+ subscription();
1159
+ });
1160
+
1161
+ test("a disconnect while a slow subscription waits resets it and the next connect subscribes again", t => {
1162
+ const { socket, subscribes, unsubscribes, disconnect } = slowServerSocket(t);
1163
+ const { states, errors, subscription } = slowView(socket);
1164
+ t.mock.timers.tick(10_000);
1165
+ assert.equal(errors.length, 1);
1166
+ disconnect();
1167
+ t.mock.timers.tick(300_000);
1168
+ assert.equal(subscribes.length, 1, "A disconnected socket waits for its own reconnect");
1169
+ assert.deepEqual(unsubscribes, [], "There is no connection to unsubscribe on");
1170
+ assert.equal(errors.length, 1, "A disconnect reports no lost answer and no further notice");
1171
+ socket.connected = true;
1172
+ socket.emit("connect");
1173
+ assert.equal(subscribes.length, 2);
1174
+ assert.deepEqual(subscribes[1].input, subscribes[0].input);
1175
+ subscribes[1].answer({ ok: true, streamEpoch: "reconnected", state: slowState });
1176
+ assert.equal(states.length, 1);
1177
+ t.mock.timers.tick(300_000);
1178
+ assert.equal(errors.length, 1);
1179
+ subscription();
1180
+ });
1181
+
1182
+ test("Reload chat while a slow subscription waits reads history or resubscribes, ignoring the earlier late answer", async t => {
1183
+ const { socket, subscribes, unsubscribes } = slowServerSocket(t);
1184
+ const { states, errors, subscription } = slowView(socket);
1185
+ t.mock.timers.tick(10_000);
1186
+ assert.equal(errors.length, 1);
1187
+ await subscription.reload();
1188
+ assert.equal(states.length, 1, "Reload reads canonical history while the subscription keeps waiting");
1189
+ assert.equal(subscribes.length, 1);
1190
+ subscription.reload({ resubscribe: true });
1191
+ assert.equal(subscribes.length, 2, "A manual resubscribe replaces the waiting attempt by its identity");
1192
+ assert.deepEqual(subscribes[1].input, subscribes[0].input);
1193
+ assert.deepEqual(unsubscribes, []);
1194
+ t.mock.timers.tick(5_000);
1195
+ subscribes[0].answer({ ok: true, streamEpoch: "replaced", state: slowState });
1196
+ assert.equal(states.length, 1, "The replaced attempt's late answer is ignored");
1197
+ assert.equal(errors.length, 1);
1198
+ t.mock.timers.tick(5_000);
1199
+ assert.equal(errors.length, 2, "The replacement attempt has its own loading notice");
1200
+ subscribes[1].answer({ ok: true, streamEpoch: "current", state: slowState });
1201
+ assert.equal(states.length, 2);
1202
+ t.mock.timers.tick(300_000);
1203
+ assert.equal(errors.length, 2);
1204
+ assert.equal(subscribes.length, 2);
1205
+ assert.deepEqual(unsubscribes, []);
1206
+ subscription();
1207
+ });
1208
+
1079
1209
  for (const status of [401, 403, 503]) {
1080
1210
  test(`subscription read failure ${status} fences denied updates but retains transient history`, async t => {
1081
1211
  const changes = new EventEmitter();
@@ -73,7 +73,7 @@ async function* completionStream(completion = {}) {
73
73
  yield { choices: [{ delta: {}, finish_reason: completion.toolCall ? "tool_calls" : "stop" }] };
74
74
  }
75
75
 
76
- function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true } = {}) {
76
+ function createHarness(completions, { executeToolCall = null, tools: configuredTools = null, attachments, supportsAttachments = true, wrapRuntime = (runtime) => runtime } = {}) {
77
77
  const pendingCompletions = [...completions];
78
78
  const completionRequests = [];
79
79
  const transcriptMessages = [];
@@ -165,7 +165,7 @@ function createHarness(completions, { executeToolCall = null, tools: configuredT
165
165
  ...createMemoryAssistantRows({ messages: transcriptMessages }),
166
166
  aiClientFactory: dependencies.aiClientFactory, toolCatalog: dependencies.serviceToolCatalog, attachments
167
167
  });
168
- const chatService = createChatService({ ...dependencies, conversationRuntime });
168
+ const chatService = createChatService({ ...dependencies, conversationRuntime: wrapRuntime(conversationRuntime) });
169
169
 
170
170
  const streamWriter = {};
171
171
  for (const method of [
@@ -555,3 +555,49 @@ test("attachment identifiers use the conversation runtime's shape before any fil
555
555
  }
556
556
  assert.deepEqual(calls, []);
557
557
  });
558
+
559
+ function countDisposals(runtime) {
560
+ const counts = { opened: 0, disposed: 0 };
561
+ return { counts, runtime: { ...runtime, async open(input) {
562
+ const handle = await runtime.open(input);
563
+ counts.opened += 1;
564
+ return { ...handle, async dispose(options) { counts.disposed += 1; return handle.dispose(options); } };
565
+ } } };
566
+ }
567
+
568
+ 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
+ });
573
+
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");
576
+ assert.equal((await harness.run("Second question", { messageId: "message_2" })).status, "completed");
577
+ assert.deepEqual(counted.counts, { opened: 2, disposed: 2 });
578
+ assert.deepEqual(assistantMessages(harness.streamEvents), ["First answer.", "Second answer."]);
579
+ });
580
+
581
+ test("a conversation stays open until the last of its overlapping turns settles", async () => {
582
+ const counted = {};
583
+ const firstChunk = Promise.withResolvers();
584
+ const continueResponse = Promise.withResolvers();
585
+ async function* held() {
586
+ yield { choices: [{ delta: { content: "Held answer." } }] };
587
+ firstChunk.resolve();
588
+ await continueResponse.promise;
589
+ yield { choices: [{ delta: {}, finish_reason: "stop" }] };
590
+ }
591
+ const harness = createHarness([held()], {
592
+ wrapRuntime(runtime) { Object.assign(counted, countDisposals(runtime)); return counted.runtime; }
593
+ });
594
+
595
+ const first = harness.run("First question", { messageId: "message_1" });
596
+ await firstChunk.promise;
597
+ // A second tab sends while the first answer is still running; the runtime refuses it.
598
+ 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");
600
+ continueResponse.resolve();
601
+ assert.equal((await first).status, "completed");
602
+ assert.deepEqual(counted.counts, { opened: 2, disposed: 1 });
603
+ });
@@ -229,11 +229,11 @@ test("one AssistantFeature composes host access and declared data while settings
229
229
  await host.start();
230
230
  try {
231
231
  assert.equal(registrations, 1);
232
- assert.equal(wrapped.length, 10);
232
+ assert.equal(wrapped.length, 11);
233
233
  assert.ok(wrapped.includes(access.subscribeActionId));
234
234
  assert.ok(!wrapped.includes(actionIds.conversationSubscribe));
235
- assert.equal(actions.listDefinitions().length, 12);
236
- assert.equal(routes.length, 11);
235
+ assert.equal(actions.listDefinitions().length, 13);
236
+ assert.equal(routes.length, 12);
237
237
  assert.ok(routes.filter(route => route.path.endsWith("/settings")).every(route => route.contract.auth === "required"));
238
238
  assert.ok(routes.filter(route => !route.path.endsWith("/settings")).every(route => route.contract.auth === "public"));
239
239
  assert.equal(actions.getDefinition(actionIds.settingsRead).permission.require, "authenticated");