@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.
- package/fixtures/canonical-conversation/App.vue +2 -0
- package/package.json +8 -8
- package/src/client/composables/useAssistantConversation.js +20 -10
- package/src/client/support/subscribeAssistantConversation.js +4 -4
- package/src/server/actions.js +3 -3
- package/src/server/repositories/conversationsRepository.js +2 -2
- package/src/server/services/chatService.js +3 -3
- package/src/server/sqlConversationStorage.js +3 -2
- package/test/assistantConversationBinding.browser.test.js +79 -2
- package/test/assistantConversationSubscriptions.test.js +4 -2
- package/test/chatServiceLifecycle.test.js +38 -15
- package/test/sqlConversationStorage.test.js +6 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
21
|
-
"@jskit-ai/http-web": "0.2.
|
|
22
|
-
"@jskit-ai/kernel": "0.2.
|
|
23
|
-
"@jskit-ai/realtime": "0.2.
|
|
24
|
-
"@jskit-ai/shell-web": "0.2.
|
|
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.
|
|
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 &&
|
|
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(
|
|
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 ||
|
|
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([
|
|
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 (
|
|
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 || !
|
|
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:
|
|
755
|
-
|
|
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
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
|
package/src/server/actions.js
CHANGED
|
@@ -93,7 +93,7 @@ const conversationInputValidator = composeSchemaDefinitions(
|
|
|
93
93
|
{ mode: "patch", context: "assistant-runtime conversation action input" }
|
|
94
94
|
);
|
|
95
95
|
|
|
96
|
-
const
|
|
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:
|
|
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:
|
|
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
|
|
240
|
-
// runtime close retries its save.
|
|
241
|
-
turns.releasing = Promise.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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 => {
|