@jskit-ai/assistant-runtime 0.1.218 → 0.1.220

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/README.md ADDED
@@ -0,0 +1,29 @@
1
+
2
+ With the existing realtime diagnostic option (`JSKIT_REALTIME_DEBUG=1`, or
3
+ `config.realtime.debug=true`), conversation subscriptions emit one `pending`
4
+ warning if admission/snapshot reading has not returned within the client's
5
+ 10-second acknowledgement budget. The warning names the current stage and the
6
+ server-generated epoch; a conversation identity is included only after its
7
+ successful authorized open. Observer release also records the current stage and
8
+ reason. While diagnostics are enabled, every stage transition is recorded at
9
+ warning level with its duration and total elapsed time, so warning-only logs
10
+ retain the earlier admission costs as well as the step pending at timeout.
11
+ Timers are removed on acknowledgement, failure, replacement, unsubscribe
12
+ and connection close. Diagnostics contain no request body or conversation text,
13
+ and do not change authorization, subscription deadlines or model work.
14
+
15
+ Released subscriptions stop setup at the next completed phase. The existing
16
+ socket owner supplies its current subscription identity to the subscribe action;
17
+ an obsolete open cannot attach a new observer, and a late observer is detached
18
+ without starting a snapshot. Reads already in progress may finish, but cannot
19
+ acknowledge into a replacement view. Reload performs fresh authorization, and
20
+ releasing a view never stops its model turn.
21
+
22
+ Prepared application submissions may explicitly enable `queueWhileSending` to
23
+ reuse the binding's serial delivery queue while application preparation is still
24
+ running. Each queued request retains its captured payload and receipt identity.
25
+ Stop and conversation retirement cancel queued preparation through the existing
26
+ owner. A typed, definite ended-turn rejection may become one ordinary Send with
27
+ the same identity, without repeating preparation; uncertain delivery never
28
+ permits replay. With the option absent or false, prepared submissions retain
29
+ their existing non-queueing behavior.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.218",
3
+ "version": "0.1.220",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -12,7 +12,7 @@
12
12
  "./server": "./src/server/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@jskit-ai/assistant-core": "0.1.225",
15
+ "@jskit-ai/assistant-core": "0.1.227",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
@@ -390,7 +390,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
390
390
  throw new TypeError("Prepared submission requires an authored conversation request.");
391
391
  }
392
392
  if (!current.value || accessDenied.value || retry && retry.status !== "failed") return Promise.resolve(false);
393
- return submitRequest(captured, { messageId, onAccepted, prepare });
393
+ return submitRequest(captured, { messageId, onAccepted, prepare, queue: queueWhileSending === true });
394
394
  }
395
395
  async function submitDraft(payload, { messageId = crypto.randomUUID(), submissionKind = steerable.value ? "steer" : "send",
396
396
  questionText = "", onAccepted } = {}) {
@@ -463,7 +463,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
463
463
  return { ok: false, error: "This conversation no longer accepts attachments." };
464
464
  }
465
465
  }
466
- const request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
466
+ let request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
467
467
  if (prepare) {
468
468
  if (!current.value || accessDenied.value || controller.signal.aborted || request === false) return false;
469
469
  if (!request || typeof request !== "object" || Array.isArray(request)) {
@@ -471,9 +471,31 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
471
471
  }
472
472
  }
473
473
  pending.dispatched = true;
474
- return await (submission.goalRequest
475
- ? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
476
- : api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
474
+ for (let attempt = 0; ; attempt += 1) {
475
+ try {
476
+ return await (submission.goalRequest
477
+ ? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
478
+ : api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
479
+ } catch (failure) {
480
+ // The server identifies this exact pre-admission race. Keep the
481
+ // same queued words/attachments/identity and try ordinary Send
482
+ // once. A timeout, generic rejection or uncertain send never
483
+ // grants another dispatch.
484
+ if (attempt !== 0 || submission.goalRequest || request.steer !== true ||
485
+ failure.code !== "conversation_steer_turn_finished" || failure.status !== 409 ||
486
+ failure.details?.delivery && (failure.details.delivery.status !== "not-sent" ||
487
+ failure.details.delivery.messageId !== messageId)) throw failure;
488
+ const retained = delivery.find(messageId);
489
+ if (retained?.status === "accepted" || turns.value.some(turn => turn.user?.messageId === messageId && turn.user.receipt !== false)) return { ok: true };
490
+ if (!current.value || accessDenied.value || controller.signal.aborted || !retained || retained.status !== "pending") return false;
491
+ const { steer: _finishedTurn, ...nextRequest } = request;
492
+ request = nextRequest;
493
+ // Retain the new dispatch intent if Send itself fails; Retry
494
+ // must not loop back through the obsolete steering request.
495
+ retained.payload.request = nextRequest;
496
+ retained.payload.submissionKind = "send";
497
+ }
498
+ }
477
499
  }
478
500
  catch (failure) {
479
501
  if (controller.signal.aborted) return false;
@@ -482,7 +504,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
482
504
  const rejection = failure.details?.delivery;
483
505
  const notSent = Number.isInteger(failure.status) && failure.status >= 400 && failure.status < 500 &&
484
506
  rejection && !Array.isArray(rejection) && rejection.status === "not-sent" && rejection.messageId === messageId;
485
- if (notSent || SEND_REJECTIONS.has(failure.code)) return { ok: false, error: failure.message,
507
+ if (notSent || SEND_REJECTIONS.has(failure.code) || failure.code === "conversation_steer_turn_finished" && failure.status === 409 && !failure.details?.delivery) return { ok: false, error: failure.message,
486
508
  ...(prepare && failure.code ? { code: failure.code } : {}) };
487
509
  throw failure;
488
510
  } finally {
@@ -650,12 +672,13 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
650
672
  loading,
651
673
  available: Boolean(goalAvailable),
652
674
  action: goalAction.value,
675
+ working: snapshot.value?.status === "working",
653
676
  tokenBudgetSupported: capabilities.goalBudgets === true,
654
677
  goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
655
678
  sampledAt: Date.parse(view.goal.updatedAt) } : null,
656
679
  pending: Boolean(goalAction.value) || !goalAvailable || !available.value || delivery.state.sending ||
657
680
  delivery.state.messages.some(message => message.status === "uncertain"),
658
- error: goalError.value || goalLoadError.value,
681
+ error: goalError.value || goalLoadError.value || view?.error || "",
659
682
  pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
660
683
  cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
661
684
  ...Object.fromEntries(["set", "resume", "pause", "cancel"].filter(action =>
@@ -680,7 +703,10 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
680
703
  try {
681
704
  const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId);
682
705
  if (!current.value) return false;
683
- if (receipt.status === "accepted") delivery.accept(messageId);
706
+ if (receipt.status === "accepted") {
707
+ delivery.accept(messageId);
708
+ if (receipt.messageId === messageId && message?.payload.goalRequest) goalError.value = "";
709
+ }
684
710
  else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, receipt.error);
685
711
  else if (receipt.status === "unknown" && message?.status === "uncertain") {
686
712
  message.error = "Delivery is still unknown. Retry to check again.";
@@ -98,11 +98,12 @@ const AssistantFeature = defineFeature({
98
98
  config
99
99
  });
100
100
  },
101
- boot({ conversationRuntime, conversationAccess, realtime, events, config, workspaces, logger }, { outputs }) {
101
+ boot({ conversationRuntime, conversationAccess, realtime, events, config, env, workspaces, logger }, { outputs }) {
102
102
  if (!conversationRuntime || !realtime) return;
103
103
  const state = subscriptionLifecycle.get(outputs.assistant);
104
104
  state.release = registerConversationSubscriptions({
105
105
  realtime, events, actions: state.actions, config, logger, workspaceScopeSupport: workspaces?.scope || null,
106
+ diagnostics: ["1", "true", "yes", "on"].includes(String(env?.JSKIT_REALTIME_DEBUG ?? config?.realtime?.debug ?? "").trim().toLowerCase()),
106
107
  ...(conversationAccess ? {
107
108
  subscribeActionId: conversationAccess.subscribeActionId,
108
109
  requestPolicy: conversationAccess.requestPolicy
@@ -201,12 +201,15 @@ function createConversationActions({ conversationRuntime, conversationDataSchema
201
201
  if (typeof deps?.onEvent !== "function" || typeof deps?.onRelease !== "function") {
202
202
  throw new TypeError("Conversation subscriptions require their server-owned event and cleanup callbacks.");
203
203
  }
204
+ if (deps.isCurrent?.() === false) return null;
204
205
  deps.onStage?.("conversation-open");
205
206
  const conversation = await openConversation(input, context);
207
+ if (deps.isCurrent?.() === false) return null;
206
208
  deps.onStage?.("observer-attach");
207
209
  const release = await conversation.subscribe(deps.onEvent);
208
210
  try {
209
211
  deps.onRelease(release);
212
+ if (deps.isCurrent?.() === false) return null;
210
213
  deps.onStage?.("snapshot-read");
211
214
  return await conversation.read();
212
215
  } catch (error) {
@@ -31,7 +31,7 @@ function subscriptionError(error) {
31
31
  // A host may select its fixed action/request policy at composition; clients
32
32
  // never choose either. The selected action remains the authorization boundary.
33
33
  function registerConversationSubscriptions({ realtime, events, actions, config = {}, logger = null, workspaceScopeSupport = null,
34
- subscribeActionId = actionIds.conversationSubscribe, requestPolicy = "authenticated" } = {}) {
34
+ subscribeActionId = actionIds.conversationSubscribe, requestPolicy = "authenticated", diagnostics = false } = {}) {
35
35
  if (typeof realtime?.onConnection !== "function" || typeof events?.publish !== "function") {
36
36
  throw new TypeError("Conversation subscriptions require the shared realtime connection and event runtime.");
37
37
  }
@@ -50,10 +50,11 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
50
50
  const subscriptions = new Map();
51
51
  let closed = false;
52
52
 
53
- function release(subscriptionId) {
53
+ function release(subscriptionId, reason = "unsubscribe") {
54
54
  const subscription = subscriptions.get(subscriptionId);
55
55
  if (!subscription) return;
56
56
  subscriptions.delete(subscriptionId);
57
+ subscription.diagnosticRelease?.(reason);
57
58
  clearTimeout(subscription.timer);
58
59
  subscription.pending.clear();
59
60
  subscription.release?.();
@@ -67,12 +68,25 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
67
68
  let stageStartedAt = startedAt;
68
69
  let stage = "request-admission";
69
70
  let observedConversationId;
71
+ let diagnosticTimer;
72
+ function diagnostic(stageName, fields = {}) {
73
+ if (!diagnostics) return;
74
+ const now = performance.now();
75
+ try {
76
+ logger?.warn?.({ event: "assistant.conversation.subscription", subscriptionEpoch: subscription?.epoch,
77
+ ...(typeof observedConversationId === "string" ? { conversationId: observedConversationId } : {}),
78
+ stage: stageName, previousStage: stage, durationMs: now - stageStartedAt,
79
+ elapsedMs: now - startedAt, ...fields }, "Assistant conversation subscription diagnostic.");
80
+ } catch { /* Diagnostics never change admission or cleanup. */ }
81
+ }
70
82
  function onStage(next, fields = {}) {
71
83
  const now = performance.now();
72
84
  // Only a successfully opened, authorized conversation may enter diagnostics.
73
85
  if (next === "observer-attach") observedConversationId = input.conversationId;
74
86
  try {
75
- logger?.info?.({ event: "assistant.conversation.subscription", subscriptionEpoch: subscription?.epoch,
87
+ const slowOutcome = now - startedAt >= 5_000 && ["acknowledgement", "abandoned"].includes(next);
88
+ const level = diagnostics || next === "failed" || slowOutcome ? "warn" : "info";
89
+ logger?.[level]?.({ event: "assistant.conversation.subscription", subscriptionEpoch: subscription?.epoch,
76
90
  ...(typeof observedConversationId === "string" ? { conversationId: observedConversationId } : {}),
77
91
  stage: next, previousStage: stage,
78
92
  durationMs: now - stageStartedAt, elapsedMs: now - startedAt, ...fields },
@@ -83,9 +97,17 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
83
97
  }
84
98
  try {
85
99
  subscriptionId = requireSubscriptionId(input);
86
- release(subscriptionId);
100
+ release(subscriptionId, "replacement");
87
101
  subscription = { pending: new Map(), revision: 0, epoch: randomUUID(), queue: Promise.resolve() };
88
102
  subscriptions.set(subscriptionId, subscription);
103
+ if (diagnostics) {
104
+ subscription.diagnosticRelease = reason => {
105
+ clearTimeout(diagnosticTimer);
106
+ diagnostic("released", { reason });
107
+ };
108
+ diagnosticTimer = setTimeout(() => diagnostic("pending", { acknowledgementBudgetMs: 10_000 }), 10_000);
109
+ diagnosticTimer.unref?.();
110
+ }
89
111
  const current = () => !closed && socket.connected && subscriptions.get(subscriptionId) === subscription;
90
112
  onStage("authentication");
91
113
  const request = await (requestPolicy === "host" ? readRequest() : authenticate());
@@ -148,6 +170,7 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
148
170
  deps: {
149
171
  onEvent,
150
172
  onStage,
173
+ isCurrent: current,
151
174
  onRelease(detach) {
152
175
  if (!current()) detach();
153
176
  else subscription.release = detach;
@@ -167,6 +190,8 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
167
190
  status: Number(error?.status || error?.statusCode || 500) });
168
191
  if (subscriptions.get(subscriptionId) === subscription) release(subscriptionId);
169
192
  if (!closed && socket.connected) acknowledge(subscriptionError(error));
193
+ } finally {
194
+ clearTimeout(diagnosticTimer);
170
195
  }
171
196
  }
172
197
 
@@ -184,7 +209,7 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
184
209
  closed = true;
185
210
  socket.off(ASSISTANT_CONVERSATION_SUBSCRIBE, subscribe);
186
211
  socket.off(ASSISTANT_CONVERSATION_UNSUBSCRIBE, unsubscribe);
187
- for (const subscriptionId of subscriptions.keys()) release(subscriptionId);
212
+ for (const subscriptionId of subscriptions.keys()) release(subscriptionId, "connection-closed");
188
213
  };
189
214
  });
190
215
  }
@@ -147,6 +147,8 @@ test("optional goal controls follow declared command semantics and budgets witho
147
147
  await expect(start).toBeEnabled();
148
148
  await start.click();
149
149
  await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toBeVisible();
150
+ await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toHaveAttribute("aria-expanded", "false");
151
+ await f.primary.getByRole("button", { name: "Goal running", exact: true }).click();
150
152
  const pause = f.page.getByRole("button", { name: "Pause goal", exact: true });
151
153
  await expect(pause).toBeEnabled();
152
154
  await expect(f.page.getByText(budgets
@@ -191,6 +193,8 @@ test("the first native goal preserves absent identity and later controls capture
191
193
  await expect(start).toBeEnabled();
192
194
  await start.click();
193
195
  await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toBeVisible();
196
+ await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toHaveAttribute("aria-expanded", "false");
197
+ await f.primary.getByRole("button", { name: "Goal running", exact: true }).click();
194
198
  const pause = f.page.getByRole("button", { name: "Pause goal", exact: true });
195
199
  await expect(pause).toBeEnabled();
196
200
  await pause.click();
@@ -225,6 +229,7 @@ test("a lost goal receipt uses the same inspection-only ledger and never starts
225
229
  const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
226
230
  await expect(check).toBeVisible();
227
231
  await expect(f.primary.getByText("Finish the uncertain goal", { exact: true })).toHaveCount(1);
232
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value.error)).toBe("The goal's receipt was lost.");
228
233
  const requests = async () => (await f.state()).requests.filter(request => request.suffix === "/goal" && request.method === "POST");
229
234
  const [original] = await requests();
230
235
  assert.ok(original.input.messageId);
@@ -235,6 +240,7 @@ test("a lost goal receipt uses the same inspection-only ledger and never starts
235
240
  await f.command("confirm", { id: "chat:1" });
236
241
  await check.click();
237
242
  await expect(check).toHaveCount(0);
243
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value.error)).toBe("");
238
244
  await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toBeVisible();
239
245
  await expect(f.primary.getByText("Finish the uncertain goal", { exact: true })).toHaveCount(1);
240
246
  assert.equal((await requests()).length, 1);
@@ -319,7 +325,7 @@ test("a supplied goal view keeps its pinned target and refreshes through the ret
319
325
  assert.equal(commands.length, 1, "Retired viewer controls cannot target the next viewer's goal");
320
326
  });
321
327
 
322
- test("a rejected message goal retry keeps its original UUID and exact read target", options, async t => {
328
+ for (const rejection of ["conversation_goal_changed", "conversation_invalid_goal"]) test(`a rejected message goal retry keeps its original UUID and exact read target (${rejection})`, options, async t => {
323
329
  const f = await fixture(t);
324
330
  const commands = [];
325
331
  const view = { status: "available", goal: null, target: { segmentId: null,
@@ -328,8 +334,8 @@ test("a rejected message goal retry keeps its original UUID and exact read targe
328
334
  if (route.request().method() === "GET") return route.fulfill({ json: view });
329
335
  const input = route.request().postDataJSON();
330
336
  commands.push(input);
331
- if (commands.length === 1) return route.fulfill({ status: 409,
332
- json: { code: "conversation_goal_changed", error: "Goal changed before admission." } });
337
+ if (commands.length === 1) return route.fulfill({ status: rejection === "conversation_invalid_goal" ? 400 : 409,
338
+ json: { code: rejection, error: "Goal rejected before admission." } });
333
339
  return route.fulfill({ json: { status: "accepted", messageId: input.messageId, turnId: "original-goal-receipt" } });
334
340
  });
335
341
  await f.page.evaluate(() => window.conversationFixture.eventReaders([{ id: "goal-reader", goal: true }]));
@@ -337,6 +343,8 @@ test("a rejected message goal retry keeps its original UUID and exact read targe
337
343
  await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value?.pending)).toBe(false);
338
344
  assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("set", { objective: "Original objective" })), false);
339
345
  assert.equal(commands.length, 1);
346
+ assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id)?.status,
347
+ commands[0].messageId), "failed", "pre-dispatch validation is not an uncertain delivery");
340
348
  assert.ok(commands[0].messageId);
341
349
  assert.equal(commands[0].expectedSegmentId, null);
342
350
  assert.equal(commands[0].expectedGoalId, null);
@@ -764,6 +772,67 @@ for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognise
764
772
  });
765
773
  }
766
774
 
775
+ for (const ordinaryFails of [false, true]) {
776
+ test(`a finished-turn steer becomes one ordinary Send with its original identity (Send fails: ${ordinaryFails})`, options, async t => {
777
+ const f = await fixture(t);
778
+ await f.command("mode", { mode: "rejected" });
779
+ await f.command("steering", { id: "chat:1", enabled: true });
780
+ await f.command("question", { id: "chat:1", text: "Earlier work", pending: true });
781
+ await f.notify("chat:1");
782
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
783
+ let calls = 0;
784
+ await f.page.route("**/messages", async route => {
785
+ calls += 1;
786
+ const response = await route.fetch();
787
+ if (calls === 1) {
788
+ if (!ordinaryFails) await f.command("mode", { mode: "accepted" });
789
+ await route.fulfill({ response, status: 409, json: {
790
+ error: "The turn finished before dispatch.", code: "conversation_steer_turn_finished"
791
+ } });
792
+ } else await route.fulfill({ response });
793
+ });
794
+ await f.input.fill("Keep exactly these words");
795
+ await f.input.press("Enter");
796
+ await expect.poll(async () => (await f.sent()).length).toBe(2);
797
+ const [steer, send] = await f.sent();
798
+ assert.equal(steer.input.steer, true);
799
+ const { steer: _oldIntent, ...ordinary } = steer.input;
800
+ assert.deepEqual(send.input, ordinary);
801
+ if (ordinaryFails) {
802
+ await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toBeVisible();
803
+ assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).payload.request.steer,
804
+ steer.input.messageId), undefined);
805
+ await f.input.fill("Keep my newer draft");
806
+ await f.command("mode", { mode: "accepted" });
807
+ await f.primary.getByRole("button", { name: "Retry", exact: true }).click();
808
+ await expect.poll(async () => (await f.sent()).length).toBe(3);
809
+ assert.deepEqual((await f.sent())[2].input, ordinary);
810
+ await expect(f.input).toHaveValue("Keep my newer draft");
811
+ }
812
+ await expect(f.primary.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
813
+ await expect(f.primary.getByText("Keep exactly these words", { exact: true })).toHaveCount(1);
814
+ });
815
+ }
816
+
817
+ test("a finished-turn error without a definite rejection status remains uncertain and never auto-resends", options, async t => {
818
+ const f = await fixture(t);
819
+ await f.command("mode", { mode: "rejected" });
820
+ await f.command("steering", { id: "chat:1", enabled: true });
821
+ await f.command("question", { id: "chat:1", text: "Earlier work", pending: true });
822
+ await f.notify("chat:1");
823
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
824
+ await f.page.route("**/messages", async route => {
825
+ const response = await route.fetch();
826
+ await route.fulfill({ response, status: 503, json: { error: "Delivery unknown", code: "conversation_steer_turn_finished" } });
827
+ });
828
+ await f.input.fill("Retain uncertain words");
829
+ await f.input.press("Enter");
830
+ await expect.poll(async () => (await f.sent()).length).toBe(1);
831
+ const [request] = await f.sent();
832
+ await expect.poll(() => f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).status, request.input.messageId)).toBe("uncertain");
833
+ assert.equal((await f.sent()).length, 1);
834
+ });
835
+
767
836
  for (const code of ["ACTION_VALIDATION_FAILED", "conversation_not_steerable"]) {
768
837
  test(`an explicit pre-admission rejection retains original retry identity and the newer draft: ${code}`, options, async t => {
769
838
  const f = await fixture(t);
@@ -1810,6 +1879,65 @@ test("dynamic acquisition keeps the original shared readers and releases only it
1810
1879
  assert.equal((await f.sent()).length, 0);
1811
1880
  });
1812
1881
 
1882
+ for (const stop of [false, true]) {
1883
+ test(`prepared application queue retains followers until admission and respects Stop (${stop})`, options, async t => {
1884
+ const f = await fixture(t);
1885
+ await f.command("mode", { mode: "hold" });
1886
+ await f.page.evaluate(() => {
1887
+ window.preparedQueue = window.conversationFixture.acquireConversation({ conversationId: "chat:2", queueWhileSending: true });
1888
+ window.preparedCalls = [];
1889
+ const send = (id, text) => window.preparedQueue.runtime.value.submitPrepared({ message: text, request: { text } }, {
1890
+ messageId: id, prepare(submission) { window.preparedCalls.push(id); return submission.request; }
1891
+ });
1892
+ window.preparedFirst = send("prepared-first", "First authored message");
1893
+ window.preparedSecond = send("prepared-second", "Second authored message");
1894
+ });
1895
+ await expect.poll(async () => (await f.state()).held).toBe(1);
1896
+ assert.deepEqual(await f.page.evaluate(() => window.preparedCalls), ["prepared-first"]);
1897
+ assert.equal(await f.page.evaluate(() => window.preparedQueue.runtime.value.delivery.state.messages.length), 2);
1898
+ if (stop) await f.page.evaluate(() => window.preparedQueue.runtime.value.cancel());
1899
+ await f.command("mode", { mode: "accepted" });
1900
+ await f.command("accept");
1901
+ await f.page.evaluate(() => window.preparedFirst);
1902
+ const result = await f.page.evaluate(() => window.preparedSecond);
1903
+ assert.equal((await f.sent()).length, stop ? 1 : 2);
1904
+ assert.deepEqual(await f.page.evaluate(() => window.preparedCalls), stop ? ["prepared-first"] : ["prepared-first", "prepared-second"]);
1905
+ if (stop) assert.equal(result, false);
1906
+ else assert.equal(result.status, "accepted");
1907
+ });
1908
+ }
1909
+
1910
+ test("prepared steering uses the definite finished-turn fallback without repeating preparation", options, async t => {
1911
+ const f = await fixture(t);
1912
+ await f.command("mode", { mode: "rejected" });
1913
+ let calls = 0;
1914
+ await f.page.route("**/messages", async route => {
1915
+ const response = await route.fetch();
1916
+ if (++calls === 1) {
1917
+ await f.command("mode", { mode: "accepted" });
1918
+ const input = route.request().postDataJSON();
1919
+ await route.fulfill({ response, status: 409, json: {
1920
+ error: "The turn finished before dispatch.", code: "conversation_steer_turn_finished",
1921
+ details: { delivery: { status: "not-sent", messageId: input.messageId } }
1922
+ } });
1923
+ } else await route.fulfill({ response });
1924
+ });
1925
+ const result = await f.page.evaluate(async () => {
1926
+ const binding = window.conversationFixture.acquireConversation({ conversationId: "chat:2", queueWhileSending: true });
1927
+ window.prepareCount = 0;
1928
+ return binding.runtime.value.submitPrepared({ message: "Keep these words", request: { text: "Keep these words", steer: true } }, {
1929
+ messageId: "prepared-finished", prepare(submission) { window.prepareCount += 1; return submission.request; }
1930
+ });
1931
+ });
1932
+ assert.equal(result.status, "accepted");
1933
+ assert.equal(await f.page.evaluate(() => window.prepareCount), 1);
1934
+ const sent = await f.sent();
1935
+ assert.equal(sent.length, 2);
1936
+ assert.equal(sent[0].input.steer, true);
1937
+ const { steer: _ended, ...ordinary } = sent[0].input;
1938
+ assert.deepEqual(sent[1].input, ordinary);
1939
+ });
1940
+
1813
1941
  test("prepared submission keeps optimism before application creation and uses the same captured receipt owner", options, async t => {
1814
1942
  const f = await fixture(t);
1815
1943
  await f.command("mode", { mode: "hold" });
@@ -3085,3 +3213,31 @@ test("initial goal loading shows no goal commands until the empty goal is confir
3085
3213
  await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toHaveCount(0);
3086
3214
  } finally { waiting.resolve(); }
3087
3215
  });
3216
+
3217
+
3218
+ test("host goal recovery errors remain visible while Resume uses the current goal target", options, async t => {
3219
+ const f = await fixture(t);
3220
+ const view = { status: "available", error: "Goal recovery stopped because its AI account changed. Resume with the intended account.",
3221
+ goal: { id: "retained-goal", objective: "Finish the agreed work", status: "paused" },
3222
+ target: { segmentId: "retained-native", capabilities: { goals: true,
3223
+ goalCommands: { resume: { delivery: "control", interruptsTurn: false } } } } };
3224
+ const commands = [];
3225
+ await f.page.route("**/conversations/*/goal", async route => {
3226
+ if (route.request().method() === "GET") return route.fulfill({ json: view });
3227
+ commands.push(route.request().postDataJSON());
3228
+ view.error = "";
3229
+ view.goal.status = "active";
3230
+ return route.fulfill({ json: { ok: true } });
3231
+ });
3232
+ await f.page.evaluate(() => window.conversationFixture.eventReaders([{ id: "goal-reader", goal: true }]));
3233
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value?.pending)).toBe(false);
3234
+ await f.primary.getByRole("button", { name: "Goal paused", exact: true }).click();
3235
+ await expect(f.page.getByRole("alert").filter({ hasText: "Goal recovery stopped" })).toBeVisible();
3236
+ const resume = f.page.getByRole("button", { name: "Resume goal", exact: true });
3237
+ await expect(resume).toBeEnabled();
3238
+ await resume.click();
3239
+ await expect.poll(() => commands.length).toBe(1);
3240
+ assert.deepEqual(commands[0], { action: "resume", expectedSegmentId: "retained-native", expectedGoalId: "retained-goal" });
3241
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value.error)).toBe("");
3242
+ assert.deepEqual(await f.sent(), []);
3243
+ });
@@ -42,7 +42,7 @@ function requestSubscription(socket, input) {
42
42
  });
43
43
  }
44
44
 
45
- async function fixture(t) {
45
+ async function fixture(t, { diagnostics = false, onSetup } = {}) {
46
46
  const directory = await mkdtemp(join(tmpdir(), "assistant-subscriptions-"));
47
47
  const changes = new EventEmitter();
48
48
  const notify = () => changes.emit("change");
@@ -86,13 +86,16 @@ async function fixture(t) {
86
86
  conversations.set(id, conversation);
87
87
  }
88
88
  let active = 0;
89
+ let snapshotReads = 0;
89
90
  const actions = createActionCatalogue();
90
91
  const contributor = createAuthActionContextContributor();
91
92
  actions.registerContextContributor({ id: contributor.contributorId, contribute: contributor.contribute });
92
93
  actions.register({ contributorId: "test.assistant", domain: "assistant", actions: createAssistantActions({ config,
93
94
  conversationRuntime: { async open(input) {
95
+ await onSetup?.("conversation-open");
94
96
  const conversation = await runtime.open(input);
95
97
  return { ...conversation, async read() {
98
+ snapshotReads += 1;
96
99
  const barrier = nextReadBarriers.get(input.id);
97
100
  if (barrier) {
98
101
  nextReadBarriers.delete(input.id);
@@ -106,6 +109,7 @@ async function fixture(t) {
106
109
  let released = false;
107
110
  active += 1;
108
111
  notify();
112
+ await onSetup?.("observer-attach");
109
113
  return () => {
110
114
  if (released) return;
111
115
  released = true;
@@ -141,8 +145,11 @@ async function fixture(t) {
141
145
  serverSockets.add(socket);
142
146
  return () => { serverSockets.delete(socket); notify(); };
143
147
  });
144
- const stopSubscriptions = registerConversationSubscriptions({ realtime, events, actions, config,
145
- logger: { info(record) { subscriptionTimings.push(record); notify(); } } });
148
+ const stopSubscriptions = registerConversationSubscriptions({ realtime, events, actions, config, diagnostics,
149
+ logger: {
150
+ info(record) { subscriptionTimings.push({ ...record, level: "info" }); notify(); },
151
+ warn(record) { subscriptionTimings.push({ ...record, level: "warn" }); notify(); }
152
+ } });
146
153
  const published = [];
147
154
  events.register({ id: "test.assistant.published", handle(event) { published.push(event); notify(); } });
148
155
  http.listen(0, "127.0.0.1");
@@ -204,7 +211,49 @@ async function fixture(t) {
204
211
  }
205
212
  return { ids, connect, view, changes, sessions, revoked, fetches, conversations, runtimeEvents,
206
213
  realtime, transport, serverSockets, published, events, stopSubscriptions, authRequests, subscriptionTimings,
207
- holdNextRead, active: () => active };
214
+ holdNextRead, active: () => active, snapshotReads: () => snapshotReads };
215
+ }
216
+
217
+ for (const phase of ["conversation-open", "observer-attach"]) {
218
+ test(`released during ${phase} skips obsolete snapshot and preserves replacement observation`, async t => {
219
+ const entered = Promise.withResolvers();
220
+ const gate = Promise.withResolvers();
221
+ t.after(() => gate.resolve());
222
+ let held = false;
223
+ const f = await fixture(t, { diagnostics: true, async onSetup(stage) {
224
+ if (stage !== phase || held) return;
225
+ held = true;
226
+ entered.resolve();
227
+ await gate.promise;
228
+ } });
229
+ const socket = await f.connect();
230
+ const id = f.ids[0];
231
+ await f.conversations.get(id).send({ messageId: "survives-observer-release", text: "Keep working" });
232
+ await until(f.changes, () => f.fetches.length === 1);
233
+ const oldView = f.view(socket, id);
234
+ await entered.promise;
235
+ const epoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
236
+ oldView.dispose();
237
+ await until(f.changes, () => f.subscriptionTimings.some(record =>
238
+ record.subscriptionEpoch === epoch && record.stage === "released"));
239
+ const replacement = f.view(socket, id);
240
+ await until(f.changes, () => replacement.states.length === 1);
241
+ assert.equal(f.snapshotReads(), 1);
242
+ gate.resolve();
243
+ await until(f.changes, () => f.subscriptionTimings.some(record =>
244
+ record.subscriptionEpoch === epoch && record.stage === "abandoned"));
245
+ assert.equal(f.snapshotReads(), 1, "Released setup cannot start a new snapshot");
246
+ assert.equal(f.subscriptionTimings.some(record =>
247
+ record.subscriptionEpoch === epoch && record.stage === "snapshot-read"), false);
248
+ assert.equal(f.active(), 1, "Late cleanup preserves the replacement observer");
249
+ assert.equal(oldView.states.length, 0);
250
+ assert.equal(f.fetches.length, 1);
251
+ assert.equal(f.fetches[0].signal.aborted, false, "View cancellation never stops the model");
252
+ f.fetches[0].text("Still working after view replacement.");
253
+ await until(f.changes, () => replacement.received.some(event => event.text?.includes("view replacement")));
254
+ replacement.dispose();
255
+ await until(f.changes, () => f.active() === 0);
256
+ });
208
257
  }
209
258
 
210
259
  test("subscription timings identify a blocked snapshot and abandoned observation without stopping work", { timeout: 20_000 }, async t => {
@@ -247,6 +296,7 @@ test("subscription timings identify a blocked snapshot and abandoned observation
247
296
  assert.ok(records.every(record => !Object.hasOwn(record, "subscriptionId")),
248
297
  "Correlation uses the server epoch rather than arbitrary client subscription text");
249
298
  assert.equal(records.find(record => record.stage === "acknowledgement").snapshotTurns, 1);
299
+ assert.ok(records.every(record => record.level === "info"), "Normal subscriptions do not produce warnings");
250
300
  assert.equal(recovered.states[0].conversationLog[0].user.messageId, "measured-once");
251
301
  assert.equal(f.active(), 1);
252
302
  assert.equal(f.fetches.length, 1);
@@ -255,6 +305,30 @@ test("subscription timings identify a blocked snapshot and abandoned observation
255
305
  await until(f.changes, () => f.active() === 0);
256
306
  });
257
307
 
308
+ test("slow successful subscriptions warn once with the blocked stage and no conversation content", async t => {
309
+ const f = await fixture(t);
310
+ const socket = await f.connect();
311
+ const id = f.ids[0];
312
+ const barrier = f.holdNextRead(id);
313
+ const view = f.view(socket, id);
314
+ await until(f.changes, () => barrier.started);
315
+ const now = performance.now.bind(performance);
316
+ const clock = t.mock.method(performance, "now", () => now() + 6_000);
317
+ try {
318
+ barrier.resolve();
319
+ await until(f.changes, () => view.states.length === 1);
320
+ } finally { clock.mock.restore(); }
321
+ const warnings = f.subscriptionTimings.filter(record => record.level === "warn");
322
+ assert.equal(warnings.length, 1);
323
+ assert.equal(warnings[0].stage, "acknowledgement");
324
+ assert.equal(warnings[0].previousStage, "snapshot-read");
325
+ assert.ok(warnings[0].durationMs >= 6_000);
326
+ assert.ok(warnings[0].elapsedMs >= warnings[0].durationMs);
327
+ assert.equal(warnings[0].snapshotTurns, 0);
328
+ assert.equal(Object.hasOwn(warnings[0], "state"), false);
329
+ assert.equal(f.fetches.length, 0);
330
+ });
331
+
258
332
  test("the real ten-second subscription deadline releases its late observer and Reload never repeats work", { timeout: 25_000 }, async t => {
259
333
  const f = await fixture(t);
260
334
  const socket = await f.connect();
@@ -282,6 +356,11 @@ test("the real ten-second subscription deadline releases its late observer and R
282
356
  barrier.resolve();
283
357
  await until(f.changes, () => f.subscriptionTimings.some(record =>
284
358
  record.subscriptionEpoch === firstSubscriptionEpoch && record.stage === "abandoned"));
359
+ const abandoned = f.subscriptionTimings.find(record =>
360
+ record.subscriptionEpoch === firstSubscriptionEpoch && record.stage === "abandoned");
361
+ assert.equal(abandoned.level, "warn", "A timed-out snapshot remains visible at the production warning level");
362
+ assert.equal(abandoned.previousStage, "snapshot-read");
363
+ assert.ok(abandoned.durationMs >= 5_000);
285
364
  assert.equal(view.states.length, statesBeforeLateRead, "The obsolete server read never acknowledges into the recovered view");
286
365
  assert.equal(f.active(), 1, "The late read cannot release the recovered observer");
287
366
  assert.equal(f.fetches.length, 1);
@@ -304,6 +383,7 @@ test("subscription diagnostics never serialize unvalidated client identifiers",
304
383
  assert.equal(result.status, 400);
305
384
  assert.equal(f.active(), 0);
306
385
  assert.equal(f.subscriptionTimings.at(-1).stage, "failed");
386
+ assert.equal(f.subscriptionTimings.at(-1).level, "warn");
307
387
  assert.ok(f.subscriptionTimings.every(record => /^[0-9a-f-]{36}$/.test(record.subscriptionEpoch)));
308
388
  assert.ok(f.subscriptionTimings.every(record => !Object.hasOwn(record, "conversationId") &&
309
389
  !Object.hasOwn(record, "subscriptionId")));
@@ -1148,3 +1228,47 @@ test("retargeting a retained logical observer uses the current engine namespace
1148
1228
  assert.equal(active, 0);
1149
1229
  assert.equal(sends, 0);
1150
1230
  });
1231
+
1232
+
1233
+ test("opt-in subscription watchdog identifies a pending snapshot before the await returns", async t => {
1234
+ const f = await fixture(t, { diagnostics: true });
1235
+ const socket = await f.connect();
1236
+ const barrier = f.holdNextRead(f.ids[0]);
1237
+ t.mock.timers.enable({ apis: ["setTimeout"] });
1238
+ const view = f.view(socket, f.ids[0]);
1239
+ await until(f.changes, () => barrier.started);
1240
+ t.mock.timers.tick(10_000);
1241
+ t.mock.timers.reset();
1242
+ const pending = f.subscriptionTimings.find(record => record.stage === "pending");
1243
+ const stages = f.subscriptionTimings.filter(record => record.stage !== "pending");
1244
+ assert.deepEqual(stages.map(record => record.stage), ["authentication", "action-admission",
1245
+ "conversation-open", "observer-attach", "snapshot-read"]);
1246
+ assert.ok(stages.every(record => record.level === "warn"), "Opt-in stage timings survive a warning-only operational log");
1247
+ assert.ok(stages.every(record => Number.isFinite(record.durationMs) && Number.isFinite(record.elapsedMs)));
1248
+ assert.equal(pending.previousStage, "snapshot-read");
1249
+ assert.equal(pending.conversationId, f.ids[0]);
1250
+ assert.equal(pending.acknowledgementBudgetMs, 10_000);
1251
+ assert.equal(pending.level, "warn");
1252
+ assert.equal(Object.hasOwn(pending, "state"), false);
1253
+ view.dispose();
1254
+ await until(f.changes, () => f.subscriptionTimings.some(record => record.stage === "released"));
1255
+ assert.equal(f.subscriptionTimings.find(record => record.stage === "released").previousStage, "snapshot-read");
1256
+ barrier.resolve();
1257
+ await until(f.changes, () => f.subscriptionTimings.some(record => record.stage === "abandoned"));
1258
+ assert.equal(view.states.length, 0);
1259
+ assert.equal(f.fetches.length, 0, "Diagnostics do not start or replay a model turn");
1260
+ });
1261
+
1262
+ for (const diagnostics of [false, true]) test(`watchdog timer is removed after acknowledgement (diagnostics=${diagnostics})`, async t => {
1263
+ const f = await fixture(t, { diagnostics });
1264
+ const socket = await f.connect();
1265
+ t.mock.timers.enable({ apis: ["setTimeout"] });
1266
+ const view = f.view(socket, f.ids[0]);
1267
+ await until(f.changes, () => view.states.length === 1);
1268
+ t.mock.timers.tick(20_000);
1269
+ t.mock.timers.reset();
1270
+ view.dispose();
1271
+ await until(f.changes, () => f.active() === 0);
1272
+ assert.equal(f.subscriptionTimings.some(record => record.stage === "pending"), false);
1273
+ assert.equal(f.subscriptionTimings.some(record => record.stage === "released"), diagnostics);
1274
+ });