@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.7",
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.5",
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.2",
21
- "@jskit-ai/http-web": "0.2.6",
22
- "@jskit-ai/kernel": "0.2.2",
23
- "@jskit-ai/realtime": "0.2.5",
24
- "@jskit-ai/shell-web": "0.2.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.251"
28
+ "@jskit-ai/database-runtime": "0.1.252"
29
29
  },
30
30
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
31
31
  "jskit": {
@@ -136,6 +136,11 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
136
136
  const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
137
137
  const editable = computed(() => active.value && !accessDenied.value);
138
138
  const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
139
+ // A deferred message keeps the subscription while its view is hidden, so it
140
+ // is admitted against live state. A failed read leaves that state stale.
141
+ const deferredWaits = ref(0);
142
+ const listening = computed(() => active.value || current.value && deferredWaits.value > 0);
143
+ const admissible = computed(() => listening.value && !loading.value && !accessDenied.value && !error.value && Boolean(snapshot.value));
139
144
  const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
140
145
  const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
141
146
  const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
@@ -305,7 +310,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
305
310
  if (denial !== deniedHistoryRevision) {
306
311
  throw Object.assign(new Error("Access denied."), { status: 403, messageCode: "assistant.runtime.accessDenied" });
307
312
  }
308
- return current.value && active.value && subscriptionCurrent();
313
+ return current.value && listening.value && subscriptionCurrent();
309
314
  };
310
315
  // A concurrent Load older operation extends the desired span. Retry one
311
316
  // refresh in this same read, rather than dropping that newly loaded page.
@@ -354,7 +359,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
354
359
  }
355
360
  return null;
356
361
  }
357
- watch(active, enabled => {
362
+ watch(listening, enabled => {
358
363
  subscription?.();
359
364
  subscription = null;
360
365
  historyRevision += 1;
@@ -450,6 +455,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
450
455
  }
451
456
  // A deferred message waits only while the working turn cannot take it. It
452
457
  // steers as soon as that turn accepts steering, or sends once it is ready.
458
+ // Its view may be hidden. While loading or after a failed read there is no
459
+ // current state to admit it against, so the unsent message keeps waiting;
460
+ // only Stop, Cancel, retirement or lost access end it.
453
461
  function waitForAdmission({ controller }) {
454
462
  return new Promise(resolve => {
455
463
  let stop;
@@ -459,12 +467,13 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
459
467
  resolve(admission);
460
468
  };
461
469
  const check = () => {
462
- if (controller.signal.aborted || !available.value) finish(false);
470
+ if (controller.signal.aborted || accessDenied.value) finish(false);
471
+ else if (!admissible.value) return;
463
472
  else if (snapshot.value.status === "ready") finish("send");
464
473
  else if (steerable.value) finish("steer");
465
474
  else if (snapshot.value.status !== "working") finish(false);
466
475
  };
467
- stop = watch([available, snapshot, steerable], check, { flush: "sync" });
476
+ stop = watch([admissible, snapshot, steerable], check, { flush: "sync" });
468
477
  controller.signal.addEventListener("abort", check, { once: true });
469
478
  check();
470
479
  });
@@ -477,6 +486,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
477
486
  const pending = { controller: new AbortController(), started: false, dispatched: false };
478
487
  const { controller } = pending;
479
488
  pendingMessages.set(messageId, pending);
489
+ if (deferred) deferredWaits.value += 1;
480
490
  try {
481
491
  const result = await delivery.send(payload, {
482
492
  messageId, queue, isCurrent: () => current.value, onAccepted,
@@ -490,13 +500,12 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
490
500
  // Refresh through the same subscription after a queued predecessor;
491
501
  // its receipt may have arrived before the working snapshot.
492
502
  await subscription?.reload();
493
- if (error.value) return false;
494
- if (snapshot.value.status === "working" && !steerable.value) {
503
+ if (snapshot.value?.status === "working" && !steerable.value) {
495
504
  delivery.wait(messageId, t("jskit.assistant.runtime.delivery.waitingForStep"));
496
505
  }
497
506
  admission = await waitForAdmission(pending);
498
507
  delivery.wait(messageId, "");
499
- if (!admission || controller.signal.aborted || !available.value ||
508
+ if (!admission || controller.signal.aborted || !admissible.value ||
500
509
  snapshot.value.status !== (admission === "steer" ? "working" : "ready")) return false;
501
510
  if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
502
511
  return { ok: false, error: t("jskit.assistant.runtime.attachments.noLongerAccepted") };
@@ -566,6 +575,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
566
575
  return false;
567
576
  } finally {
568
577
  if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
578
+ if (deferred) deferredWaits.value -= 1;
569
579
  if (current.value) subscription?.reload();
570
580
  }
571
581
  }
@@ -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");