@jskit-ai/assistant-runtime 0.1.197 → 0.1.199

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.197",
3
+ "version": "0.1.199",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -12,19 +12,19 @@
12
12
  "./server": "./src/server/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@jskit-ai/assistant-core": "0.1.204",
15
+ "@jskit-ai/assistant-core": "0.1.206",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/http-runtime": "0.1.224",
20
- "@jskit-ai/http-web": "0.1.71",
21
- "@jskit-ai/kernel": "0.1.226",
22
- "@jskit-ai/realtime": "0.1.223",
23
- "@jskit-ai/shell-web": "0.1.230",
19
+ "@jskit-ai/http-runtime": "0.1.226",
20
+ "@jskit-ai/http-web": "0.1.73",
21
+ "@jskit-ai/kernel": "0.1.228",
22
+ "@jskit-ai/realtime": "0.1.225",
23
+ "@jskit-ai/shell-web": "0.1.232",
24
24
  "@tanstack/vue-query": "^5.90.5",
25
25
  "vue": "^3.5.13",
26
26
  "vuetify": "^4.0.0",
27
- "@jskit-ai/database-runtime": "0.1.226"
27
+ "@jskit-ai/database-runtime": "0.1.228"
28
28
  },
29
29
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
30
30
  "jskit": {
@@ -50,7 +50,6 @@ defineExpose({ focus: () => conversation.value?.focus(), runtime: binding.runtim
50
50
  <slot :name="name" v-bind="scope" :runtime="binding.runtime.value" />
51
51
  </template>
52
52
  <template #composer-feedback>
53
- <p v-if="binding.error.value" role="status" class="assistant-client-conversation__error">{{ binding.error.value }}</p>
54
53
  <slot name="composer-feedback" :runtime="binding.runtime.value" />
55
54
  </template>
56
55
  </AssistantConversationElement>
@@ -58,5 +57,4 @@ defineExpose({ focus: () => conversation.value?.focus(), runtime: binding.runtim
58
57
 
59
58
  <style scoped>
60
59
  .assistant-client-conversation { flex: 1 1 auto; height: 100%; min-height: 0; min-width: 0; }
61
- .assistant-client-conversation__error { color: rgb(var(--v-theme-error)); font-size: .85rem; margin: .25rem 0; overflow-wrap: anywhere; }
62
60
  </style>
@@ -31,7 +31,7 @@ function configureAssistantConversations(app, { actorKey, api = null, request, c
31
31
  // occur after dispatch and must retain an uncertain receipt.
32
32
  const SEND_REJECTIONS = new Set([
33
33
  "ACTION_VALIDATION_FAILED", "ACTION_PERMISSION_DENIED", "ACTION_SURFACE_FORBIDDEN",
34
- "conversation_invalid_message", "conversation_busy", "conversation_invalid_goal", "conversation_goal_changed"
34
+ "conversation_invalid_message", "conversation_busy", "conversation_not_steerable", "conversation_invalid_goal", "conversation_goal_changed"
35
35
  ]);
36
36
 
37
37
  function isQuestionConfiguration(value) {
@@ -46,7 +46,7 @@ function draftAfterAcceptedSubmission(currentDraft = "", submittedDraft = "") {
46
46
  return submitted && current.startsWith(submitted) ? current.slice(submitted.length) : current;
47
47
  }
48
48
 
49
- function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, draftStorage, application, goalReadEnabled }) {
49
+ function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, deferWhileWorking, draftStorage, application, goalReadEnabled }) {
50
50
  const disposed = ref(false);
51
51
  const placementRevision = ref(0);
52
52
  const stopPlacement = placement.subscribe(() => { placementRevision.value += 1; });
@@ -90,8 +90,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
90
90
  const editable = computed(() => active.value && !accessDenied.value);
91
91
  const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
92
92
  const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
93
- const queueing = computed(() => queueWhileSending !== false && steerable.value);
94
- const canSubmit = computed(() => available.value && (snapshot.value.status === "ready" || steerable.value) &&
93
+ const queueing = computed(() => queueWhileSending !== false && (steerable.value || deferWhileWorking));
94
+ const canSubmit = computed(() => available.value && (snapshot.value.status === "ready" || steerable.value || deferWhileWorking && snapshot.value.status === "working") &&
95
95
  (queueWhileSending !== false || !delivery.state.sending) &&
96
96
  !delivery.state.messages.some(message => message.status === "uncertain"));
97
97
  const savedDraft = toValue(draftStorage);
@@ -109,9 +109,11 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
109
109
  const messages = Array.isArray(saved?.messages) ? saved.messages.filter(message =>
110
110
  typeof message?.id === "string" && typeof message.text === "string" &&
111
111
  typeof message.payload?.message === "string") : [];
112
- const restoredMessages = messages.filter(message => !delivery.find(message.id))
113
- .map(message => ({ ...message, status: "failed", error: message.error ||
114
- "Delivery was not confirmed before this page closed. Check the conversation before retrying." }));
112
+ const restoredMessages = messages.filter(message => !delivery.find(message.id)).map(message => {
113
+ const status = ["failed", "accepted"].includes(message.status) ? message.status : "uncertain";
114
+ return { ...message, status, checking: false, error: message.error || (status === "accepted" ? "" :
115
+ "Delivery was not confirmed before this page closed. Check the conversation before retrying.") };
116
+ });
115
117
  delivery.state.messages = unmatchedOptimisticMessages(turns.value, [...delivery.state.messages, ...restoredMessages], { receiptsOnly: true });
116
118
  } catch {
117
119
  draft.value = "";
@@ -232,6 +234,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
232
234
  }, { immediate: true, flush: "sync" });
233
235
  watch(current, value => {
234
236
  if (value) return;
237
+ for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
235
238
  delivery.reset();
236
239
  snapshot.value = null;
237
240
  olderPages.value = [];
@@ -251,7 +254,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
251
254
  disposed.value = true;
252
255
  subscription?.();
253
256
  stopPlacement();
254
- for (const controller of pendingMessages.values()) controller.abort();
257
+ for (const pending of pendingMessages.values()) pending.controller.abort();
255
258
  pendingMessages.clear();
256
259
  goalReadController?.abort();
257
260
  globalThis.removeEventListener?.("focus", refreshVisibleGoal);
@@ -268,7 +271,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
268
271
  const requestedSteering = request?.steer === true;
269
272
  if (requestedSteering && snapshot.value.capabilities?.steering !== true) return false;
270
273
  const steering = requestedSteering || steerable.value;
271
- if ((snapshot.value.status === "working" || delivery.state.sending) && !steering) return false;
274
+ const deferred = deferWhileWorking && !retry && !steering;
275
+ if ((snapshot.value.status === "working" || delivery.state.sending) && !steering && !deferred) return false;
272
276
  if ((retry?.payload || payload).displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) return false;
273
277
  const captured = retry?.payload?.request ? retry.payload : { ...(retry?.payload || payload), request: payload.request || {
274
278
  text: String(payload.message || ""),
@@ -276,7 +280,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
276
280
  ...(payload.displayAttachments?.length ? { attachmentIds: payload.displayAttachments.map(file => file.attachmentId) } : {}),
277
281
  ...(steerable.value ? { steer: true } : {})
278
282
  } };
279
- return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && steering });
283
+ return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred), deferred });
280
284
  }
281
285
  function submitPrepared(payload, { messageId = crypto.randomUUID(), onAccepted, prepare } = {}) {
282
286
  if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
@@ -315,16 +319,50 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
315
319
  }
316
320
  return result;
317
321
  }
318
- async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare }) {
322
+ function waitUntilReady({ controller }) {
323
+ return new Promise(resolve => {
324
+ let stop;
325
+ const finish = ready => {
326
+ stop?.();
327
+ controller.signal.removeEventListener("abort", check);
328
+ resolve(ready);
329
+ };
330
+ const check = () => {
331
+ if (controller.signal.aborted || !available.value) finish(false);
332
+ else if (snapshot.value.status === "ready") finish(true);
333
+ else if (snapshot.value.status !== "working") finish(false);
334
+ };
335
+ stop = watch([available, snapshot], check, { flush: "sync" });
336
+ controller.signal.addEventListener("abort", check, { once: true });
337
+ check();
338
+ });
339
+ }
340
+
341
+ async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false }) {
342
+ if (pendingMessages.has(messageId)) return false;
343
+ // Register before the original serial tail so Stop/retirement also reaches
344
+ // a local follower whose deliver callback has not started yet.
345
+ const pending = { controller: new AbortController(), started: false, dispatched: false, deferred };
346
+ const { controller } = pending;
347
+ pendingMessages.set(messageId, pending);
319
348
  try {
320
349
  const result = await delivery.send(payload, {
321
350
  messageId, queue, isCurrent: () => current.value, onAccepted,
322
351
  receiptTurns: turns, uncertainOnError: true,
323
352
  async deliver(submission) {
324
- const controller = new AbortController();
325
- pendingMessages.set(messageId, controller);
326
- let dispatched = false;
353
+ pending.started = true;
327
354
  try {
355
+ if (controller.signal.aborted) return false;
356
+ if (deferred) {
357
+ // Refresh through the same subscription after a queued predecessor;
358
+ // its receipt may have arrived before the working snapshot.
359
+ await subscription?.reload();
360
+ if (error.value || !await waitUntilReady(pending)) return false;
361
+ if (controller.signal.aborted || !available.value || snapshot.value.status !== "ready") return false;
362
+ if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
363
+ return { ok: false, error: "This conversation no longer accepts attachments." };
364
+ }
365
+ }
328
366
  const request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
329
367
  if (prepare) {
330
368
  if (!current.value || accessDenied.value || controller.signal.aborted || request === false) return false;
@@ -332,20 +370,20 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
332
370
  throw new TypeError("Application preparation must return its conversation request or false.");
333
371
  }
334
372
  }
335
- dispatched = true;
373
+ pending.dispatched = true;
336
374
  return await (submission.goalRequest
337
375
  ? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
338
376
  : api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
339
377
  }
340
378
  catch (failure) {
341
379
  if (controller.signal.aborted) return false;
342
- if (prepare && !dispatched) return { ok: false, error: failure.message,
380
+ if (prepare && !pending.dispatched) return { ok: false, error: failure.message,
343
381
  ...(failure.code ? { code: failure.code } : {}) };
344
382
  if (SEND_REJECTIONS.has(failure.code)) return { ok: false, error: failure.message,
345
383
  ...(prepare && failure.code ? { code: failure.code } : {}) };
346
384
  throw failure;
347
385
  } finally {
348
- if (pendingMessages.get(messageId) === controller) pendingMessages.delete(messageId);
386
+ if (pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
349
387
  }
350
388
  }
351
389
  });
@@ -355,13 +393,15 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
355
393
  if (prepare) return { ok: false, status: "uncertain", error: failure.message,
356
394
  ...(failure.code ? { code: failure.code } : {}) };
357
395
  return false;
358
- } finally { if (current.value) subscription?.reload(); }
396
+ } finally {
397
+ if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
398
+ if (current.value) subscription?.reload();
399
+ }
359
400
  }
360
401
  function cancelMessage(messageId) {
361
- const controller = pendingMessages.get(messageId);
362
- if (!controller) return false;
363
- controller.abort();
364
- pendingMessages.delete(messageId);
402
+ const pending = pendingMessages.get(messageId);
403
+ if (!pending) return false;
404
+ pending.controller.abort();
365
405
  return true;
366
406
  }
367
407
 
@@ -515,6 +555,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
515
555
  async function cancel() {
516
556
  if (!editable.value || stopping.value) return false;
517
557
  stopping.value = true;
558
+ for (const pending of pendingMessages.values()) {
559
+ if (pending.deferred && !pending.dispatched) pending.controller.abort();
560
+ }
518
561
  try { return await api.cancelConversation(identity.conversationId); }
519
562
  catch (failure) { receiveError(failure); return false; }
520
563
  finally { stopping.value = false; if (current.value) subscription?.reload(); }
@@ -536,7 +579,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
536
579
 
537
580
  const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
538
581
  snapshot, draft, draftMessageId, draftAttachments, draftRetry, draftRetryMatches, settleDraftRetry,
539
- error, loading, stopping, delivery, turns,
582
+ error, accessDenied, loading, stopping, delivery, turns,
540
583
  hasMoreBefore, loadingMore, loadMoreError, loadMore,
541
584
  send, submitPrepared, submitDraft, cancel, cancelMessage, inspectDelivery, changeGoal, refreshGoal, goalState, goalView, goalLoadError, questions,
542
585
  reload() { const job = subscription?.reload(); void refreshGoal(); return job; } };
@@ -581,7 +624,7 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
581
624
  conversation: {
582
625
  turns: conversationTurnsFromMessages(messages.value.map(message => ({ ...message, text: message.content }))),
583
626
  assistantLabel: display.assistantLabel || "Assistant", welcomeMessage: display.welcomeMessage,
584
- visible: display.visible, variant: display.variant, scrollKey: toValue(scope)
627
+ visible: display.visible, retainWhenHidden: display.retainWhenHidden, variant: display.variant, scrollKey: toValue(scope)
585
628
  },
586
629
  composer: {
587
630
  draft: draft.value, disabled: busy.value, pending: busy.value,
@@ -610,7 +653,7 @@ function conversationBindingSetup({ socket, boundedTask = null } = {}) {
610
653
 
611
654
  function createConversationBinding({ conversationId, endpoint = "", surfaceId = "", hostSurfaceId = "", workspaceSlug,
612
655
  actorKey: suppliedActorKey, api: suppliedApi = null, socket: suppliedSocket = null, active = true, onEvent,
613
- clearDraftOn: suppliedClearDraftOn, queueWhileSending, draftWhileLoading = false,
656
+ clearDraftOn: suppliedClearDraftOn, queueWhileSending, deferWhileWorking = false, draftWhileLoading = false,
614
657
  draftStorage = null, application = null, boundedTask = null,
615
658
  data, attachments = null, suggestions = null, models = null, questions = null, goal = null, presentation = {} } = {}, setup) {
616
659
  const { app, routeContext, placement, workspaceScope, socket: setupSocket, config, defaults = {} } = setup ||
@@ -622,6 +665,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
622
665
  });
623
666
  if (!["dispatch", "accepted"].includes(clearDraftOn)) throw new TypeError("clearDraftOn must be dispatch or accepted.");
624
667
  if (queueWhileSending !== undefined && typeof queueWhileSending !== "boolean") throw new TypeError("queueWhileSending must be a boolean.");
668
+ if (typeof deferWhileWorking !== "boolean") throw new TypeError("deferWhileWorking must be a boolean.");
625
669
  if (typeof draftWhileLoading !== "boolean") throw new TypeError("draftWhileLoading must be a boolean.");
626
670
  if (application !== null && typeof application !== "function") throw new TypeError("application must be a factory.");
627
671
  if (boundedTask !== null) return useBoundedTask(boundedTask, { active, data, presentation });
@@ -679,7 +723,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
679
723
  const key = JSON.stringify(["assistant", target.actorKey, target.endpoint, target.targetSurfaceId,
680
724
  target.hostSurfaceId, target.workspaceSlug, target.conversationId]);
681
725
  retained = retainAssistantConversation(app, key, readers => createConversation(target, {
682
- readers, socket, actorKey, placement, queueWhileSending, draftStorage, application, goalReadEnabled: toValue(goal) === true,
726
+ readers, socket, actorKey, placement, queueWhileSending, deferWhileWorking, draftStorage, application, goalReadEnabled: toValue(goal) === true,
683
727
  api: suppliedApi || defaults.api || createAssistantApi({ request: defaults.request || assistantHttpClient.request,
684
728
  resolveBasePath: () => target.endpoint, resolveSurfaceId: () => target.hostSurfaceId })
685
729
  }), { active, questions, goal, onEvent });
@@ -818,12 +862,13 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
818
862
  models: current?.available.value ? modelOwner.value : null, questions: prompts,
819
863
  goal: (toValue(goal) === true ? null : toValue(goal)) || current?.goalState.value || null,
820
864
  conversation: { turns: current?.turns.value || [], interimReply: state?.interimReply || null,
865
+ error: current?.error.value || state?.error || "", errorReloadable: Boolean(current) && !current.accessDenied.value,
821
866
  scrollKey: JSON.stringify(identity.value),
822
867
  hasMoreBefore: current?.hasMoreBefore.value || false,
823
868
  loadingMore: current?.loadingMore.value || false, loadMoreError: current?.loadMoreError.value || "",
824
- loading: current?.loading.value || false, working: state?.status === "working" && !current.delivery.state.sending,
869
+ loading: current?.loading.value || false, working: !current?.error.value && state?.status === "working" && !current.delivery.state.sending,
825
870
  assistantLabel: display.assistantLabel || "Assistant", welcomeMessage: display.welcomeMessage ?? "What would you like to do?",
826
- systemLabel: display.systemLabel, variant: display.variant, visible: display.visible,
871
+ systemLabel: display.systemLabel, variant: display.variant, visible: display.visible, retainWhenHidden: display.retainWhenHidden,
827
872
  userMessageFormat: display.userMessageFormat, progressPreviewLimit: display.progressPreviewLimit,
828
873
  previewMessage: current?.available.value ? display.previewMessage : null },
829
874
  composer: { draft, disabled: !editable ||
@@ -59,6 +59,7 @@ function subscribeAssistantConversation({
59
59
  publishState({ ...options, canonical: true });
60
60
  }
61
61
  function reload() {
62
+ if (!listening) return subscribe();
62
63
  if (reloadInFlight) {
63
64
  reloadQueued = true;
64
65
  return reloadInFlight;
@@ -82,7 +83,24 @@ function subscribeAssistantConversation({
82
83
  for (const [id, entry] of completed) if (settled.has(entry.turnId)) completed.delete(id);
83
84
  receiveState(snapshot);
84
85
  }).catch(error => {
85
- if (current()) onError(error);
86
+ if (current()) {
87
+ if ([401, 403].includes(Number(error.status || error.statusCode))) {
88
+ if (epoch === null) {
89
+ disconnected();
90
+ if (socket.connected) socket.emit(ASSISTANT_CONVERSATION_UNSUBSCRIBE, { subscriptionId });
91
+ } else {
92
+ // Retain invalidation notifications for a fresh authorized read, not cached content.
93
+ generation += 1;
94
+ reloadInFlight = null;
95
+ reloadQueued = false;
96
+ }
97
+ state = null;
98
+ streaming = null;
99
+ completed.clear();
100
+ settledTurns.clear();
101
+ }
102
+ onError(error);
103
+ }
86
104
  }).finally(() => {
87
105
  pendingReads.delete(pendingRead);
88
106
  if (reloadInFlight !== job) return;
@@ -100,6 +118,10 @@ function subscribeAssistantConversation({
100
118
  if (payload.streamEpoch !== epoch || !Number.isSafeInteger(payload.streamRevision) || payload.streamRevision <= revision) return;
101
119
  revision = payload.streamRevision;
102
120
  const event = payload.event;
121
+ if (!state) {
122
+ if (SNAPSHOT_EVENTS.has(event.type)) reload();
123
+ return;
124
+ }
103
125
  let changedPresentation = Object.hasOwn(event, "interimReply");
104
126
  if (changedPresentation && state) state = { ...state, interimReply: event.interimReply };
105
127
  if (event.type === "transcript") {
@@ -164,7 +186,9 @@ function subscribeAssistantConversation({
164
186
  listening = false;
165
187
  pending = [];
166
188
  if (socket.connected) socket.emit(ASSISTANT_CONVERSATION_UNSUBSCRIBE, { subscriptionId });
167
- onError(error || Object.assign(new Error(response?.error || "Conversation subscription failed."), {
189
+ onError(error ? Object.assign(new Error("Chat updates could not reconnect. Reload chat to try again.", { cause: error }), {
190
+ code: "assistant_subscription_transport_failed"
191
+ }) : Object.assign(new Error(response?.error || "Conversation subscription failed."), {
168
192
  code: response?.code, statusCode: response?.status
169
193
  }));
170
194
  return;
@@ -15,6 +15,7 @@ const AssistantFeature = defineFeature({
15
15
  http: "runtime.http"
16
16
  },
17
17
  optional: {
18
+ logger: "runtime.logger",
18
19
  conversationRuntime: "assistant.conversations",
19
20
  conversationAccess: "assistant.conversation.access",
20
21
  assistantConfigService: "assistant.settings",
@@ -97,11 +98,11 @@ const AssistantFeature = defineFeature({
97
98
  config
98
99
  });
99
100
  },
100
- boot({ conversationRuntime, conversationAccess, realtime, events, config, workspaces }, { outputs }) {
101
+ boot({ conversationRuntime, conversationAccess, realtime, events, config, workspaces, logger }, { outputs }) {
101
102
  if (!conversationRuntime || !realtime) return;
102
103
  const state = subscriptionLifecycle.get(outputs.assistant);
103
104
  state.release = registerConversationSubscriptions({
104
- realtime, events, actions: state.actions, config, workspaceScopeSupport: workspaces?.scope || null,
105
+ realtime, events, actions: state.actions, config, logger, workspaceScopeSupport: workspaces?.scope || null,
105
106
  ...(conversationAccess ? {
106
107
  subscribeActionId: conversationAccess.subscribeActionId,
107
108
  requestPolicy: conversationAccess.requestPolicy
@@ -201,10 +201,13 @@ 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
+ deps.onStage?.("conversation-open");
204
205
  const conversation = await openConversation(input, context);
206
+ deps.onStage?.("observer-attach");
205
207
  const release = await conversation.subscribe(deps.onEvent);
206
208
  try {
207
209
  deps.onRelease(release);
210
+ deps.onStage?.("snapshot-read");
208
211
  return await conversation.read();
209
212
  } catch (error) {
210
213
  release();
@@ -30,7 +30,7 @@ function subscriptionError(error) {
30
30
  // Register one conversation subscription owner per shared realtime runtime.
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
- function registerConversationSubscriptions({ realtime, events, actions, config = {}, workspaceScopeSupport = null,
33
+ function registerConversationSubscriptions({ realtime, events, actions, config = {}, logger = null, workspaceScopeSupport = null,
34
34
  subscribeActionId = actionIds.conversationSubscribe, requestPolicy = "authenticated" } = {}) {
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.");
@@ -63,14 +63,34 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
63
63
  if (typeof acknowledge !== "function" || closed) return;
64
64
  let subscription;
65
65
  let subscriptionId;
66
+ const startedAt = performance.now();
67
+ let stageStartedAt = startedAt;
68
+ let stage = "request-admission";
69
+ let observedConversationId;
70
+ function onStage(next, fields = {}) {
71
+ const now = performance.now();
72
+ // Only a successfully opened, authorized conversation may enter diagnostics.
73
+ if (next === "observer-attach") observedConversationId = input.conversationId;
74
+ try {
75
+ logger?.info?.({ event: "assistant.conversation.subscription", subscriptionEpoch: subscription?.epoch,
76
+ ...(typeof observedConversationId === "string" ? { conversationId: observedConversationId } : {}),
77
+ stage: next, previousStage: stage,
78
+ durationMs: now - stageStartedAt, elapsedMs: now - startedAt, ...fields },
79
+ "Assistant conversation subscription stage");
80
+ } catch { /* Diagnostics must not change conversation admission or cleanup. */ }
81
+ stage = next;
82
+ stageStartedAt = now;
83
+ }
66
84
  try {
67
85
  subscriptionId = requireSubscriptionId(input);
68
86
  release(subscriptionId);
69
87
  subscription = { pending: new Map(), revision: 0, epoch: randomUUID(), queue: Promise.resolve() };
70
88
  subscriptions.set(subscriptionId, subscription);
71
89
  const current = () => !closed && socket.connected && subscriptions.get(subscriptionId) === subscription;
90
+ onStage("authentication");
72
91
  const request = await (requestPolicy === "host" ? readRequest() : authenticate());
73
- if (!current()) return;
92
+ if (!current()) { onStage("abandoned"); return; }
93
+ onStage("action-admission");
74
94
  const assistantSurface = resolveAssistantSurfaceConfig(config, input.targetSurfaceId);
75
95
  if (!assistantSurface) throw new AppError(404, "Assistant not found.");
76
96
  const requiresWorkspace = assistantSurface.runtimeSurfaceRequiresWorkspace;
@@ -127,15 +147,24 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
127
147
  input: { ...routeState.actionInput, conversationId: input.conversationId },
128
148
  deps: {
129
149
  onEvent,
150
+ onStage,
130
151
  onRelease(detach) {
131
152
  if (!current()) detach();
132
153
  else subscription.release = detach;
133
154
  }
134
155
  }
135
156
  });
136
- if (!current()) return;
157
+ if (!current()) { onStage("abandoned"); return; }
158
+ onStage("acknowledgement", {
159
+ ...(Array.isArray(state?.conversationLog) ? { snapshotTurns: state.conversationLog.length } : {}),
160
+ ...(Number.isSafeInteger(state?.pagination?.limit) ? { snapshotLimit: state.pagination.limit } : {})
161
+ });
137
162
  acknowledge({ ok: true, subscriptionId, streamEpoch: subscription.epoch, state });
163
+ // Sending an acknowledgement does not prove that a timed-out client received it.
164
+ onStage("acknowledgement-sent");
138
165
  } catch (error) {
166
+ onStage("failed", { code: String(error?.code || "assistant_subscription_failed"),
167
+ status: Number(error?.status || error?.statusCode || 500) });
139
168
  if (subscriptions.get(subscriptionId) === subscription) release(subscriptionId);
140
169
  if (!closed && socket.connected) acknowledge(subscriptionError(error));
141
170
  }
@@ -601,12 +601,140 @@ test("canonical receipts settle a held response and uncertain delivery stays ins
601
601
  await expect(f.primary.getByText("Keep this exact uncertain request", { exact: true })).toHaveCount(0);
602
602
  });
603
603
 
604
- test("an explicit pre-admission rejection retains original retry identity and the newer draft", options, async t => {
604
+ for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognised"]) {
605
+ test(`saved delivery preserves its actual admission fact through page reload: ${status}`, options, async t => {
606
+ const f = await fixture(t);
607
+ const messageId = `reload-${status}`;
608
+ const attach = async () => {
609
+ await f.page.evaluate(() => {
610
+ window.savedBinding = window.conversationFixture.acquireConversation({
611
+ conversationId: "chat:2", draftStorage: { storage: sessionStorage, key: "original-delivery-reload" }
612
+ });
613
+ window.conversationFixture.target("chat:2");
614
+ });
615
+ await expect.poll(() => f.page.evaluate(() => window.savedBinding.runtime.value?.available.value)).toBe(true);
616
+ };
617
+ await attach();
618
+ await f.command("mode", { mode: status === "accepted" ? "product-native-duplicate"
619
+ : status === "failed" ? "rejected" : status === "pending" ? "hold" : "uncertain" });
620
+ await f.page.evaluate(messageId => {
621
+ const runtime = window.savedBinding.runtime.value;
622
+ runtime.draft.value = "Keep my separate newer draft";
623
+ window.savedSubmission = runtime.send({ message: "Exact original saved words",
624
+ data: { clientId: "original-client", focus: { project: "original-project" } } }, { messageId });
625
+ }, messageId);
626
+ await expect.poll(async () => (await f.sent()).length).toBe(1);
627
+ if (status !== "pending") await f.page.evaluate(() => window.savedSubmission);
628
+ const unconfirmed = ["uncertain", "pending", "unrecognised"].includes(status);
629
+ await f.command("question", { id: "chat:2", text: "An unrelated completed answer.", pending: false });
630
+ if (unconfirmed) {
631
+ await f.command("history", { id: "chat:2", limit: 20, turns: [{ turnId: messageId,
632
+ user: { messageId, role: "user", text: "Exact original saved words", receipt: false }
633
+ }] });
634
+ }
635
+ await f.notify("chat:2");
636
+ await expect.poll(() => f.page.evaluate(() => window.savedBinding.runtime.value.snapshot.value.status)).toBe("ready");
637
+ if (status === "unrecognised") {
638
+ await f.page.evaluate(() => {
639
+ const saved = JSON.parse(sessionStorage.getItem("original-delivery-reload"));
640
+ saved.messages[0].status = "unrecognised";
641
+ sessionStorage.setItem("original-delivery-reload", JSON.stringify(saved));
642
+ });
643
+ }
644
+ let releaseInspection;
645
+ if (status === "uncertain") {
646
+ const gate = Promise.withResolvers();
647
+ t.after(() => gate.resolve());
648
+ releaseInspection = gate.resolve;
649
+ await f.page.route("**/deliveries/**/inspect", async route => {
650
+ await gate.promise;
651
+ await route.fulfill({ json: { status: "unknown", messageId } }).catch(() => {});
652
+ });
653
+ await f.page.evaluate(messageId => { void window.savedBinding.runtime.value.inspectDelivery(messageId); }, messageId);
654
+ await expect.poll(() => f.page.evaluate(() => JSON.parse(sessionStorage.getItem("original-delivery-reload")).messages[0].checking)).toBe(true);
655
+ }
656
+ const saved = await f.page.evaluate(() => JSON.parse(sessionStorage.getItem("original-delivery-reload")));
657
+ assert.equal(saved.messages[0].status, status);
658
+ await f.page.reload();
659
+ releaseInspection?.();
660
+ if (releaseInspection) await f.page.unroute("**/deliveries/**/inspect");
661
+ await expect(f.input).toBeEnabled();
662
+ await attach();
663
+ await expect(f.input).toHaveValue(saved.draft);
664
+ const restored = await f.page.evaluate(messageId => window.savedBinding.runtime.value.delivery.find(messageId), messageId);
665
+ assert.equal(restored.status, unconfirmed ? "uncertain" : status);
666
+ assert.equal(restored.checking, false, "an inspector from the old page cannot disable recovery on the new page");
667
+ assert.equal(restored.id, saved.messages[0].id);
668
+ assert.deepEqual(restored.payload, saved.messages[0].payload);
669
+ assert.equal((await f.sent()).length, 1, "restoration never dispatches a saved request");
670
+ assert.equal(await f.page.evaluate(() => window.savedBinding.runtime.value.canSubmit.value), !unconfirmed);
671
+ if (unconfirmed) {
672
+ const check = f.primary.getByRole("button", { name: "Check delivery", exact: true });
673
+ try { await expect(check).toBeEnabled(); }
674
+ catch (failure) {
675
+ t.diagnostic(JSON.stringify(await f.page.evaluate(() => {
676
+ const saved = window.savedBinding.runtime.value;
677
+ const primary = window.conversationFixture.current();
678
+ return { shared: saved === primary, savedIdentity: saved.identity, primaryIdentity: primary.identity,
679
+ canonical: saved.turns.value, messages: saved.delivery.state.messages,
680
+ projected: saved.delivery.turns(saved.turns.value) };
681
+ })));
682
+ await f.page.screenshot({ path: `/tmp/jskit-saved-delivery-reload-${status}-20261006.png` });
683
+ throw failure;
684
+ }
685
+ for (const action of ["Retry", "Cancel", "Edit"]) {
686
+ await expect(f.primary.getByRole("button", { name: action, exact: true })).toHaveCount(0);
687
+ }
688
+ // The fixture's original inspector matches any same-ID row. Keep the
689
+ // nonreceipt projection proof above, then inspect genuine absence.
690
+ await f.command("history", { id: "chat:2", turns: [], limit: 20 });
691
+ await f.notify("chat:2");
692
+ await expect(check).toBeEnabled();
693
+ assert.equal(await f.page.evaluate(messageId => window.savedBinding.runtime.value.delivery.find(messageId).status, messageId), "uncertain",
694
+ "removing a nonreceipt row is not evidence of rejection or admission");
695
+ await check.click();
696
+ await expect(check).toBeEnabled();
697
+ assert.equal(await f.page.evaluate(messageId => window.savedBinding.runtime.value.delivery.find(messageId).status, messageId), "uncertain");
698
+ assert.equal((await f.sent()).length, 1, "unknown inspection cannot resend or acknowledge");
699
+ if (status === "pending") await f.command("accept");
700
+ else await f.command("confirm", { id: "chat:2" });
701
+ await f.notify("chat:2");
702
+ await expect(check).toHaveCount(0);
703
+ assert.equal((await f.sent()).length, 1, "only the actual canonical receipt settles the original identity");
704
+ } else {
705
+ await expect(f.primary.getByRole("button", { name: "Check delivery", exact: true })).toHaveCount(0);
706
+ await expect(f.primary.getByRole("button", { name: "Retry", exact: true })).toHaveCount(status === "failed" ? 1 : 0);
707
+ }
708
+ });
709
+ }
710
+
711
+ for (const code of ["ACTION_VALIDATION_FAILED", "conversation_not_steerable"]) {
712
+ test(`an explicit pre-admission rejection retains original retry identity and the newer draft: ${code}`, options, async t => {
605
713
  const f = await fixture(t);
606
714
  await f.command("mode", { mode: "rejected" });
715
+ if (code === "conversation_not_steerable") {
716
+ await f.command("steering", { id: "chat:1", enabled: true });
717
+ await f.command("question", { id: "chat:1", text: "An earlier answer is still finishing.", pending: true });
718
+ await f.notify("chat:1");
719
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
720
+ await f.page.route("**/messages", async route => {
721
+ const response = await route.fetch();
722
+ await route.fulfill(response.status() === 400
723
+ ? { response, status: 409, json: { error: "The message was rejected before admission.", code } }
724
+ : { response });
725
+ });
726
+ }
607
727
  await f.input.fill("Retry only this original message");
608
728
  await f.input.press("Enter");
609
729
  await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toBeVisible();
730
+ const [rejected] = await f.sent();
731
+ assert.equal((await f.sent()).length, 1, "a definite rejection never silently resubmits steering as new work");
732
+ assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).status, rejected.input.messageId), "failed");
733
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().canSubmit.value), true);
734
+ await expect(f.primary.getByRole("button", { name: "Check delivery", exact: true })).toHaveCount(0);
735
+ if (code === "conversation_not_steerable") {
736
+ assert.equal(rejected.input.steer, true);
737
+ }
610
738
  await f.input.fill("My newer draft");
611
739
  await f.command("mode", { mode: "accepted" });
612
740
  await f.primary.getByRole("button", { name: "Retry", exact: true }).click();
@@ -619,7 +747,118 @@ test("an explicit pre-admission rejection retains original retry identity and th
619
747
  await f.primary.getByRole("button", { name: "Stop", exact: true }).click();
620
748
  await expect(f.primary.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0);
621
749
  assert.equal((await f.state()).requests.filter(request => request.suffix === "/cancel").length, 1);
750
+ if (code === "conversation_not_steerable") {
751
+ await f.command("mode", { mode: "rejected" });
752
+ for (const action of ["Cancel", "Edit"]) {
753
+ await f.command("question", { id: "chat:1", text: "An earlier answer is still finishing.", pending: true });
754
+ await f.notify("chat:1");
755
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
756
+ const text = `Known rejected steer for ${action}`;
757
+ await f.input.fill(text);
758
+ await f.input.press("Enter");
759
+ await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toBeVisible();
760
+ const request = (await f.sent()).at(-1);
761
+ assert.equal(request.input.steer, true);
762
+ await f.input.fill("Keep my newer draft");
763
+ await f.primary.getByRole("button", { name: action, exact: true }).click();
764
+ await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toHaveCount(0);
765
+ assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id), request.input.messageId), null);
766
+ await expect(f.input).toHaveValue(action === "Edit" ? `${text}\n\nKeep my newer draft` : "Keep my newer draft");
767
+ }
768
+ assert.equal((await f.sent()).length, 4, "Cancel and Edit do not dispatch another prompt");
769
+ assert.equal((await f.state()).requests.filter(request => request.suffix === "/cancel").length, 1, "failed-message recovery does not stop agent work");
770
+ }
622
771
  });
772
+ }
773
+
774
+ test("opted-in nonsteerable follow-ups use the original serial queue and dispatch only after ready", options, async t => {
775
+ const f = await fixture(t);
776
+ await f.page.evaluate(() => {
777
+ window.deferredBinding = window.conversationFixture.acquireConversation({
778
+ conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true
779
+ });
780
+ window.conversationFixture.target("chat:2");
781
+ });
782
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
783
+ await expect(f.input).toBeEnabled();
784
+ await f.input.fill("Original nonsteerable request");
785
+ await f.input.press("Enter");
786
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.status)).toBe("working");
787
+ await f.page.evaluate(() => window.conversationFixture.data({ clientId: "typed", focus: { project: "captured" } }));
788
+ await f.input.fill("Typed follow-up while answering");
789
+ await f.input.press("Enter");
790
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages.length)).toBe(1);
791
+ const [typed] = await f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages);
792
+ await f.page.evaluate(() => {
793
+ window.conversationFixture.retainVoice();
794
+ window.queuedSpeech = window.conversationFixture.sendVoice("Spoken follow-up while answering", {
795
+ clientId: "voice", focus: { project: "voice-captured" }
796
+ });
797
+ window.conversationFixture.changeFocus({ project: "later" });
798
+ });
799
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages.length)).toBe(2);
800
+ const [, spoken] = await f.page.evaluate(() => window.conversationFixture.current().delivery.state.messages);
801
+ await expect(f.primary.getByText(typed.text, { exact: true })).toHaveCount(1);
802
+ await expect(f.primary.getByText(spoken.text, { exact: true })).toHaveCount(1);
803
+ await expect(f.input).toBeEnabled();
804
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().canSubmit.value), true);
805
+ assert.equal((await f.sent()).length, 1, "both authored follow-ups wait locally without HTTP");
806
+ await f.command("finish", { id: "chat:2" });
807
+ await f.notify("chat:2");
808
+ await expect.poll(async () => (await f.sent()).length).toBe(2);
809
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().snapshot.value.status)).toBe("working");
810
+ assert.deepEqual((await f.sent())[1].input, {
811
+ messageId: typed.id, text: typed.text, data: { clientId: "typed", focus: { project: "captured" } }
812
+ });
813
+ assert.equal((await f.sent()).length, 2, "the next follower waits for the first follow-up's real turn");
814
+ await f.command("finish", { id: "chat:2" });
815
+ await f.notify("chat:2");
816
+ await expect.poll(async () => (await f.sent()).length).toBe(3);
817
+ assert.deepEqual((await f.sent())[2].input, {
818
+ messageId: spoken.id, text: spoken.text, data: { clientId: "voice", focus: { project: "voice-captured" } }
819
+ });
820
+ assert.equal((await f.page.evaluate(() => window.queuedSpeech)).status, "accepted");
821
+ assert.equal((await f.state()).requests.some(request => request.suffix === "/cancel"), false,
822
+ "ordinary follow-ups never stop the original turn or output owner");
823
+ await f.command("steering", { id: "chat:2", enabled: true });
824
+ await f.notify("chat:2");
825
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
826
+ assert.equal((await f.page.evaluate(() => window.conversationFixture.sendVoice("Native steering remains immediate"))).status, "accepted");
827
+ assert.equal((await f.sent()).at(-1).input.steer, true);
828
+ });
829
+
830
+ for (const action of ["stop", "actor", "denied"]) {
831
+ test(`opted-in local followers cannot dispatch after ${action}`, options, async t => {
832
+ const f = await fixture(t);
833
+ await f.page.evaluate(() => {
834
+ window.deferredBinding = window.conversationFixture.acquireConversation({
835
+ conversationId: "chat:2", deferWhileWorking: true, queueWhileSending: true
836
+ });
837
+ window.conversationFixture.target("chat:2");
838
+ });
839
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current()?.identity.conversationId)).toBe("chat:2");
840
+ await f.command("mode", { mode: "hold" });
841
+ await f.page.evaluate(() => {
842
+ window.firstDeferred = window.deferredBinding.runtime.value.send({ message: "First in-flight request" }, { messageId: "first-held" });
843
+ });
844
+ await expect.poll(async () => (await f.state()).held).toBe(1);
845
+ await f.page.evaluate(() => {
846
+ window.followerDeferred = window.deferredBinding.runtime.value.send({ message: "Local follower only" }, { messageId: "local-follower" });
847
+ });
848
+ assert.equal((await f.sent()).length, 1);
849
+ if (action === "stop") await f.page.evaluate(() => window.deferredBinding.runtime.value.cancel());
850
+ else if (action === "actor") await f.page.evaluate(() => window.conversationFixture.actor("different-actor"));
851
+ else {
852
+ await f.command("deny", { denied: true });
853
+ await f.notify("chat:2");
854
+ await expect.poll(() => f.page.evaluate(() => window.deferredBinding.runtime.value.available.value)).toBe(false);
855
+ }
856
+ await f.command("accept");
857
+ assert.equal(await f.page.evaluate(() => window.followerDeferred), false);
858
+ assert.equal((await f.sent()).length, 1, "a canceled local follower never reaches HTTP behind the original serial predecessor");
859
+ assert.equal((await f.state()).requests.filter(request => request.suffix === "/cancel").length, action === "stop" ? 1 : 0);
860
+ });
861
+ }
623
862
 
624
863
  test("authored application data stays captured across retry, a lost receipt and changed focus", options, async t => {
625
864
  const f = await fixture(t);
@@ -1866,3 +2105,70 @@ test("acceptance settles a voice-retained conversation after its text view retar
1866
2105
  await expect(f.input).toHaveValue("The next conversation's draft stays.");
1867
2106
  assert.equal((await f.sent()).length, 1);
1868
2107
  });
2108
+
2109
+ test("transient subscription errors keep loaded history and recover through the banner while denial clears private content", options, async t => {
2110
+ const f = await fixture(t, 390, { acceptedDraft: true });
2111
+ await f.input.fill("Start a controlled ongoing answer.");
2112
+ await f.input.press("Enter");
2113
+ const stop = f.primary.getByRole("button", { name: "Stop", exact: true });
2114
+ await expect(stop).toBeVisible();
2115
+ const sentBeforeRecovery = (await f.sent()).length;
2116
+ assert.equal(sentBeforeRecovery, 1);
2117
+ const workingHint = f.primary.locator(".assistant-composer-support__assistant-status");
2118
+ await expect(workingHint).toHaveText("Assistant is working…");
2119
+ const turns = Array.from({ length: 30 }, (_, index) => ({
2120
+ turnId: `saved-${index}`,
2121
+ user: { messageId: `question-${index}`, role: "user", text: `Private saved question ${index}. ` + "Keep the original discussion. ".repeat(8) },
2122
+ assistant: { messageId: `answer-${index}`, role: "assistant", text: `Private saved answer ${index}. ` + "Read the saved answer without losing your place. ".repeat(8) }
2123
+ }));
2124
+ await f.command("history", { id: "chat:1", turns, limit: 30 });
2125
+ await f.notify("chat:1");
2126
+ const body = f.primary.locator(".assistant-transcript__body");
2127
+ await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
2128
+ await expect(f.primary.locator(".assistant-transcript__settling")).toHaveCount(0);
2129
+ await f.input.fill("Keep my newer private draft.");
2130
+ await body.hover();
2131
+ await f.page.mouse.wheel(0, -1000);
2132
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
2133
+ await body.evaluate(element => { element.scrollTop = 420; window.loadedUpdateBody = element; });
2134
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
2135
+ await f.input.evaluate(element => { element.focus({ preventScroll: true }); element.setSelectionRange(2, 7); });
2136
+ const reads = "**/api/assistant/home/conversations/*";
2137
+ await f.page.route(reads, route => route.fulfill({ status: 503, json: { error: "Chat updates are temporarily unavailable." } }));
2138
+ await f.page.evaluate(() => window.conversationFixture.current().reload());
2139
+ const banner = f.primary.locator(".assistant-transcript__error");
2140
+ await expect(banner).toContainText("Chat updates are temporarily unavailable.");
2141
+ await expect(workingHint).toHaveCount(0);
2142
+ await expect(stop).toBeVisible();
2143
+ await expect(f.primary.getByText("Chat updates are temporarily unavailable.", { exact: true })).toHaveCount(1);
2144
+ await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
2145
+ assert.equal(await body.evaluate(element => element === window.loadedUpdateBody), true);
2146
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
2147
+ await expect(f.input).toBeFocused();
2148
+ assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [2, 7]);
2149
+ await expect(f.input).toHaveValue("Keep my newer private draft.");
2150
+ await f.page.evaluate(() => window.conversationFixture.socket.reconnect());
2151
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.socket.inspect().active)).toBe(0);
2152
+ await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
2153
+ await f.page.unroute(reads);
2154
+ await banner.getByRole("button", { name: "Reload chat", exact: true }).click();
2155
+ await expect(banner).toHaveCount(0);
2156
+ await expect(workingHint).toHaveText("Assistant is working…");
2157
+ await expect(stop).toBeVisible();
2158
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.socket.inspect().active)).toBe(1);
2159
+ assert.equal(await body.evaluate(element => element === window.loadedUpdateBody), true);
2160
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
2161
+ await expect(f.input).toHaveValue("Keep my newer private draft.");
2162
+ await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
2163
+ assert.equal((await f.sent()).length, sentBeforeRecovery, "Subscription recovery never sends the retained draft");
2164
+ await f.command("deny", { denied: true });
2165
+ await f.page.evaluate(() => window.conversationFixture.current().reload());
2166
+ await expect(banner).toContainText("Access denied.");
2167
+ await expect(banner.getByRole("button", { name: "Reload chat", exact: true })).toHaveCount(0);
2168
+ await expect(body.locator(".assistant-transcript__turn")).toHaveCount(0);
2169
+ await expect(f.input).toHaveValue("");
2170
+ await expect(f.input).toBeDisabled();
2171
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.socket.inspect().active)).toBe(1);
2172
+ assert.equal((await f.sent()).length, sentBeforeRecovery);
2173
+ assert.ok(await f.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
2174
+ });
@@ -52,6 +52,7 @@ async function fixture(t) {
52
52
  const fetches = [];
53
53
  const runtimeEvents = [];
54
54
  const authRequests = [];
55
+ const subscriptionTimings = [];
55
56
  const nextReadBarriers = new Map();
56
57
  const clientSessions = new WeakMap();
57
58
  const context = { actor: { id: "42" }, surface: "home", channel: "internal" };
@@ -140,7 +141,8 @@ async function fixture(t) {
140
141
  serverSockets.add(socket);
141
142
  return () => { serverSockets.delete(socket); notify(); };
142
143
  });
143
- const stopSubscriptions = registerConversationSubscriptions({ realtime, events, actions, config });
144
+ const stopSubscriptions = registerConversationSubscriptions({ realtime, events, actions, config,
145
+ logger: { info(record) { subscriptionTimings.push(record); notify(); } } });
144
146
  const published = [];
145
147
  events.register({ id: "test.assistant.published", handle(event) { published.push(event); notify(); } });
146
148
  http.listen(0, "127.0.0.1");
@@ -201,9 +203,113 @@ async function fixture(t) {
201
203
  return barrier;
202
204
  }
203
205
  return { ids, connect, view, changes, sessions, revoked, fetches, conversations, runtimeEvents,
204
- realtime, transport, serverSockets, published, events, stopSubscriptions, authRequests, holdNextRead, active: () => active };
206
+ realtime, transport, serverSockets, published, events, stopSubscriptions, authRequests, subscriptionTimings,
207
+ holdNextRead, active: () => active };
205
208
  }
206
209
 
210
+ test("subscription timings identify a blocked snapshot and abandoned observation without stopping work", { timeout: 20_000 }, async t => {
211
+ const f = await fixture(t);
212
+ const socket = await f.connect();
213
+ const id = f.ids[0];
214
+ await f.conversations.get(id).send({ messageId: "measured-once", text: "Continue while history is being read" });
215
+ await until(f.changes, () => f.fetches.length === 1);
216
+ const barrier = f.holdNextRead(id);
217
+ const view = f.view(socket, id);
218
+ await until(f.changes, () => barrier.started);
219
+ assert.deepEqual(f.subscriptionTimings.map(record => record.stage), [
220
+ "authentication", "action-admission", "conversation-open", "observer-attach", "snapshot-read"
221
+ ]);
222
+ assert.equal(f.active(), 1);
223
+ assert.equal(view.states.length, 0);
224
+ view.dispose();
225
+ await until(f.changes, () => f.active() === 0);
226
+ barrier.resolve();
227
+ await until(f.changes, () => f.subscriptionTimings.at(-1)?.stage === "abandoned");
228
+ const abandoned = f.subscriptionTimings.at(-1);
229
+ assert.equal(abandoned.previousStage, "snapshot-read");
230
+ assert.equal(abandoned.conversationId, id);
231
+ assert.equal(f.fetches[0].signal.aborted, false);
232
+ assert.equal(f.fetches.length, 1);
233
+ assert.equal(view.states.length, 0, "An abandoned read never acknowledges stale conversation content");
234
+
235
+ const recovered = f.view(socket, id);
236
+ await until(f.changes, () => recovered.states.length === 1);
237
+ const records = f.subscriptionTimings.filter(record => record.subscriptionEpoch !== abandoned.subscriptionEpoch);
238
+ assert.deepEqual(records.map(record => record.stage), ["authentication", "action-admission", "conversation-open",
239
+ "observer-attach", "snapshot-read", "acknowledgement", "acknowledgement-sent"]);
240
+ assert.ok(records.every(record => record.event === "assistant.conversation.subscription" &&
241
+ Number.isFinite(record.durationMs) && record.durationMs >= 0 &&
242
+ Number.isFinite(record.elapsedMs) && record.elapsedMs >= record.durationMs));
243
+ assert.ok(records.slice(1).every((record, index) => record.elapsedMs >= records[index].elapsedMs));
244
+ assert.ok(records.slice(0, 3).every(record => !Object.hasOwn(record, "conversationId")),
245
+ "Unvalidated request identity is never copied into diagnostics");
246
+ assert.ok(records.slice(3).every(record => record.conversationId === id));
247
+ assert.ok(records.every(record => !Object.hasOwn(record, "subscriptionId")),
248
+ "Correlation uses the server epoch rather than arbitrary client subscription text");
249
+ assert.equal(records.find(record => record.stage === "acknowledgement").snapshotTurns, 1);
250
+ assert.equal(recovered.states[0].conversationLog[0].user.messageId, "measured-once");
251
+ assert.equal(f.active(), 1);
252
+ assert.equal(f.fetches.length, 1);
253
+ assert.equal(f.fetches[0].signal.aborted, false);
254
+ recovered.dispose();
255
+ await until(f.changes, () => f.active() === 0);
256
+ });
257
+
258
+ test("the real ten-second subscription deadline releases its late observer and Reload never repeats work", { timeout: 25_000 }, async t => {
259
+ const f = await fixture(t);
260
+ const socket = await f.connect();
261
+ const socketId = socket.id;
262
+ const id = f.ids[0];
263
+ await f.conversations.get(id).send({ messageId: "survives-timeout", text: "Keep working through a slow snapshot" });
264
+ await until(f.changes, () => f.fetches.length === 1);
265
+ const barrier = f.holdNextRead(id);
266
+ const view = f.view(socket, id);
267
+ await until(f.changes, () => barrier.started);
268
+ const firstSubscriptionEpoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
269
+ while (!view.errors.length || f.active() !== 0) {
270
+ await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
271
+ }
272
+ assert.equal(view.errors[0].message, "Chat updates could not reconnect. Reload chat to try again.");
273
+ assert.equal(view.errors[0].cause.message, "operation has timed out");
274
+ assert.equal(socket.id, socketId);
275
+ assert.equal(socket.connected, true);
276
+ assert.equal(view.states.length, 0);
277
+ assert.equal(f.fetches[0].signal.aborted, false);
278
+ view.dispose.reload();
279
+ await until(f.changes, () => view.states.length === 1 && f.active() === 1);
280
+ assert.equal(view.states[0].conversationLog[0].user.messageId, "survives-timeout");
281
+ const statesBeforeLateRead = view.states.length;
282
+ barrier.resolve();
283
+ await until(f.changes, () => f.subscriptionTimings.some(record =>
284
+ record.subscriptionEpoch === firstSubscriptionEpoch && record.stage === "abandoned"));
285
+ assert.equal(view.states.length, statesBeforeLateRead, "The obsolete server read never acknowledges into the recovered view");
286
+ assert.equal(f.active(), 1, "The late read cannot release the recovered observer");
287
+ assert.equal(f.fetches.length, 1);
288
+ assert.equal(f.fetches[0].signal.aborted, false);
289
+ assert.equal(socket.listeners(ASSISTANT_CONVERSATION_EVENT).length, 1);
290
+ f.fetches[0].text("Still running after the timed-out observation.");
291
+ await until(f.changes, () => view.received.some(event => event.text?.includes("timed-out observation")));
292
+ view.dispose();
293
+ await until(f.changes, () => f.active() === 0);
294
+ });
295
+
296
+ test("subscription diagnostics never serialize unvalidated client identifiers", async t => {
297
+ const f = await fixture(t);
298
+ const socket = await f.connect();
299
+ const privateText = "unvalidated-client-content-must-not-enter-logs";
300
+ const result = await requestSubscription(socket, {
301
+ subscriptionId: privateText, conversationId: { text: privateText }, targetSurfaceId: "home", hostSurfaceId: "home"
302
+ });
303
+ assert.equal(result.ok, false);
304
+ assert.equal(result.status, 400);
305
+ assert.equal(f.active(), 0);
306
+ assert.equal(f.subscriptionTimings.at(-1).stage, "failed");
307
+ assert.ok(f.subscriptionTimings.every(record => /^[0-9a-f-]{36}$/.test(record.subscriptionEpoch)));
308
+ assert.ok(f.subscriptionTimings.every(record => !Object.hasOwn(record, "conversationId") &&
309
+ !Object.hasOwn(record, "subscriptionId")));
310
+ assert.equal(JSON.stringify(f.subscriptionTimings).includes(privateText), false);
311
+ });
312
+
207
313
  test("five chats share one authenticated socket and release independently without stopping turns", { timeout: 20_000 }, async t => {
208
314
  const f = await fixture(t);
209
315
  const socket = await f.connect();
@@ -237,6 +343,95 @@ test("five chats share one authenticated socket and release independently withou
237
343
  assert.ok(views.every(view => view.errors.length === 0));
238
344
  });
239
345
 
346
+ test("Reload recovers a failed initial subscription on the same connected socket without repeating native work", { timeout: 20_000 }, async t => {
347
+ const f = await fixture(t);
348
+ const socket = await f.connect();
349
+ const socketId = socket.id;
350
+ const barrier = f.holdNextRead(f.ids[0]);
351
+ const view = f.view(socket, f.ids[0]);
352
+ await until(f.changes, () => barrier.started);
353
+ barrier.reject(new Error("The initial history read failed."));
354
+ await until(f.changes, () => view.errors.length === 1 && f.active() === 0);
355
+ assert.equal(socket.connected, true);
356
+ assert.equal(view.states.length, 0);
357
+
358
+ await f.conversations.get(f.ids[0]).send({ messageId: "accepted-once", text: "Keep this admitted turn" });
359
+ await until(f.changes, () => f.fetches.length === 1);
360
+ f.revoked.add(f.ids[0]);
361
+ const authBeforeRetry = f.authRequests.length;
362
+ view.dispose.reload();
363
+ await until(f.changes, () => view.errors.length === 2);
364
+ assert.equal(view.errors.at(-1).statusCode, 403, "Reload must authorize again rather than reuse failed subscription authority");
365
+ assert.ok(f.authRequests.length > authBeforeRetry);
366
+ assert.equal(f.active(), 0);
367
+ assert.equal(view.states.length, 0);
368
+ assert.equal(socket.id, socketId);
369
+
370
+ f.revoked.delete(f.ids[0]);
371
+ view.dispose.reload();
372
+ await until(f.changes, () => view.states.length === 1 && f.active() === 1);
373
+ assert.equal(view.states[0].conversationLog[0].user.messageId, "accepted-once");
374
+ assert.equal(socket.id, socketId, "Recovery does not need a reconnect or replacement socket");
375
+ assert.equal(socket.listeners(ASSISTANT_CONVERSATION_EVENT).length, 1);
376
+ assert.equal([...f.serverSockets][0].listenerCount(ASSISTANT_CONVERSATION_SUBSCRIBE), 1);
377
+ await view.dispose.reload();
378
+ assert.equal(f.active(), 1, "A healthy Reload reads without adding another observer");
379
+ f.fetches[0].text("Output continues after recovery.");
380
+ await until(f.changes, () => view.received.some(event => event.text?.includes("after recovery")));
381
+ assert.equal(f.fetches.length, 1, "Reload never repeats the accepted model request");
382
+ assert.equal(f.fetches[0].signal.aborted, false);
383
+ view.dispose();
384
+ await until(f.changes, () => f.active() === 0);
385
+ view.dispose.reload();
386
+ assert.equal(socket.listeners(ASSISTANT_CONVERSATION_EVENT).length, 0);
387
+ assert.equal(f.fetches[0].signal.aborted, false, "Disposing presentation does not stop admitted work");
388
+ });
389
+
390
+ for (const failure of ["rejection", "timeout"]) {
391
+ test(`Reload after initial ${failure} retains subscription identity and ignores stale or disposed acknowledgements`, () => {
392
+ const socket = new EventEmitter();
393
+ socket.connected = true;
394
+ const requests = [], states = [], errors = [];
395
+ let reads = 0;
396
+ socket.timeout = timeout => ({ emit(name, input, acknowledge) {
397
+ assert.equal(timeout, 10_000);
398
+ assert.equal(name, ASSISTANT_CONVERSATION_SUBSCRIBE);
399
+ requests.push({ input, acknowledge });
400
+ } });
401
+ const dispose = subscribeAssistantConversation({ socket, conversationId: "exact-chat", targetSurfaceId: "target", hostSurfaceId: "host",
402
+ workspaceSlug: "workspace", read() { reads += 1; }, onState: state => states.push(state), onError: error => errors.push(error) });
403
+ requests[0].acknowledge(failure === "timeout" ? new Error("operation has timed out") : null,
404
+ { ok: false, error: "Temporary subscription failure.", status: 500 });
405
+ assert.equal(errors.length, 1);
406
+ dispose.reload();
407
+ dispose.reload();
408
+ assert.equal(requests.length, 2, "An acknowledgement already pending is not another subscription");
409
+ assert.deepEqual(requests[1].input, requests[0].input);
410
+ assert.deepEqual(requests[1].input, { subscriptionId: requests[0].input.subscriptionId, conversationId: "exact-chat",
411
+ targetSurfaceId: "target", hostSurfaceId: "host", workspaceSlug: "workspace" });
412
+ const state = { id: "exact-chat", conversationLog: [], streaming: { revision: 0, messages: [] } };
413
+ requests[0].acknowledge(null, { ok: true, streamEpoch: "old", state });
414
+ assert.equal(states.length, 0, "The earlier generation cannot replace a pending recovery");
415
+ socket.connected = false;
416
+ socket.emit("disconnect");
417
+ dispose.reload();
418
+ assert.equal(requests.length, 2, "Disconnected Reload cannot create a subscription");
419
+ requests[1].acknowledge(null, { ok: true, streamEpoch: "late", state });
420
+ assert.equal(states.length, 0);
421
+ socket.connected = true;
422
+ dispose.reload();
423
+ assert.equal(requests.length, 3);
424
+ dispose();
425
+ requests[2].acknowledge(null, { ok: true, streamEpoch: "disposed", state });
426
+ dispose.reload();
427
+ assert.equal(requests.length, 3);
428
+ assert.equal(states.length, 0);
429
+ assert.equal(errors.length, 1);
430
+ assert.equal(reads, 0, "Recovery subscribes rather than reading under missing subscription authority");
431
+ for (const event of [ASSISTANT_CONVERSATION_EVENT, "connect", "disconnect"]) assert.equal(socket.listenerCount(event), 0);
432
+ });
433
+ }
434
+
240
435
  test("reconnect reauthorizes and reads current state without repeating an admitted submission", { timeout: 20_000 }, async t => {
241
436
  const f = await fixture(t);
242
437
  const socket = await f.connect("owner-session", { recovery: true });
@@ -690,3 +885,108 @@ test("host subscription policy retains authenticated actor metadata and per-even
690
885
  assert.ok(cookies.length >= 5);
691
886
  assert.ok(cookies.every(value => value === "host-session"));
692
887
  });
888
+
889
+ for (const status of [401, 403, 503]) {
890
+ test(`subscription read failure ${status} fences denied updates but retains transient history`, async t => {
891
+ const changes = new EventEmitter();
892
+ const socket = new EventEmitter();
893
+ socket.connected = true;
894
+ const requests = [], states = [], errors = [], unsubscribed = [], events = [];
895
+ socket.on(ASSISTANT_CONVERSATION_UNSUBSCRIBE, input => unsubscribed.push(input));
896
+ socket.timeout = () => ({ emit(name, input, acknowledge) {
897
+ assert.equal(name, ASSISTANT_CONVERSATION_SUBSCRIBE);
898
+ requests.push({ input, acknowledge });
899
+ } });
900
+ const user = { messageId: "question", role: "user", text: "Private saved question" };
901
+ const initial = { id: "chat", status: "working", conversationLog: [{ turnId: "turn", user }],
902
+ streaming: { revision: 0, messages: [] } };
903
+ const recovered = { id: "chat", status: "ready", conversationLog: [], streaming: { revision: 0, messages: [] } };
904
+ const failure = Object.assign(new Error(status === 503 ? "Updates unavailable." : "Access denied."), { statusCode: status });
905
+ let reads = 0;
906
+ let denied = true;
907
+ const dispose = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
908
+ read() { reads += 1; if (denied) throw failure; return recovered; },
909
+ onState(state) { states.push(state); changes.emit("change"); },
910
+ onError(error) { errors.push(error); changes.emit("change"); }, onEvent: event => events.push(event) });
911
+ t.after(dispose);
912
+ requests[0].acknowledge(null, { ok: true, streamEpoch: "old", state: initial });
913
+ const subscriptionId = requests[0].input.subscriptionId;
914
+ await dispose.reload();
915
+ assert.deepEqual(errors, [failure]);
916
+ assert.equal(reads, 1);
917
+ assert.equal(states.length, 1, "A failed read cannot replace the loaded snapshot");
918
+ function deliver(event, revision) {
919
+ socket.emit(ASSISTANT_CONVERSATION_EVENT, { subscriptionId, conversationId: "chat", streamEpoch: "old",
920
+ streamRevision: revision, event });
921
+ }
922
+ const message = { messageId: "answer", turnId: "turn", role: "assistant", status: "inProgress", text: "Late private output" };
923
+ deliver({ type: "message", ...message, streaming: { revision: 1, messages: [message] } }, 1);
924
+ assert.equal(unsubscribed.length, 0, "The original notification observer remains available for authorized recovery");
925
+ if (status === 503) {
926
+ assert.equal(states.length, 2);
927
+ assert.equal(states.at(-1).turns[0].user.text, user.text);
928
+ assert.equal(states.at(-1).turns[0].assistant.text, message.text);
929
+ assert.equal(events.length, 1);
930
+ return;
931
+ }
932
+ assert.equal(states.length, 1, "Late events cannot restore content cleared by the denied access owner");
933
+ deliver({ type: "transcript", patch: { type: "upsert-turn", turn: { turnId: "turn", user } } }, 2);
934
+ assert.equal(states.length, 1, "A late patch cannot create another denied snapshot");
935
+ assert.deepEqual(events, [], "Denied presentation hooks are not forwarded before a fresh authorized read");
936
+ requests[0].acknowledge(null, { ok: true, streamEpoch: "old", state: initial });
937
+ assert.equal(states.length, 1, "An obsolete acknowledgement cannot restore the denied snapshot");
938
+ denied = false;
939
+ deliver({ type: "settled", turnId: "turn" }, 3);
940
+ await until(changes, () => states.length === 2);
941
+ assert.deepEqual(states.at(-1).turns, [], "Fresh canonical state excludes old buffered output");
942
+ assert.equal(requests.length, 1, "Recovery uses the original observer and authoritative read");
943
+ assert.equal(reads, 2);
944
+ deliver({ type: "message", ...message, text: "Authorized new output",
945
+ streaming: { revision: 2, messages: [{ ...message, text: "Authorized new output" }] } }, 4);
946
+ assert.equal(states.length, 3);
947
+ assert.equal(states.at(-1).turns[0].assistant.text, "Authorized new output");
948
+ assert.equal(events.length, 1);
949
+ assert.deepEqual(errors, [failure]);
950
+ assert.equal(reads, 2, "Presentation after recovery does not issue another read or resend");
951
+ });
952
+ }
953
+
954
+ test("denied Reload before the initial acknowledgement retires only that pending observer and recovers on the same socket", async t => {
955
+ const socket = new EventEmitter();
956
+ socket.connected = true;
957
+ const requests = [], states = [], errors = [], unsubscribed = [];
958
+ socket.on(ASSISTANT_CONVERSATION_UNSUBSCRIBE, input => unsubscribed.push(input));
959
+ socket.timeout = () => ({ emit(name, input, acknowledge) {
960
+ assert.equal(name, ASSISTANT_CONVERSATION_SUBSCRIBE);
961
+ requests.push({ input, acknowledge });
962
+ } });
963
+ const failure = Object.assign(new Error("Access denied."), { statusCode: 401 });
964
+ let reads = 0;
965
+ const dispose = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
966
+ read() { reads += 1; throw failure; }, onState: state => states.push(state), onError: error => errors.push(error) });
967
+ t.after(dispose);
968
+ const subscriptionId = requests[0].input.subscriptionId;
969
+ await dispose.reload();
970
+ assert.deepEqual(errors, [failure]);
971
+ assert.deepEqual(unsubscribed, [{ subscriptionId }]);
972
+ assert.equal(reads, 1);
973
+ assert.equal(socket.connected, true, "Denied admission retires only this observer, not the shared socket");
974
+ const privateState = { id: "chat", status: "ready", conversationLog: [{ turnId: "private",
975
+ user: { messageId: "private-question", role: "user", text: "Old private history" } }], streaming: { revision: 0, messages: [] } };
976
+ requests[0].acknowledge(null, { ok: true, streamEpoch: "old", state: privateState });
977
+ socket.emit(ASSISTANT_CONVERSATION_EVENT, { subscriptionId, conversationId: "chat", streamEpoch: "old",
978
+ streamRevision: 1, event: { type: "settled", turnId: "private" } });
979
+ assert.deepEqual(states, [], "Late old acknowledgements and notifications cannot restore private content");
980
+ assert.equal(reads, 1);
981
+ dispose.reload();
982
+ assert.equal(requests.length, 2);
983
+ assert.deepEqual(requests[1].input, requests[0].input);
984
+ requests[0].acknowledge(null, { ok: true, streamEpoch: "old", state: privateState });
985
+ assert.deepEqual(states, []);
986
+ const recovered = { id: "chat", status: "ready", conversationLog: [], streaming: { revision: 0, messages: [] } };
987
+ requests[1].acknowledge(null, { ok: true, streamEpoch: "fresh", state: recovered });
988
+ assert.equal(states.length, 1);
989
+ assert.deepEqual(states[0].turns, []);
990
+ assert.equal(reads, 1, "Recovery uses fresh subscription admission rather than repeating the old read or inference");
991
+ assert.deepEqual(errors, [failure]);
992
+ });