@jskit-ai/assistant-runtime 0.2.7 → 0.2.9

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; },
@@ -110,6 +110,12 @@ export default defineConfig({
110
110
  for (const pending of held.splice(0)) pending.respond(pending.receipt);
111
111
  return json({ ok: true });
112
112
  }
113
+ if (url.pathname === "/fixture/start-queued-goal") {
114
+ const state = states.get(input.id);
115
+ changeGoal(state, state.queuedGoal);
116
+ state.queuedGoal = null;
117
+ return json({ ok: true });
118
+ }
113
119
  if (url.pathname === "/fixture/finish") {
114
120
  const state = states.get(input.id);
115
121
  state.status = "ready";
@@ -145,6 +151,11 @@ export default defineConfig({
145
151
  }
146
152
  const receipt = command.delivery === "message" ? accept(state, { messageId: input.messageId,
147
153
  text: input.objective || (input.action === "resume" ? "Resume the goal." : "Cancel the goal.") }) : null;
154
+ // The command is accepted behind the running turn; its goal changes later.
155
+ if (receipt && mode === "queue-goal") {
156
+ state.queuedGoal = input;
157
+ return json(receipt);
158
+ }
148
159
  changeGoal(state, input);
149
160
  const result = receipt || state.goal || null;
150
161
  return json(result, result === null ? 204 : 200);
package/locales/en.json CHANGED
@@ -74,6 +74,7 @@
74
74
  "goal": {
75
75
  "budgetsUnsupported": "This conversation does not support goal token budgets.",
76
76
  "changeFailed": "Could not change the goal.",
77
+ "notStarted": "The goal did not start. Check the chat before starting it again.",
77
78
  "unavailable": "Goal status is unavailable.",
78
79
  "resumeMessage": "Resume the goal.",
79
80
  "cancelMessage": "Cancel the goal."
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.9",
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.7",
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": {
@@ -110,6 +110,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
110
110
  const loading = ref(true);
111
111
  const stopping = ref(false);
112
112
  const goalAction = ref("");
113
+ // An accepted Set or Resume waiting for its goal keeps Pause and Cancel usable.
114
+ const goalSettling = ref(false);
113
115
  const goalTargetCurrent = ref(false);
114
116
  const goalError = ref("");
115
117
  const goalView = shallowRef(null);
@@ -136,6 +138,11 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
136
138
  const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
137
139
  const editable = computed(() => active.value && !accessDenied.value);
138
140
  const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
141
+ // A deferred message keeps the subscription while its view is hidden, so it
142
+ // is admitted against live state. A failed read leaves that state stale.
143
+ const deferredWaits = ref(0);
144
+ const listening = computed(() => active.value || current.value && deferredWaits.value > 0);
145
+ const admissible = computed(() => listening.value && !loading.value && !accessDenied.value && !error.value && Boolean(snapshot.value));
139
146
  const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
140
147
  const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
141
148
  const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
@@ -215,6 +222,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
215
222
  let goalSegment = null;
216
223
  let goalReadController = null;
217
224
  let goalReadQueued = false;
225
+ let goalCommandSequence = 0;
218
226
  const readsGoals = computed(() => goalReadEnabled || snapshot.value?.capabilities?.goals === true ||
219
227
  [...readers.values()].some(reader => toValue(reader.goal) === true));
220
228
  watch(readsGoals, enabled => { if (enabled) void refreshGoal(); });
@@ -305,7 +313,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
305
313
  if (denial !== deniedHistoryRevision) {
306
314
  throw Object.assign(new Error("Access denied."), { status: 403, messageCode: "assistant.runtime.accessDenied" });
307
315
  }
308
- return current.value && active.value && subscriptionCurrent();
316
+ return current.value && listening.value && subscriptionCurrent();
309
317
  };
310
318
  // A concurrent Load older operation extends the desired span. Retry one
311
319
  // refresh in this same read, rather than dropping that newly loaded page.
@@ -354,7 +362,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
354
362
  }
355
363
  return null;
356
364
  }
357
- watch(active, enabled => {
365
+ watch(listening, enabled => {
358
366
  subscription?.();
359
367
  subscription = null;
360
368
  historyRevision += 1;
@@ -372,6 +380,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
372
380
  for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
373
381
  clearConversationView();
374
382
  goalAction.value = "";
383
+ goalSettling.value = false;
375
384
  goalReadQueued = false;
376
385
  questions.reset();
377
386
  }, { flush: "sync" });
@@ -450,6 +459,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
450
459
  }
451
460
  // A deferred message waits only while the working turn cannot take it. It
452
461
  // steers as soon as that turn accepts steering, or sends once it is ready.
462
+ // Its view may be hidden. While loading or after a failed read there is no
463
+ // current state to admit it against, so the unsent message keeps waiting;
464
+ // only Stop, Cancel, retirement or lost access end it.
453
465
  function waitForAdmission({ controller }) {
454
466
  return new Promise(resolve => {
455
467
  let stop;
@@ -459,12 +471,13 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
459
471
  resolve(admission);
460
472
  };
461
473
  const check = () => {
462
- if (controller.signal.aborted || !available.value) finish(false);
474
+ if (controller.signal.aborted || accessDenied.value) finish(false);
475
+ else if (!admissible.value) return;
463
476
  else if (snapshot.value.status === "ready") finish("send");
464
477
  else if (steerable.value) finish("steer");
465
478
  else if (snapshot.value.status !== "working") finish(false);
466
479
  };
467
- stop = watch([available, snapshot, steerable], check, { flush: "sync" });
480
+ stop = watch([admissible, snapshot, steerable], check, { flush: "sync" });
468
481
  controller.signal.addEventListener("abort", check, { once: true });
469
482
  check();
470
483
  });
@@ -477,10 +490,13 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
477
490
  const pending = { controller: new AbortController(), started: false, dispatched: false };
478
491
  const { controller } = pending;
479
492
  pendingMessages.set(messageId, pending);
493
+ if (deferred) deferredWaits.value += 1;
480
494
  try {
481
495
  const result = await delivery.send(payload, {
496
+ // A goal command settles on the server's goal answer, not on its
497
+ // transcript turn, which can arrive while its goal is still changing.
482
498
  messageId, queue, isCurrent: () => current.value, onAccepted,
483
- receiptTurns: turns, uncertainOnError: true,
499
+ receiptTurns: payload.goalRequest ? null : turns, uncertainOnError: true,
484
500
  async deliver(submission) {
485
501
  pending.started = true;
486
502
  let admission = null;
@@ -490,13 +506,12 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
490
506
  // Refresh through the same subscription after a queued predecessor;
491
507
  // its receipt may have arrived before the working snapshot.
492
508
  await subscription?.reload();
493
- if (error.value) return false;
494
- if (snapshot.value.status === "working" && !steerable.value) {
509
+ if (snapshot.value?.status === "working" && !steerable.value) {
495
510
  delivery.wait(messageId, t("jskit.assistant.runtime.delivery.waitingForStep"));
496
511
  }
497
512
  admission = await waitForAdmission(pending);
498
513
  delivery.wait(messageId, "");
499
- if (!admission || controller.signal.aborted || !available.value ||
514
+ if (!admission || controller.signal.aborted || !admissible.value ||
500
515
  snapshot.value.status !== (admission === "steer" ? "working" : "ready")) return false;
501
516
  if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
502
517
  return { ok: false, error: t("jskit.assistant.runtime.attachments.noLongerAccepted") };
@@ -566,6 +581,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
566
581
  return false;
567
582
  } finally {
568
583
  if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
584
+ if (deferred) deferredWaits.value -= 1;
569
585
  if (current.value) subscription?.reload();
570
586
  }
571
587
  }
@@ -686,7 +702,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
686
702
  // it stops and goes straight to the goal operation, even while a message is
687
703
  // sending or its receipt is unknown. Set and Resume wait for those.
688
704
  const stopping = action === "pause" || action === "cancel";
689
- if (!available.value || view?.status !== "available" || goalLoadError.value || goalAction.value || !goalTargetCurrent.value ||
705
+ if (!available.value || view?.status !== "available" || goalLoadError.value ||
706
+ goalAction.value && !(stopping && goalSettling.value) || !goalTargetCurrent.value ||
690
707
  !["message", "control"].includes(command?.delivery) ||
691
708
  !stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
692
709
  if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
@@ -699,11 +716,16 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
699
716
  expectedGoalId: view.goal?.id || null,
700
717
  ...(action === "set" ? { objective: String(input.objective || "").trim(),
701
718
  ...(input.tokenBudget == null ? {} : { tokenBudget: input.tokenBudget }) } : {}) };
719
+ const before = view.goal ? { id: view.goal.id, status: view.goal.status } : null;
720
+ const sequence = ++goalCommandSequence;
721
+ let accepted = false;
702
722
  goalAction.value = action;
723
+ goalSettling.value = false;
703
724
  goalError.value = "";
704
725
  try {
726
+ let result;
705
727
  if (retry || !stopping && command.delivery === "message") {
706
- const result = await submitRequest(retry?.payload || {
728
+ result = await submitRequest(retry?.payload || {
707
729
  message: action === "set" ? request.objective
708
730
  : t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
709
731
  goalRequest: request
@@ -714,11 +736,12 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
714
736
  }
715
737
  return false;
716
738
  }
717
- return result;
739
+ } else {
740
+ result = await api.updateConversationGoal(identity.conversationId,
741
+ command.delivery === "message" ? { ...request, messageId } : request);
742
+ if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
718
743
  }
719
- const result = await api.updateConversationGoal(identity.conversationId,
720
- command.delivery === "message" ? { ...request, messageId } : request);
721
- if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
744
+ accepted = true;
722
745
  return result;
723
746
  } catch (failure) {
724
747
  if (current.value) {
@@ -727,13 +750,43 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
727
750
  }
728
751
  return false;
729
752
  } finally {
730
- if (current.value) {
731
- goalAction.value = "";
753
+ // The command stays Starting, Resuming, Pausing or Cancelling until a goal
754
+ // read made after the server's answer arrives; until then the control
755
+ // shows no stale goal or enabled form. An accepted Set or Resume also
756
+ // waits while its goal waits behind the running turn.
757
+ if (current.value && goalCommandSequence === sequence) {
732
758
  await goalRead;
733
759
  await refreshGoal();
760
+ if (accepted && !stopping) await awaitGoalChange(before, sequence);
734
761
  subscription?.reload();
735
762
  }
763
+ if (goalCommandSequence === sequence) {
764
+ goalAction.value = "";
765
+ goalSettling.value = false;
766
+ }
767
+ }
768
+ }
769
+ // An accepted Set or Resume changes the server's goal, now or once the turn
770
+ // it waits behind has ended. When the conversation stops working without
771
+ // that change, a fresh read decides and the reason is shown. A later Pause
772
+ // or Cancel ends the wait.
773
+ const goalChangedFrom = before => {
774
+ const goal = goalView.value?.goal;
775
+ return Boolean(goal) && (goal.id !== before?.id || goal.status !== before?.status);
776
+ };
777
+ async function awaitGoalChange(before, sequence) {
778
+ const settled = () => !current.value || goalCommandSequence !== sequence || goalChangedFrom(before);
779
+ await subscription?.reload();
780
+ if (!settled()) goalSettling.value = true;
781
+ while (!settled() && snapshot.value?.status === "working") {
782
+ await new Promise(resolve => {
783
+ const stop = watch([goalView, () => snapshot.value?.status, current, goalAction], () => { stop(); resolve(); });
784
+ });
736
785
  }
786
+ if (settled()) return;
787
+ await goalRead;
788
+ await refreshGoal();
789
+ if (!settled()) goalError.value = t("jskit.assistant.runtime.goal.notStarted");
737
790
  }
738
791
  const goalState = computed(() => {
739
792
  const view = goalView.value;
@@ -742,7 +795,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
742
795
  const commands = capabilities.goalCommands || {};
743
796
  const loading = !goalTargetCurrent.value && !goalLoadError.value;
744
797
  const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
745
- const stopPending = Boolean(goalAction.value) || !goalAvailable || !available.value;
798
+ const stopPending = Boolean(goalAction.value) && !goalSettling.value || !goalAvailable || !available.value;
746
799
  return {
747
800
  enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
748
801
  loading,
@@ -752,7 +805,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
752
805
  tokenBudgetSupported: capabilities.goalBudgets === true,
753
806
  goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
754
807
  sampledAt: Date.parse(view.goal.updatedAt) } : null,
755
- pending: stopPending || delivery.state.sending || delivery.state.messages.some(message => message.status === "uncertain"),
808
+ pending: Boolean(goalAction.value) || stopPending || delivery.state.sending ||
809
+ delivery.state.messages.some(message => message.status === "uncertain"),
756
810
  stopPending,
757
811
  error: goalError.value || goalLoadError.value || view?.error || "",
758
812
  pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
@@ -393,6 +393,117 @@ test("a lost goal receipt uses the same inspection-only ledger and never starts
393
393
  assert.equal((await requests()).length, 1, "An undeclared goal command is not exposed or dispatched");
394
394
  });
395
395
 
396
+ test("Start goal stays Starting… until the goal is read after the server's answer, and no second start is sent", options, async t => {
397
+ const f = await fixture(t);
398
+ await f.command("goals", { id: "chat:1", budgets: false, commands: {
399
+ set: { delivery: "message", interruptsTurn: false }, pause: { delivery: "control", interruptsTurn: true }
400
+ } });
401
+ await f.notify("chat:1");
402
+ const starts = async () => (await f.state()).requests.filter(request => request.suffix === "/goal" && request.method === "POST");
403
+ await f.primary.getByRole("button", { name: "Set goal", exact: true }).click();
404
+ const objective = f.page.getByRole("textbox", { name: "Goal objective" });
405
+ const start = f.page.locator(".assistant-goal__form button[type=submit]");
406
+ await objective.fill("Finish while the goal read is slow");
407
+ // Goal reads made after Start wait, as a slow server read does.
408
+ const reads = Promise.withResolvers();
409
+ let heldReads = 0;
410
+ await f.page.route("**/conversations/*/goal", async route => {
411
+ if (route.request().method() === "GET") {
412
+ heldReads += 1;
413
+ await reads.promise;
414
+ }
415
+ await route.continue();
416
+ });
417
+ await start.click();
418
+ await expect.poll(async () => (await starts()).length).toBe(1);
419
+ await expect.poll(() => heldReads).toBeGreaterThan(0);
420
+ // The server has answered; the control has not yet read the goal it started.
421
+ await expect(start).toHaveText("Starting…");
422
+ await expect(start).toBeDisabled();
423
+ await expect(objective).toHaveValue("Finish while the goal read is slow");
424
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("set", { objective: "A second start" })), false);
425
+ reads.resolve();
426
+ await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toHaveAttribute("aria-expanded", "false");
427
+ assert.equal((await starts()).length, 1, "One Start sends one goal command");
428
+ });
429
+
430
+ test("a Start accepted behind the running turn stays Starting… until its goal runs, and says so when it never starts", options, async t => {
431
+ const f = await fixture(t);
432
+ await f.command("goals", { id: "chat:1", budgets: false, commands: {
433
+ set: { delivery: "message", interruptsTurn: false }, cancel: { delivery: "control", interruptsTurn: false }
434
+ } });
435
+ await f.notify("chat:1");
436
+ const starts = async () => (await f.state()).requests.filter(request =>
437
+ request.suffix === "/goal" && request.method === "POST" && request.input.action === "set");
438
+ const control = name => f.primary.getByRole("button", { name, exact: true });
439
+ const objective = f.page.getByRole("textbox", { name: "Goal objective" });
440
+ const start = f.page.locator(".assistant-goal__form button[type=submit]");
441
+ await f.command("mode", { mode: "queue-goal" });
442
+ await control("Set goal").click();
443
+ await objective.fill("Start after the current turn");
444
+ await start.click();
445
+ await expect.poll(async () => (await starts()).length).toBe(1);
446
+ await f.notify("chat:1");
447
+ // Accepted, but its goal waits behind the running turn.
448
+ await expect(start).toHaveText("Starting…");
449
+ await expect(start).toBeDisabled();
450
+ await expect(objective).toHaveValue("Start after the current turn");
451
+ await f.page.keyboard.press("Escape");
452
+ await expect(control("Starting…")).toBeVisible();
453
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("set", { objective: "A second start" })), false);
454
+ await f.command("start-queued-goal", { id: "chat:1" });
455
+ await f.notify("chat:1");
456
+ await expect(control("Goal running")).toBeVisible();
457
+ assert.equal((await starts()).length, 1);
458
+
459
+ // A Start the conversation finishes without says so and keeps its objective.
460
+ await control("Goal running").click();
461
+ await f.page.getByRole("button", { name: "Cancel goal", exact: true }).click();
462
+ await expect(control("Set goal")).toBeVisible();
463
+ await objective.fill("Never takes effect");
464
+ await start.click();
465
+ await expect.poll(async () => (await starts()).length).toBe(2);
466
+ await f.notify("chat:1");
467
+ await expect(start).toBeDisabled();
468
+ await f.command("finish", { id: "chat:1" });
469
+ await f.notify("chat:1");
470
+ await expect(f.page.getByRole("alert").filter({ hasText: "The goal did not start. Check the chat before starting it again." })).toBeVisible();
471
+ await expect(objective).toHaveValue("Never takes effect");
472
+ await expect(start).toBeEnabled();
473
+ assert.equal((await starts()).length, 2);
474
+ });
475
+
476
+ test("Cancel stays available while a Resume waits behind the running turn and ends that wait", options, async t => {
477
+ const f = await fixture(t);
478
+ await f.command("goals", { id: "chat:1", budgets: false, commands: {
479
+ set: { delivery: "message", interruptsTurn: false }, resume: { delivery: "message", interruptsTurn: false },
480
+ pause: { delivery: "control", interruptsTurn: false }, cancel: { delivery: "control", interruptsTurn: false }
481
+ } });
482
+ await f.notify("chat:1");
483
+ const control = name => f.primary.getByRole("button", { name, exact: true });
484
+ const menu = name => f.page.getByRole("button", { name, exact: true });
485
+ await control("Set goal").click();
486
+ await f.page.getByRole("textbox", { name: "Goal objective" }).fill("Resume after the current turn");
487
+ await f.page.locator(".assistant-goal__form button[type=submit]").click();
488
+ await expect(control("Goal running")).toHaveAttribute("aria-expanded", "false");
489
+ await control("Goal running").click();
490
+ await menu("Pause goal").click();
491
+ await expect(control("Goal paused")).toBeVisible();
492
+ await f.command("mode", { mode: "queue-goal" });
493
+ await menu("Resume goal").click();
494
+ await expect(menu("Resuming…")).toBeDisabled();
495
+ await f.notify("chat:1");
496
+ await expect(menu("Cancel goal")).toBeEnabled();
497
+ await expect(menu("Resuming…")).toBeDisabled();
498
+ await menu("Cancel goal").click();
499
+ await expect(control("Set goal")).toBeVisible();
500
+ await f.command("finish", { id: "chat:1" });
501
+ await f.notify("chat:1");
502
+ await expect(f.page.getByRole("alert")).toHaveCount(0);
503
+ const commands = (await f.state()).requests.filter(request => request.suffix === "/goal" && request.method === "POST");
504
+ assert.deepEqual(commands.map(request => request.input.action), ["set", "pause", "resume", "cancel"]);
505
+ });
506
+
396
507
  test("a supplied goal view keeps its pinned target and refreshes through the retained binding without polling", options, async t => {
397
508
  const f = await fixture(t);
398
509
  const goal = { id: "retained-goal", objective: "The plain product objective", status: "paused",
@@ -1135,6 +1246,85 @@ test("a deferred follow-up explains its wait and steers once the working turn ac
1135
1246
  await expect(f.primary.getByText(/^Pending/)).toHaveCount(0);
1136
1247
  });
1137
1248
 
1249
+ // An application keeps a retained view mounted but inactive while another
1250
+ // conversation is shown. A message waiting for admission there was never sent:
1251
+ // hiding its view must not fail it as "Message could not be sent.", and the
1252
+ // person who moved on must not have to return before it is sent.
1253
+ test("a deferred message sends while its view is hidden once the session admits it", options, async t => {
1254
+ const f = await fixture(t);
1255
+ await f.page.evaluate(() => {
1256
+ window.readerActive = window.conversationFixture.activity(true);
1257
+ window.deferredBinding = window.conversationFixture.acquireConversation({
1258
+ conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true, active: window.readerActive
1259
+ });
1260
+ });
1261
+ const deferred = () => f.page.evaluate(() => {
1262
+ const runtime = window.deferredBinding.runtime.value;
1263
+ const { id, status, error, waiting } = runtime.delivery.state.messages.find(message => message.text === "Queued while working") || {};
1264
+ return { available: runtime.available.value, status: runtime.snapshot.value?.status, message: { id, status, error, waiting },
1265
+ subscriptions: window.conversationFixture.socket.inspect().active };
1266
+ });
1267
+ await expect.poll(async () => (await deferred()).available).toBe(true);
1268
+ const { subscriptions } = await deferred();
1269
+ await f.page.evaluate(() => { window.original = window.deferredBinding.runtime.value.send({ message: "Original nonsteerable request" }); });
1270
+ await expect.poll(async () => (await deferred()).status).toBe("working");
1271
+ await f.page.evaluate(() => { window.queued = window.deferredBinding.runtime.value.send({ message: "Queued while working" }); });
1272
+ await expect.poll(async () => (await deferred()).message.waiting).toBe("Waiting for the assistant to finish this step");
1273
+ await f.page.evaluate(() => { window.readerActive.value = false; });
1274
+ await expect.poll(async () => (await deferred()).available).toBe(false);
1275
+ const hidden = await deferred();
1276
+ assert.equal(hidden.message.status, "pending", `hiding the view is not a failed delivery: ${hidden.message.error}`);
1277
+ assert.equal(hidden.subscriptions, subscriptions, "the waiting message keeps its live state");
1278
+ assert.equal((await f.sent()).length, 1, "the working turn still cannot take the message");
1279
+ await f.command("finish", { id: "chat:2" });
1280
+ await f.notify("chat:2");
1281
+ await expect.poll(async () => (await f.sent()).length).toBe(2);
1282
+ const [, followUp] = await f.sent();
1283
+ assert.deepEqual([followUp.input.messageId, followUp.input.text], [hidden.message.id, "Queued while working"]);
1284
+ assert.equal((await f.page.evaluate(() => window.queued)).status, "accepted");
1285
+ const sent = await deferred();
1286
+ assert.equal(sent.available, false, "the message was sent while its view stayed hidden");
1287
+ await expect.poll(async () => (await deferred()).subscriptions, { message: "an empty queue releases the hidden view's subscription" }).toBe(subscriptions - 1);
1288
+ await f.page.evaluate(() => { window.readerActive.value = true; });
1289
+ await expect.poll(async () => (await deferred()).available).toBe(true);
1290
+ assert.equal((await f.sent()).length, 2, "the queued message is sent once");
1291
+ });
1292
+
1293
+ // A lost subscription leaves the last snapshot shown with its error while it
1294
+ // retries. Its refresh then reads nothing, so that stale error says nothing
1295
+ // about the unsent message: it waits for the recovered state and sends once.
1296
+ test("a deferred message waits through a failing resubscription instead of failing", options, async t => {
1297
+ const f = await fixture(t);
1298
+ await f.page.evaluate(() => {
1299
+ window.deferredBinding = window.conversationFixture.acquireConversation({
1300
+ conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true
1301
+ });
1302
+ });
1303
+ const deferred = () => f.page.evaluate(() => {
1304
+ const runtime = window.deferredBinding.runtime.value;
1305
+ const { id, status, error } = runtime.delivery.state.messages.find(message => message.text === "Sent while reconnecting") || {};
1306
+ return { available: runtime.available.value, error: runtime.error.value, message: { id, status, error } };
1307
+ });
1308
+ await expect.poll(async () => (await deferred()).available).toBe(true);
1309
+ let failing = true;
1310
+ await f.page.route(url => url.pathname.endsWith("/conversations/chat%3A2"), route => failing && route.request().method() === "GET"
1311
+ ? route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "Conversation temporarily unavailable." }) })
1312
+ : route.continue());
1313
+ await f.page.evaluate(() => window.conversationFixture.socket.reconnect());
1314
+ await expect.poll(async () => (await deferred()).error).toBe("Conversation temporarily unavailable.");
1315
+ assert.equal((await deferred()).available, true, "the last snapshot stays shown");
1316
+ await f.page.evaluate(() => { window.queued = window.deferredBinding.runtime.value.send({ message: "Sent while reconnecting" }); });
1317
+ await expect.poll(async () => (await deferred()).message.status).toBe("pending");
1318
+ const waiting = await deferred();
1319
+ assert.equal((await f.sent()).length, 0, "nothing is admitted against the stale snapshot");
1320
+ failing = false;
1321
+ // The subscription's own retry, not the message, recovers the state.
1322
+ await expect.poll(async () => (await f.sent()).length, { timeout: 15_000 }).toBe(1);
1323
+ const [sent] = await f.sent();
1324
+ assert.deepEqual([sent.input.messageId, sent.input.text], [waiting.message.id, "Sent while reconnecting"]);
1325
+ assert.equal((await f.page.evaluate(() => window.queued)).status, "accepted");
1326
+ });
1327
+
1138
1328
  for (const steer of [false, true]) {
1139
1329
  test(`Stop cancels locally queued native ${steer ? "steering" : "startup"} follow-ups`, options, async t => {
1140
1330
  const f = await fixture(t);
@@ -3293,10 +3483,13 @@ test("a background goal read keeps current pinned controls available and seriali
3293
3483
  await expect(f.page.getByRole("button", { name: "Cancel goal", exact: true })).toBeDisabled();
3294
3484
  assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("cancel")), false);
3295
3485
  releaseCommand();
3296
- await expect(pause).toBeEnabled();
3486
+ // The answered Pause stays Pausing… until a goal read made after its answer
3487
+ // lands, so the stale goal never offers its controls again.
3488
+ await expect(f.page.getByRole("button", { name: "Pausing…", exact: true })).toBeDisabled();
3297
3489
  assert.equal(commands.length, 1, "Only the actual Pause command was admitted");
3298
3490
  holdRead = false;
3299
3491
  releaseRead();
3492
+ await expect(pause).toBeEnabled();
3300
3493
  await f.page.evaluate(() => window.fixtureBackgroundGoalRead);
3301
3494
  assert.deepEqual(await f.sent(), []);
3302
3495
  } finally {
@@ -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");