@jskit-ai/assistant-runtime 0.2.7 → 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.
|
|
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
|
}
|
|
@@ -1135,6 +1135,85 @@ test("a deferred follow-up explains its wait and steers once the working turn ac
|
|
|
1135
1135
|
await expect(f.primary.getByText(/^Pending/)).toHaveCount(0);
|
|
1136
1136
|
});
|
|
1137
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
|
+
|
|
1138
1217
|
for (const steer of [false, true]) {
|
|
1139
1218
|
test(`Stop cancels locally queued native ${steer ? "steering" : "startup"} follow-ups`, options, async t => {
|
|
1140
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");
|