@jskit-ai/assistant-runtime 0.1.205 → 0.1.207

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.205",
3
+ "version": "0.1.207",
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.212",
15
+ "@jskit-ai/assistant-core": "0.1.214",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/http-runtime": "0.1.232",
20
- "@jskit-ai/http-web": "0.1.79",
21
- "@jskit-ai/kernel": "0.1.234",
22
- "@jskit-ai/realtime": "0.1.231",
23
- "@jskit-ai/shell-web": "0.1.238",
19
+ "@jskit-ai/http-runtime": "0.1.234",
20
+ "@jskit-ai/http-web": "0.1.81",
21
+ "@jskit-ai/kernel": "0.1.236",
22
+ "@jskit-ai/realtime": "0.1.233",
23
+ "@jskit-ai/shell-web": "0.1.240",
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.234"
27
+ "@jskit-ai/database-runtime": "0.1.236"
28
28
  },
29
29
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
30
30
  "jskit": {
@@ -47,7 +47,19 @@ function draftAfterAcceptedSubmission(currentDraft = "", submittedDraft = "") {
47
47
  return submitted && current.startsWith(submitted) ? current.slice(submitted.length) : current;
48
48
  }
49
49
 
50
- function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, deferWhileWorking, draftStorage, application, goalReadEnabled }) {
50
+ function resolveWorkingPolicy(snapshot, { deferWhileWorking, admitWhileWorking }) {
51
+ const resolve = (name, option) => {
52
+ const value = typeof option === "function" ? option(snapshot) : option;
53
+ if (typeof value !== "boolean") throw new TypeError(`${name} must return a boolean.`);
54
+ return value;
55
+ };
56
+ const deferred = resolve("deferWhileWorking", deferWhileWorking);
57
+ const admitted = resolve("admitWhileWorking", admitWhileWorking);
58
+ if (deferred && admitted) throw new TypeError("Choose either admitWhileWorking or deferWhileWorking.");
59
+ return { deferWhileWorking: deferred, admitWhileWorking: admitted };
60
+ }
61
+
62
+ function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, deferWhileWorking, admitWhileWorking, draftStorage, application, goalReadEnabled }) {
51
63
  const disposed = ref(false);
52
64
  const placementRevision = ref(0);
53
65
  const stopPlacement = placement.subscribe(() => { placementRevision.value += 1; });
@@ -73,7 +85,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
73
85
  const loading = ref(true);
74
86
  const stopping = ref(false);
75
87
  const goalPending = ref(false);
76
- const goalLoading = ref(false);
88
+ const goalTargetCurrent = ref(false);
77
89
  const goalError = ref("");
78
90
  const goalView = shallowRef(null);
79
91
  const goalLoadError = ref("");
@@ -101,8 +113,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
101
113
  const editable = computed(() => active.value && !accessDenied.value);
102
114
  const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
103
115
  const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
104
- const queueing = computed(() => queueWhileSending !== false && (steerable.value || deferWhileWorking));
105
- const canSubmit = computed(() => available.value && (snapshot.value.status === "ready" || steerable.value || deferWhileWorking && snapshot.value.status === "working") &&
116
+ const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
117
+ const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
118
+ const canSubmit = computed(() => available.value && (snapshot.value.status === "ready" || steerable.value || (workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking) && snapshot.value.status === "working") &&
106
119
  (queueWhileSending !== false || !delivery.state.sending) &&
107
120
  !delivery.state.messages.some(message => message.status === "uncertain"));
108
121
  const savedDraft = toValue(draftStorage);
@@ -206,6 +219,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
206
219
  if (state.segmentId !== goalSegment) {
207
220
  // Keep the open goal menu while its first native identity arrives. The
208
221
  // pending read disables commands until it supplies the new exact target.
222
+ goalTargetCurrent.value = false;
209
223
  goalReadController?.abort();
210
224
  }
211
225
  if (readsGoals.value && (initial || goalChanged || state.segmentId !== goalSegment)) void refreshGoal();
@@ -228,6 +242,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
228
242
  delivery.reset();
229
243
  goalError.value = "";
230
244
  goalView.value = null;
245
+ goalTargetCurrent.value = false;
231
246
  goalLoadError.value = "";
232
247
  goalReadController?.abort();
233
248
  }
@@ -339,8 +354,12 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
339
354
  const requestedSteering = request?.steer === true;
340
355
  if (requestedSteering && snapshot.value.capabilities?.steering !== true) return false;
341
356
  const steering = requestedSteering || steerable.value;
342
- const deferred = deferWhileWorking && !retry && !steering;
343
- if ((snapshot.value.status === "working" || delivery.state.sending) && !steering && !deferred) return false;
357
+ const policy = workingPolicy.value;
358
+ const deferred = policy.deferWhileWorking && !retry && !steering;
359
+ // An application queue can admit ordinary messages without native steering
360
+ // or waiting for ready. It still owns admission, permissions and receipts.
361
+ const appAdmission = policy.admitWhileWorking && !steering;
362
+ if ((snapshot.value.status === "working" || delivery.state.sending) && !steering && !deferred && !appAdmission) return false;
344
363
  if ((retry?.payload || payload).displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) return false;
345
364
  const captured = retry?.payload?.request ? retry.payload : { ...(retry?.payload || payload), request: payload.request || {
346
365
  text: String(payload.message || ""),
@@ -348,7 +367,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
348
367
  ...(payload.displayAttachments?.length ? { attachmentIds: payload.displayAttachments.map(file => file.attachmentId) } : {}),
349
368
  ...(steerable.value ? { steer: true } : {})
350
369
  } };
351
- return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred), deferred });
370
+ return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred || appAdmission), deferred, appAdmission });
352
371
  }
353
372
  function submitPrepared(payload, { messageId = crypto.randomUUID(), onAccepted, prepare } = {}) {
354
373
  if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
@@ -406,11 +425,11 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
406
425
  });
407
426
  }
408
427
 
409
- async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false }) {
428
+ async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false, appAdmission = false }) {
410
429
  if (pendingMessages.has(messageId)) return false;
411
430
  // Register before the original serial tail so Stop/retirement also reaches
412
431
  // a local follower whose deliver callback has not started yet.
413
- const pending = { controller: new AbortController(), started: false, dispatched: false, deferred };
432
+ const pending = { controller: new AbortController(), started: false, dispatched: false, deferred, appAdmission };
414
433
  const { controller } = pending;
415
434
  pendingMessages.set(messageId, pending);
416
435
  try {
@@ -420,7 +439,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
420
439
  async deliver(submission) {
421
440
  pending.started = true;
422
441
  try {
423
- if (controller.signal.aborted) return false;
442
+ if (controller.signal.aborted || appAdmission && (!available.value || stopping.value || !workingPolicy.value.admitWhileWorking)) return false;
424
443
  if (deferred) {
425
444
  // Refresh through the same subscription after a queued predecessor;
426
445
  // its receipt may have arrived before the working snapshot.
@@ -447,7 +466,10 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
447
466
  if (controller.signal.aborted) return false;
448
467
  if (prepare && !pending.dispatched) return { ok: false, error: failure.message,
449
468
  ...(failure.code ? { code: failure.code } : {}) };
450
- if (SEND_REJECTIONS.has(failure.code)) return { ok: false, error: failure.message,
469
+ const rejection = failure.details?.delivery;
470
+ const notSent = Number.isInteger(failure.status) && failure.status >= 400 && failure.status < 500 &&
471
+ rejection && !Array.isArray(rejection) && rejection.status === "not-sent" && rejection.messageId === messageId;
472
+ if (notSent || SEND_REJECTIONS.has(failure.code)) return { ok: false, error: failure.message,
451
473
  ...(prepare && failure.code ? { code: failure.code } : {}) };
452
474
  throw failure;
453
475
  } finally {
@@ -519,7 +541,6 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
519
541
  const capabilities = snapshot.value.capabilities;
520
542
  const controller = new AbortController();
521
543
  goalReadController = controller;
522
- goalLoading.value = true;
523
544
  const job = Promise.resolve().then(() => api.readConversationGoal(identity.conversationId, {
524
545
  signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
525
546
  })).then(result => {
@@ -529,6 +550,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
529
550
  goalView.value = result && Object.hasOwn(result, "target") ? result : {
530
551
  status: "available", goal: result, target: { segmentId: segment, capabilities }
531
552
  };
553
+ // A background read does not invalidate this observation. Its supplied
554
+ // goal target may intentionally differ from the visible chat segment.
555
+ goalTargetCurrent.value = true;
532
556
  goalLoadError.value = "";
533
557
  return true;
534
558
  }).catch(failure => {
@@ -540,7 +564,6 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
540
564
  if (goalRead !== job) return;
541
565
  goalRead = null;
542
566
  goalReadController = null;
543
- goalLoading.value = false;
544
567
  if (goalReadQueued) {
545
568
  goalReadQueued = false;
546
569
  void refreshGoal();
@@ -558,7 +581,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
558
581
  const view = goalView.value;
559
582
  const target = view?.target;
560
583
  const command = target?.capabilities?.goalCommands?.[action];
561
- if (!available.value || view?.status !== "available" || goalLoadError.value || goalPending.value || goalLoading.value ||
584
+ if (!available.value || view?.status !== "available" || goalLoadError.value || goalPending.value || !goalTargetCurrent.value ||
562
585
  !["message", "control"].includes(command?.delivery) ||
563
586
  delivery.state.messages.some(message => message.status === "uncertain")) return false;
564
587
  if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
@@ -613,7 +636,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
613
636
  tokenBudgetSupported: capabilities.goalBudgets === true,
614
637
  goal: view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
615
638
  sampledAt: Date.parse(view.goal.updatedAt) } : null,
616
- pending: goalPending.value || goalLoading.value || !available.value || delivery.state.sending ||
639
+ pending: goalPending.value || !goalTargetCurrent.value || !available.value || delivery.state.sending ||
617
640
  delivery.state.messages.some(message => message.status === "uncertain"),
618
641
  error: goalError.value,
619
642
  pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
@@ -626,7 +649,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
626
649
  if (!editable.value || stopping.value) return false;
627
650
  stopping.value = true;
628
651
  for (const pending of pendingMessages.values()) {
629
- if (pending.deferred && !pending.dispatched) pending.controller.abort();
652
+ if ((pending.deferred || pending.appAdmission) && !pending.dispatched) pending.controller.abort();
630
653
  }
631
654
  try { return await api.cancelConversation(identity.conversationId); }
632
655
  catch (failure) { receiveError(failure); return false; }
@@ -653,7 +676,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
653
676
  error, accessDenied, loading, stopping, delivery, turns,
654
677
  hasMoreBefore, loadingMore, loadMoreError, loadMore,
655
678
  send, submitPrepared, submitDraft, cancel, cancelMessage, inspectDelivery, changeGoal, refreshGoal, goalState, goalView, goalLoadError, questions,
656
- reload() { const job = subscription?.reload(); void refreshGoal(); return job; } };
679
+ reload(options) { const job = subscription?.reload(options); void refreshGoal(); return job; } };
657
680
  runtime.application = application ? application(runtime) : null;
658
681
  return runtime;
659
682
  }
@@ -724,7 +747,7 @@ function conversationBindingSetup({ socket, boundedTask = null } = {}) {
724
747
 
725
748
  function createConversationBinding({ conversationId, endpoint = "", surfaceId = "", hostSurfaceId = "", workspaceSlug,
726
749
  actorKey: suppliedActorKey, api: suppliedApi = null, socket: suppliedSocket = null, active = true, onEvent,
727
- clearDraftOn: suppliedClearDraftOn, queueWhileSending, deferWhileWorking = false, draftWhileLoading = false,
750
+ clearDraftOn: suppliedClearDraftOn, queueWhileSending, deferWhileWorking = false, admitWhileWorking = false, draftWhileLoading = false,
728
751
  draftStorage = null, application = null, boundedTask = null,
729
752
  data, attachments = null, suggestions = null, models = null, questions = null, goal = null, presentation = {} } = {}, setup) {
730
753
  const { app, routeContext, placement, workspaceScope, socket: setupSocket, config, defaults = {} } = setup ||
@@ -736,7 +759,9 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
736
759
  });
737
760
  if (!["dispatch", "accepted"].includes(clearDraftOn)) throw new TypeError("clearDraftOn must be dispatch or accepted.");
738
761
  if (queueWhileSending !== undefined && typeof queueWhileSending !== "boolean") throw new TypeError("queueWhileSending must be a boolean.");
739
- if (typeof deferWhileWorking !== "boolean") throw new TypeError("deferWhileWorking must be a boolean.");
762
+ if (!["boolean", "function"].includes(typeof deferWhileWorking)) throw new TypeError("deferWhileWorking must be a boolean.");
763
+ if (!["boolean", "function"].includes(typeof admitWhileWorking)) throw new TypeError("admitWhileWorking must be a boolean.");
764
+ resolveWorkingPolicy(null, { deferWhileWorking, admitWhileWorking });
740
765
  if (typeof draftWhileLoading !== "boolean") throw new TypeError("draftWhileLoading must be a boolean.");
741
766
  if (application !== null && typeof application !== "function") throw new TypeError("application must be a factory.");
742
767
  if (boundedTask !== null) return useBoundedTask(boundedTask, { active, data, presentation });
@@ -794,7 +819,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
794
819
  const key = JSON.stringify(["assistant", target.actorKey, target.endpoint, target.targetSurfaceId,
795
820
  target.hostSurfaceId, target.workspaceSlug, target.conversationId]);
796
821
  retained = retainAssistantConversation(app, key, readers => createConversation(target, {
797
- readers, socket, actorKey, placement, queueWhileSending, deferWhileWorking, draftStorage, application, goalReadEnabled: toValue(goal) === true,
822
+ readers, socket, actorKey, placement, queueWhileSending, deferWhileWorking, admitWhileWorking, draftStorage, application, goalReadEnabled: toValue(goal) === true,
798
823
  api: suppliedApi || defaults.api || createAssistantApi({ request: defaults.request || assistantHttpClient.request,
799
824
  resolveBasePath: () => target.endpoint, resolveSurfaceId: () => target.hostSurfaceId })
800
825
  }), { active, questions, goal, onEvent });
@@ -65,9 +65,10 @@ function subscribeAssistantConversation({
65
65
  function receiveState(snapshot, options) {
66
66
  state = snapshot;
67
67
  receiveStreaming(snapshot.streaming);
68
- publishState({ ...options, canonical: true });
68
+ publishState({ canonical: true, ...options });
69
69
  }
70
- function reload() {
70
+ function reload({ resubscribe = false } = {}) {
71
+ if (resubscribe) return subscribe({ preserveHistory: true });
71
72
  if (!listening) return subscribe();
72
73
  if (reloadInFlight) {
73
74
  reloadQueued = true;
@@ -179,7 +180,7 @@ function subscribeAssistantConversation({
179
180
  if (epoch === null) pending.push(payload);
180
181
  else deliver(payload);
181
182
  }
182
- function subscribe() {
183
+ function subscribe({ preserveHistory = false } = {}) {
183
184
  if (disposed || !socket.connected) return;
184
185
  const attempt = ++generation;
185
186
  epoch = null;
@@ -206,7 +207,7 @@ function subscribeAssistantConversation({
206
207
  return;
207
208
  }
208
209
  epoch = response.streamEpoch;
209
- receiveState(response.state, { initial: true });
210
+ receiveState(response.state, { initial: !preserveHistory, canonical: !preserveHistory });
210
211
  for (const payload of pending.sort((left, right) => left.streamRevision - right.streamRevision)) deliver(payload);
211
212
  pending = [];
212
213
  });
@@ -218,7 +218,9 @@ function createChatService({
218
218
  writer.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: request.messageId, status: "unconfirmed" });
219
219
  return { conversationId: response.meta?.conversationId, messageId: request.messageId, status: "unconfirmed" };
220
220
  }
221
- if (response.failure && !response.meta) throw new AppError(response.failure.status, response.failure.message);
221
+ if (response.failure && !response.meta) throw new AppError(response.failure.status, response.failure.message, {
222
+ code: response.failure.code, details: response.failure.details
223
+ });
222
224
  if (response.meta) writer.sendMeta(response.meta);
223
225
  if (response.answer) writer.sendAssistantMessage(response.answer);
224
226
  if (response.error) writer.sendError(response.error);
@@ -268,7 +270,9 @@ function createChatService({
268
270
  result = await executeTurn();
269
271
  } catch (error) {
270
272
  response.failure = { message: String(error?.message || "Assistant request failed."),
271
- status: Number(error?.status || error?.statusCode || 500) };
273
+ status: Number(error?.status || error?.statusCode || 500),
274
+ ...(error instanceof AppError ? { code: error.code,
275
+ ...(error.details && String(error.code).trim() !== "ACTION_PERMISSION_DENIED" ? { details: error.details } : {}) } : {}) };
272
276
  await turnRequests.update(claim, response, "failed");
273
277
  throw error;
274
278
  }
@@ -2479,3 +2479,416 @@ test("canonical loaded-history replay retains older completed output and its aut
2479
2479
  assert.equal(display.assistant.text, "Live older completed reply");
2480
2480
  assert.deepEqual(await f.sent(), []);
2481
2481
  });
2482
+
2483
+
2484
+ test("an exact host pre-admission receipt preserves one manual retry and actor and draft guards", options, async t => {
2485
+ const f = await fixture(t);
2486
+ await f.command("mode", { mode: "rejected" });
2487
+ await f.page.route("**/messages", async route => {
2488
+ const response = await route.fetch();
2489
+ const input = route.request().postDataJSON();
2490
+ await route.fulfill({ response, status: 409, json: { code: "host_capture_rejected",
2491
+ error: "The host refused this message before native admission.",
2492
+ details: { delivery: { status: "not-sent", messageId: input.messageId } } } });
2493
+ });
2494
+ await f.page.evaluate(() => window.conversationFixture.data({ originId: "captured-tab", choice: "original" }));
2495
+ await f.input.fill("Retain this exact original request");
2496
+ await f.input.press("Enter");
2497
+ await expect(f.primary.getByText("Failed: The host refused this message before native admission.", { exact: true })).toBeVisible();
2498
+ const [original] = await f.sent();
2499
+ assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).status, original.input.messageId), "failed");
2500
+ await expect(f.primary.getByRole("button", { name: "Check delivery", exact: true })).toHaveCount(0);
2501
+ await f.input.fill("Keep my newer draft");
2502
+ await f.page.evaluate(() => window.conversationFixture.data({ originId: "newer-tab", choice: "newer" }));
2503
+ assert.equal((await f.sent()).length, 1, "a rejection never retries automatically");
2504
+ await f.page.unroute("**/messages");
2505
+ await f.command("mode", { mode: "accepted" });
2506
+ await f.primary.getByRole("button", { name: "Retry", exact: true }).click();
2507
+ await expect.poll(async () => (await f.sent()).length).toBe(2);
2508
+ const requests = await f.sent();
2509
+ assert.deepEqual(requests[1].input, original.input, "manual Retry retains the ID, words and captured selection/data");
2510
+ await expect(f.input).toHaveValue("Keep my newer draft");
2511
+ await f.page.evaluate(() => {
2512
+ window.rejectedActorRuntime = window.conversationFixture.current();
2513
+ window.conversationFixture.actor("another-actor");
2514
+ });
2515
+ assert.equal(await f.page.evaluate(() => window.rejectedActorRuntime.send({ message: "Must not dispatch" },
2516
+ { messageId: "foreign-actor" })), false);
2517
+ assert.equal((await f.sent()).length, 2, "receipt metadata grants no authority to a retired actor");
2518
+ });
2519
+
2520
+ for (const kind of ["mismatched-id", "malformed-status", "missing-marker", "post-routing-failure"]) {
2521
+ test(`unconfirmed host delivery metadata remains inspection-only: ${kind}`, options, async t => {
2522
+ const f = await fixture(t);
2523
+ await f.command("mode", { mode: "rejected" });
2524
+ await f.page.route("**/messages", async route => {
2525
+ const response = await route.fetch();
2526
+ const input = route.request().postDataJSON();
2527
+ await route.fulfill({ response, status: kind === "post-routing-failure" ? 500 : 409,
2528
+ json: { code: "host_dispatch_failed", error: "Delivery cannot be confirmed.", details: { delivery: kind === "missing-marker" ? null : {
2529
+ status: kind === "malformed-status" ? "rejected" : "not-sent",
2530
+ messageId: kind === "mismatched-id" ? "another-message" : input.messageId
2531
+ } } } });
2532
+ });
2533
+ await f.input.fill("Do not resubmit unknown work");
2534
+ await f.input.press("Enter");
2535
+ const check = f.primary.getByRole("button", { name: "Check delivery", exact: true });
2536
+ await expect(check).toBeVisible();
2537
+ const [original] = await f.sent();
2538
+ await check.click();
2539
+ await expect.poll(() => f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).checking,
2540
+ original.input.messageId)).toBe(false);
2541
+ assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).status,
2542
+ original.input.messageId), "uncertain");
2543
+ await expect(f.primary.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
2544
+ assert.equal((await f.sent()).length, 1, "missing native/canonical history never proves not-sent or resubmits");
2545
+ });
2546
+ }
2547
+
2548
+ for (const width of [390, 768, 1280]) {
2549
+ test(`application admission accepts typed and spoken follow-ups during nonsteerable work at ${width}px`, options, async t => {
2550
+ const f = await fixture(t, width);
2551
+ await f.page.evaluate(() => {
2552
+ window.admissionBinding = window.conversationFixture.acquireConversation({
2553
+ conversationId: "chat:2", admitWhileWorking: true, queueWhileSending: true
2554
+ });
2555
+ window.conversationFixture.target("chat:2");
2556
+ });
2557
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
2558
+ await f.input.fill("Initial request remains working");
2559
+ await f.input.press("Enter");
2560
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.status)).toBe("working");
2561
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().steerable.value), false);
2562
+ await f.command("mode", { mode: "hold" });
2563
+ await f.page.evaluate(() => window.conversationFixture.data({ clientId: "typed", focus: { project: "captured" } }));
2564
+ await f.input.fill("Typed clarification while answering");
2565
+ await f.input.press("Enter");
2566
+ await expect.poll(async () => (await f.state()).held).toBe(1);
2567
+ const typed = (await f.sent())[1].input;
2568
+ await f.page.evaluate(() => {
2569
+ window.conversationFixture.retainVoice();
2570
+ window.admittedSpeech = window.conversationFixture.sendVoice("Spoken clarification while answering", {
2571
+ clientId: "voice", focus: { project: "voice-captured" }
2572
+ });
2573
+ window.conversationFixture.changeFocus({ project: "later" });
2574
+ });
2575
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages.length)).toBe(2);
2576
+ const spoken = (await f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages))[1];
2577
+ assert.equal((await f.sent()).length, 2, "the voice follower waits for the preceding admission receipt");
2578
+ await f.input.fill("Keep this newer draft");
2579
+ await f.command("mode", { mode: "accepted" });
2580
+ await f.command("accept", { holdResponse: true });
2581
+ await f.notify("chat:2");
2582
+ await expect.poll(async () => (await f.sent()).length).toBe(3);
2583
+ assert.deepEqual(typed, {
2584
+ messageId: typed.messageId, text: "Typed clarification while answering",
2585
+ data: { clientId: "typed", focus: { project: "captured" } }
2586
+ });
2587
+ assert.deepEqual((await f.sent())[2].input, {
2588
+ messageId: spoken.id, text: spoken.text, data: { clientId: "voice", focus: { project: "voice-captured" } }
2589
+ });
2590
+ assert.notEqual(typed.messageId, spoken.id);
2591
+ assert.equal((await f.page.evaluate(() => window.admittedSpeech)).status, "accepted");
2592
+ assert.equal((await f.state()).held, 1, "a real canonical receipt releases the queue before the old HTTP response");
2593
+ await f.command("release-response");
2594
+ await expect(f.input).toHaveValue("Keep this newer draft");
2595
+ assert.equal((await f.state()).states["chat:2"].status, "working", "no ready transition is needed for app admission");
2596
+ assert.equal((await f.state()).requests.some(request => request.suffix === "/cancel"), false);
2597
+ assert.equal((await f.sent()).some(request => Object.hasOwn(request.input, "steer")), false);
2598
+ const layout = await f.primary.evaluate(element => {
2599
+ const rect = element.getBoundingClientRect();
2600
+ return { right: rect.right, width: innerWidth, overflow: document.documentElement.scrollWidth > innerWidth };
2601
+ });
2602
+ assert.ok(layout.right <= layout.width + 1);
2603
+ assert.equal(layout.overflow, false);
2604
+ });
2605
+ }
2606
+
2607
+ for (const action of ["stop", "actor", "denied"]) {
2608
+ test(`application admission retains local-follower fences after ${action}`, options, async t => {
2609
+ const f = await fixture(t);
2610
+ await f.page.evaluate(() => {
2611
+ window.admissionBinding = window.conversationFixture.acquireConversation({
2612
+ conversationId: "chat:2", admitWhileWorking: true, queueWhileSending: true
2613
+ });
2614
+ window.conversationFixture.target("chat:2");
2615
+ });
2616
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
2617
+ await f.command("mode", { mode: "hold" });
2618
+ await f.page.evaluate(() => {
2619
+ window.firstAdmission = window.admissionBinding.runtime.value.send({ message: "First held admission" }, { messageId: "first-app-held" });
2620
+ });
2621
+ await expect.poll(async () => (await f.state()).held).toBe(1);
2622
+ await f.page.evaluate(() => {
2623
+ window.localAdmission = window.admissionBinding.runtime.value.send({ message: "Undispatched follower" }, { messageId: "local-app-follower" });
2624
+ });
2625
+ assert.equal((await f.sent()).length, 1);
2626
+ if (action === "stop") await f.page.evaluate(() => window.admissionBinding.runtime.value.cancel());
2627
+ else if (action === "actor") await f.page.evaluate(() => window.conversationFixture.actor("different-actor"));
2628
+ else {
2629
+ await f.command("deny", { denied: true });
2630
+ await f.notify("chat:2");
2631
+ await expect.poll(() => f.page.evaluate(() => window.admissionBinding.runtime.value.available.value)).toBe(false);
2632
+ }
2633
+ await f.command("accept");
2634
+ assert.equal(await f.page.evaluate(() => window.localAdmission), false);
2635
+ assert.equal((await f.sent()).length, 1, "local followers never dispatch after their scope is stopped or retired");
2636
+ assert.equal((await f.state()).requests.filter(request => request.suffix === "/cancel").length, action === "stop" ? 1 : 0);
2637
+ });
2638
+ }
2639
+
2640
+ test("application admission keeps unknown delivery inspection-only and rejects conflicting policies", options, async t => {
2641
+ const f = await fixture(t);
2642
+ const invalid = await f.page.evaluate(() => [
2643
+ { admitWhileWorking: "yes" }, { admitWhileWorking: true, deferWhileWorking: true }
2644
+ ].map(options => {
2645
+ try { window.conversationFixture.acquireConversation({ conversationId: "chat:2", ...options }); }
2646
+ catch (failure) { return { name: failure.name, message: failure.message }; }
2647
+ return null;
2648
+ }));
2649
+ assert.deepEqual(invalid, [
2650
+ { name: "TypeError", message: "admitWhileWorking must be a boolean." },
2651
+ { name: "TypeError", message: "Choose either admitWhileWorking or deferWhileWorking." }
2652
+ ]);
2653
+ await f.page.evaluate(() => {
2654
+ window.admissionBinding = window.conversationFixture.acquireConversation({
2655
+ conversationId: "chat:2", admitWhileWorking: true, queueWhileSending: true
2656
+ });
2657
+ window.conversationFixture.target("chat:2");
2658
+ });
2659
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
2660
+ await f.command("question", { id: "chat:2", text: "Still working", pending: true });
2661
+ await f.notify("chat:2");
2662
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.status)).toBe("working");
2663
+ await f.command("mode", { mode: "uncertain" });
2664
+ await f.input.fill("Do not repeat unknown work");
2665
+ await f.input.press("Enter");
2666
+ const check = f.primary.getByRole("button", { name: "Check delivery", exact: true });
2667
+ await expect(check).toBeVisible();
2668
+ const [original] = await f.sent();
2669
+ await f.input.fill("Keep my newer draft");
2670
+ assert.equal(await f.page.evaluate(() => window.admissionBinding.runtime.value.send({ message: "Cannot bypass uncertainty" })), false);
2671
+ await check.click();
2672
+ await expect.poll(() => f.page.evaluate(id => window.admissionBinding.runtime.value.delivery.find(id).checking,
2673
+ original.input.messageId)).toBe(false);
2674
+ assert.equal((await f.sent()).length, 1);
2675
+ await expect(f.primary.getByRole("button", { name: "Retry", exact: true })).toHaveCount(0);
2676
+ await expect(f.input).toHaveValue("Keep my newer draft");
2677
+ });
2678
+
2679
+ // Keep the original HTTP/browser fixture; its canonical read carries the real
2680
+ // engine independently of a native-looking product model-selection label.
2681
+ async function modeAwareAdmissionFixture(t) {
2682
+ const f = await fixture(t, 390);
2683
+ let engine;
2684
+ await f.page.route(/\/api\/assistant\/home\/conversations\/chat%3A2(?:\?.*)?$/u, async route => {
2685
+ const response = await route.fetch();
2686
+ const state = await response.json();
2687
+ await route.fulfill({ response, json: { ...state,
2688
+ engine, assistantSelection: { engineId: "codex" } } });
2689
+ });
2690
+ await f.page.evaluate(() => {
2691
+ const native = snapshot => ["codex", "claude", "opencode"].includes(snapshot?.engine);
2692
+ window.modeBinding = window.conversationFixture.acquireConversation({ conversationId: "chat:2",
2693
+ admitWhileWorking: native, deferWhileWorking: snapshot => !native(snapshot), queueWhileSending: true });
2694
+ window.conversationFixture.target("chat:2");
2695
+ window.modeRuntime = window.modeBinding.runtime.value;
2696
+ });
2697
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
2698
+ await expect.poll(() => f.page.evaluate(() => window.modeRuntime.available.value)).toBe(true);
2699
+ return { ...f, async engine(value) {
2700
+ engine = value;
2701
+ await f.notify("chat:2");
2702
+ await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value?.engine || null)).toBe(value || null);
2703
+ } };
2704
+ }
2705
+
2706
+ test("mode-aware application admission retains typed and spoken native and API policies at one identity", options, async t => {
2707
+ const f = await modeAwareAdmissionFixture(t);
2708
+ await f.command("question", { id: "chat:2", text: "Unresolved backend is working", pending: true });
2709
+ await f.notify("chat:2");
2710
+ await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value.status)).toBe("working");
2711
+ await f.page.evaluate(() => { window.unresolvedAdmission = window.modeRuntime.send({ message: "Wait for original readiness" }); });
2712
+ await expect.poll(() => f.page.evaluate(() => window.modeRuntime.delivery.state.messages.length)).toBe(1);
2713
+ assert.equal((await f.sent()).length, 0, "An unknown backend retains existing deferral");
2714
+ await f.engine("codex");
2715
+ assert.equal((await f.sent()).length, 0, "A captured deferred request is never promoted to native admission");
2716
+ await f.command("question", { id: "chat:2", text: "Unresolved backend is working", pending: false });
2717
+ await f.notify("chat:2");
2718
+ await expect.poll(async () => (await f.sent()).length).toBe(1);
2719
+ assert.equal((await f.page.evaluate(() => window.unresolvedAdmission)).status, "accepted");
2720
+ await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value.status)).toBe("working");
2721
+ await f.command("mode", { mode: "hold" });
2722
+ await f.input.fill("Native typed clarification");
2723
+ await f.input.press("Enter");
2724
+ await expect.poll(async () => (await f.state()).held).toBe(1);
2725
+ await f.page.evaluate(() => {
2726
+ window.conversationFixture.retainVoice();
2727
+ window.nativeVoiceAdmission = window.conversationFixture.sendVoice("Native spoken clarification");
2728
+ });
2729
+ await expect.poll(() => f.page.evaluate(() => window.modeRuntime.delivery.state.messages.length)).toBe(2);
2730
+ assert.equal((await f.sent()).length, 2, "Voice waits for the same real predecessor receipt");
2731
+ await f.input.fill("Retain my draft across backend selection");
2732
+ await f.command("mode", { mode: "accepted" });
2733
+ await f.command("accept", { holdResponse: true });
2734
+ await f.notify("chat:2");
2735
+ await expect.poll(async () => (await f.sent()).length).toBe(3);
2736
+ assert.equal((await f.page.evaluate(() => window.nativeVoiceAdmission)).status, "accepted");
2737
+ assert.equal((await f.state()).states["chat:2"].status, "working");
2738
+ await f.command("release-response");
2739
+ await f.command("finish", { id: "chat:2" });
2740
+ await f.engine("api");
2741
+ await expect(f.input).toHaveValue("Retain my draft across backend selection");
2742
+ await f.command("question", { id: "chat:2", text: "API is still answering", pending: true });
2743
+ await f.notify("chat:2");
2744
+ await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value.status)).toBe("working");
2745
+ await f.input.fill("API typed clarification");
2746
+ await f.input.press("Enter");
2747
+ await f.page.evaluate(() => { window.apiVoiceAdmission = window.conversationFixture.sendVoice("API spoken clarification"); });
2748
+ await expect.poll(() => f.page.evaluate(() => window.modeRuntime.delivery.state.messages.length)).toBe(2);
2749
+ assert.equal((await f.sent()).length, 3, "A native-looking label cannot bypass the real API mode's ready wait");
2750
+ await f.command("question", { id: "chat:2", text: "API is still answering", pending: false });
2751
+ await f.notify("chat:2");
2752
+ await expect.poll(async () => (await f.sent()).length).toBe(4);
2753
+ assert.equal((await f.sent())[3].input.text, "API typed clarification");
2754
+ await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value.status)).toBe("working");
2755
+ assert.equal((await f.sent()).length, 4, "The second API follower retains the preceding-turn barrier");
2756
+ await f.command("finish", { id: "chat:2" });
2757
+ await f.notify("chat:2");
2758
+ await expect.poll(async () => (await f.sent()).length).toBe(5);
2759
+ assert.equal((await f.page.evaluate(() => window.apiVoiceAdmission)).status, "accepted");
2760
+ await f.command("finish", { id: "chat:2" });
2761
+ await f.engine("opencode");
2762
+ await f.command("question", { id: "chat:2", text: "Native work resumes", pending: true });
2763
+ await f.notify("chat:2");
2764
+ await expect.poll(() => f.page.evaluate(() => window.modeRuntime.snapshot.value.status)).toBe("working");
2765
+ assert.equal((await f.page.evaluate(() => window.conversationFixture.sendVoice("Native admission after returning"))).status, "accepted");
2766
+ assert.equal((await f.sent()).length, 6);
2767
+ assert.equal(await f.page.evaluate(() => window.modeRuntime === window.modeBinding.runtime.value &&
2768
+ window.modeRuntime === window.conversationFixture.current()), true);
2769
+ assert.equal((await f.page.evaluate(() => window.conversationFixture.voiceState())).id, "chat:2");
2770
+ assert.equal((await f.sent()).some(request => Object.hasOwn(request.input, "steer")), false);
2771
+ assert.equal((await f.state()).requests.some(request => request.suffix === "/cancel"), false);
2772
+ });
2773
+
2774
+ test("mode-aware application admission refuses a captured follower after the actual backend changes", options, async t => {
2775
+ const f = await modeAwareAdmissionFixture(t);
2776
+ await f.engine("claude");
2777
+ await f.command("mode", { mode: "hold" });
2778
+ await f.page.evaluate(() => { window.modeFirst = window.modeRuntime.send({ message: "Original in-flight admission" }); });
2779
+ await expect.poll(async () => (await f.state()).held).toBe(1);
2780
+ await f.page.evaluate(() => { window.modeFollower = window.modeRuntime.send({ message: "Local native follower" }); });
2781
+ await expect.poll(() => f.page.evaluate(() => window.modeRuntime.delivery.state.messages.length)).toBe(2);
2782
+ await f.engine("api");
2783
+ await f.command("accept");
2784
+ assert.equal(await f.page.evaluate(() => window.modeFollower), false);
2785
+ assert.equal((await f.sent()).length, 1, "A captured native policy cannot dispatch into a changed API owner");
2786
+ assert.equal((await f.state()).requests.some(request => request.suffix === "/cancel"), false);
2787
+ });
2788
+
2789
+ test("mode-aware application admission rejects nonboolean asynchronous and conflicting predicates", options, async t => {
2790
+ const f = await fixture(t);
2791
+ const failures = await f.page.evaluate(() => [
2792
+ { admitWhileWorking: () => "yes" },
2793
+ { deferWhileWorking: () => Promise.resolve(true) },
2794
+ { admitWhileWorking: () => true, deferWhileWorking: () => true }
2795
+ ].map(policy => {
2796
+ try { window.conversationFixture.acquireConversation({ conversationId: "chat:2", ...policy }); }
2797
+ catch (error) { return { name: error.name, message: error.message }; }
2798
+ return null;
2799
+ }));
2800
+ assert.deepEqual(failures, [
2801
+ { name: "TypeError", message: "admitWhileWorking must return a boolean." },
2802
+ { name: "TypeError", message: "deferWhileWorking must return a boolean." },
2803
+ { name: "TypeError", message: "Choose either admitWhileWorking or deferWhileWorking." }
2804
+ ]);
2805
+ assert.deepEqual(await f.sent(), []);
2806
+ });
2807
+
2808
+ test("a background goal read keeps current pinned controls available and serializes actual commands", options, async t => {
2809
+ const f = await fixture(t);
2810
+ const view = { status: "available", goal: { id: "current-goal", objective: "Keep working", status: "active" },
2811
+ target: { segmentId: "pinned-native-goal", capabilities: { goals: true,
2812
+ goalCommands: { pause: { delivery: "control", interruptsTurn: false }, cancel: { delivery: "control", interruptsTurn: false } } } } };
2813
+ const commands = [];
2814
+ let holdRead = false;
2815
+ let releaseRead;
2816
+ let releaseCommand;
2817
+ await f.page.route("**/conversations/*/goal", async route => {
2818
+ if (route.request().method() === "GET") {
2819
+ if (holdRead) await new Promise(resolve => { releaseRead = resolve; });
2820
+ return route.fulfill({ json: view });
2821
+ }
2822
+ commands.push(route.request().postDataJSON());
2823
+ await new Promise(resolve => { releaseCommand = resolve; });
2824
+ return route.fulfill({ json: { ok: true } });
2825
+ });
2826
+ try {
2827
+ await f.page.evaluate(() => window.conversationFixture.eventReaders([{ id: "goal-reader", goal: true }]));
2828
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value?.pending)).toBe(false);
2829
+ await f.primary.getByRole("button", { name: "Goal running", exact: true }).click();
2830
+ holdRead = true;
2831
+ await f.page.evaluate(() => { window.fixtureBackgroundGoalRead = window.conversationFixture.current().refreshGoal(); });
2832
+ await expect.poll(() => Boolean(releaseRead)).toBe(true);
2833
+ const pause = f.page.getByRole("button", { name: "Pause goal", exact: true });
2834
+ await expect(pause).toBeEnabled();
2835
+ await pause.click();
2836
+ await expect.poll(() => commands.length).toBe(1);
2837
+ assert.deepEqual(commands[0], { action: "pause", expectedSegmentId: "pinned-native-goal", expectedGoalId: "current-goal" });
2838
+ await expect(f.page.getByRole("button", { name: "Cancel goal", exact: true })).toBeDisabled();
2839
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("cancel")), false);
2840
+ releaseCommand();
2841
+ await expect(pause).toBeEnabled();
2842
+ assert.equal(commands.length, 1, "Only the actual Pause command was admitted");
2843
+ holdRead = false;
2844
+ releaseRead();
2845
+ await f.page.evaluate(() => window.fixtureBackgroundGoalRead);
2846
+ assert.deepEqual(await f.sent(), []);
2847
+ } finally {
2848
+ holdRead = false;
2849
+ releaseCommand?.();
2850
+ releaseRead?.();
2851
+ }
2852
+ });
2853
+
2854
+ test("changing the observed native segment blocks cached goal controls until the replacement read completes", options, async t => {
2855
+ const f = await fixture(t);
2856
+ const commands = [];
2857
+ const view = { status: "available", goal: { id: "old-goal", objective: "Old goal", status: "active" },
2858
+ target: { segmentId: "old-pinned-target", capabilities: { goals: true,
2859
+ goalCommands: { pause: { delivery: "control", interruptsTurn: false } } } } };
2860
+ let held = false;
2861
+ let releaseRead;
2862
+ await f.page.route("**/conversations/*/goal", async route => {
2863
+ if (route.request().method() === "GET") {
2864
+ if (held) await new Promise(resolve => { releaseRead = resolve; });
2865
+ return route.fulfill({ json: view });
2866
+ }
2867
+ commands.push(route.request().postDataJSON());
2868
+ return route.fulfill({ json: { ok: true } });
2869
+ });
2870
+ try {
2871
+ await f.page.evaluate(() => window.conversationFixture.eventReaders([{ id: "goal-reader", goal: true }]));
2872
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value?.pending)).toBe(false);
2873
+ held = true;
2874
+ await f.command("goals", { id: "chat:1", segmentId: "replacement-native-observation", budgets: false,
2875
+ commands: view.target.capabilities.goalCommands });
2876
+ await f.notify("chat:1");
2877
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.segmentId)).toBe("replacement-native-observation");
2878
+ await expect.poll(() => Boolean(releaseRead)).toBe(true);
2879
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().goalState.value.pending), true);
2880
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("pause")), false);
2881
+ assert.deepEqual(commands, [], "A previous observation cannot authorize a command for the replacement thread");
2882
+ view.goal.id = "replacement-goal";
2883
+ view.target.segmentId = "replacement-pinned-target";
2884
+ held = false;
2885
+ releaseRead();
2886
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value.pending)).toBe(false);
2887
+ assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("pause")), { ok: true });
2888
+ assert.deepEqual(commands, [{ action: "pause", expectedSegmentId: "replacement-pinned-target", expectedGoalId: "replacement-goal" }]);
2889
+ assert.deepEqual(await f.sent(), []);
2890
+ } finally {
2891
+ held = false;
2892
+ releaseRead?.();
2893
+ }
2894
+ });
@@ -736,7 +736,7 @@ test("removing the integration releases every observer and callback while the sh
736
736
  assert.equal(f.serverSockets.size, 0, "Provider shutdown also runs unrelated connection cleanup");
737
737
  });
738
738
 
739
- async function hostSubscriptionFixture(t, authService = null) {
739
+ async function hostSubscriptionFixture(t, authService = null, conversationRuntime = null) {
740
740
  const changes = new EventEmitter();
741
741
  const notify = () => changes.emit("change");
742
742
  const listeners = new Set();
@@ -753,7 +753,7 @@ async function hostSubscriptionFixture(t, authService = null) {
753
753
  notify();
754
754
  return () => { listeners.delete(listener); notify(); };
755
755
  } };
756
- const definitions = createAssistantActions({ config, conversationRuntime: { async open() { return conversation; } } });
756
+ const definitions = createAssistantActions({ config, conversationRuntime: conversationRuntime || { async open() { return conversation; } } });
757
757
  const original = definitions.find(definition => definition.id === actionIds.conversationSubscribe);
758
758
  const hostActionId = "test.host.conversation.subscribe";
759
759
  actions.register({ contributorId: "test.host", domain: "assistant", actions: [...definitions, {
@@ -990,3 +990,115 @@ test("denied Reload before the initial acknowledgement retires only that pending
990
990
  assert.equal(reads, 1, "Recovery uses fresh subscription admission rather than repeating the old read or inference");
991
991
  assert.deepEqual(errors, [failure]);
992
992
  });
993
+
994
+
995
+ test("retargeting a retained logical observer uses the current engine namespace and original same-ID subscription owner", { timeout: 20_000 }, async t => {
996
+ let selected = "opencode";
997
+ let allowed = true;
998
+ const states = new Map(["opencode", "codex"].map(engine => [engine, {
999
+ engine, configuration: {}, status: "ready", conversationLog: [], threadId: `${engine}-thread`
1000
+ }]));
1001
+ const oldMessage = { messageId: "old-overlay", role: "assistant", turnId: "old-turn", status: "inProgress", text: "Old engine overlay" };
1002
+ const streams = new Map([["opencode", { revision: 100, messages: [oldMessage] }], ["codex", { revision: 0, messages: [] }]]);
1003
+ const originalListeners = new Map();
1004
+ let sends = 0;
1005
+ let active = 0;
1006
+ let maximum = 0;
1007
+ const commands = { async send() { sends += 1; }, async cancel() {}, async select() {}, async inspectDelivery() {} };
1008
+ const runtime = createConversationRuntime({ authorize: () => allowed, host: {
1009
+ async conversation({ id, context }) {
1010
+ const engine = context.selected;
1011
+ return { namespace: `${engine}:${id}`, sessionId: id, engine, commands,
1012
+ read: () => states.get(engine), readStream: () => streams.get(engine) };
1013
+ }
1014
+ } });
1015
+ t.after(() => runtime.close());
1016
+ async function current() { return runtime.open({ id: "host-conversation", context: { selected } }); }
1017
+ const f = await hostSubscriptionFixture(t, null, { async open() {
1018
+ return { read: async () => (await current()).read(), async subscribe(listener) {
1019
+ const engine = selected;
1020
+ const release = await (await current()).subscribe(listener);
1021
+ originalListeners.set(engine, listener);
1022
+ active += 1;
1023
+ maximum = Math.max(maximum, active);
1024
+ return () => { release(); active -= 1; };
1025
+ } };
1026
+ } });
1027
+ f.registerHost();
1028
+ const socket = await f.connect();
1029
+ const requests = [];
1030
+ const timeout = socket.timeout.bind(socket);
1031
+ socket.timeout = milliseconds => {
1032
+ const target = timeout(milliseconds);
1033
+ return { emit(name, input, acknowledge) {
1034
+ requests.push({ name, input });
1035
+ return target.emit(name, input, acknowledge);
1036
+ } };
1037
+ };
1038
+ const loaded = [];
1039
+ const errors = [];
1040
+ const updates = [];
1041
+ const release = subscribeAssistantConversation({ socket, conversationId: "host-conversation",
1042
+ targetSurfaceId: "home", hostSurfaceId: "home", read: async () => (await current()).read(),
1043
+ onState(state, options) { loaded.push({ state, options }); f.changes.emit("change"); },
1044
+ onEvent(event) { updates.push(event); f.changes.emit("change"); },
1045
+ onError(error) { errors.push(error); f.changes.emit("change"); } });
1046
+ t.after(release);
1047
+ await until(f.changes, () => loaded.length === 1);
1048
+ assert.equal(loaded[0].state.engine, "opencode");
1049
+ assert.equal(loaded[0].options.initial, true);
1050
+ assert.equal(loaded[0].state.streaming.revision, 100);
1051
+ assert.ok(loaded[0].state.turns.some(turn => turn.assistant?.text === oldMessage.text));
1052
+ selected = "codex";
1053
+ await release.reload();
1054
+ assert.equal(loaded.at(-1).state.engine, "codex", "A canonical read can already use the successor owner");
1055
+ assert.equal(f.calls.length, 1, "Ordinary Reload does not retarget the original observer");
1056
+ await runtime.publishNative({ namespace: "codex:host-conversation", sessionId: "host-conversation", event: { type: "phase", phase: "working" } });
1057
+ assert.equal(updates.length, 0, "The new engine namespace has no observer before retargeting");
1058
+
1059
+ release.reload({ resubscribe: true });
1060
+ await until(f.changes, () => loaded.length === 3);
1061
+ assert.equal(f.calls.length, 2);
1062
+ assert.equal(active, 1);
1063
+ assert.equal(maximum, 1, "The original server releases the predecessor before attaching its successor");
1064
+ assert.deepEqual(loaded.at(-1).options, { initial: false, canonical: false }, "Retarget snapshot preserves the mounted loaded history");
1065
+ assert.equal(loaded.at(-1).state.streaming.revision, 0, "The original epoch transition resets the predecessor's high stream revision");
1066
+ assert.equal(loaded.at(-1).state.turns.some(turn => turn.assistant?.text === oldMessage.text), false);
1067
+ originalListeners.get("opencode")({ type: "phase", phase: "obsolete" });
1068
+ originalListeners.get("opencode")({ type: "message", ...oldMessage, text: "Late foreign overlay",
1069
+ streaming: { revision: 200, messages: [{ ...oldMessage, text: "Late foreign overlay" }] } });
1070
+ const partial = { messageId: "answer", role: "assistant", turnId: "000001", status: "inProgress", text: "New engine partial" };
1071
+ streams.set("codex", { revision: 1, messages: [partial] });
1072
+ await runtime.publishNative({ namespace: "codex:host-conversation", sessionId: "host-conversation", event: {
1073
+ payload: { conversationStream: streams.get("codex") }
1074
+ } });
1075
+ await until(f.changes, () => loaded.at(-1).state.turns.some(turn => turn.assistant?.text === partial.text));
1076
+ assert.equal(loaded.at(-1).state.streaming.revision, 1);
1077
+ assert.equal(loaded.at(-1).state.turns.some(turn => turn.assistant?.text === "Late foreign overlay"), false);
1078
+ await runtime.publishNative({ namespace: "codex:host-conversation", sessionId: "host-conversation", event: { type: "phase", phase: "working" } });
1079
+ await until(f.changes, () => updates.some(event => event.phase === "working"));
1080
+ assert.equal(requests.length, 2);
1081
+ assert.ok(requests.every(request => request.name === ASSISTANT_CONVERSATION_SUBSCRIBE));
1082
+ assert.equal(requests[1].input.subscriptionId, requests[0].input.subscriptionId);
1083
+ assert.equal(f.published.at(-1).realtime.payload.subscriptionId, requests[0].input.subscriptionId);
1084
+ assert.equal(updates.some(event => event.phase === "obsolete"), false, "Late retired observer callbacks cannot publish");
1085
+ const answer = { messageId: "answer", role: "assistant", text: "Final from the selected engine" };
1086
+ states.set("codex", { ...states.get("codex"), status: "ready", conversationLog: [{ turnId: "000001", messages: [answer], assistant: answer }] });
1087
+ streams.set("codex", { revision: 2, messages: [] });
1088
+ await runtime.publishNative({ namespace: "codex:host-conversation", sessionId: "host-conversation", event: {
1089
+ payload: { conversationLogPatch: { type: "upsert-turn", turn: states.get("codex").conversationLog[0] },
1090
+ conversationStream: streams.get("codex"), agentSession: { turn: { active: false } } }
1091
+ } });
1092
+ await until(f.changes, () => loaded.at(-1).state.status === "ready" && loaded.at(-1).state.turns.some(turn => turn.assistant?.text === answer.text));
1093
+ assert.equal(sends, 0, "Observer refresh and final-state recovery never admit inference");
1094
+ assert.deepEqual(errors, []);
1095
+ allowed = false;
1096
+ release.reload({ resubscribe: true });
1097
+ await until(f.changes, () => errors.length === 1);
1098
+ assert.equal(active, 0, "Revoked access releases the predecessor and cannot attach another observer");
1099
+ assert.equal(errors[0].statusCode, 403);
1100
+ release();
1101
+ release.reload({ resubscribe: true });
1102
+ assert.equal(active, 0);
1103
+ assert.equal(sends, 0);
1104
+ });
@@ -4,6 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import knex from "knex";
7
+ import { AppError } from "@jskit-ai/kernel/server/runtime";
7
8
  import { createConversationTranscript } from "@jskit-ai/assistant-core/server/conversation";
8
9
  import { verifyConversationStorageContract } from "@jskit-ai/assistant-core/testing/conversation-storage";
9
10
  import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
@@ -203,3 +204,221 @@ test("the SQL facade uses common inference and durable tool rows while supplied
203
204
  { code: "conversation_forbidden" });
204
205
  }
205
206
  });
207
+
208
+ test("the SQL facade replays historical inspection failures with their exact public code and row IDs without executing work", async t => {
209
+ const f = await fixture(t);
210
+ const conversation = await f.conversation();
211
+ const first = await f.messagesRepository.create({ conversationId: conversation.id, role: "user",
212
+ clientMessageSid: "first-legacy-question", contentText: "First" });
213
+ const next = await f.messagesRepository.create({ conversationId: conversation.id, role: "user",
214
+ clientMessageSid: "next-legacy-question", contentText: "Next" });
215
+ await f.messagesRepository.create({ conversationId: conversation.id, role: "assistant", contentText: "Unknown owner" });
216
+ const unchanged = await rows(f.db);
217
+ let inference = 0;
218
+ let effects = 0;
219
+ const aiClientFactory = { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test",
220
+ createChatCompletionStream() { inference++; assert.fail("Historical inspection cannot invoke inference"); } }) };
221
+ const catalog = { resolveToolSet: () => ({ tools: [] }),
222
+ executeToolCall() { effects++; assert.fail("Historical inspection cannot invoke application effects"); } };
223
+ const runtime = createAssistantConversationRuntime({ ...f, aiClientFactory, toolCatalog: catalog });
224
+ t.after(() => runtime.close());
225
+ const turnRequests = createTurnRequestsRepository(f.db);
226
+ const dependencies = { conversationRuntime: runtime, aiClientFactory, serviceToolCatalog: catalog,
227
+ transcriptService: createTranscriptService(f), turnRequests,
228
+ assistantConfigService: { resolveSystemPrompt: async () => "Read the supplied context" },
229
+ appConfig: { surfaceDefinitions: { assistant: { enabled: true, requiresWorkspace: false } },
230
+ assistantSurfaces: { assistant: { settingsSurfaceId: "assistant", configScope: "global" } } } };
231
+ const input = { targetSurfaceId: "assistant", conversationId: conversation.id,
232
+ messageId: "inspect-legacy-history", input: "Continue", history: [] };
233
+ const events = [];
234
+ const options = { context: { actor: { id: "1" } }, streamWriter: Object.fromEntries(
235
+ ["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"]
236
+ .map(name => [name, event => events.push({ name, event })])) };
237
+ const expectedDetails = { issues: [{ code: "overlapping_historical_turns", rowIds: [first.id, next.id] }] };
238
+ const requireInspection = error => {
239
+ assert.equal(error instanceof AppError, true);
240
+ assert.equal(error.status, 409);
241
+ assert.equal(error.code, "assistant_history_inspection_required");
242
+ assert.deepEqual(error.details, expectedDetails);
243
+ return true;
244
+ };
245
+ await assert.rejects(createChatService(dependencies).streamChat(input, options), requireInspection);
246
+ const receipt = await f.db("assistant_turn_requests").first();
247
+ assert.equal(receipt.status, "failed");
248
+ const restarted = createChatService({ ...dependencies, turnRequests: createTurnRequestsRepository(f.db) });
249
+ await assert.rejects(restarted.streamChat(input, options), requireInspection);
250
+ assert.deepEqual(JSON.parse(receipt.response_json).failure, {
251
+ message: "This conversation has ambiguous historical SQL rows. Inspect the reported rows offline before using the common runtime; no history was changed.",
252
+ status: 409, code: "assistant_history_inspection_required", details: expectedDetails
253
+ });
254
+ assert.deepEqual(await f.db("assistant_turn_requests"), [receipt], "Replay preserves the original request claim");
255
+ assert.deepEqual(await rows(f.db), unchanged, "Inspection and replay leave historical rows and metadata untouched");
256
+ assert.deepEqual(events, [], "The refused request never starts a stream");
257
+ assert.equal(inference, 0);
258
+ assert.equal(effects, 0);
259
+
260
+ const oldFailure = { message: "Previously recorded failure", status: 409 };
261
+ const oldInput = { ...input, messageId: "old-failure-receipt" };
262
+ const oldClaim = await turnRequests.claim({ actorUserId: "1", surfaceId: "assistant", workspaceId: null },
263
+ { conversationId: conversation.id, messageId: oldInput.messageId, input: oldInput.input, history: [], integrationId: "" });
264
+ await turnRequests.update(oldClaim, { failure: oldFailure }, "failed");
265
+ await assert.rejects(restarted.streamChat(oldInput, options), error => {
266
+ assert.equal(error.status, oldFailure.status);
267
+ assert.equal(error.message, oldFailure.message);
268
+ assert.equal(error.code, "APP_ERROR");
269
+ assert.equal(error.details, undefined);
270
+ return true;
271
+ });
272
+ const oldSaved = await turnRequests.find({ actorUserId: "1", surfaceId: "assistant", workspaceId: null }, oldInput.messageId);
273
+ assert.deepEqual(oldSaved.response, { failure: oldFailure }, "Replay does not backfill historical failure receipts");
274
+
275
+ for (const [index, failure] of [
276
+ new AppError(403, "Permission denied", { code: "ACTION_PERMISSION_DENIED", details: { privateRule: "private-permission-probe" } }),
277
+ Object.assign(new Error("Request failed"), { status: 503, code: "PRIVATE_ENGINE_ERROR", details: { privateRule: "private-permission-probe" } })
278
+ ].entries()) {
279
+ const hiddenInput = { ...input, messageId: `private-failure-${index}` };
280
+ const denied = createChatService({ ...dependencies, assistantConfigService: {
281
+ resolveSystemPrompt() { throw failure; }
282
+ } });
283
+ await assert.rejects(denied.streamChat(hiddenInput, options), error => error === failure);
284
+ const saved = await turnRequests.find({ actorUserId: "1", surfaceId: "assistant", workspaceId: null }, hiddenInput.messageId);
285
+ assert.deepEqual(saved.response.failure, { message: failure.message, status: failure.status,
286
+ ...(failure instanceof AppError ? { code: "ACTION_PERMISSION_DENIED" } : {}) });
287
+ assert.doesNotMatch(JSON.stringify(saved.response), /private-permission-probe|PRIVATE_ENGINE_ERROR|stack/u);
288
+ await assert.rejects(restarted.streamChat(hiddenInput, options), error => {
289
+ assert.equal(error.status, failure.status);
290
+ assert.equal(error.code, failure instanceof AppError ? "ACTION_PERMISSION_DENIED" : "APP_ERROR");
291
+ assert.equal(error.details, undefined);
292
+ return true;
293
+ });
294
+ }
295
+ assert.equal(inference, 0);
296
+ assert.equal(effects, 0);
297
+ assert.deepEqual(await rows(f.db), unchanged);
298
+ });
299
+
300
+ for (const shape of ["orphan-output", "orphan-tool-result", "unconfirmed-tool"]) {
301
+ test(`the original SQL facade retains ${shape} history and admits only an authorized fresh conversation`, async t => {
302
+ const f = await fixture(t);
303
+ const conversation = await f.conversationsRepository.create({ createdByUserId: "1", surfaceId: "assistant",
304
+ workspaceId: "77", metadata: { retainedHistory: shape } });
305
+ const fields = { conversationId: conversation.id, workspaceId: "77", actorUserId: "1", role: "assistant", kind: "chat" };
306
+ if (shape !== "orphan-output") {
307
+ await f.messagesRepository.create({ ...fields, role: "user", clientMessageSid: "legacy-question", contentText: "Existing question" });
308
+ }
309
+ const offending = await f.messagesRepository.create({ ...fields,
310
+ ...(shape === "orphan-output" ? { contentText: "Answer without an authored row" }
311
+ : { kind: shape === "orphan-tool-result" ? "tool_result" : "tool_call",
312
+ contentText: shape === "orphan-tool-result" ? '{"ok":true,"result":{"found":1}}' : "{}",
313
+ metadata: { toolCallId: "legacy-operation", tool: "inventory_lookup" } }) });
314
+ const issueCode = { "orphan-output": "orphan_output", "orphan-tool-result": "orphan_tool_result",
315
+ "unconfirmed-tool": "unconfirmed_historical_tool" }[shape];
316
+ const expectedDetails = { issues: [{ code: issueCode, rowIds: [offending.id] }] };
317
+ const unchanged = await rows(f.db);
318
+ const historical = await f.messagesRepository.listByConversationScope(conversation.id, { workspaceId: "77" });
319
+ let allowFreshInference = false;
320
+ const requests = [];
321
+ let effects = 0;
322
+ const aiClientFactory = { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "fixture-model",
323
+ async createChatCompletionStream(request) {
324
+ assert.equal(allowFreshInference, true, "Historical refusal and foreign admission must not start inference");
325
+ requests.push(structuredClone(request.messages));
326
+ return (async function* () {
327
+ yield { choices: [{ delta: { content: "Fresh answer." }, finish_reason: "stop" }] };
328
+ })();
329
+ } }) };
330
+ const catalog = { limits: { maxToolArgumentBytes: 4096 }, resolveToolSet: () => ({ tools: [] }),
331
+ toOpenAiToolSchema: tool => ({ type: "function", function: tool }),
332
+ executeToolCall() { effects++; assert.fail("Retained history cannot repeat an application effect"); } };
333
+ const runtime = createAssistantConversationRuntime({ ...f, aiClientFactory, toolCatalog: catalog });
334
+ t.after(() => runtime.close());
335
+ const turnRequests = createTurnRequestsRepository(f.db);
336
+ const dependencies = { conversationRuntime: runtime, aiClientFactory, serviceToolCatalog: catalog,
337
+ transcriptService: createTranscriptService(f), turnRequests,
338
+ assistantConfigService: { resolveSystemPrompt: async () => "Read only the admitted request" },
339
+ workspaceScopeSupport: { resolveWorkspace: context => context.requestMeta.resolvedWorkspaceContext.workspace },
340
+ appConfig: { surfaceDefinitions: {
341
+ assistant: { enabled: true, requiresWorkspace: true }, other: { enabled: true, requiresWorkspace: true }
342
+ }, assistantSurfaces: {
343
+ assistant: { settingsSurfaceId: "assistant", configScope: "workspace" },
344
+ other: { settingsSurfaceId: "other", configScope: "workspace" }
345
+ } } };
346
+ const events = [];
347
+ const options = { input: { targetSurfaceId: "assistant" },
348
+ context: { actor: { id: "1" }, requestMeta: { resolvedWorkspaceContext: { workspace: { id: "77" } } } },
349
+ streamWriter: Object.fromEntries(
350
+ ["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"]
351
+ .map(name => [name, event => events.push({ name, event })])) };
352
+ const input = { targetSurfaceId: "assistant", conversationId: conversation.id, messageId: "inspect-retained-history",
353
+ input: "Continue", history: [] };
354
+ const service = createChatService(dependencies);
355
+ await assert.rejects(service.streamChat(input, options), error => {
356
+ assert.equal(error instanceof AppError, true);
357
+ assert.equal(error.status, 409);
358
+ assert.equal(error.code, "assistant_history_inspection_required");
359
+ assert.deepEqual(error.details, expectedDetails);
360
+ return true;
361
+ });
362
+ const receipt = await f.db("assistant_turn_requests").first();
363
+ const restarted = createChatService({ ...dependencies, turnRequests: createTurnRequestsRepository(f.db) });
364
+ await assert.rejects(restarted.streamChat(input, options), error => {
365
+ assert.equal(error.status, 409);
366
+ assert.equal(error.code, "assistant_history_inspection_required");
367
+ assert.deepEqual(error.details, expectedDetails);
368
+ return true;
369
+ });
370
+ assert.deepEqual(await rows(f.db), unchanged, "Refusal and replay cannot repair or discard historical rows");
371
+ assert.deepEqual(await f.db("assistant_turn_requests"), [receipt], "Exact-ID replay preserves the failed claim");
372
+ assert.deepEqual(events, []);
373
+ assert.equal(requests.length, 0);
374
+ assert.equal(effects, 0);
375
+ const listed = await restarted.listConversations({}, options);
376
+ assert.equal(listed.items.some(entry => entry.id === conversation.id), true);
377
+ const loaded = await restarted.getConversationMessages(conversation.id, {}, options);
378
+ assert.deepEqual(loaded.entries, historical, "Authorized browsing reads original rows independently of common projection");
379
+
380
+ for (const [index, foreign] of [
381
+ { surface: "assistant", actor: "2", workspace: "77" },
382
+ { surface: "other", actor: "1", workspace: "77" },
383
+ { surface: "assistant", actor: "1", workspace: "78" }
384
+ ].entries()) {
385
+ const deniedOptions = { ...options, input: { targetSurfaceId: foreign.surface }, context: {
386
+ actor: { id: foreign.actor }, requestMeta: { resolvedWorkspaceContext: { workspace: { id: foreign.workspace } } }
387
+ } };
388
+ await assert.rejects(restarted.getConversationMessages(conversation.id, {}, deniedOptions), error => {
389
+ assert.equal(error.status, 404);
390
+ assert.equal(error.details, undefined, "Foreign readers cannot inspect row IDs");
391
+ return true;
392
+ });
393
+ assert.equal((await restarted.listConversations({}, deniedOptions)).items.some(entry => entry.id === conversation.id), false);
394
+ await assert.rejects(restarted.streamChat({ ...input, targetSurfaceId: foreign.surface,
395
+ messageId: `foreign-history-${index}` }, deniedOptions), error => {
396
+ assert.equal(error.status, 404);
397
+ assert.equal(error.details, undefined, "Foreign sends fail before historical inspection");
398
+ return true;
399
+ });
400
+ await assert.rejects(runtime.open({ id: conversation.id, context: { actor: { id: foreign.actor },
401
+ surface: foreign.surface, assistantRequest: { conversation, workspace: { id: foreign.workspace }, history: [] } } }),
402
+ { code: "conversation_forbidden" });
403
+ }
404
+ assert.deepEqual(await rows(f.db), unchanged);
405
+ assert.deepEqual(events, []);
406
+ assert.equal(requests.length, 0);
407
+ assert.equal(effects, 0);
408
+
409
+ allowFreshInference = true;
410
+ const fresh = await restarted.streamChat({ targetSurfaceId: "assistant", messageId: "fresh-after-inspection",
411
+ input: "A new independent request", history: [] }, options);
412
+ assert.equal(fresh.status, "completed", JSON.stringify(events));
413
+ assert.notEqual(fresh.conversationId, conversation.id);
414
+ assert.equal(requests.length, 1, "A fresh conversation uses one admitted model response");
415
+ assert.equal(requests[0].some(message => message.content === "Existing question" ||
416
+ message.content === "Answer without an authored row"), false, "Fresh admission does not reseed ambiguous history");
417
+ assert.equal(effects, 0, "Fresh recovery cannot replay an unconfirmed legacy effect");
418
+ assert.equal(events.at(-1).name, "sendDone");
419
+ assert.deepEqual(await f.db("assistant_messages").where({ conversation_id: conversation.id }).orderBy("id"), unchanged.messages);
420
+ assert.deepEqual(await f.db("assistant_conversations").where({ id: conversation.id }).orderBy("id"), unchanged.conversations);
421
+ assert.deepEqual(await f.db("assistant_turn_requests").where({ id: receipt.id }), [receipt]);
422
+ assert.deepEqual((await restarted.getConversationMessages(conversation.id, {}, options)).entries, historical);
423
+ });
424
+ }