@jskit-ai/assistant-runtime 0.1.198 → 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.198",
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.205",
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.225",
20
- "@jskit-ai/http-web": "0.1.72",
21
- "@jskit-ai/kernel": "0.1.227",
22
- "@jskit-ai/realtime": "0.1.224",
23
- "@jskit-ai/shell-web": "0.1.231",
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.227"
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>
@@ -579,7 +579,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
579
579
 
580
580
  const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
581
581
  snapshot, draft, draftMessageId, draftAttachments, draftRetry, draftRetryMatches, settleDraftRetry,
582
- error, loading, stopping, delivery, turns,
582
+ error, accessDenied, loading, stopping, delivery, turns,
583
583
  hasMoreBefore, loadingMore, loadMoreError, loadMore,
584
584
  send, submitPrepared, submitDraft, cancel, cancelMessage, inspectDelivery, changeGoal, refreshGoal, goalState, goalView, goalLoadError, questions,
585
585
  reload() { const job = subscription?.reload(); void refreshGoal(); return job; } };
@@ -624,7 +624,7 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
624
624
  conversation: {
625
625
  turns: conversationTurnsFromMessages(messages.value.map(message => ({ ...message, text: message.content }))),
626
626
  assistantLabel: display.assistantLabel || "Assistant", welcomeMessage: display.welcomeMessage,
627
- visible: display.visible, variant: display.variant, scrollKey: toValue(scope)
627
+ visible: display.visible, retainWhenHidden: display.retainWhenHidden, variant: display.variant, scrollKey: toValue(scope)
628
628
  },
629
629
  composer: {
630
630
  draft: draft.value, disabled: busy.value, pending: busy.value,
@@ -862,12 +862,13 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
862
862
  models: current?.available.value ? modelOwner.value : null, questions: prompts,
863
863
  goal: (toValue(goal) === true ? null : toValue(goal)) || current?.goalState.value || null,
864
864
  conversation: { turns: current?.turns.value || [], interimReply: state?.interimReply || null,
865
+ error: current?.error.value || state?.error || "", errorReloadable: Boolean(current) && !current.accessDenied.value,
865
866
  scrollKey: JSON.stringify(identity.value),
866
867
  hasMoreBefore: current?.hasMoreBefore.value || false,
867
868
  loadingMore: current?.loadingMore.value || false, loadMoreError: current?.loadMoreError.value || "",
868
- 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,
869
870
  assistantLabel: display.assistantLabel || "Assistant", welcomeMessage: display.welcomeMessage ?? "What would you like to do?",
870
- systemLabel: display.systemLabel, variant: display.variant, visible: display.visible,
871
+ systemLabel: display.systemLabel, variant: display.variant, visible: display.visible, retainWhenHidden: display.retainWhenHidden,
871
872
  userMessageFormat: display.userMessageFormat, progressPreviewLimit: display.progressPreviewLimit,
872
873
  previewMessage: current?.available.value ? display.previewMessage : null },
873
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
  }
@@ -2105,3 +2105,70 @@ test("acceptance settles a voice-retained conversation after its text view retar
2105
2105
  await expect(f.input).toHaveValue("The next conversation's draft stays.");
2106
2106
  assert.equal((await f.sent()).length, 1);
2107
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
+ });