@jskit-ai/assistant-runtime 0.1.197 → 0.1.198

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.197",
3
+ "version": "0.1.198",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -12,19 +12,19 @@
12
12
  "./server": "./src/server/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@jskit-ai/assistant-core": "0.1.204",
15
+ "@jskit-ai/assistant-core": "0.1.205",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/http-runtime": "0.1.224",
20
- "@jskit-ai/http-web": "0.1.71",
21
- "@jskit-ai/kernel": "0.1.226",
22
- "@jskit-ai/realtime": "0.1.223",
23
- "@jskit-ai/shell-web": "0.1.230",
19
+ "@jskit-ai/http-runtime": "0.1.225",
20
+ "@jskit-ai/http-web": "0.1.72",
21
+ "@jskit-ai/kernel": "0.1.227",
22
+ "@jskit-ai/realtime": "0.1.224",
23
+ "@jskit-ai/shell-web": "0.1.231",
24
24
  "@tanstack/vue-query": "^5.90.5",
25
25
  "vue": "^3.5.13",
26
26
  "vuetify": "^4.0.0",
27
- "@jskit-ai/database-runtime": "0.1.226"
27
+ "@jskit-ai/database-runtime": "0.1.227"
28
28
  },
29
29
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
30
30
  "jskit": {
@@ -31,7 +31,7 @@ function configureAssistantConversations(app, { actorKey, api = null, request, c
31
31
  // occur after dispatch and must retain an uncertain receipt.
32
32
  const SEND_REJECTIONS = new Set([
33
33
  "ACTION_VALIDATION_FAILED", "ACTION_PERMISSION_DENIED", "ACTION_SURFACE_FORBIDDEN",
34
- "conversation_invalid_message", "conversation_busy", "conversation_invalid_goal", "conversation_goal_changed"
34
+ "conversation_invalid_message", "conversation_busy", "conversation_not_steerable", "conversation_invalid_goal", "conversation_goal_changed"
35
35
  ]);
36
36
 
37
37
  function isQuestionConfiguration(value) {
@@ -46,7 +46,7 @@ function draftAfterAcceptedSubmission(currentDraft = "", submittedDraft = "") {
46
46
  return submitted && current.startsWith(submitted) ? current.slice(submitted.length) : current;
47
47
  }
48
48
 
49
- function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, draftStorage, application, goalReadEnabled }) {
49
+ function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, deferWhileWorking, draftStorage, application, goalReadEnabled }) {
50
50
  const disposed = ref(false);
51
51
  const placementRevision = ref(0);
52
52
  const stopPlacement = placement.subscribe(() => { placementRevision.value += 1; });
@@ -90,8 +90,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
90
90
  const editable = computed(() => active.value && !accessDenied.value);
91
91
  const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
92
92
  const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
93
- const queueing = computed(() => queueWhileSending !== false && steerable.value);
94
- const canSubmit = computed(() => available.value && (snapshot.value.status === "ready" || steerable.value) &&
93
+ const queueing = computed(() => queueWhileSending !== false && (steerable.value || deferWhileWorking));
94
+ const canSubmit = computed(() => available.value && (snapshot.value.status === "ready" || steerable.value || deferWhileWorking && snapshot.value.status === "working") &&
95
95
  (queueWhileSending !== false || !delivery.state.sending) &&
96
96
  !delivery.state.messages.some(message => message.status === "uncertain"));
97
97
  const savedDraft = toValue(draftStorage);
@@ -109,9 +109,11 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
109
109
  const messages = Array.isArray(saved?.messages) ? saved.messages.filter(message =>
110
110
  typeof message?.id === "string" && typeof message.text === "string" &&
111
111
  typeof message.payload?.message === "string") : [];
112
- const restoredMessages = messages.filter(message => !delivery.find(message.id))
113
- .map(message => ({ ...message, status: "failed", error: message.error ||
114
- "Delivery was not confirmed before this page closed. Check the conversation before retrying." }));
112
+ const restoredMessages = messages.filter(message => !delivery.find(message.id)).map(message => {
113
+ const status = ["failed", "accepted"].includes(message.status) ? message.status : "uncertain";
114
+ return { ...message, status, checking: false, error: message.error || (status === "accepted" ? "" :
115
+ "Delivery was not confirmed before this page closed. Check the conversation before retrying.") };
116
+ });
115
117
  delivery.state.messages = unmatchedOptimisticMessages(turns.value, [...delivery.state.messages, ...restoredMessages], { receiptsOnly: true });
116
118
  } catch {
117
119
  draft.value = "";
@@ -232,6 +234,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
232
234
  }, { immediate: true, flush: "sync" });
233
235
  watch(current, value => {
234
236
  if (value) return;
237
+ for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
235
238
  delivery.reset();
236
239
  snapshot.value = null;
237
240
  olderPages.value = [];
@@ -251,7 +254,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
251
254
  disposed.value = true;
252
255
  subscription?.();
253
256
  stopPlacement();
254
- for (const controller of pendingMessages.values()) controller.abort();
257
+ for (const pending of pendingMessages.values()) pending.controller.abort();
255
258
  pendingMessages.clear();
256
259
  goalReadController?.abort();
257
260
  globalThis.removeEventListener?.("focus", refreshVisibleGoal);
@@ -268,7 +271,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
268
271
  const requestedSteering = request?.steer === true;
269
272
  if (requestedSteering && snapshot.value.capabilities?.steering !== true) return false;
270
273
  const steering = requestedSteering || steerable.value;
271
- if ((snapshot.value.status === "working" || delivery.state.sending) && !steering) return false;
274
+ const deferred = deferWhileWorking && !retry && !steering;
275
+ if ((snapshot.value.status === "working" || delivery.state.sending) && !steering && !deferred) return false;
272
276
  if ((retry?.payload || payload).displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) return false;
273
277
  const captured = retry?.payload?.request ? retry.payload : { ...(retry?.payload || payload), request: payload.request || {
274
278
  text: String(payload.message || ""),
@@ -276,7 +280,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
276
280
  ...(payload.displayAttachments?.length ? { attachmentIds: payload.displayAttachments.map(file => file.attachmentId) } : {}),
277
281
  ...(steerable.value ? { steer: true } : {})
278
282
  } };
279
- return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && steering });
283
+ return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred), deferred });
280
284
  }
281
285
  function submitPrepared(payload, { messageId = crypto.randomUUID(), onAccepted, prepare } = {}) {
282
286
  if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
@@ -315,16 +319,50 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
315
319
  }
316
320
  return result;
317
321
  }
318
- async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare }) {
322
+ function waitUntilReady({ controller }) {
323
+ return new Promise(resolve => {
324
+ let stop;
325
+ const finish = ready => {
326
+ stop?.();
327
+ controller.signal.removeEventListener("abort", check);
328
+ resolve(ready);
329
+ };
330
+ const check = () => {
331
+ if (controller.signal.aborted || !available.value) finish(false);
332
+ else if (snapshot.value.status === "ready") finish(true);
333
+ else if (snapshot.value.status !== "working") finish(false);
334
+ };
335
+ stop = watch([available, snapshot], check, { flush: "sync" });
336
+ controller.signal.addEventListener("abort", check, { once: true });
337
+ check();
338
+ });
339
+ }
340
+
341
+ async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false }) {
342
+ if (pendingMessages.has(messageId)) return false;
343
+ // Register before the original serial tail so Stop/retirement also reaches
344
+ // a local follower whose deliver callback has not started yet.
345
+ const pending = { controller: new AbortController(), started: false, dispatched: false, deferred };
346
+ const { controller } = pending;
347
+ pendingMessages.set(messageId, pending);
319
348
  try {
320
349
  const result = await delivery.send(payload, {
321
350
  messageId, queue, isCurrent: () => current.value, onAccepted,
322
351
  receiptTurns: turns, uncertainOnError: true,
323
352
  async deliver(submission) {
324
- const controller = new AbortController();
325
- pendingMessages.set(messageId, controller);
326
- let dispatched = false;
353
+ pending.started = true;
327
354
  try {
355
+ if (controller.signal.aborted) return false;
356
+ if (deferred) {
357
+ // Refresh through the same subscription after a queued predecessor;
358
+ // its receipt may have arrived before the working snapshot.
359
+ await subscription?.reload();
360
+ if (error.value || !await waitUntilReady(pending)) return false;
361
+ if (controller.signal.aborted || !available.value || snapshot.value.status !== "ready") return false;
362
+ if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
363
+ return { ok: false, error: "This conversation no longer accepts attachments." };
364
+ }
365
+ }
328
366
  const request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
329
367
  if (prepare) {
330
368
  if (!current.value || accessDenied.value || controller.signal.aborted || request === false) return false;
@@ -332,20 +370,20 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
332
370
  throw new TypeError("Application preparation must return its conversation request or false.");
333
371
  }
334
372
  }
335
- dispatched = true;
373
+ pending.dispatched = true;
336
374
  return await (submission.goalRequest
337
375
  ? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
338
376
  : api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
339
377
  }
340
378
  catch (failure) {
341
379
  if (controller.signal.aborted) return false;
342
- if (prepare && !dispatched) return { ok: false, error: failure.message,
380
+ if (prepare && !pending.dispatched) return { ok: false, error: failure.message,
343
381
  ...(failure.code ? { code: failure.code } : {}) };
344
382
  if (SEND_REJECTIONS.has(failure.code)) return { ok: false, error: failure.message,
345
383
  ...(prepare && failure.code ? { code: failure.code } : {}) };
346
384
  throw failure;
347
385
  } finally {
348
- if (pendingMessages.get(messageId) === controller) pendingMessages.delete(messageId);
386
+ if (pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
349
387
  }
350
388
  }
351
389
  });
@@ -355,13 +393,15 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
355
393
  if (prepare) return { ok: false, status: "uncertain", error: failure.message,
356
394
  ...(failure.code ? { code: failure.code } : {}) };
357
395
  return false;
358
- } finally { if (current.value) subscription?.reload(); }
396
+ } finally {
397
+ if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
398
+ if (current.value) subscription?.reload();
399
+ }
359
400
  }
360
401
  function cancelMessage(messageId) {
361
- const controller = pendingMessages.get(messageId);
362
- if (!controller) return false;
363
- controller.abort();
364
- pendingMessages.delete(messageId);
402
+ const pending = pendingMessages.get(messageId);
403
+ if (!pending) return false;
404
+ pending.controller.abort();
365
405
  return true;
366
406
  }
367
407
 
@@ -515,6 +555,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
515
555
  async function cancel() {
516
556
  if (!editable.value || stopping.value) return false;
517
557
  stopping.value = true;
558
+ for (const pending of pendingMessages.values()) {
559
+ if (pending.deferred && !pending.dispatched) pending.controller.abort();
560
+ }
518
561
  try { return await api.cancelConversation(identity.conversationId); }
519
562
  catch (failure) { receiveError(failure); return false; }
520
563
  finally { stopping.value = false; if (current.value) subscription?.reload(); }
@@ -610,7 +653,7 @@ function conversationBindingSetup({ socket, boundedTask = null } = {}) {
610
653
 
611
654
  function createConversationBinding({ conversationId, endpoint = "", surfaceId = "", hostSurfaceId = "", workspaceSlug,
612
655
  actorKey: suppliedActorKey, api: suppliedApi = null, socket: suppliedSocket = null, active = true, onEvent,
613
- clearDraftOn: suppliedClearDraftOn, queueWhileSending, draftWhileLoading = false,
656
+ clearDraftOn: suppliedClearDraftOn, queueWhileSending, deferWhileWorking = false, draftWhileLoading = false,
614
657
  draftStorage = null, application = null, boundedTask = null,
615
658
  data, attachments = null, suggestions = null, models = null, questions = null, goal = null, presentation = {} } = {}, setup) {
616
659
  const { app, routeContext, placement, workspaceScope, socket: setupSocket, config, defaults = {} } = setup ||
@@ -622,6 +665,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
622
665
  });
623
666
  if (!["dispatch", "accepted"].includes(clearDraftOn)) throw new TypeError("clearDraftOn must be dispatch or accepted.");
624
667
  if (queueWhileSending !== undefined && typeof queueWhileSending !== "boolean") throw new TypeError("queueWhileSending must be a boolean.");
668
+ if (typeof deferWhileWorking !== "boolean") throw new TypeError("deferWhileWorking must be a boolean.");
625
669
  if (typeof draftWhileLoading !== "boolean") throw new TypeError("draftWhileLoading must be a boolean.");
626
670
  if (application !== null && typeof application !== "function") throw new TypeError("application must be a factory.");
627
671
  if (boundedTask !== null) return useBoundedTask(boundedTask, { active, data, presentation });
@@ -679,7 +723,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
679
723
  const key = JSON.stringify(["assistant", target.actorKey, target.endpoint, target.targetSurfaceId,
680
724
  target.hostSurfaceId, target.workspaceSlug, target.conversationId]);
681
725
  retained = retainAssistantConversation(app, key, readers => createConversation(target, {
682
- readers, socket, actorKey, placement, queueWhileSending, draftStorage, application, goalReadEnabled: toValue(goal) === true,
726
+ readers, socket, actorKey, placement, queueWhileSending, deferWhileWorking, draftStorage, application, goalReadEnabled: toValue(goal) === true,
683
727
  api: suppliedApi || defaults.api || createAssistantApi({ request: defaults.request || assistantHttpClient.request,
684
728
  resolveBasePath: () => target.endpoint, resolveSurfaceId: () => target.hostSurfaceId })
685
729
  }), { active, questions, goal, onEvent });
@@ -601,12 +601,140 @@ test("canonical receipts settle a held response and uncertain delivery stays ins
601
601
  await expect(f.primary.getByText("Keep this exact uncertain request", { exact: true })).toHaveCount(0);
602
602
  });
603
603
 
604
- test("an explicit pre-admission rejection retains original retry identity and the newer draft", options, async t => {
604
+ for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognised"]) {
605
+ test(`saved delivery preserves its actual admission fact through page reload: ${status}`, options, async t => {
606
+ const f = await fixture(t);
607
+ const messageId = `reload-${status}`;
608
+ const attach = async () => {
609
+ await f.page.evaluate(() => {
610
+ window.savedBinding = window.conversationFixture.acquireConversation({
611
+ conversationId: "chat:2", draftStorage: { storage: sessionStorage, key: "original-delivery-reload" }
612
+ });
613
+ window.conversationFixture.target("chat:2");
614
+ });
615
+ await expect.poll(() => f.page.evaluate(() => window.savedBinding.runtime.value?.available.value)).toBe(true);
616
+ };
617
+ await attach();
618
+ await f.command("mode", { mode: status === "accepted" ? "product-native-duplicate"
619
+ : status === "failed" ? "rejected" : status === "pending" ? "hold" : "uncertain" });
620
+ await f.page.evaluate(messageId => {
621
+ const runtime = window.savedBinding.runtime.value;
622
+ runtime.draft.value = "Keep my separate newer draft";
623
+ window.savedSubmission = runtime.send({ message: "Exact original saved words",
624
+ data: { clientId: "original-client", focus: { project: "original-project" } } }, { messageId });
625
+ }, messageId);
626
+ await expect.poll(async () => (await f.sent()).length).toBe(1);
627
+ if (status !== "pending") await f.page.evaluate(() => window.savedSubmission);
628
+ const unconfirmed = ["uncertain", "pending", "unrecognised"].includes(status);
629
+ await f.command("question", { id: "chat:2", text: "An unrelated completed answer.", pending: false });
630
+ if (unconfirmed) {
631
+ await f.command("history", { id: "chat:2", limit: 20, turns: [{ turnId: messageId,
632
+ user: { messageId, role: "user", text: "Exact original saved words", receipt: false }
633
+ }] });
634
+ }
635
+ await f.notify("chat:2");
636
+ await expect.poll(() => f.page.evaluate(() => window.savedBinding.runtime.value.snapshot.value.status)).toBe("ready");
637
+ if (status === "unrecognised") {
638
+ await f.page.evaluate(() => {
639
+ const saved = JSON.parse(sessionStorage.getItem("original-delivery-reload"));
640
+ saved.messages[0].status = "unrecognised";
641
+ sessionStorage.setItem("original-delivery-reload", JSON.stringify(saved));
642
+ });
643
+ }
644
+ let releaseInspection;
645
+ if (status === "uncertain") {
646
+ const gate = Promise.withResolvers();
647
+ t.after(() => gate.resolve());
648
+ releaseInspection = gate.resolve;
649
+ await f.page.route("**/deliveries/**/inspect", async route => {
650
+ await gate.promise;
651
+ await route.fulfill({ json: { status: "unknown", messageId } }).catch(() => {});
652
+ });
653
+ await f.page.evaluate(messageId => { void window.savedBinding.runtime.value.inspectDelivery(messageId); }, messageId);
654
+ await expect.poll(() => f.page.evaluate(() => JSON.parse(sessionStorage.getItem("original-delivery-reload")).messages[0].checking)).toBe(true);
655
+ }
656
+ const saved = await f.page.evaluate(() => JSON.parse(sessionStorage.getItem("original-delivery-reload")));
657
+ assert.equal(saved.messages[0].status, status);
658
+ await f.page.reload();
659
+ releaseInspection?.();
660
+ if (releaseInspection) await f.page.unroute("**/deliveries/**/inspect");
661
+ await expect(f.input).toBeEnabled();
662
+ await attach();
663
+ await expect(f.input).toHaveValue(saved.draft);
664
+ const restored = await f.page.evaluate(messageId => window.savedBinding.runtime.value.delivery.find(messageId), messageId);
665
+ assert.equal(restored.status, unconfirmed ? "uncertain" : status);
666
+ assert.equal(restored.checking, false, "an inspector from the old page cannot disable recovery on the new page");
667
+ assert.equal(restored.id, saved.messages[0].id);
668
+ assert.deepEqual(restored.payload, saved.messages[0].payload);
669
+ assert.equal((await f.sent()).length, 1, "restoration never dispatches a saved request");
670
+ assert.equal(await f.page.evaluate(() => window.savedBinding.runtime.value.canSubmit.value), !unconfirmed);
671
+ if (unconfirmed) {
672
+ const check = f.primary.getByRole("button", { name: "Check delivery", exact: true });
673
+ try { await expect(check).toBeEnabled(); }
674
+ catch (failure) {
675
+ t.diagnostic(JSON.stringify(await f.page.evaluate(() => {
676
+ const saved = window.savedBinding.runtime.value;
677
+ const primary = window.conversationFixture.current();
678
+ return { shared: saved === primary, savedIdentity: saved.identity, primaryIdentity: primary.identity,
679
+ canonical: saved.turns.value, messages: saved.delivery.state.messages,
680
+ projected: saved.delivery.turns(saved.turns.value) };
681
+ })));
682
+ await f.page.screenshot({ path: `/tmp/jskit-saved-delivery-reload-${status}-20261006.png` });
683
+ throw failure;
684
+ }
685
+ for (const action of ["Retry", "Cancel", "Edit"]) {
686
+ await expect(f.primary.getByRole("button", { name: action, exact: true })).toHaveCount(0);
687
+ }
688
+ // The fixture's original inspector matches any same-ID row. Keep the
689
+ // nonreceipt projection proof above, then inspect genuine absence.
690
+ await f.command("history", { id: "chat:2", turns: [], limit: 20 });
691
+ await f.notify("chat:2");
692
+ await expect(check).toBeEnabled();
693
+ assert.equal(await f.page.evaluate(messageId => window.savedBinding.runtime.value.delivery.find(messageId).status, messageId), "uncertain",
694
+ "removing a nonreceipt row is not evidence of rejection or admission");
695
+ await check.click();
696
+ await expect(check).toBeEnabled();
697
+ assert.equal(await f.page.evaluate(messageId => window.savedBinding.runtime.value.delivery.find(messageId).status, messageId), "uncertain");
698
+ assert.equal((await f.sent()).length, 1, "unknown inspection cannot resend or acknowledge");
699
+ if (status === "pending") await f.command("accept");
700
+ else await f.command("confirm", { id: "chat:2" });
701
+ await f.notify("chat:2");
702
+ await expect(check).toHaveCount(0);
703
+ assert.equal((await f.sent()).length, 1, "only the actual canonical receipt settles the original identity");
704
+ } else {
705
+ await expect(f.primary.getByRole("button", { name: "Check delivery", exact: true })).toHaveCount(0);
706
+ await expect(f.primary.getByRole("button", { name: "Retry", exact: true })).toHaveCount(status === "failed" ? 1 : 0);
707
+ }
708
+ });
709
+ }
710
+
711
+ for (const code of ["ACTION_VALIDATION_FAILED", "conversation_not_steerable"]) {
712
+ test(`an explicit pre-admission rejection retains original retry identity and the newer draft: ${code}`, options, async t => {
605
713
  const f = await fixture(t);
606
714
  await f.command("mode", { mode: "rejected" });
715
+ if (code === "conversation_not_steerable") {
716
+ await f.command("steering", { id: "chat:1", enabled: true });
717
+ await f.command("question", { id: "chat:1", text: "An earlier answer is still finishing.", pending: true });
718
+ await f.notify("chat:1");
719
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
720
+ await f.page.route("**/messages", async route => {
721
+ const response = await route.fetch();
722
+ await route.fulfill(response.status() === 400
723
+ ? { response, status: 409, json: { error: "The message was rejected before admission.", code } }
724
+ : { response });
725
+ });
726
+ }
607
727
  await f.input.fill("Retry only this original message");
608
728
  await f.input.press("Enter");
609
729
  await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toBeVisible();
730
+ const [rejected] = await f.sent();
731
+ assert.equal((await f.sent()).length, 1, "a definite rejection never silently resubmits steering as new work");
732
+ assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).status, rejected.input.messageId), "failed");
733
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().canSubmit.value), true);
734
+ await expect(f.primary.getByRole("button", { name: "Check delivery", exact: true })).toHaveCount(0);
735
+ if (code === "conversation_not_steerable") {
736
+ assert.equal(rejected.input.steer, true);
737
+ }
610
738
  await f.input.fill("My newer draft");
611
739
  await f.command("mode", { mode: "accepted" });
612
740
  await f.primary.getByRole("button", { name: "Retry", exact: true }).click();
@@ -619,8 +747,119 @@ test("an explicit pre-admission rejection retains original retry identity and th
619
747
  await f.primary.getByRole("button", { name: "Stop", exact: true }).click();
620
748
  await expect(f.primary.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0);
621
749
  assert.equal((await f.state()).requests.filter(request => request.suffix === "/cancel").length, 1);
750
+ if (code === "conversation_not_steerable") {
751
+ await f.command("mode", { mode: "rejected" });
752
+ for (const action of ["Cancel", "Edit"]) {
753
+ await f.command("question", { id: "chat:1", text: "An earlier answer is still finishing.", pending: true });
754
+ await f.notify("chat:1");
755
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
756
+ const text = `Known rejected steer for ${action}`;
757
+ await f.input.fill(text);
758
+ await f.input.press("Enter");
759
+ await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toBeVisible();
760
+ const request = (await f.sent()).at(-1);
761
+ assert.equal(request.input.steer, true);
762
+ await f.input.fill("Keep my newer draft");
763
+ await f.primary.getByRole("button", { name: action, exact: true }).click();
764
+ await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toHaveCount(0);
765
+ assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id), request.input.messageId), null);
766
+ await expect(f.input).toHaveValue(action === "Edit" ? `${text}\n\nKeep my newer draft` : "Keep my newer draft");
767
+ }
768
+ assert.equal((await f.sent()).length, 4, "Cancel and Edit do not dispatch another prompt");
769
+ assert.equal((await f.state()).requests.filter(request => request.suffix === "/cancel").length, 1, "failed-message recovery does not stop agent work");
770
+ }
771
+ });
772
+ }
773
+
774
+ test("opted-in nonsteerable follow-ups use the original serial queue and dispatch only after ready", options, async t => {
775
+ const f = await fixture(t);
776
+ await f.page.evaluate(() => {
777
+ window.deferredBinding = window.conversationFixture.acquireConversation({
778
+ conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true
779
+ });
780
+ window.conversationFixture.target("chat:2");
781
+ });
782
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
783
+ await expect(f.input).toBeEnabled();
784
+ await f.input.fill("Original nonsteerable request");
785
+ await f.input.press("Enter");
786
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.status)).toBe("working");
787
+ await f.page.evaluate(() => window.conversationFixture.data({ clientId: "typed", focus: { project: "captured" } }));
788
+ await f.input.fill("Typed follow-up while answering");
789
+ await f.input.press("Enter");
790
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages.length)).toBe(1);
791
+ const [typed] = await f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages);
792
+ await f.page.evaluate(() => {
793
+ window.conversationFixture.retainVoice();
794
+ window.queuedSpeech = window.conversationFixture.sendVoice("Spoken follow-up while answering", {
795
+ clientId: "voice", focus: { project: "voice-captured" }
796
+ });
797
+ window.conversationFixture.changeFocus({ project: "later" });
798
+ });
799
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages.length)).toBe(2);
800
+ const [, spoken] = await f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages);
801
+ await expect(f.primary.getByText(typed.text, { exact: true })).toHaveCount(1);
802
+ await expect(f.primary.getByText(spoken.text, { exact: true })).toHaveCount(1);
803
+ await expect(f.input).toBeEnabled();
804
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().canSubmit.value), true);
805
+ assert.equal((await f.sent()).length, 1, "both authored follow-ups wait locally without HTTP");
806
+ await f.command("finish", { id: "chat:2" });
807
+ await f.notify("chat:2");
808
+ await expect.poll(async () => (await f.sent()).length).toBe(2);
809
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.status)).toBe("working");
810
+ assert.deepEqual((await f.sent())[1].input, {
811
+ messageId: typed.id, text: typed.text, data: { clientId: "typed", focus: { project: "captured" } }
812
+ });
813
+ assert.equal((await f.sent()).length, 2, "the next follower waits for the first follow-up's real turn");
814
+ await f.command("finish", { id: "chat:2" });
815
+ await f.notify("chat:2");
816
+ await expect.poll(async () => (await f.sent()).length).toBe(3);
817
+ assert.deepEqual((await f.sent())[2].input, {
818
+ messageId: spoken.id, text: spoken.text, data: { clientId: "voice", focus: { project: "voice-captured" } }
819
+ });
820
+ assert.equal((await f.page.evaluate(() => window.queuedSpeech)).status, "accepted");
821
+ assert.equal((await f.state()).requests.some(request => request.suffix === "/cancel"), false,
822
+ "ordinary follow-ups never stop the original turn or output owner");
823
+ await f.command("steering", { id: "chat:2", enabled: true });
824
+ await f.notify("chat:2");
825
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
826
+ assert.equal((await f.page.evaluate(() => window.conversationFixture.sendVoice("Native steering remains immediate"))).status, "accepted");
827
+ assert.equal((await f.sent()).at(-1).input.steer, true);
622
828
  });
623
829
 
830
+ for (const action of ["stop", "actor", "denied"]) {
831
+ test(`opted-in local followers cannot dispatch after ${action}`, options, async t => {
832
+ const f = await fixture(t);
833
+ await f.page.evaluate(() => {
834
+ window.deferredBinding = window.conversationFixture.acquireConversation({
835
+ conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true
836
+ });
837
+ window.conversationFixture.target("chat:2");
838
+ });
839
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
840
+ await f.command("mode", { mode: "hold" });
841
+ await f.page.evaluate(() => {
842
+ window.firstDeferred = window.deferredBinding.runtime.value.send({ message: "First in-flight request" }, { messageId: "first-held" });
843
+ });
844
+ await expect.poll(async () => (await f.state()).held).toBe(1);
845
+ await f.page.evaluate(() => {
846
+ window.followerDeferred = window.deferredBinding.runtime.value.send({ message: "Local follower only" }, { messageId: "local-follower" });
847
+ });
848
+ assert.equal((await f.sent()).length, 1);
849
+ if (action === "stop") await f.page.evaluate(() => window.deferredBinding.runtime.value.cancel());
850
+ else if (action === "actor") await f.page.evaluate(() => window.conversationFixture.actor("different-actor"));
851
+ else {
852
+ await f.command("deny", { denied: true });
853
+ await f.notify("chat:2");
854
+ await expect.poll(() => f.page.evaluate(() => window.deferredBinding.runtime.value.available.value)).toBe(false);
855
+ }
856
+ await f.command("accept");
857
+ assert.equal(await f.page.evaluate(() => window.followerDeferred), false);
858
+ assert.equal((await f.sent()).length, 1, "a canceled local follower never reaches HTTP behind the original serial predecessor");
859
+ assert.equal((await f.state()).requests.filter(request => request.suffix === "/cancel").length, action === "stop" ? 1 : 0);
860
+ });
861
+ }
862
+
624
863
  test("authored application data stays captured across retry, a lost receipt and changed focus", options, async t => {
625
864
  const f = await fixture(t);
626
865
  const originalData = { clientId: "typed-client", focus: { project: "first", session: "original" } };