@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.
- package/README.md +2 -1
- package/fixtures/canonical-conversation/vite.config.mjs +22 -2
- package/locales/en.json +2 -1
- package/package.json +8 -8
- package/src/client/composables/useAssistantConversation.js +67 -17
- package/src/client/support/subscribeAssistantConversation.js +18 -5
- package/src/server/actionIds.js +1 -0
- package/src/server/actions.js +15 -0
- package/src/server/registerRoutes.js +19 -0
- package/src/server/services/chatService.js +108 -74
- package/test/actionDefinitions.test.js +2 -1
- package/test/assistantConversationBinding.browser.test.js +240 -13
- package/test/assistantConversationOperations.test.js +66 -9
- package/test/assistantConversationSubscriptions.test.js +155 -25
- package/test/chatServiceLifecycle.test.js +48 -2
- package/test/featureRuntime.test.js +3 -3
|
@@ -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
|
|
44
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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("
|
|
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-
|
|
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
|
|
343
|
-
while (!view.errors.length
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
assert.equal(
|
|
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, () =>
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
assert.equal(
|
|
363
|
-
assert.
|
|
364
|
-
assert.
|
|
365
|
-
assert.equal(f.
|
|
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
|
|
370
|
-
await until(f.changes, () => view.received.some(event => event.text?.includes("
|
|
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,
|
|
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,
|
|
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,
|
|
236
|
-
assert.equal(routes.length,
|
|
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");
|