@jskit-ai/assistant-runtime 0.1.202 → 0.1.205

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.
@@ -169,6 +169,7 @@ export default defineConfig({
169
169
  if (match[2].startsWith("/deliveries/") && match[2].endsWith("/inspect")) {
170
170
  const messageId = decodeURIComponent(match[2].split("/")[2]);
171
171
  const receipt = state.conversationLog.find(turn => turn.user?.messageId === messageId);
172
+ if (!receipt && mode === "not-sent") return json({ status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message." });
172
173
  return json(receipt ? { status: "accepted", messageId, turnId: receipt.turnId } : { status: "unknown", messageId });
173
174
  }
174
175
  return json({ error: "Unknown fixture operation" }, 404);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.202",
3
+ "version": "0.1.205",
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.209",
15
+ "@jskit-ai/assistant-core": "0.1.212",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/http-runtime": "0.1.229",
20
- "@jskit-ai/http-web": "0.1.76",
21
- "@jskit-ai/kernel": "0.1.231",
22
- "@jskit-ai/realtime": "0.1.228",
23
- "@jskit-ai/shell-web": "0.1.235",
19
+ "@jskit-ai/http-runtime": "0.1.232",
20
+ "@jskit-ai/http-web": "0.1.79",
21
+ "@jskit-ai/kernel": "0.1.234",
22
+ "@jskit-ai/realtime": "0.1.231",
23
+ "@jskit-ai/shell-web": "0.1.238",
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.231"
27
+ "@jskit-ai/database-runtime": "0.1.234"
28
28
  },
29
29
  "description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
30
30
  "jskit": {
@@ -6,18 +6,19 @@ import { useAssistantQuestions } from "@jskit-ai/assistant-core/client/conversat
6
6
  import { assistantHttpClient, createAssistantApi } from "@jskit-ai/assistant-core/client";
7
7
  import { buildAssistantApiPath } from "@jskit-ai/assistant-core/shared";
8
8
  import {
9
- conversationTurnsFromMessages, latestAssistantMessageAwaitingUserReply, mergeConversationLogPages,
9
+ conversationTurnsFromMessages, latestAssistantMessageAwaitingUserReply, mergeConversationLogPages, mergeConversationStream,
10
10
  normalizeConversationLogPage, normalizeConversationLogPagination
11
11
  } from "@jskit-ai/assistant-core/shared/conversation";
12
12
  import { useRealtimeSocket } from "@jskit-ai/realtime/client/composables/useRealtimeEvent";
13
13
  import { useSurfaceRouteContext } from "@jskit-ai/shell-web/client/navigation/useSurfaceRouteContext";
14
14
  import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
15
15
  import { useWorkspaceWebScopeSupport } from "../support/workspaceScopeSupport.js";
16
- import { subscribeAssistantConversation } from "../support/subscribeAssistantConversation.js";
16
+ import { subscribeAssistantConversation, refreshedHistoryPages, patchLoadedHistoryPages } from "../support/subscribeAssistantConversation.js";
17
17
 
18
18
  const text = value => String(toValue(value) ?? "").trim();
19
19
  // Keep the injection key stable across optimized package and raw Vue imports.
20
20
  const conversationDefaultsKey = Symbol.for("jskit.assistant-runtime.conversation.defaults");
21
+ const compareTurnIds = (left, right) => String(left).localeCompare(String(right), undefined, { numeric: true });
21
22
 
22
23
  /** Configure this Vue application's existing conversation binding before mounting. */
23
24
  function configureAssistantConversations(app, { actorKey, api = null, request, clearDraftOn = "dispatch" } = {}) {
@@ -80,11 +81,21 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
80
81
  const delivery = createAssistantMessageDelivery();
81
82
  const pendingMessages = new Map();
82
83
  const olderPages = ref([]);
84
+ let historyRevision = 0;
85
+ let deniedHistoryRevision = 0;
83
86
  const loadingMore = ref(false);
84
87
  const loadMoreError = ref("");
85
- const turns = computed(() => mergeConversationLogPages([
86
- ...olderPages.value, { conversationLog: snapshot.value?.turns || [] }
87
- ]).conversationLog);
88
+ const turns = computed(() => {
89
+ const saved = mergeConversationLogPages([
90
+ ...olderPages.value, { conversationLog: snapshot.value?.conversationLog || [] }
91
+ ]).conversationLog;
92
+ // Use the original overlay against the full loaded saved span. A synthetic
93
+ // live older turn must not replace its authored request or saved progress.
94
+ const messages = (snapshot.value?.turns || []).flatMap(turn => turn.messages || [
95
+ turn.system, turn.user, ...(turn.thinking || []), ...(turn.commentary || []), turn.assistant
96
+ ].filter(Boolean));
97
+ return mergeConversationStream(saved, { messages });
98
+ });
88
99
  const oldestLoadedPage = computed(() => olderPages.value[0] || snapshot.value);
89
100
  const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
90
101
  const editable = computed(() => active.value && !accessDenied.value);
@@ -173,8 +184,18 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
173
184
  previous?.capabilities?.goalCommands, previous?.capabilities?.goalBudgets]) !==
174
185
  JSON.stringify([state.goal, state.capabilities?.goals, state.capabilities?.goalCommands, state.capabilities?.goalBudgets]);
175
186
  if (canonical) {
176
- olderPages.value = [];
187
+ historyRevision += 1;
188
+ olderPages.value = initial ? [] : state[refreshedHistoryPages] || [];
177
189
  loadMoreError.value = "";
190
+ } else if (olderPages.value.length) {
191
+ // Transcript patches may evict the head of the bounded live page before
192
+ // its canonical read completes. Retain saved rows, never display overlays.
193
+ const first = state.conversationLog?.[0]?.turnId;
194
+ const evicted = first ? (previous?.conversationLog || []).filter(turn => compareTurnIds(turn.turnId, first) < 0) : [];
195
+ if (evicted.length) {
196
+ historyRevision += 1;
197
+ olderPages.value = [...olderPages.value, normalizeConversationLogPage({ conversationLog: evicted })];
198
+ }
178
199
  }
179
200
  snapshot.value = state;
180
201
  loading.value = false;
@@ -196,7 +217,9 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
196
217
  error.value = failure.message;
197
218
  accessDenied.value = [401, 403].includes(Number(failure.status || failure.statusCode));
198
219
  if (accessDenied.value) {
220
+ deniedHistoryRevision += 1;
199
221
  snapshot.value = null;
222
+ historyRevision += 1;
200
223
  olderPages.value = [];
201
224
  draft.value = "";
202
225
  draftMessageId.value = "";
@@ -210,6 +233,11 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
210
233
  }
211
234
  }
212
235
  function receiveEvent(event) {
236
+ if (event.type === "transcript" && olderPages.value.some(page =>
237
+ page.conversationLog.some(turn => turn.turnId === event.patch?.turn?.turnId))) {
238
+ historyRevision += 1;
239
+ olderPages.value = patchLoadedHistoryPages(olderPages.value, event.patch);
240
+ }
213
241
  if (event.type === "goal") void refreshGoal();
214
242
  for (const [token, reader] of [...readers]) {
215
243
  if (!current.value) return;
@@ -220,15 +248,54 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
220
248
  catch {}
221
249
  }
222
250
  }
251
+ async function readLoadedHistory({ current: subscriptionCurrent }) {
252
+ const denial = deniedHistoryRevision;
253
+ const valid = () => {
254
+ if (denial !== deniedHistoryRevision) throw Object.assign(new Error("Access denied."), { status: 403 });
255
+ return current.value && active.value && subscriptionCurrent();
256
+ };
257
+ // A concurrent Load older operation extends the desired span. Retry one
258
+ // refresh in this same read, rather than dropping that newly loaded page.
259
+ while (valid()) {
260
+ const revision = historyRevision;
261
+ const oldest = olderPages.value[0]?.conversationLog?.[0]?.turnId;
262
+ const latest = await api.readConversation(identity.conversationId);
263
+ if (!valid()) return latest;
264
+ const pages = [];
265
+ let page = normalizeConversationLogPage(latest);
266
+ const cursors = new Set();
267
+ while (oldest && page.pagination.hasMoreBefore) {
268
+ const cursor = page.pagination.nextBeforeTurnId || page.pagination.oldestTurnId;
269
+ if (cursor && compareTurnIds(cursor, oldest) <= 0) break;
270
+ if (!cursor || cursors.has(cursor)) throw new Error("Older conversation history could not be refreshed. Reload chat to try again.");
271
+ cursors.add(cursor);
272
+ page = normalizeConversationLogPage(await api.readConversation(identity.conversationId, {
273
+ beforeTurnId: cursor, limit: latest.pagination.limit
274
+ }));
275
+ if (!valid()) return latest;
276
+ // A removed oldest row can make this page reach farther back than the
277
+ // reader loaded. Keep the original visible boundary and page metadata.
278
+ const saved = page.conversationLog.filter(turn => compareTurnIds(turn.turnId, oldest) >= 0);
279
+ pages.unshift(normalizeConversationLogPage({ ...page, conversationLog: saved, pagination: {
280
+ ...page.pagination, count: saved.length, oldestTurnId: saved[0]?.turnId || "",
281
+ hasMoreBefore: page.pagination.hasMoreBefore || saved.length < page.conversationLog.length
282
+ } }));
283
+ }
284
+ if (revision !== historyRevision) continue;
285
+ return { ...latest, [refreshedHistoryPages]: pages.filter(page => page.conversationLog.length) };
286
+ }
287
+ return null;
288
+ }
223
289
  watch(active, enabled => {
224
290
  subscription?.();
225
291
  subscription = null;
292
+ historyRevision += 1;
226
293
  if (!enabled) return;
227
294
  loading.value = true;
228
295
  subscription = subscribeAssistantConversation({ socket, conversationId: identity.conversationId,
229
296
  targetSurfaceId: identity.targetSurfaceId, hostSurfaceId: identity.hostSurfaceId,
230
297
  ...(identity.workspaceSlug ? { workspaceSlug: identity.workspaceSlug } : {}),
231
- read: () => api.readConversation(identity.conversationId), onState: receiveState,
298
+ read: readLoadedHistory, onState: receiveState,
232
299
  onError: receiveError, onEvent: receiveEvent
233
300
  });
234
301
  }, { immediate: true, flush: "sync" });
@@ -237,6 +304,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
237
304
  for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
238
305
  delivery.reset();
239
306
  snapshot.value = null;
307
+ historyRevision += 1;
240
308
  olderPages.value = [];
241
309
  draft.value = "";
242
310
  draftMessageId.value = "";
@@ -416,17 +484,19 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
416
484
  finish({ changed: false, loaded: false });
417
485
  return false;
418
486
  }
487
+ const revision = historyRevision;
419
488
  loadingMore.value = true;
420
489
  loadMoreError.value = "";
421
490
  try {
422
491
  const page = await api.readConversation(identity.conversationId, {
423
492
  beforeTurnId, limit: snapshot.value.pagination.limit
424
493
  });
425
- if (!available.value) {
494
+ if (!available.value || revision !== historyRevision) {
426
495
  finish({ changed: false, loaded: false });
427
496
  return false;
428
497
  }
429
498
  const previousOldestTurnId = String(turns.value[0]?.turnId || "").trim();
499
+ historyRevision += 1;
430
500
  olderPages.value = [normalizeConversationLogPage(page), ...olderPages.value];
431
501
  const nextOldestTurnId = String(turns.value[0]?.turnId || "").trim();
432
502
  const changed = Boolean(nextOldestTurnId && nextOldestTurnId !== previousOldestTurnId);
@@ -571,6 +641,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
571
641
  const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId);
572
642
  if (!current.value) return false;
573
643
  if (receipt.status === "accepted") delivery.accept(messageId);
644
+ else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, receipt.error);
574
645
  subscription?.reload();
575
646
  return receipt;
576
647
  } catch (failure) { receiveError(failure); return false; }
@@ -5,6 +5,15 @@ import {
5
5
  ASSISTANT_CONVERSATION_UNSUBSCRIBE
6
6
  } from "../../shared/conversationRealtime.js";
7
7
 
8
+ // Private to the original client history/subscription coordination. Symbols
9
+ // survive local snapshot spreads but never enter the conversation wire format.
10
+ const refreshedHistoryPages = Symbol("assistant.refreshedHistoryPages");
11
+
12
+ function patchLoadedHistoryPages(pages = [], patch) {
13
+ return pages.map(page => page.conversationLog.some(turn => turn.turnId === patch?.turn?.turnId)
14
+ ? applyConversationLogPatch(page, patch, { limit: page.pagination?.limit }) || page : page);
15
+ }
16
+
8
17
  const SNAPSHOT_EVENTS = new Set(["accepted", "settled", "configuration", "phase", "error", "replaced", "goal"]);
9
18
 
10
19
  function subscribeAssistantConversation({
@@ -72,13 +81,16 @@ function subscribeAssistantConversation({
72
81
  const job = Promise.resolve().then(() => {
73
82
  if (!current()) return null;
74
83
  pendingReads.add(pendingRead);
75
- return read();
84
+ return read({ current });
76
85
  }).then(snapshot => {
77
86
  if (!current()) return;
78
87
  // Ported from the original history reader: retain only delivered updates
79
88
  // received while this request was in flight before replacing its cache.
80
89
  for (const patch of pendingRead.patches) {
81
90
  snapshot = applyConversationLogPatch(snapshot, patch, { limit: snapshot.pagination?.limit });
91
+ if (snapshot[refreshedHistoryPages]) {
92
+ snapshot[refreshedHistoryPages] = patchLoadedHistoryPages(snapshot[refreshedHistoryPages], patch);
93
+ }
82
94
  }
83
95
  for (const [id, entry] of completed) if (settled.has(entry.turnId)) completed.delete(id);
84
96
  receiveState(snapshot);
@@ -226,4 +238,4 @@ function subscribeAssistantConversation({
226
238
  return Object.assign(release, { reload });
227
239
  }
228
240
 
229
- export { subscribeAssistantConversation };
241
+ export { subscribeAssistantConversation, refreshedHistoryPages, patchLoadedHistoryPages };
@@ -36,6 +36,54 @@ async function fixture(t, width = 1280, { acceptedDraft = false, draftWhileLoadi
36
36
  return { page, primary, input, command, state, sent, notify };
37
37
  }
38
38
 
39
+ test("inspecting a restored no-admission request releases Send while preserving newer draft and uploads", options, async t => {
40
+ const f = await fixture(t, 390);
41
+ const attach = async () => {
42
+ await f.page.evaluate(() => {
43
+ window.savedBinding = window.conversationFixture.acquireConversation({
44
+ conversationId: "chat:2", draftStorage: { storage: sessionStorage, key: "not-sent-recovery" }
45
+ });
46
+ window.conversationFixture.target("chat:2");
47
+ });
48
+ await expect.poll(() => f.page.evaluate(() => window.savedBinding.runtime.value?.available.value)).toBe(true);
49
+ };
50
+ await attach();
51
+ await f.command("mode", { mode: "uncertain" });
52
+ await f.input.fill("Original unadmitted words");
53
+ await f.input.press("Enter");
54
+ const check = f.primary.getByRole("button", { name: "Check delivery", exact: true });
55
+ await expect(check).toBeVisible();
56
+ const [original] = await f.sent();
57
+ await f.input.fill("A separate newer draft");
58
+ await f.command("question", { id: "chat:2", text: "An unrelated completed answer.", pending: false });
59
+ await f.notify("chat:2");
60
+ await f.page.reload();
61
+ await expect(f.input).toBeEnabled();
62
+ await attach();
63
+ await expect(f.input).toHaveValue("A separate newer draft");
64
+ const send = f.primary.getByRole("button", { name: "Send message", exact: true });
65
+ await expect(send).toBeDisabled();
66
+ await f.page.evaluate(() => window.conversationFixture.attachments(true));
67
+ await f.primary.locator("input[type=file]").setInputFiles({ name: "newer.txt", mimeType: "text/plain", buffer: Buffer.from("Keep this upload") });
68
+ const uploads = () => f.page.evaluate(() => window.conversationFixture.attachmentState());
69
+ await expect.poll(async () => (await uploads()).ready.length).toBe(1);
70
+ await f.command("mode", { mode: "not-sent" });
71
+ await check.click();
72
+ await expect(check).toHaveCount(0);
73
+ await expect(send).toBeEnabled();
74
+ await expect(f.input).toHaveValue("A separate newer draft");
75
+ assert.equal((await f.sent()).length, 1, "Inspection never resends the old request or submits the new draft");
76
+ assert.deepEqual((await uploads()).acknowledged, []);
77
+ assert.deepEqual((await uploads()).deleted, []);
78
+ assert.equal((await uploads()).ready[0].fileName, "newer.txt");
79
+ await f.command("mode", { mode: "product-accepted" });
80
+ await send.click();
81
+ await expect.poll(async () => (await f.sent()).length).toBe(2);
82
+ const next = (await f.sent())[1];
83
+ assert.notEqual(next.input.messageId, original.input.messageId);
84
+ assert.equal(next.input.text, "A separate newer draft");
85
+ });
86
+
39
87
  test("the canonical standard element keeps draft/focus/layout while applying the shared stream snapshot", options, async t => {
40
88
  const f = await fixture(t, 390);
41
89
  await expect(f.primary.getByRole("button", { name: "Set goal", exact: true })).toHaveCount(0);
@@ -2172,3 +2220,262 @@ test("transient subscription errors keep loaded history and recover through the
2172
2220
  assert.equal((await f.sent()).length, sentBeforeRecovery);
2173
2221
  assert.ok(await f.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
2174
2222
  });
2223
+
2224
+ test("canonical incoming messages preserve paged history and the scrolled-up reader", options, async t => {
2225
+ const f = await fixture(t, 390);
2226
+ const turns = Array.from({ length: 24 }, (_, index) => ({
2227
+ turnId: String(index + 1).padStart(6, "0"),
2228
+ user: { messageId: `loaded-user-${index}`, role: "user", text: `Loaded question ${index + 1}. ` + "Keep the earlier discussion. ".repeat(6) },
2229
+ assistant: { messageId: `loaded-answer-${index}`, role: "assistant", text: `Loaded answer ${index + 1}. ` + "Keep the earlier answer. ".repeat(6) }
2230
+ }));
2231
+ await f.command("history", { id: "chat:1", turns, limit: 6 });
2232
+ await f.notify("chat:1");
2233
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
2234
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
2235
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
2236
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(18);
2237
+ const body = f.primary.locator(".assistant-transcript__body");
2238
+ const previousScroll = await body.evaluate(element => element.scrollTop);
2239
+ await body.hover();
2240
+ await f.page.mouse.wheel(0, -300);
2241
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeLessThan(previousScroll);
2242
+ await body.evaluate(element => { element.scrollTop = 420; });
2243
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
2244
+ await f.input.fill("Keep this unsent draft");
2245
+ await f.input.evaluate(element => { element.focus({ preventScroll: true }); element.setSelectionRange(2, 7); });
2246
+ turns.push({ turnId: "000025", user: { messageId: "incoming-user", role: "user", text: "New incoming question" } });
2247
+ await f.command("history", { id: "chat:1", turns, limit: 6 });
2248
+ await f.notify("chat:1");
2249
+ await expect(f.primary.getByText("New incoming question", { exact: true })).toHaveCount(1);
2250
+ const ids = await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId));
2251
+ assert.deepEqual(ids, turns.slice(6).map(turn => turn.turnId), "A canonical incoming message must retain every loaded turn, including the row evicted from the latest page");
2252
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
2253
+ await expect(f.input).toHaveValue("Keep this unsent draft");
2254
+ await expect(f.input).toBeFocused();
2255
+ assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [2, 7]);
2256
+ assert.deepEqual(await f.sent(), [], "History observation does not send or resend work");
2257
+ });
2258
+
2259
+
2260
+ for (const width of [390, 768, 1280]) {
2261
+ test(`canonical loaded-history refresh is atomic and removes saved rows at width ${width}`, options, async t => {
2262
+ const f = await fixture(t, width);
2263
+ const turns = Array.from({ length: 24 }, (_, index) => ({
2264
+ turnId: String(index + 1).padStart(6, "0"),
2265
+ user: { role: "user", messageId: `atomic-user-${index}`, text: `Atomic saved question ${index + 1}. ` + "Retain visible discussion. ".repeat(8) },
2266
+ assistant: { role: "assistant", messageId: `atomic-answer-${index}`, text: `Atomic saved answer ${index + 1}. ` + "Retain visible answer. ".repeat(8) }
2267
+ }));
2268
+ await f.command("history", { id: "chat:1", turns, limit: 6 });
2269
+ await f.notify("chat:1");
2270
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
2271
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
2272
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
2273
+ const body = f.primary.locator(".assistant-transcript__body");
2274
+ const previousScroll = await body.evaluate(element => element.scrollTop);
2275
+ await body.hover();
2276
+ await f.page.mouse.wheel(0, -300);
2277
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeLessThan(previousScroll);
2278
+ await body.evaluate(element => { window.atomicHistoryBody = element; element.scrollTop = 420; });
2279
+ await f.input.fill("Unsent while history refreshes");
2280
+ await f.input.evaluate(element => { element.focus({ preventScroll: true }); element.setSelectionRange(3, 8); });
2281
+ const previousIds = turns.slice(6).map(turn => turn.turnId);
2282
+ const fresh = turns.filter(turn => !["000007", "000010"].includes(turn.turnId));
2283
+ fresh[8] = { ...fresh[8], assistant: { ...fresh[8].assistant, text: "Authoritatively replaced saved answer" } };
2284
+ fresh.push({ turnId: "000025", user: { role: "user", messageId: "atomic-incoming", text: "Atomic new question" } });
2285
+ await f.command("history", { id: "chat:1", turns: fresh, limit: 6 });
2286
+ let release;
2287
+ let held = false;
2288
+ const reads = "**/api/assistant/home/conversations/chat%3A1?**";
2289
+ await f.page.route(reads, async route => {
2290
+ if (!new URL(route.request().url()).searchParams.has("beforeTurnId") || held) return route.continue();
2291
+ held = true;
2292
+ const response = await route.fetch();
2293
+ await new Promise(resolve => { release = resolve; });
2294
+ await route.fulfill({ response });
2295
+ });
2296
+ await f.notify("chat:1");
2297
+ await expect.poll(() => Boolean(release)).toBe(true);
2298
+ assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)), previousIds);
2299
+ assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
2300
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
2301
+ await expect(f.input).toHaveValue("Unsent while history refreshes");
2302
+ await expect(f.input).toBeFocused();
2303
+ assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);
2304
+ release();
2305
+ await expect(f.primary.getByText("Atomic new question", { exact: true })).toHaveCount(1);
2306
+ assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)),
2307
+ fresh.filter(turn => Number(turn.turnId) >= 7).map(turn => turn.turnId));
2308
+ await expect(f.primary.getByText("Authoritatively replaced saved answer", { exact: true })).toHaveCount(1);
2309
+ await expect(f.primary.getByText(/Atomic saved question (7|10)\./)).toHaveCount(0);
2310
+ assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
2311
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
2312
+ await expect(f.input).toHaveValue("Unsent while history refreshes");
2313
+ await expect(f.input).toBeFocused();
2314
+ assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);
2315
+ assert.ok(await f.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
2316
+ assert.deepEqual(await f.sent(), []);
2317
+ await f.page.unroute(reads);
2318
+ });
2319
+ }
2320
+
2321
+ test("concurrent older-history loading extends canonical refresh without reviving revoked pages", options, async t => {
2322
+ const f = await fixture(t, 390);
2323
+ const turns = Array.from({ length: 24 }, (_, index) => ({
2324
+ turnId: String(index + 1).padStart(6, "0"),
2325
+ user: { role: "user", messageId: `concurrent-${index}`, text: `Concurrent saved question ${index + 1}` }
2326
+ }));
2327
+ await f.command("history", { id: "chat:1", turns, limit: 6 });
2328
+ await f.notify("chat:1");
2329
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
2330
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
2331
+ turns.push({ turnId: "000025", user: { role: "user", messageId: "concurrent-new", text: "Concurrent new question" } });
2332
+ await f.command("history", { id: "chat:1", turns, limit: 6 });
2333
+ let release;
2334
+ let held = false;
2335
+ const reads = "**/api/assistant/home/conversations/chat%3A1?**";
2336
+ await f.page.route(reads, async route => {
2337
+ if (!new URL(route.request().url()).searchParams.has("beforeTurnId") || held) return route.continue();
2338
+ held = true;
2339
+ const response = await route.fetch();
2340
+ await new Promise(resolve => { release = resolve; });
2341
+ await route.fulfill({ response });
2342
+ });
2343
+ await f.notify("chat:1");
2344
+ await expect.poll(() => Boolean(release)).toBe(true);
2345
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
2346
+ release();
2347
+ await expect(f.primary.getByText("Concurrent new question", { exact: true })).toHaveCount(1);
2348
+ assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)), turns.slice(6).map(turn => turn.turnId));
2349
+ await f.page.unroute(reads);
2350
+
2351
+ held = false;
2352
+ release = null;
2353
+ await f.page.route(reads, async route => {
2354
+ if (!new URL(route.request().url()).searchParams.has("beforeTurnId") || held) return route.continue();
2355
+ held = true;
2356
+ const response = await route.fetch();
2357
+ await new Promise(resolve => { release = resolve; });
2358
+ await route.fulfill({ response });
2359
+ });
2360
+ await f.notify("chat:1");
2361
+ await expect.poll(() => Boolean(release)).toBe(true);
2362
+ await f.command("deny", { denied: true });
2363
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), false);
2364
+ await expect(f.input).toBeDisabled();
2365
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(0);
2366
+ release();
2367
+ await expect(f.primary.locator(".assistant-transcript__error")).toContainText("Access denied.");
2368
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(0);
2369
+ assert.deepEqual(await f.sent(), []);
2370
+ await f.page.unroute(reads);
2371
+ });
2372
+
2373
+
2374
+ test("loaded saved history bridges transcript evictions and rejects a retired reader's page response", options, async t => {
2375
+ const f = await fixture(t, 390);
2376
+ const turns = Array.from({ length: 24 }, (_, index) => ({
2377
+ turnId: String(index + 1).padStart(6, "0"),
2378
+ user: { role: "user", messageId: `patch-history-${index}`, text: `Patch saved question ${index + 1}` }
2379
+ }));
2380
+ await f.command("history", { id: "chat:1", turns, limit: 6 });
2381
+ await f.notify("chat:1");
2382
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
2383
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
2384
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
2385
+ const incoming = { turnId: "000025", user: { role: "user", messageId: "patch-incoming", text: "Patch incoming question" } };
2386
+ await f.page.evaluate(turn => window.conversationFixture.socket.notify("chat:1", {
2387
+ type: "transcript", patch: { type: "upsert-turn", turn }
2388
+ }), incoming);
2389
+ await expect(f.primary.getByText("Patch incoming question", { exact: true })).toHaveCount(1);
2390
+ assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)),
2391
+ [...turns.slice(6), incoming].map(turn => turn.turnId), "A bounded live patch must not lose its evicted saved row");
2392
+ await f.command("history", { id: "chat:1", turns: [...turns, incoming], limit: 6 });
2393
+ let release;
2394
+ let held = false;
2395
+ const reads = "**/api/assistant/home/conversations/chat%3A1?**";
2396
+ await f.page.route(reads, async route => {
2397
+ if (!new URL(route.request().url()).searchParams.has("beforeTurnId") || held) return route.continue();
2398
+ held = true;
2399
+ const response = await route.fetch();
2400
+ await new Promise(resolve => { release = resolve; });
2401
+ await route.fulfill({ response });
2402
+ });
2403
+ await f.notify("chat:1");
2404
+ await expect.poll(() => Boolean(release)).toBe(true);
2405
+ await f.page.evaluate(() => {
2406
+ window.retiredHistoryReader = window.conversationFixture.current();
2407
+ window.conversationFixture.actor("different-actor");
2408
+ });
2409
+ await expect.poll(() => f.page.evaluate(() => window.retiredHistoryReader.current.value)).toBe(false);
2410
+ release();
2411
+ await expect.poll(() => f.page.evaluate(() => window.retiredHistoryReader.turns.value.length)).toBe(0);
2412
+ await expect.poll(() => f.page.evaluate(() => window.retiredHistoryReader.snapshot.value)).toBe(null);
2413
+ assert.deepEqual(await f.sent(), []);
2414
+ await f.page.unroute(reads);
2415
+ });
2416
+
2417
+ test("canonical rewind replaces the loaded saved window without retaining removed turns", options, async t => {
2418
+ const f = await fixture(t, 768);
2419
+ const turns = Array.from({ length: 24 }, (_, index) => ({
2420
+ turnId: String(index + 1).padStart(6, "0"),
2421
+ user: { role: "user", messageId: `rewind-${index}`, text: `Rewind saved question ${index + 1}` }
2422
+ }));
2423
+ await f.command("history", { id: "chat:1", turns, limit: 6 });
2424
+ await f.notify("chat:1");
2425
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
2426
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
2427
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
2428
+ await f.command("history", { id: "chat:1", turns: turns.slice(0, 12), limit: 6 });
2429
+ await f.notify("chat:1");
2430
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)))
2431
+ .toEqual(turns.slice(6, 12).map(turn => turn.turnId));
2432
+ assert.deepEqual(await f.sent(), []);
2433
+ });
2434
+
2435
+
2436
+ test("canonical loaded-history replay retains older completed output and its authored row", options, async t => {
2437
+ const f = await fixture(t, 390);
2438
+ const turns = Array.from({ length: 25 }, (_, index) => ({
2439
+ turnId: String(index + 1).padStart(6, "0"),
2440
+ user: { role: "user", messageId: `older-replay-user-${index}`, text: `Older replay question ${index + 1}` },
2441
+ ...(index === 18 ? { commentary: [{ role: "commentary", messageId: "older-saved-progress", text: "Saved older progress" }] } : {})
2442
+ }));
2443
+ await f.command("history", { id: "chat:1", turns, limit: 6 });
2444
+ await f.notify("chat:1");
2445
+ await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().turns.value.length)).toBe(6);
2446
+ assert.equal(await f.page.evaluate(() => window.conversationFixture.current().loadMore()), true);
2447
+ let release;
2448
+ let held = false;
2449
+ const reads = "**/api/assistant/home/conversations/chat%3A1?**";
2450
+ await f.page.route(reads, async route => {
2451
+ if (!new URL(route.request().url()).searchParams.has("beforeTurnId") || held) return route.continue();
2452
+ held = true;
2453
+ const response = await route.fetch();
2454
+ await new Promise(resolve => { release = resolve; });
2455
+ await route.fulfill({ response });
2456
+ });
2457
+ await f.notify("chat:1");
2458
+ await expect.poll(() => Boolean(release)).toBe(true);
2459
+ const assistant = { role: "assistant", messageId: "older-replayed-answer", text: "Delivered older completed output" };
2460
+ await f.page.evaluate(assistant => window.conversationFixture.socket.notify("chat:1", {
2461
+ type: "transcript", patch: { type: "upsert-turn", turn: { turnId: "000019", assistant } }
2462
+ }), assistant);
2463
+ release();
2464
+ await expect(f.primary.getByText("Delivered older completed output", { exact: true })).toHaveCount(1);
2465
+ const saved = await f.page.evaluate(() => window.conversationFixture.current().turns.value.find(turn => turn.turnId === "000019"));
2466
+ assert.equal(saved.user.text, "Older replay question 19");
2467
+ assert.equal(saved.commentary[0].text, "Saved older progress");
2468
+ assert.equal(saved.assistant.text, "Delivered older completed output");
2469
+ assert.equal((await f.page.evaluate(() => window.conversationFixture.current().snapshot.value.conversationLog.length)), 6);
2470
+ await f.page.unroute(reads);
2471
+ await f.page.evaluate(() => window.conversationFixture.socket.notify("chat:1", {
2472
+ type: "message", status: "complete", turnId: "000019", messageId: "older-display-only-output", role: "assistant", text: "Live older completed reply",
2473
+ streaming: { revision: 1, messages: [] }
2474
+ }));
2475
+ await expect(f.primary.getByText("Live older completed reply", { exact: true })).toHaveCount(1);
2476
+ const display = await f.page.evaluate(() => window.conversationFixture.current().turns.value.find(turn => turn.turnId === "000019"));
2477
+ assert.equal(display.user.text, "Older replay question 19", "An older turn overlay cannot replace its authored saved row");
2478
+ assert.equal(display.commentary[0].text, "Saved older progress");
2479
+ assert.equal(display.assistant.text, "Live older completed reply");
2480
+ assert.deepEqual(await f.sent(), []);
2481
+ });