diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index fd50372e03..57a96d024c 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -19,7 +19,7 @@ }, "partial-history-notice.spec.ts": { "tests": 1, - "electron": "bounded paging crosses renderer, preload and Host transcript storage; the return-to-tail assertion also uses a real pointer hit-test" + "electron": "bounded paging crosses renderer, preload and Host transcript storage: each page comes from the Host only when the reader asks, and the Host Turn index lists the whole Session before it is loaded; the return-to-tail assertion also uses a real pointer hit-test. Where the arriving rows leave the reader is renderer-owned and lives in the PrependedHistoryKeepsMeasuredHeights story." }, "proxy-password-editing.spec.ts": { "tests": 1, @@ -69,10 +69,6 @@ "tests": 4, "electron": "observation seeding, reconnect and settle are Host subscriptions surviving a renderer remount" }, - "transcript-scroll-cost.spec.ts": { - "tests": 1, - "electron": "One traversal exercises real Host transcript paging through preload/IPC, bounded retained ranges, reader displacement when pages install, and a return-to-tail Host read. Fixture motion runs in the TranscriptRenderCost story." - }, "workhub-layout.spec.ts": { "tests": 3, "electron": "WorkHub model configuration and attachment sending cross the Host boundary; the Project New task action must dismiss the native dock owned by a separate WebContentsView; the native Workbar menu must coexist with the live sibling WebContentsView without hiding its compositor; the compact native window grows only with the inline composer wheel and keeps its bottom anchor; opening context details routes to Main’s Workbar without resizing or docking the floating conversation, while the thinking menu stays within the compact bounds, and pointer dragging scrolls the no-drag wheel instead of moving the frameless window; native floating preserves macOS Dock visibility; a held-open Host Turn verifies immediate prompt visibility, durable transcript handoff and persistence after renderer reload" diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index c829032904..31c14b6c9e 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -394,6 +394,7 @@ export async function withE2eWindow( { seed, readinessSelector, + readinessTimeoutMs = 20_000, e2eFixtureScenario, locale, platform, @@ -407,6 +408,8 @@ export async function withE2eWindow( }: { seed: boolean; readinessSelector: string; + /** Raise only for a scenario whose seeding is itself heavy enough to delay the first paint. */ + readinessTimeoutMs?: number; e2eFixtureScenario?: string; locale?: 'zh-CN' | 'zh-TW' | 'en'; /** #1312: force app:info's platform so the window boots natively into that platform's `data-os` cascade. */ @@ -486,7 +489,7 @@ export async function withE2eWindow( } // Centralize the cold-start wait so test bodies are flake-free under retries:0. try { - await page.waitForSelector(readinessSelector, { timeout: 20_000 }); + await page.waitForSelector(readinessSelector, { timeout: readinessTimeoutMs }); if (invocableSkills) { await waitForInvocableSkills(page, ['project-only', 'workspace-only']); } @@ -504,7 +507,7 @@ export async function withE2eWindow( env: buildFixtureEnv(userDataDir, homeDir, { scenario: e2eFixtureScenario, locale, platform, showWindow: visibleWindow }), }); const restored = await app.firstWindow(); - await restored.waitForSelector(readinessSelector, { timeout: 20_000 }); + await restored.waitForSelector(readinessSelector, { timeout: readinessTimeoutMs }); return restored; } }); } finally { @@ -531,8 +534,8 @@ type E2eTestFixtures = { renameFocusWindow: { page: Page; app: ElectronApplication }; parentRemovalWindow: Page; railRenderWindow: Page; - promptRailWindow: Page; partialHistoryWindow: Page; + largeHistoryWindow: { page: Page; app: ElectronApplication }; newTaskTargetWindow: Page; directoryReferenceWindow: { page: Page; folder: string }; accessibilityNarrativeWindow: Page; @@ -647,27 +650,8 @@ export const test = base.extend({ use, ); }, - // A multi-prompt transcript. Each cost assertion gets an isolated Host and - // renderer so observation state cannot bleed between tests. The window is - // shown because these cases drive the real compositor through CDP. - promptRailWindow: async ({}, use) => { - await withE2eWindow({ - seed: false, - // A rendered turn, deliberately not the rail: Playwright treats a - // zero-area element as hidden, so gating readiness on a tick would turn - // every rail regression into a 20s cold-start timeout instead of the - // assertion that names it. - readinessSelector: '[data-turn-id]', - e2eFixtureScenario: 'chat-prompt-rail', - // Every other fixture window names its locale; without one the renderer - // takes the host's, so any test that reaches a control by its label - // passes on a Chinese desktop and cannot find it on an English CI runner. - locale: 'zh-CN', - showWindow: true, - }, use); - }, - // A transcript larger than the bounded Desktop range. Clicking an unloaded - // prompt exercises the real load-around path and its partial-history UI. + // A transcript larger than the Desktop history budget, so earlier Turns load + // only through the load-earlier control. partialHistoryWindow: async ({}, use) => { await withE2eWindow({ seed: false, @@ -677,6 +661,22 @@ export const test = base.extend({ showWindow: true, }, use); }, + // Three transcripts past the real 64 MiB history budget, for weighing what + // holding the loaded history costs the renderer. Writing them is hundreds of + // MiB of durable transcript, which is why the cold-start wait is raised. + largeHistoryWindow: async ({}, use) => { + await withE2eWindow( + { + seed: false, + readinessSelector: '[data-turn-id]', + readinessTimeoutMs: 300_000, + e2eFixtureScenario: 'chat-large-history', + locale: 'zh-CN', + showWindow: true, + }, + async (page, { app }) => use({ page, app }), + ); + }, // A data-backed conversation with settled tool evidence and the workbar open // beside it. Shown because the accessibility journey follows real native // focus order through the transcript into the composer controls. diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts index 226d0367e0..062170417d 100644 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -17,53 +17,80 @@ * under the License. */ +/** + * A Session too large for one read: what the Host hands over is a tail, and + * the rest comes only when the reader asks, one bounded page at a time. + * + * Where the arriving rows leave the reader is renderer-owned and lives in the + * `PrependedHistoryKeepsMeasuredHeights` browser story over real layout. + */ + +import type { Page } from '@playwright/test'; import { expect, test } from './fixtures'; -const TURN = '.maka-transcript-turn'; +const SCROLLER = '[data-chat-scroll-container="true"]'; +const TICK = '.maka-prompt-rail-tick'; /** Turns the partial-history fixture seeds. */ const PARTIAL_HISTORY_TURN_COUNT = 18; -test('a bounded transcript range reaches its whole history without a control to ask', async ({ +async function frames(page: Page, count = 2): Promise { + await page.evaluate((remaining) => new Promise((resolve) => { + const step = (left: number): void => { + if (left === 0) resolve(); + else requestAnimationFrame(() => step(left - 1)); + }; + step(remaining); + }), count); +} + +/** Wheel to the top as a reader would; a programmatic scroll does not release the tail pin. */ +async function wheelToTop(page: Page): Promise { + const scroller = page.locator(SCROLLER); + await expect(async () => { + // Re-read each attempt: the window may still be resizing. + const box = await scroller.boundingBox(); + if (!box) throw new Error('the chat scroll container has no box'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 4); + await page.mouse.wheel(0, -4_000); + await frames(page, 4); + expect(await scroller.evaluate((element) => element.scrollTop)).toBe(0); + }).toPass({ timeout: 30_000 }); + await frames(page, 4); +} + +test('a transcript over the history budget loads earlier Turns only on request', async ({ partialHistoryWindow: page, }) => { + test.setTimeout(90_000); await page.setViewportSize({ width: 1_400, height: 800 }); + const loadEarlier = page.getByRole('button', { name: '载入更早的记录' }); + const pendingLoad = page.locator('button:disabled', { hasText: '载入更早的记录' }); + const ticks = page.locator(TICK); - await expect(page.locator(TURN).first()).toBeVisible(); - expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); + await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`)).toBeVisible(); + // The Host Turn index lists the whole Session before its history is loaded. + await expect(ticks).toHaveCount(PARTIAL_HISTORY_TURN_COUNT); + await expect(loadEarlier).toHaveCount(1); - const oldestPrompt = page.locator( - '.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]', - ); - await expect(oldestPrompt).toBeVisible(); - await oldestPrompt.click(); + let loads = 0; + while ((await loadEarlier.count()) > 0) { + await wheelToTop(page); + await loadEarlier.click(); + await expect(pendingLoad).toHaveCount(0, { timeout: 30_000 }); + loads += 1; + expect(loads).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); + } + expect(loads).toBeGreaterThan(0); + await expect(ticks).toHaveCount(PARTIAL_HISTORY_TURN_COUNT); + await wheelToTop(page); await expect(page.locator('[data-turn-id="turn-partial-history-1"]')).toBeVisible(); - // Where the jump landed, read from the reading position rather than from - // `data-search-highlight`: that highlight clears itself 2.2s after the - // command lands, so waiting for the Turn to mount and then asserting it - // fails whenever loading the page around it takes longer than the flash — - // measured here as a 3s pass turning into an 18s timeout under load. - await expect(oldestPrompt).toHaveAttribute('data-active', 'true'); - // A jump lands on its own page, not on the whole history. - expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); - - // The newer side of the jump fills on its own as the reader moves into it. - await page.mouse.move(700, 400); - await expect(async () => { - await page.mouse.wheel(0, 400); - await expect(page.locator('[data-turn-id="turn-partial-history-3"]')).toBeVisible(); - }).toPass({ timeout: 30_000 }); const returnToLatest = page.getByRole('button', { name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, }); await expect(returnToLatest).toBeVisible(); await returnToLatest.click(); - - // Reading the tail page and rebuilding the window around it is slower than - // the paging above, and measured past the suite's 10s expect timeout here. - await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`)) - .toBeVisible({ timeout: 30_000 }); - await expect(oldestPrompt).toBeVisible(); - expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); + await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`)).toBeVisible(); + await expect(ticks).toHaveCount(PARTIAL_HISTORY_TURN_COUNT); }); diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts deleted file mode 100644 index 3d1eac57f2..0000000000 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ /dev/null @@ -1,373 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * What one scroll through the transcript COSTS, asserted as counts. - * - * The suite this replaces asserted wall-clock frame timings, and a timing - * assertion on a shared runner either flakes or gets switched off — that one - * was switched off behind an env var nothing ever set, so it never ran at all - * and every regression it existed to catch shipped. These assertions are - * structural: a number that does not move between runs on the same code, and - * does move when the thing it guards regresses. They run in ordinary CI. - * - * Gestures are RELATIVE input — a real wheel through CDP, which is also what - * the product's own history paging listens for. The replaced suite drove - * scrolling by writing absolute `scrollTop` values per frame, which erases the - * scroll-anchoring correction the browser applied since the previous frame, so - * the probe fought the scroller and produced displacement that looked like a - * product bug. - */ - -import type { CDPSession, Page } from '@playwright/test'; -import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; -import { expect, test } from './fixtures'; - -const SCROLLER = '[data-chat-scroll-container="true"]'; -const TURN = '.maka-transcript-turn'; - -/** - * Generous on purpose: the property worth guarding is that paging through the - * whole history stops adding Turns, and a range that kept everything it paged - * in would mount all of them. - */ -const MOUNTED_TURNS_MAX = 40; - -/** - * How far a range boundary is allowed to move the reader, in CSS pixels. - * - * A boundary both installs a page and drops the far side of the band, and the - * two settle within the same quiet frame, so what is measurable is their sum. - * Not a tolerance for "close enough" motion: anchoring holds that sum to a - * fraction of a Turn, where a frame that lost the reader lands a Turn away or - * more. - */ -const BOUNDARY_DISPLACEMENT_MAX_PX = 40; - -declare global { - interface Window { - __makaTranscriptDisplacement?: { - boundaries: TranscriptBoundary[]; - isSettled(): boolean; - stop(): void; - }; - } -} - -/** - * One frame where the mounted range changed: a page installed, or the band - * trimmed, or both. - */ -interface TranscriptBoundary { - readonly firstBefore: string; - readonly firstAfter: string; - readonly mountedBefore: number; - readonly mountedAfter: number; - readonly grewPx: number; - readonly scrolledPx: number; - /** Turns present in both frames, so a reader position can be compared. */ - readonly carried: number; - readonly worstTurnId: string | null; - readonly worstPx: number; -} - -/** - * Real wheel input at the centre of the scroller. Relative by construction: a - * wheel tick asks the compositor to move by a delta from wherever the scroller - * currently is, so an anchoring correction between ticks survives instead of - * being overwritten. - */ -async function wheel( - page: Page, - cdp: CDPSession, - options: { ticks: number; deltaY: number }, -): Promise { - const box = await page.locator(SCROLLER).boundingBox(); - if (!box) throw new Error('the chat scroll container has no box'); - const x = box.x + box.width / 2; - const y = box.y + box.height / 2; - for (let tick = 0; tick < options.ticks; tick += 1) { - await cdp.send('Input.dispatchMouseEvent', { - type: 'mouseWheel', - x, - y, - deltaX: 0, - deltaY: options.deltaY, - }); - await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); - } - await page.evaluate(() => new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - )); -} - -/** - * Watch every frame for a change in the mounted range, and measure what that - * change did to the reader. - * - * What must not move is where a Turn sits ON SCREEN, so the measurement is its - * viewport `top` and nothing else. Its position in the DOCUMENT is expected to - * move — installing a page above the reader is exactly what shifts it — and - * scroll anchoring answers that by adding the same amount to `scrollTop`, which - * is why the reader sees nothing. Measuring the document position instead would - * report every correctly absorbed page as a displacement the size of the page. - * - * Sampled per frame rather than per gesture: the frame that installs a page is - * the only one where the reader can be lost, and a per-gesture reading would - * subtract the reader's own scrolling back out and see nothing. - */ -async function observeDisplacement(page: Page): Promise { - await page.evaluate((scrollerSelector) => { - const scroller = document.querySelector(scrollerSelector); - if (!scroller) throw new Error('the chat scroll container is missing'); - const read = () => { - const tops = new Map(); - for (const turn of document.querySelectorAll('[data-turn-id]')) { - const turnId = turn.dataset.turnId; - if (turnId) tops.set(turnId, turn.getBoundingClientRect().top); - } - return { - scrollTop: scroller.scrollTop, - scrollHeight: scroller.scrollHeight, - tops, - key: [...tops.keys()].join(','), - }; - }; - // Arm in the page's native event dispatch, before the authority's deferred - // publication. Arming from Playwright after wheel() returns races the same - // rendering frames that publish the range and can miss every boundary. - let recording = false; - const record = (on: boolean): void => { - if (recording === on) return; - recording = on; - previous = read(); - settled = null; - }; - const onWheel = (event: Event): void => { - const { deltaY } = event as WheelEvent; - const remaining = deltaY < 0 ? scroller.scrollTop - : scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop; - // Edge input cannot move the viewport and may never emit scrollend. - record(remaining <= 0); - }; - const onScrollEnd = (): void => record(true); - scroller.addEventListener('wheel', onWheel, { capture: true, passive: true }); - scroller.addEventListener('scrollend', onScrollEnd, { capture: true }); - const state: { - boundaries: unknown[]; - isSettled(): boolean; - stop(): void; - } = { - boundaries: [], - isSettled: () => recording && settled === null, - stop: () => { - running = false; - scroller.removeEventListener('wheel', onWheel, true); - scroller.removeEventListener('scrollend', onScrollEnd, true); - }, - }; - let running = true; - let previous = read(); - // The last frame before the range started changing. Held across a run of - // changing frames so the measurement spans settled state to settled state: - // scroll anchoring corrects after layout, so a reading taken inside the - // change would report a correction that never reached the screen. - let settled: ReturnType | null = null; - const tick = (): void => { - if (!running) return; - const current = read(); - if (!recording) { - settled = null; - previous = current; - requestAnimationFrame(tick); - return; - } - if (current.key !== previous.key) { - if (!settled) settled = previous; - } else if (settled) { - const before = settled; - settled = null; - const scrolled = current.scrollTop - before.scrollTop; - let carried = 0; - let worstPx = 0; - let worstTurnId: string | null = null; - for (const [turnId, top] of current.tops) { - const wasAt = before.tops.get(turnId); - if (wasAt === undefined) continue; - carried += 1; - const displaced = Math.abs(top - wasAt); - if (displaced > worstPx) { - worstPx = displaced; - worstTurnId = turnId; - } - } - state.boundaries.push({ - firstBefore: before.key.split(',')[0] ?? '', - firstAfter: current.key.split(',')[0] ?? '', - mountedBefore: before.tops.size, - mountedAfter: current.tops.size, - grewPx: current.scrollHeight - before.scrollHeight, - scrolledPx: scrolled, - carried, - worstTurnId, - worstPx, - }); - } - previous = current; - requestAnimationFrame(tick); - }; - window.__makaTranscriptDisplacement = state as never; - requestAnimationFrame(tick); - }, SCROLLER); -} - -async function displacement(page: Page): Promise { - return page.evaluate(() => { - const state = window.__makaTranscriptDisplacement; - if (!state) throw new Error('the transcript displacement probe is missing'); - state.stop(); - return state.boundaries; - }); -} - -/** - * A transcript opened at its tail keeps fetching older history until two - * screens of it sit above the reader, and trims what falls outside the band it - * retains, so the mounted rows churn for as long as that runs. Wait for the - * window to stop moving before touching a row: a locator resolved mid-churn - * points at an element the Renderer has already unmounted. - * - * Timed out against that ramp rather than the suite's 10s default, which is - * sized for UI already on screen. - */ -async function settled(page: Page): Promise { - const mounted = async (): Promise => page.evaluate(() => { - const turns = document.querySelectorAll('[data-turn-id]'); - return `${turns.length}:${turns[0]?.getAttribute('data-turn-id')}`; - }); - let previous = await mounted(); - await expect - .poll(async () => { - await page.waitForTimeout(250); - const current = await mounted(); - const stable = current === previous; - previous = current; - return stable; - }, { timeout: 30_000 }) - .toBe(true); -} - -async function moveToTail(page: Page): Promise { - await settled(page); - // The bounded transcript can replace its last Turn between locator - // resolution and scrolling. The scroll container owns this movement. - await page.locator(SCROLLER).evaluate((element) => { element.scrollTop = element.scrollHeight; }); - await page.evaluate(() => new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - )); -} - -/** - * The affordance a reader who has paged away uses to come back. Waited for - * rather than probed: `isVisible()` answers about this instant, so a probe on - * a loaded runner falls through to whatever the else branch was before the - * button has rendered — which is how the suite this replaces carried an - * untested fallback through a prompt-rail tick that no run ever reached. - */ -async function returnToLatest(page: Page): Promise { - const returnLatest = page.getByRole('button', { - name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, - }); - await expect(returnLatest).toBeVisible(); - await returnLatest.click(); -} - -/** - * The scenario #5163 was reported from: quit Desktop, start it again, open a - * long Session, and scroll upward through history without stopping. The reader - * perceives stalls or jumps around range boundaries. - * - * A mounted-range bound alone does not say - * where the reader ended up while a page was installing, which is the whole of - * what that report is about. This one measures it: every frame the mounted - * range changes, whatever Turn the reader can still see must hold its viewport - * position. - * - * Each burst contains consecutive native wheel ticks. Publication boundaries - * are sampled after scrollend, separately from the reader's own movement. - * - * Displacement in pixels rather than frame timings on purpose — see this file's - * header for what happened to the timing assertions this suite replaced. A - * stall and a jump have the same cause here (a page boundary that moves - * content out from under the reader) and only one of them can be asserted - * without a clock. - */ -test('Host history paging stays bounded, preserves the reader and returns to latest', async ({ - promptRailWindow: page, -}) => { - test.setTimeout(120_000); - await page.setViewportSize({ width: 1_000, height: 700 }); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1); - const cdp = await page.context().newCDPSession(page); - const turns = page.locator('[data-turn-id]'); - await moveToTail(page); - await observeDisplacement(page); - let pages = 0; - let mountedMax = await turns.count(); - - for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { - const firstBefore = await turns.first().getAttribute('data-turn-id'); - if (firstBefore === 'turn-prompt-rail-1') break; - await expect - .poll(async () => { - await wheel(page, cdp, { ticks: 12, deltaY: -120 }); - await page.waitForFunction(() => window.__makaTranscriptDisplacement?.isSettled()); - return turns.first().getAttribute('data-turn-id'); - }) - .not.toBe(firstBefore); - pages += 1; - mountedMax = Math.max(mountedMax, await turns.count()); - // Let the probe compare the changed range with its next rendered frame - // before another wheel closes the measurement interval. - await page.evaluate(() => new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - )); - } - - expect(pages).toBeGreaterThan(0); - await expect(turns.first()).toHaveAttribute('data-turn-id', 'turn-prompt-rail-1'); - expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); - - const boundaries = await displacement(page); - // The probe has to have seen the thing it measures: a run that paged nothing, - // or one where every boundary replaced the range wholesale and carried no - // Turn across, proves nothing about the reader. - expect(boundaries.length).toBeGreaterThan(0); - expect(boundaries.filter((boundary) => boundary.carried > 0).length).toBeGreaterThan(0); - - const displaced = boundaries.filter((boundary) => boundary.worstPx > BOUNDARY_DISPLACEMENT_MAX_PX); - expect(displaced, `range boundaries moved the reader: ${JSON.stringify(displaced)}`) - .toEqual([]); - - await returnToLatest(page); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1, { timeout: 30_000 }); - expect(await turns.count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); -}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8fbfcea323..8c4e003b1c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -45,6 +45,7 @@ "typecheck:stories": "tsc -p tsconfig.storybook.json --noEmit", "test:dist": "node --test --test-force-exit \"dist/main/**/*.test.js\" scripts/dev-app-runtime.test.mjs scripts/vite-workspace-packages.test.mjs", "e2e": "npm run build:with-deps && playwright test --config e2e/playwright.config.ts", + "measure": "npm run build:with-deps && playwright test --config perf/playwright.config.ts", "build:with-deps": "npm run build:workspace-deps && npm run build", "smoke:real-window": "npm run build:with-deps && node ../../scripts/desktop-real-window-smoke.mjs", "smoke:programmatic-window": "npm run build:with-deps && node ../../scripts/desktop-real-window-smoke.mjs --programmatic-only", diff --git a/apps/desktop/perf/playwright.config.ts b/apps/desktop/perf/playwright.config.ts new file mode 100644 index 0000000000..0fc6911e27 --- /dev/null +++ b/apps/desktop/perf/playwright.config.ts @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { defineConfig } from '@playwright/test'; + +/** + * Measurement harnesses that launch a real Electron window but do not belong + * to the E2E tier. They seed hundreds of MiB and take minutes each, which is + * worth paying when a number is in question and not worth paying on every + * push, so nothing here runs in CI. + * + * Run from apps/desktop via `npm run measure`, which builds the app first. + */ +export default defineConfig({ + testDir: '.', + workers: 1, + captureGitInfo: { commit: false, diff: false }, + retries: 0, + timeout: 2_400_000, + expect: { timeout: 10_000 }, + outputDir: 'test-results', + use: { + trace: 'retain-on-failure', + video: 'retain-on-failure', + screenshot: 'only-on-failure', + }, +}); diff --git a/apps/desktop/perf/transcript-memory.spec.ts b/apps/desktop/perf/transcript-memory.spec.ts new file mode 100644 index 0000000000..cafc1dd533 --- /dev/null +++ b/apps/desktop/perf/transcript-memory.spec.ts @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { CDPSession, ElectronApplication, Page } from '@playwright/test'; +import { expect, test } from '../e2e/fixtures'; + +const TICK = '.maka-prompt-rail-tick'; +interface SessionUnderTest { + name: string; + turnPrefix: string; + /** Whether opening this Session stops at the budget and offers the rest. */ + offersMoreHistory?: boolean; +} + +/** The deep one: several rounds of load-earlier, which is what search back does. */ +const DEEP_SESSION: SessionUnderTest = { + name: '大历史内存基准会话 1', + turnPrefix: 'turn-e2e-fixture-large-history-1-', + offersMoreHistory: true, +}; +/** Two more past the budget, so the rotation visits distinct transcripts. */ +const OTHER_LARGE_SESSIONS: SessionUnderTest[] = [ + { + name: '大历史内存基准会话 2', + turnPrefix: 'turn-e2e-fixture-large-history-2-', + offersMoreHistory: true, + }, + { + name: '大历史内存基准会话 3', + turnPrefix: 'turn-e2e-fixture-large-history-3-', + offersMoreHistory: true, + }, +]; +const SMALL_SESSION: SessionUnderTest = { + name: '模型管理与工具调用示例', + turnPrefix: 'turn-fixture-', +}; +const MIB = 1024 * 1024; + +/** + * How much more the renderer may be holding after a second visit to the same + * Session than after the first. Dropping the window's eviction means nothing + * trims a loaded transcript while its Session is open, so the question this + * spec exists to answer is whether leaving gives the memory back — and the + * failure that would matter is not a held transcript but a growing pile of + * them. Slack for allocator noise in a ~500 MiB renderer, not for a leak. + */ +const RETAINED_GROWTH_MIB = 8; + +async function heapBytes(cdp: CDPSession): Promise { + const { metrics } = await cdp.send('Performance.getMetrics'); + return metrics.find((metric) => metric.name === 'JSHeapUsedSize')?.value ?? 0; +} + +/** Collect first: an uncollected transcript is indistinguishable from a retained one. */ +async function settledHeapBytes(cdp: CDPSession, page: Page): Promise { + await cdp.send('HeapProfiler.collectGarbage'); + await page.waitForTimeout(250); + await cdp.send('HeapProfiler.collectGarbage'); + return heapBytes(cdp); +} + +async function rendererResidentBytes(app: ElectronApplication): Promise { + const metrics = await app.evaluate(({ app: electron }) => electron.getAppMetrics()); + return metrics + .filter((entry) => entry.type === 'Tab' || entry.type === 'renderer') + .reduce((total, entry) => total + entry.memory.workingSetSize * 1024, 0); +} + +/** The earliest prompt the rail names — the loaded transcript's head. */ +async function earliestPrompt(page: Page): Promise { + return (await page.locator(TICK).first().getAttribute('aria-label')) ?? ''; +} + +async function openSession(page: Page, { name, turnPrefix, offersMoreHistory }: SessionUnderTest): Promise { + const expand = page.getByRole('button', { name: '展开侧边栏' }); + if ((await expand.count()) > 0) await expand.first().click(); + // Scoped to the list: the titlebar carries the active Session's name too. + const row = page + .getByRole('navigation', { name: '任务列表' }) + .getByRole('button', { name: new RegExp(`^${name}\\s`) }) + .first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + const started = Date.now(); + await row.click(); + // The titlebar renames before the transcript arrives, and a mounted Turn on + // its own can still belong to the Session being left, so wait for both. + await expect( + page.getByRole('button', { name: new RegExp(`^${name} — `) }), + ).toBeVisible({ timeout: 120_000 }); + await expect(page.locator(`[data-turn-id^="${turnPrefix}"]`).first()).toBeVisible({ + timeout: 300_000, + }); + // A Turn is on screen from the Main replica's cache long before the budgeted + // read has finished arriving, and the load-earlier control appears with it. + // What marks the end is the transcript's head no longer moving. + if (offersMoreHistory) { + await expect(page.getByRole('button', { name: '载入更早的记录' })).toHaveCount(1, { + timeout: 300_000, + }); + let head = await earliestPrompt(page); + for (let still = 0; still < 4; still += 1) { + await page.waitForTimeout(500); + const next = await earliestPrompt(page); + if (next !== head) still = -1; + head = next; + } + } + return Date.now() - started; +} + +test('transcripts past the history budget give their memory back when the reader leaves', async ({ + largeHistoryWindow: { page, app }, +}) => { + await page.setViewportSize({ width: 1_400, height: 800 }); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Performance.enable'); + const ticks = page.locator(TICK); + const loadEarlier = page.getByRole('button', { name: '载入更早的记录' }); + const samples: Record[] = []; + const sample = async (stage: string, extra: Record = {}): Promise => { + const heap = await settledHeapBytes(cdp, page); + const row = { + stage, + heapMiB: Number((heap / MIB).toFixed(1)), + rendererResidentMiB: Number(((await rendererResidentBytes(app)) / MIB).toFixed(1)), + loadedTurns: await ticks.count(), + ...extra, + }; + samples.push(row); + console.log(JSON.stringify(row)); + return heap; + }; + + await expect(page.locator('[data-turn-id]').first()).toBeVisible({ timeout: 120_000 }); + // Read the baseline from a small Session in the same renderer, so the + // comparison is the transcript's own cost and not the shell's. + await openSession(page, SMALL_SESSION); + const baseline = await sample('baseline (small session)'); + + const openMs = await openSession(page, DEEP_SESSION); + const opened = await sample('opened at the budget', { openMs }); + // The read stops at the budget, so the control is on offer rather than the + // whole transcript being resident. + await expect(loadEarlier).toHaveCount(1); + + let loads = 0; + while ((await loadEarlier.count()) > 0 && loads < 40) { + // The rail samples a fixed number of ticks however much is loaded, so + // progress is the earliest prompt it names, not how many it names. + const before = await earliestPrompt(page); + const started = Date.now(); + await loadEarlier.first().click(); + await expect.poll(() => earliestPrompt(page), { timeout: 120_000 }).not.toBe(before); + loads += 1; + await sample(`after load ${loads}`, { loadMs: Date.now() - started }); + } + const full = await sample('whole transcript loaded', { loads }); + + await openSession(page, SMALL_SESSION); + const released = await sample('reader moved to another session'); + + // Two more transcripts, each as large, each a different Session, then the + // first again. A renderer that kept one per Session visited would show it + // across this rotation and nowhere else: returning to the same Session + // forever costs the same whether or not anything is being kept. + const releases: number[] = []; + for (const visit of [...OTHER_LARGE_SESSIONS, DEEP_SESSION]) { + const visitMs = await openSession(page, visit); + await sample(`opened ${visit.name}`, { visitMs }); + await openSession(page, SMALL_SESSION); + releases.push(await sample(`moved away from ${visit.name}`)); + } + + const output = path.resolve('perf-results/transcript-memory.json'); + await mkdir(path.dirname(output), { recursive: true }); + await writeFile( + output, + JSON.stringify( + { + conditions: + 'Real Electron, real Host, real storage. One renderer throughout; every sample is taken after two forced collections. The baseline is a small Session open in the same renderer.', + samples, + }, + null, + 2, + ), + ); + + expect(full, 'loading earlier history has to cost something to be worth measuring') + .toBeGreaterThan(opened); + // One round of load-earlier cannot show whether repeated loading stays + // linear, so the fixture has to stay deep enough to need several. + expect(loads, 'the deep fixture no longer needs repeated loads').toBeGreaterThanOrEqual(2); + // What survives a switch is the Session just left; it is replaced, not added + // to — whether the reader returns to a transcript or moves on to another + // one. Each departure is compared with the first departure of the same + // shape, so the deep Session's fully loaded remnant is not the yardstick. + const [firstRelease, ...laterReleases] = releases; + expect(released, 'the deep session should still be resident right after it was left') + .toBeGreaterThan(firstRelease); + for (const [visit, release] of laterReleases.entries()) { + expect( + (release - firstRelease) / MIB, + `visit ${visit + 2} left ${((release - firstRelease) / MIB).toFixed(1)} MiB more behind than the first`, + ).toBeLessThanOrEqual(RETAINED_GROWTH_MIB); + } +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 030b29268f..330b62ccb4 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -337,7 +337,7 @@ "@maka/ui": 1 }, "importSpecifiers": 10, - "nonTriviaTokens": 3616 + "nonTriviaTokens": 3612 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 4, @@ -503,7 +503,7 @@ "react": 1 }, "importSpecifiers": 18, - "nonTriviaTokens": 3718 + "nonTriviaTokens": 3677 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 5, @@ -861,7 +861,7 @@ "react": 1 }, "importSpecifiers": 100, - "nonTriviaTokens": 13279 + "nonTriviaTokens": 13152 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index da339d70fe..5095764bac 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -14363,6 +14363,36 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: virtua@0.48.8 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/inokawa/virtua.git + +--- LICENSE --- +MIT License + +Copyright (c) 2022 inokawa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: which@2.0.2 Declared license: ISC Selected license: ISC diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts index 0e60dd6668..1679a872d1 100644 --- a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -75,7 +75,7 @@ export function createTransientState() { export function createActionsDeps() { const activeIdRef = { current: undefined as string | undefined }; return { - onFollowLatest: async (_sessionId: string) => true, + onFollowLatest: (_sessionId: string) => true, uiLocale: 'en' as const, activeIdRef, captureComposerImportOwner: () => ({ diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index a9210208c7..618156edec 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -632,22 +632,9 @@ describe('composer first-send cleanup', () => { assert.equal(resolved, 0); }); - it('cancels restoration and accepts a message while latest history catches up in the background', async () => { - const latest = deferred(); + it('cancels restoration and follows latest before accepting a message', async () => { const order: string[] = []; const activeIdRef = { current: 'existing-session' as string | undefined }; - const transcript = { - store: { - sessionId: 'existing-session', - range: () => ({ sessionId: 'existing-session', hasNewer: false }), - snapshot: () => ({ messages: [] }), - }, - async loadLatest() { - order.push('latest'); - await latest.promise; - }, - } as unknown as DesktopTranscriptRangeController; - const transcriptRangeRef = { current: transcript as DesktopTranscriptRangeController | undefined }; const restoreWindow = installWindow({ sessions: { submitMessage: async () => { @@ -661,17 +648,14 @@ describe('composer first-send cleanup', () => { const sending = createAppShellChatActions({ ...createActionsDeps(), activeIdRef, - transcriptRangeRef, onFollowLatest: (sessionId) => prepareTranscriptForSend({ - sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef, - cancel: () => { order.push('cancel-restore'); }, followLatest: () => {}, + sessionId, currentSessionId: activeIdRef, + cancel: () => { order.push('cancel-restore'); }, + followLatest: () => { order.push('follow-latest'); }, }), }).send('hello'); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(order, ['cancel-restore', 'latest', 'send']); assert.equal(await sending, true); - latest.resolve(); - assert.deepEqual(order, ['cancel-restore', 'latest', 'send']); + assert.deepEqual(order, ['cancel-restore', 'follow-latest', 'send']); } finally { restoreWindow(); } @@ -746,49 +730,6 @@ describe('composer first-send cleanup', () => { } }); - for (const initialized of [false, true]) { - it(`does not navigate the previous Session controller (${initialized ? 'initialized' : 'opening'}) while sending`, async () => { - const submissions: string[] = []; - let latestReads = 0; - const transcript = { - store: { - sessionId: 'previous-session', - range: () => { - if (!initialized) throw new Error('Desktop transcript range is not initialized'); - return { sessionId: 'previous-session' }; - }, - }, - loadLatest: async () => { latestReads += 1; }, - } as unknown as DesktopTranscriptRangeController; - const restoreWindow = installWindow({ - sessions: { - submitMessage: async (sessionId: string) => { - submissions.push(sessionId); - return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; - }, - }, - }); - const activeIdRef = { current: 'selected-session' }; - const transcriptRangeRef = { current: transcript }; - try { - const result = await createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef, - transcriptRangeRef, - onFollowLatest: (sessionId) => prepareTranscriptForSend({ - sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef, - cancel: () => {}, - followLatest: (sessionId) => { assert.equal(sessionId, 'selected-session'); }, - }), - }).send('hello'); - assert.equal(result, true); - assert.deepEqual(submissions, ['selected-session']); - assert.equal(latestReads, 0, 'the previous Session must not be navigated'); - } finally { - restoreWindow(); - } - }); - } }); /** * #1433 round 5: the failure feedback for a send is addressed to the surface diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index c0ac030ecd..96463e705c 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -36,7 +36,6 @@ import { } from '../../renderer/app-shell-session-ui-state.js'; import { createTranscriptRestoreLifecycle, - refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, } from '../../renderer/features/conversation/testing.js'; @@ -187,13 +186,12 @@ describe('app shell session UI state controller', () => { notifications += 1; }); - controller.setTranscriptReadingAnchor('drop', { turnId: 'turn-drop', sequence: 7 }); - controller.setTranscriptReadingAnchor('keep', { turnId: 'turn-keep', sequence: 11 }); controller.setTranscriptReadingAnchor('drop', { turnId: 'turn-drop' }); + controller.setTranscriptReadingAnchor('keep', { turnId: 'turn-keep' }); assert.deepEqual(controller.transcriptReadingAnchorBySessionRef.current, { - drop: { turnId: 'turn-drop', sequence: 7 }, - keep: { turnId: 'turn-keep', sequence: 11 }, + drop: { turnId: 'turn-drop' }, + keep: { turnId: 'turn-keep' }, }); assert.equal(notifications, 0, 'reading anchors have no live render subscriber'); @@ -224,169 +222,59 @@ describe('app shell session UI state controller', () => { assert.equal(notifications, 2); }); - it('clears Owner landmarks and ignores their late response when the active Session becomes a Guest', async () => { - let resolveOwner!: (value: { throughSequence: number; landmarks: string[] }) => void; - let index: { sessionId: string; throughSequence: number | null; turns: readonly string[] } | undefined = { - sessionId: 'owner-session', throughSequence: 0, turns: ['previous-owner-turn'], - }; - const dispose = refreshTranscriptTurnLandmarks({ - sessionId: 'owner-session', - newestDurablePromptSequence: 1, - list: () => new Promise<{ throughSequence: number; landmarks: string[] }>((resolve) => { - resolveOwner = resolve; - }), - isCurrent: () => true, - setIndex: (value) => { index = value; }, - }); - // The shell cleans up the Owner effect and passes no ownerActiveId for Guests. - dispose?.(); - refreshTranscriptTurnLandmarks({ - sessionId: undefined, - newestDurablePromptSequence: 1, - list: async () => assert.fail('Guests cannot query Owner turn landmarks'), - isCurrent: () => true, - setIndex: (value) => { index = value; }, - }); - resolveOwner({ throughSequence: 1, landmarks: ['owner-turn'] }); - await Promise.resolve(); - assert.equal(index, undefined); - }); - - it('enriches a Turn-only reading anchor when its range sequence arrives later', async () => { - let anchor: { turnId: string; sequence?: number } | undefined; - restoreSessionTranscriptRange({ - lifecycle: createTranscriptRestoreLifecycle(), - sessionId: 'session', - readingAnchor: { turnId: 'turn' }, - controller: { - store: { - sessionId: 'session', - range: () => ({ sessionId: 'session' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => 17, - newestDurableUserSequence: () => 17, - snapshot: () => ({ messages: [] }), - }, - loadAround: async () => assert.fail('the resident Turn must not load another range'), - }, - isCurrent: () => true, - setReadingAnchor: (_sessionId, next) => { - anchor = next; - }, - onError: (error) => assert.fail(String(error)), - }); - - assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 }); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 }); - }); - - it('does not enrich a reading anchor from another Session range', () => { - let sequenceReads = 0; - let anchor: { turnId: string; sequence?: number } | undefined; + it('does not restore a reading anchor from another Session range', () => { + let anchor: { turnId: string } | undefined = { turnId: 'turn' }; restoreSessionTranscriptRange({ lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'active', readingAnchor: { turnId: 'turn' }, controller: { store: { - sessionId: 'stale', - range: () => ({ sessionId: 'stale' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => { - sequenceReads += 1; - return 17; - }, - newestDurableUserSequence: () => 17, + range: () => ({ sessionId: 'stale', hasOlder: false, ready: true }), snapshot: () => ({ messages: [] }), }, - loadAround: async () => assert.fail('a stale range must not load'), + loadEarlier: async () => assert.fail('a stale range must not load'), }, isCurrent: () => true, setReadingAnchor: (_sessionId, next) => { anchor = next; }, + onRestoreUnavailable: () => assert.fail('a stale range cannot declare the anchor unavailable'), onError: (error) => assert.fail(String(error)), }); - assert.equal(sequenceReads, 0); - assert.equal(anchor, undefined); + assert.deepEqual(anchor, { turnId: 'turn' }); }); - it('abandons a Turn-only restore that remains absent after the range is ready', async () => { - let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'missing' }; + it('abandons a restore that remains absent once no earlier history is left', async () => { + let anchor: { turnId: string } | undefined = { turnId: 'missing' }; let unavailable: { sessionId: string; turnId: string } | undefined; - const options = { + restoreSessionTranscriptRange({ lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'session', readingAnchor: { turnId: 'missing' }, controller: { store: { - sessionId: 'session', - range: () => ({ sessionId: 'session' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => null, - snapshot: () => ({ messages: [] }), + range: () => ({ sessionId: 'session', hasOlder: false, ready: true }), + snapshot: () => ({ messages: [{ turnId: 'latest' }] }), }, - loadAround: async () => assert.fail('a Turn-only anchor has no load target'), + loadEarlier: async () => assert.fail('all history is already loaded'), }, isCurrent: () => true, - setReadingAnchor: (_sessionId: string, next: { turnId: string; sequence?: number } | undefined) => { + setReadingAnchor: (_sessionId, next) => { anchor = next; }, - onRestoreUnavailable: (sessionId: string, turnId: string) => { + onRestoreUnavailable: (sessionId, turnId) => { unavailable = { sessionId, turnId }; }, - onError: (error: unknown) => assert.fail(String(error)), - }; - - restoreSessionTranscriptRange(options); + onError: (error) => assert.fail(String(error)), + }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(anchor, undefined); assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'missing' }); }); - it('abandons a known-sequence restore when loadAround cannot make the Turn resident', async () => { - let loadedSequence: number | undefined; - let unavailable: { sessionId: string; turnId: string } | undefined; - let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'removed', sequence: 23 }; - const options = { - lifecycle: createTranscriptRestoreLifecycle(), - sessionId: 'session', - readingAnchor: { turnId: 'removed', sequence: 23 }, - controller: { - store: { - sessionId: 'session', - range: () => ({ sessionId: 'session' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => 29, - snapshot: () => ({ messages: [{ id: 'latest' }] }), - }, - loadAround: async (sequence: number) => { - loadedSequence = sequence; - }, - }, - isCurrent: () => true, - setReadingAnchor: (_sessionId: string, next: { turnId: string; sequence?: number } | undefined) => { - anchor = next; - }, - onRestoreUnavailable: (sessionId: string, turnId: string) => { - unavailable = { sessionId, turnId }; - }, - onError: (error: unknown) => assert.fail(String(error)), - }; - - restoreSessionTranscriptRange(options); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(loadedSequence, 23); - assert.equal(anchor, undefined); - assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'removed' }); - }); - it('keeps the synchronous live-turn ref aligned with reducer updates', () => { const controller = createAppShellSessionUiStateController(); const projection = [armLiveTurn('turn-1')]; diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index 06bc141298..e4281aefac 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -20,25 +20,25 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; +import { deferred, waitFor } from '@maka/core/test-only/async-primitives'; import { SESSION_CONTINUITY_SCHEMA_VERSION } from '@maka/runtime-host/protocol'; import { + encodeDesktopTranscriptBatches, encodeDesktopTranscriptChange, - encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot, + type TranscriptBatchIdentity, } from '../desktop-transcript-ipc.js'; import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE, - DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES, DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, + type DesktopTranscriptHandle, } from '../../preload/transcript-contract.js'; import { createDesktopTranscriptReconnectRecovery, - createRecoveringDesktopTranscriptRangeController, createDesktopTranscriptRangeController, DesktopTranscriptRangeStore, } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { TranscriptReadSupersededError } from '../../renderer/features/conversation/index.js'; import { mergeSettledMessages } from '../../renderer/settled-message-merge.js'; import { readSettledMessages, @@ -108,152 +108,223 @@ test('cancels settlement while transcript open is pending', async () => { } }); -for (const paged of [false, true]) { - test(`reads one Host-owned Turn outside the bounded transcript tail${paged ? ' across multiple pages' : ''}`, async () => { - const sessionKey = JSON.stringify(['host-1', 'session-1']); - const turnB: StoredMessage[] = [ - userMessage('follow-up one', 'user-b'), - { ...assistantMessage('answer B', 'assistant-b'), turnId: 'turn-b', ts: 4 }, - { - type: 'turn_state', - id: 'complete-b', - turnId: 'turn-b', - ts: 5, - status: 'completed', +test('reads one Host-owned Turn outside the bounded transcript tail', async () => { + const sessionKey = JSON.stringify(['host-1', 'session-1']); + const turnB: StoredMessage[] = [ + userMessage('follow-up one', 'user-b'), + { ...assistantMessage('answer B', 'assistant-b'), turnId: 'turn-b', ts: 4 }, + { type: 'turn_state', id: 'complete-b', turnId: 'turn-b', ts: 5, status: 'completed' }, + ]; + const turnC: StoredMessage[] = [ + userMessage('follow-up two', 'user-c'), + { ...assistantMessage('answer C', 'assistant-c'), turnId: 'turn-c', ts: 7 }, + { type: 'turn_state', id: 'complete-c', turnId: 'turn-c', ts: 8, status: 'completed' }, + ]; + const turnReads: string[] = []; + let deliverySequence = 0; + + const result = await readSettledMessagesFrom( + { + transcripts: { + async readTurn(sessionId, turnId) { + assert.equal(sessionId, sessionKey); + turnReads.push(turnId); + return turnB; + }, + open: async (_sessionId, handler, _registerCancellation, mode) => { + assert.equal(mode, 'tail'); + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 8, + durable: turnC.map((message, index) => ({ sequence: index + 6, message })), + hasOlder: true, + })) handler({ ...batch, deliverySequence: ++deliverySequence }); + return transcriptHandle( + { sessionId: sessionKey, generation: 'generation-1', hostEpoch: 'host-1' }, + { + readThroughMessageId: 'complete-c', + async acknowledgeTail() { assert.fail('A recovery read must not mark the Session read'); }, + }, + ); + }, }, - ]; - const turnC: StoredMessage[] = [ - userMessage('follow-up two', 'user-c'), - { ...assistantMessage('answer C', 'assistant-c'), turnId: 'turn-c', ts: 7 }, - { - type: 'turn_state', - id: 'complete-c', - turnId: 'turn-c', - ts: 8, - status: 'completed', + }, + sessionKey, + { requiredTurnId: 'turn-b' }, + ); + + assert.deepEqual(turnReads, ['turn-b']); + assert.deepEqual(result, { messages: [...turnB, ...turnC], settled: true }); +}); + +/** + * A byte-bounded tail can start inside the required Turn: its terminal row is + * there and its earlier rows are not. A terminal row is evidence about + * execution, not about how much of the Turn came back, so settling on it alone + * returns a Turn with a hole in it. + */ +test('reads the required Turn when the tail begins inside it', async () => { + const sessionKey = JSON.stringify(['host-1', 'session-1']); + const whole: StoredMessage[] = [ + userMessage('the question', 'user-b'), + { ...assistantMessage('the intermediate step', 'assistant-b-step'), turnId: 'turn-b', ts: 4 }, + { ...assistantMessage('the final answer', 'assistant-b'), turnId: 'turn-b', ts: 5 }, + { type: 'turn_state', id: 'complete-b', turnId: 'turn-b', ts: 6, status: 'completed' }, + ]; + // What a 16 KiB bootstrap has room for once the final answer is large. + const tail = whole.slice(2); + const turnReads: string[] = []; + let deliverySequence = 0; + + const result = await readSettledMessagesFrom( + { + transcripts: { + async readTurn(_sessionId, turnId) { + turnReads.push(turnId); + return whole; + }, + open: async (_sessionId, handler) => { + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 8, + durable: tail.map((message, index) => ({ sequence: index + 7, message })), + hasOlder: true, + })) handler({ ...batch, deliverySequence: ++deliverySequence }); + return transcriptHandle( + { sessionId: sessionKey, generation: 'generation-1', hostEpoch: 'host-1' }, + { readThroughMessageId: 'complete-b' }, + ); + }, }, - ]; - const navigations: number[] = []; - const extensions: number[] = []; - let deliverySequence = 0; + }, + sessionKey, + { requiredTurnId: 'turn-b' }, + ); - const result = await readSettledMessagesFrom( - { - sessions: { - listTurns: async (sessionId) => { - assert.equal(sessionId, sessionKey); - return [{ turnId: 'turn-b', firstSequence: 3, status: 'completed' }]; - }, + assert.deepEqual(turnReads, ['turn-b'], 'the terminal row was taken as proof the Turn was whole'); + assert.deepEqual(result.messages, whole); +}); + +/** + * Side Chat reseeding asks for the Session, not for a Turn — it has no way to + * know which Turn a byte-bounded tail stopped inside. The tail says, so the + * caller does not have to remember to ask. + */ +test('reads the Turn the tail begins inside when the caller names none', async () => { + const sessionKey = JSON.stringify(['host-1', 'session-1']); + const whole: StoredMessage[] = [ + userMessage('the question', 'user-b'), + { ...assistantMessage('the intermediate step', 'assistant-b-step'), turnId: 'turn-b', ts: 4 }, + { ...assistantMessage('the final answer', 'assistant-b'), turnId: 'turn-b', ts: 5 }, + { type: 'turn_state', id: 'complete-b', turnId: 'turn-b', ts: 6, status: 'completed' }, + ]; + const tail = whole.slice(2); + const turnReads: string[] = []; + let deliverySequence = 0; + + const result = await readSettledMessagesFrom( + { + transcripts: { + async readTurn(_sessionId, turnId) { + turnReads.push(turnId); + return whole; }, - transcripts: { - open: async (_sessionId, handler) => { - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', - generation: 'generation-1', - hostEpoch: 'host-1', - durableThrough: 8, - durable: turnC.map((message, index) => ({ sequence: index + 6, message })), - hasOlder: true, - hasNewer: false, - })) handler({ ...batch, deliverySequence: ++deliverySequence }); - return { - sessionId: sessionKey, - generation: 'generation-1', - hostEpoch: 'host-1', - readThroughMessageId: 'complete-c', - async acknowledgeTail() { assert.fail('A recovery read must not mark the Session read'); }, - async loadBefore() {}, - async loadAfter(sequence, maxBytes, navigation) { - assert.equal(maxBytes, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - assert.equal(sequence, 4); - extensions.push(sequence); - for (const batch of encodeDesktopTranscriptPage({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigation, - }, { - durableThrough: 8, - durable: [{ sequence: 5, message: turnB[2]! }], - hasNewer: true, - }, { direction: 'newer', anchor: sequence })) { - handler({ ...batch, deliverySequence: ++deliverySequence }); - } - }, - async loadAround(sequence, maxBytes, navigation) { - assert.equal(maxBytes, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - navigations.push(sequence); - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', - generation: 'generation-1', - hostEpoch: 'host-1', - durableThrough: 8, - durable: (paged ? turnB.slice(0, 2) : turnB).map((message, index) => ({ sequence: index + 3, message })), - hasOlder: true, - hasNewer: true, - }, navigation)) handler({ ...batch, deliverySequence: ++deliverySequence }); - }, - async loadLatest() {}, - async close() {}, - }; - }, + open: async (_sessionId, handler) => { + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: false, + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 8, + durable: tail.map((message, index) => ({ sequence: index + 7, message })), + hasOlder: true, + })) handler({ ...batch, deliverySequence: ++deliverySequence }); + return transcriptHandle( + { sessionId: sessionKey, generation: 'generation-1', hostEpoch: 'host-1' }, + { readThroughMessageId: 'complete-b' }, + ); }, }, - sessionKey, - { requiredTurnId: 'turn-b' }, - ); + }, + sessionKey, + ); - assert.deepEqual(navigations, [3]); - assert.deepEqual(extensions, paged ? [4] : []); - assert.deepEqual(result, { messages: [...turnB, ...turnC], settled: true }); - }); -} + assert.deepEqual(turnReads, ['turn-b']); + assert.deepEqual(result, { messages: whole, settled: true }); +}); + +test('settles on a tail that begins between two Turns without reading one', async () => { + const sessionKey = JSON.stringify(['host-1', 'session-1']); + const tail: StoredMessage[] = [ + userMessage('the question', 'user-b'), + { ...assistantMessage('the answer', 'assistant-b'), turnId: 'turn-b', ts: 4 }, + { type: 'turn_state', id: 'complete-b', turnId: 'turn-b', ts: 5, status: 'completed' }, + ]; + let deliverySequence = 0; + + const result = await readSettledMessagesFrom( + { + transcripts: { + async readTurn() { assert.fail('a whole tail needs no Turn read'); }, + open: async (_sessionId, handler) => { + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 8, + durable: tail.map((message, index) => ({ sequence: index + 6, message })), + hasOlder: true, + })) handler({ ...batch, deliverySequence: ++deliverySequence }); + return transcriptHandle( + { sessionId: sessionKey, generation: 'generation-1', hostEpoch: 'host-1' }, + { readThroughMessageId: 'complete-b' }, + ); + }, + }, + }, + sessionKey, + ); + + assert.deepEqual(result, { messages: tail, settled: true }); +}); -for (const hasSequence of [false, true]) { - test(`does not settle when a targeted Host-owned Turn ${hasSequence ? 'cannot be recovered' : 'has no indexed sequence'}`, async () => { +for (const failure of ['is empty', 'fails'] as const) { + test(`does not settle when the targeted Host-owned Turn read ${failure}`, async () => { const sessionKey = JSON.stringify(['host-1', 'session-1']); const tail: StoredMessage[] = [ { ...assistantMessage('answer C', 'assistant-c'), turnId: 'turn-c', ts: 7 }, - { - type: 'turn_state', - id: 'complete-c', - turnId: 'turn-c', - ts: 8, - status: 'completed', - }, + { type: 'turn_state', id: 'complete-c', turnId: 'turn-c', ts: 8, status: 'completed' }, ]; - let targetedRead = false; let deliverySequence = 0; const result = await readSettledMessagesFrom( { - sessions: { - listTurns: async () => [{ - turnId: 'missing-turn', status: 'completed', ...(hasSequence ? { firstSequence: 3 } : {}), - }], - }, transcripts: { + async readTurn() { + if (failure === 'fails') throw new Error('Turn read failed'); + return []; + }, open: async (_sessionId, handler) => { for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', durableThrough: 8, durable: tail.map((message, index) => ({ sequence: index + 7, message })), hasOlder: true, - hasNewer: false, })) handler({ ...batch, deliverySequence: ++deliverySequence }); - return { - sessionId: sessionKey, - generation: 'generation-1', - hostEpoch: 'host-1', - readThroughMessageId: 'complete-c', - async acknowledgeTail() {}, - async loadBefore() {}, - async loadAfter() {}, - async loadAround() { - targetedRead = true; - }, - async loadLatest() {}, - async close() {}, - }; + return transcriptHandle( + { sessionId: sessionKey, generation: 'generation-1', hostEpoch: 'host-1' }, + { readThroughMessageId: 'complete-c' }, + ); }, }, }, @@ -261,7 +332,6 @@ for (const hasSequence of [false, true]) { { requiredTurnId: 'missing-turn' }, ); - assert.equal(targetedRead, hasSequence); assert.deepEqual(result, { messages: tail, settled: false }); }); } @@ -275,11 +345,11 @@ test('assembles a fragmented durable record once and ignores its replay', () => }; const store = transcriptStore(); for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, ...identity, durableThrough: null, durable: [], hasOlder: false, - hasNewer: false, })) store.accept(batch); const change = [...encodeDesktopTranscriptChange(identity, { @@ -288,74 +358,42 @@ test('assembles a fragmented durable record once and ignores its replay', () => durableUpserts: [{ sequence: 4, message }], })]; assert.ok(change.length > 1); - for (const batch of change) { + for (const [index, batch] of change.entries()) { assert.ok( batch.fragments.reduce( (total, fragment) => total + fragment.data.byteLength, 0, ) <= DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, ); - store.accept(batch); + assert.equal(store.accept(batch), index === change.length - 1); } assert.deepEqual(store.snapshot().messages, [message]); assert.equal(store.hasDurableMessage(message.id), true); - for (const batch of change) assert.equal(store.accept(batch), false); + for (const batch of change) store.accept(batch); assert.deepEqual(store.snapshot().messages, [message]); }); -test('tracks the newest resident durable prompt as the window changes', () => { - const identity = { - sessionId: 'session-1', - generation: 'generation-1', - hostEpoch: 'host-1', - }; - const store = transcriptStore(); - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, - durableThrough: 3, - durable: [ - { sequence: 1, message: userMessage('older', 'user-1') }, - { sequence: 2, message: assistantMessage('answer') }, - { sequence: 3, message: userMessage('newer', 'user-3') }, - ], - hasOlder: false, - hasNewer: false, - })) store.accept(batch); - assert.equal(store.newestDurableUserSequence(), 3); - - for (const batch of encodeDesktopTranscriptChange(identity, { - coversFrom: 3, - durableThrough: 4, - durableUpserts: [{ sequence: 4, message: assistantMessage('latest') }], })) store.accept(batch); - assert.equal(store.newestDurableUserSequence(), 3); - - store.retain(2, null); - assert.equal(store.newestDurableUserSequence(), 3); - store.retain(4, null); - assert.equal(store.newestDurableUserSequence(), null); -}); - test('drops stale transcript batches after a generation reset', () => { const store = transcriptStore(); const oldBatches = [...encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, sessionId: 'session-1', generation: 'old', hostEpoch: 'host-1', durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('old') }], hasOlder: false, - hasNewer: false, })]; const nextMessage = assistantMessage('new'); const nextBatches = [...encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, sessionId: 'session-1', generation: 'next', hostEpoch: 'host-2', durableThrough: 2, durable: [{ sequence: 2, message: nextMessage }], hasOlder: true, - hasNewer: false, })]; for (const batch of oldBatches) store.accept(batch); @@ -365,7 +403,8 @@ test('drops stale transcript batches after a generation reset', () => { { coversFrom: 2, durableThrough: 3, - durableUpserts: [{ sequence: 3, message: assistantMessage('stale') }], }, + durableUpserts: [{ sequence: 3, message: assistantMessage('stale') }], + }, )]; for (const batch of staleChange) assert.equal(store.accept(batch), false); assert.deepEqual(store.snapshot().messages, [nextMessage]); @@ -379,13 +418,15 @@ test('cached reload snapshots allow the same live transcript generation to resum hostEpoch: 'host-1', }; let opens = 0; + const errors: unknown[] = []; const deliveries: Array<{ generation: string; accepted: boolean }> = []; - const publish = (generation: string, text: string, navigation?: number) => { + const publish = (generation: string, text: string) => { for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, ...identity, generation, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage(text) }], - hasOlder: false, hasNewer: false, - }, navigation)) deliveries.push({ generation, accepted: store.accept(batch) }); + hasOlder: false, + })) deliveries.push({ generation, accepted: store.accept(batch) }); }; const controller = createDesktopTranscriptRangeController(store, async () => { opens += 1; @@ -395,20 +436,8 @@ test('cached reload snapshots allow the same live transcript generation to resum } // The event subscription keeps the main-process replica alive between opens. publish(identity.generation, `live-${opens}`); - return { - ...identity, readThroughMessageId: null, - async acknowledgeTail() {}, - async loadBefore() {}, - async loadAfter() {}, - async loadAround(_sequence, _maxBytes, navigation) { - publish(identity.generation, `live-${opens}`, navigation); - }, - async loadLatest(navigation) { - publish(identity.generation, `live-${opens}`, navigation); - }, - async close() {}, - }; - }); + return transcriptHandle(identity); + }, { onError: (error) => errors.push(error) }); try { await controller.ready(); @@ -424,8 +453,10 @@ test('cached reload snapshots allow the same live transcript generation to resum for (const batch of encodeDesktopTranscriptChange(identity, { coversFrom: 1, durableThrough: 2, - durableUpserts: [{ sequence: 2, message: updated }], })) assert.equal(store.accept(batch), true); + durableUpserts: [{ sequence: 2, message: updated }], + })) assert.equal(store.accept(batch), true); assert.deepEqual(store.snapshot().messages, [assistantMessage('live-3'), updated]); + assert.deepEqual(errors, []); } finally { await controller.close(); } @@ -435,9 +466,10 @@ test('a replacement live generation retires the previous replica through cached for (const cachedGenerations of [[], ['cached:first', 'cached:second']]) { const store = transcriptStore(); const snapshot = (generation: string) => [...encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, sessionId: 'session-1', generation, hostEpoch: 'host-1', durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage(generation) }], - hasOlder: false, hasNewer: false, + hasOlder: false, })]; const generations = ['previous-live', ...cachedGenerations, 'replacement-live']; for (const generation of generations) { @@ -451,7 +483,8 @@ test('a replacement live generation retires the previous replica through cached }, { coversFrom: 1, durableThrough: 2, - durableUpserts: [{ sequence: 2, message: assistantMessage('stale', 'stale') }], })) assert.equal(store.accept(batch), false); + durableUpserts: [{ sequence: 2, message: assistantMessage('stale', 'stale') }], + })) assert.equal(store.accept(batch), false); } assert.strictEqual(store.snapshot(), replacement); assert.deepEqual(store.snapshot().messages, [assistantMessage('replacement-live')]); @@ -468,11 +501,11 @@ test('keeps unchanged message references stable across immutable range snapshots const secondMessage = assistantMessage('second', 'assistant-2'); const store = transcriptStore(); for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, ...identity, durableThrough: 1, durable: [{ sequence: 1, message: firstMessage }], hasOlder: false, - hasNewer: false, })) store.accept(batch); const first = store.snapshot(); @@ -484,7 +517,8 @@ test('keeps unchanged message references stable across immutable range snapshots for (const batch of encodeDesktopTranscriptChange(identity, { coversFrom: 1, durableThrough: 2, - durableUpserts: [{ sequence: 2, message: secondMessage }], })) store.accept(batch); + durableUpserts: [{ sequence: 2, message: secondMessage }], + })) store.accept(batch); const second = store.snapshot(); assert.notStrictEqual(second, first); @@ -492,6 +526,203 @@ test('keeps unchanged message references stable across immutable range snapshots assert.deepEqual(second.messages, [firstMessage, secondMessage]); }); +test('a reset spanning several batches publishes once, when it is ready', () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('old') }], + hasOlder: false, + })) store.accept(batch); + const installed = store.snapshot(); + let commits = 0; + store.subscribe(() => { commits += 1; }); + + const next = { ...identity, generation: 'generation-2' }; + const newest = assistantMessage('x'.repeat(300 * 1024), 'assistant-3'); + const older = assistantMessage('older', 'assistant-2'); + const running = assistantMessage('streaming', 'assistant-4'); + const batches = [ + ...encodeDesktopTranscriptBatches(next, { + durableThrough: 4, durable: [{ sequence: 4, message: running }, { sequence: 3, message: newest }], + hasOlder: true, reset: true, ready: false, + }), + ...encodeDesktopTranscriptBatches(next, { + durableThrough: 4, durable: [{ sequence: 2, message: older }], + hasOlder: false, reset: false, ready: true, + }), + ]; + assert.ok(batches.length > 2, 'the reset has to span several batches'); + for (const batch of batches.slice(0, -1)) { + assert.equal(store.accept(batch), false); + assert.strictEqual(store.snapshot(), installed); + } + assert.equal(commits, 0); + + assert.equal(store.accept(batches.at(-1)!), true); + assert.equal(commits, 1); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['assistant-2', 'assistant-3', 'assistant-4']); + assert.equal(store.range().generation, 'generation-2'); + assert.equal(store.range().hasOlder, false); +}); + +test('earlier history installs only below the oldest row it was read for', () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...identity, durableThrough: 6, hasOlder: true, + durable: [5, 6].map((sequence) => ({ sequence, message: assistantMessage(`${sequence}`, `assistant-${sequence}`) })), + })) store.accept(batch); + const ids = () => store.snapshot().messages.map(({ id }) => id); + + for (const batch of earlierBatches(identity, 6, [4], false)) assert.equal(store.accept(batch), false); + assert.deepEqual(ids(), ['assistant-5', 'assistant-6']); + + for (const batch of earlierBatches(identity, 5, [3, 4], true)) assert.equal(store.accept(batch), true); + assert.deepEqual(ids(), ['assistant-3', 'assistant-4', 'assistant-5', 'assistant-6']); + assert.equal(store.range().hasOlder, true); + assert.equal(store.range().durableThrough, 6); + + for (const batch of earlierBatches(identity, 5, [2], false)) assert.equal(store.accept(batch), false); + assert.deepEqual(ids(), ['assistant-3', 'assistant-4', 'assistant-5', 'assistant-6']); + assert.equal(store.range().hasOlder, true); + assert.equal(store.needsReload(), false); + + for (const batch of earlierBatches(identity, 3, [2], false)) assert.equal(store.accept(batch), true); + assert.deepEqual(ids(), ['assistant-2', 'assistant-3', 'assistant-4', 'assistant-5', 'assistant-6']); + assert.equal(store.range().hasOlder, false); +}); + +test('a tail change that does not continue the held rows reopens the transcript', async () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + const row = (sequence: number) => ({ sequence, message: assistantMessage(`${sequence}`, `assistant-${sequence}`) }); + const errors: unknown[] = []; + let opens = 0; + const controller = createDesktopTranscriptRangeController(store, async () => { + opens += 1; + const durable = opens === 1 ? [row(1)] : [row(1), row(2), row(9)]; + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...identity, durableThrough: durable.at(-1)!.sequence, durable, hasOlder: false, + })) store.accept(batch); + return transcriptHandle(identity); + }, { onError: (error) => errors.push(error) }); + const ids = () => store.snapshot().messages.map(({ id }) => id); + try { + await controller.ready(); + + for (const batch of encodeDesktopTranscriptChange(identity, { + coversFrom: 1, durableThrough: 2, durableUpserts: [row(2)], + })) assert.equal(store.accept(batch), true); + assert.deepEqual(ids(), ['assistant-1', 'assistant-2']); + assert.equal(store.range().durableThrough, 2); + assert.equal(store.needsReload(), false); + + for (const batch of encodeDesktopTranscriptChange(identity, { + coversFrom: 7, durableThrough: 9, durableUpserts: [row(9)], + })) store.accept(batch); + assert.deepEqual(ids(), ['assistant-1', 'assistant-2'], 'nothing proves 9 adjacent to what is held'); + assert.equal(store.range().durableThrough, 2); + assert.equal(store.needsReload(), true); + + await waitFor(() => opens === 2 && !store.needsReload(), { timeoutMs: 5_000 }); + assert.deepEqual(ids(), ['assistant-1', 'assistant-2', 'assistant-9']); + assert.equal(store.range().durableThrough, 9); + assert.deepEqual(errors, []); + } finally { + await controller.close(); + } +}); + +test('loadEarlier shares one in-flight read and reports its failure', async () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + const failure = new Error('earlier read failed'); + const gate = deferred(); + const errors: unknown[] = []; + let reads = 0; + const controller = createDesktopTranscriptRangeController(store, async () => { + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...identity, durableThrough: 5, hasOlder: true, + durable: [{ sequence: 5, message: assistantMessage('5', 'assistant-5') }], + })) store.accept(batch); + return transcriptHandle(identity, { + async loadEarlier() { + reads += 1; + await gate.promise; + throw failure; + }, + }); + }, { onError: (error) => errors.push(error) }); + try { + await controller.ready(); + const first = controller.loadEarlier(); + assert.strictEqual(controller.loadEarlier(), first); + gate.resolve(); + await first; + assert.equal(reads, 1); + assert.deepEqual(errors, [failure]); + + await controller.loadEarlier(); + assert.equal(reads, 2, 'a settled read does not block the next one'); + + for (const batch of earlierBatches(identity, 5, [4], false)) store.accept(batch); + await controller.loadEarlier(); + assert.equal(reads, 2, 'nothing is read once no earlier history remains'); + } finally { + await controller.close(); + } +}); + +test('a read down to a sequence waits out a pending read, skips held history, and a reopen resumes from the oldest held row', async () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + const gate = deferred(); + const reads: (number | undefined)[] = []; + const resumes: (number | undefined)[] = []; + const controller = createDesktopTranscriptRangeController(store, async (_signal, resumeFrom) => { + resumes.push(resumeFrom); + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...identity, durableThrough: 9, hasOlder: true, + durable: [{ sequence: 9, message: assistantMessage('9', 'assistant-9') }], + })) store.accept(batch); + return transcriptHandle(identity, { + async loadEarlier(throughSequence) { + reads.push(throughSequence); + if (throughSequence === undefined) { + await gate.promise; + for (const batch of earlierBatches(identity, 9, [7], true)) store.accept(batch); + } else { + for (const batch of earlierBatches(identity, 7, [3, 5], true)) store.accept(batch); + } + }, + }); + }, { onError: (error) => assert.fail(String(error)) }); + try { + await controller.ready(); + const pending = controller.loadEarlier(); + const targeted = controller.loadEarlier(3); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(reads, [undefined]); + gate.resolve(); + await Promise.all([pending, targeted]); + assert.deepEqual(reads, [undefined, 3]); + assert.equal(store.range().oldestSequence, 3); + + await controller.loadEarlier(5); + assert.deepEqual(reads, [undefined, 3], 'a Turn already held is not read again'); + + await controller.reload(); + assert.deepEqual(resumes, [undefined, 3]); + } finally { + await controller.close(); + } +}); + test('bounds the default active transcript range by Turn identities', async () => { const messages = Array.from({ length: 200 }, (_, sequence) => ({ identity: sequence, @@ -523,7 +754,6 @@ test('bounds the default active transcript range by Turn identities', async () = ); assert.equal(snapshot.durable.at(-1)?.sequence, 199); assert.equal(snapshot.hasOlder, true); - assert.equal(snapshot.hasNewer, false); }); test('bounds the default active transcript range by presentation bytes', async () => { @@ -545,10 +775,9 @@ test('bounds the default active transcript range by presentation bytes', async ( (total, { message }) => total + Buffer.byteLength(JSON.stringify(message), 'utf8'), 0, ); - assert.ok(bytes <= DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + assert.ok(bytes <= DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES); assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [12, 13, 14, 15]); assert.equal(snapshot.hasOlder, true); - assert.equal(snapshot.hasNewer, false); }); test('keeps an oversized latest Turn visible after bootstrap eviction', async () => { @@ -558,7 +787,7 @@ test('keeps an oversized latest Turn visible after bootstrap eviction', async () }; const latest = { identity: 1, - message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'), + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1), 'assistant-1'), }; const bootstrapPage = transcriptPage('older', null, latest.identity); const handle = runtimeHostSessionFixture({ @@ -574,12 +803,15 @@ test('keeps an oversized latest Turn visible after bootstrap eviction', async () assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [latest.identity]); assert.equal(replica.snapshot().hasOlder, true); + // Eviction drops rows by their owner, which is not where a Turn ends when + // another Turn's rows are written between them. + assert.equal(replica.snapshot().beginsAtTurnBoundary, false); }); test('keeps an oversized latest Turn visible before a trailing session note', async () => { const latest = { identity: 0, - message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-0'), + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1), 'assistant-0'), }; const trailingNote = { identity: 1, @@ -590,11 +822,7 @@ test('keeps an oversized latest Turn visible before a trailing session note', as kind: 'mode_change' as const, }, }; - const bootstrapPage = { - ...transcriptPage('older', null, trailingNote.identity), - rangeBoundarySequence: latest.identity, - protectedTurnSequence: latest.identity, - }; + const bootstrapPage = transcriptPage('older', null, trailingNote.identity); const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), @@ -660,14 +888,10 @@ test('keeps an oversized Turn visible when the watermark advances onto it', asyn }; const latest = { identity: 1, - message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'), + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1), 'assistant-1'), }; const bootstrapPage = transcriptPage('older', null, older.identity); - const newerPage = { - ...transcriptPage('newer', null, latest.identity), - rangeBoundarySequence: latest.identity, - protectedTurnSequence: latest.identity, - }; + const newerPage = transcriptPage('newer', null, latest.identity); const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), @@ -693,7 +917,7 @@ test('keeps an oversized settled Turn visible before a trailing session note', a }; const latest = { identity: 1, - message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'), + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1), 'assistant-1'), }; const trailingNote = { identity: 2, @@ -705,11 +929,7 @@ test('keeps an oversized settled Turn visible before a trailing session note', a }, }; const bootstrapPage = transcriptPage('older', null, older.identity); - const newerPage = { - ...transcriptPage('newer', null, trailingNote.identity), - rangeBoundarySequence: trailingNote.identity, - protectedTurnSequence: latest.identity, - }; + const newerPage = transcriptPage('newer', null, trailingNote.identity); const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), @@ -725,109 +945,64 @@ test('keeps an oversized settled Turn visible before a trailing session note', a await replica.advance(trailingNote.identity); - const snapshot = replica.snapshot(); - assert.ok(snapshot.durable.some(({ sequence }) => sequence === latest.identity)); + assert.ok(replica.snapshot().durable.some(({ sequence }) => sequence === latest.identity)); }); -for (const direction of ['older', 'newer'] as const) { - test(`does not resurrect a discarded replica when ${direction} history load is in flight`, async () => { - // A pending page must not repopulate or publish a reclaimed replica. - const messages = [0, 1, 2, 3, 4].map((sequence) => ({ - identity: sequence, - message: assistantMessage(String(sequence), `assistant-${sequence}`), - })); - const page = (nextCursor: string | null) => ({ - kind: 'page' as const, - sessionId: 'session-1', - direction: 'older' as const, - throughSequence: 4, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page('older'); - const adjacentPage = { ...page(null), direction }; - let releasePage: () => void = () => {}; - const pageGate = new Promise((resolve) => { - releasePage = resolve; - }); - let signalEntered: () => void = () => {}; - const pageEntered = new Promise((resolve) => { - signalEntered = resolve; - }); - const changes: { durableUpserts: readonly { sequence: number }[] }[] = []; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { durable: bootstrapPage }, - decodeTranscriptPage: async (candidate) => candidate === bootstrapPage - ? { messages: direction === 'older' ? messages.slice(4) : messages.slice(0, 1), nextCursor: 'older' } - : { messages: messages.slice(2, 4), nextCursor: null }, - loadTranscriptPage: async () => { - signalEntered(); - await pageGate; - return adjacentPage; - }, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes: 1024 * 1024, - onChange: (_replica, change) => changes.push(change), - }); - - // Reclaim memory while an adjacent history page is pending. - const loading = direction === 'older' - ? replica.loadBefore(4, 128 * 1024) - : replica.loadAfter(1, 128 * 1024); - await pageEntered; - replica.discard(); - assert.equal(replica.resident, false); - releasePage(); - await loading; - - assert.equal(changes.length, 0, 'a discarded replica must not publish an in-flight history page'); - assert.equal(replica.resident, false); - assert.equal(replica.residentBytes, 0); +test('does not resurrect a discarded replica when a history page read is in flight', async () => { + const messages = [0, 1, 2, 3, 4].map((sequence) => ({ + identity: sequence, + message: assistantMessage(String(sequence), `assistant-${sequence}`), + })); + const bootstrapPage = transcriptPage('older', 'older', 4); + const olderPage = transcriptPage('older', null, 4); + const pageGate = deferred(); + const pageEntered = deferred(); + const changes: DesktopTranscriptReplicaChange[] = []; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { durable: bootstrapPage }, + decodeTranscriptPage: async (candidate) => candidate === bootstrapPage + ? { messages: messages.slice(4), nextCursor: 'older' } + : { messages: messages.slice(2, 4), nextCursor: null }, + loadTranscriptPage: async () => { + pageEntered.resolve(); + await pageGate.promise; + return olderPage; + }, + async close() {}, }); -} + const replica = await DesktopTranscriptReplica.prepare(handle, { + maxResidentBytes: 1024 * 1024, + onChange: (_replica, change) => changes.push(change), + }); + + const reading = replica.readOlderPage(4, 'older'); + await pageEntered.promise; + replica.discard(); + pageGate.resolve(); + await assert.rejects(reading, /evicted/); + + assert.equal(changes.length, 0, 'a discarded replica must not publish an in-flight history page'); + assert.equal(replica.resident, false); + assert.equal(replica.residentBytes, 0); +}); test('does not drive a discarded replica terminal when a contiguous catch-up is in flight', async () => { - // Same post-await `#resident` invariant on the ordinary contiguous catch-up - // path: another observed session's LRU `discard()` reclaims this replica while - // a `direction: 'newer'` page is pending. The per-page callback already returns - // early, but without the post-loop guard the watermark check would throw - // `correlation_changed` and drive the session terminal. A discarded replica has - // no watermark to meet — catch-up must return cleanly, not reject. + // Another observed Session's LRU `discard()` reclaims this replica while a + // newer page is pending. A discarded replica has no watermark to meet, so + // catch-up must return cleanly rather than reject with `correlation_changed`. const messages = [0, 1, 2, 3, 4].map((sequence) => ({ identity: sequence, message: assistantMessage(String(sequence), `assistant-${sequence}`), })); const appended = { identity: 5, message: assistantMessage('5', 'assistant-5') }; - const page = (nextCursor: string | null, throughSequence: number) => ({ - kind: 'page' as const, - sessionId: 'session-1', - direction: 'newer' as const, - throughSequence, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page(null, 4); - const newerPage = page(null, 5); - let releaseNewer: () => void = () => {}; - const newerGate = new Promise((resolve) => { - releaseNewer = resolve; - }); - let signalEntered: () => void = () => {}; - const newerEntered = new Promise((resolve) => { - signalEntered = resolve; - }); - const changes: { durableUpserts: readonly { sequence: number }[] }[] = []; + const bootstrapPage = transcriptPage('newer', null, 4); + const newerPage = transcriptPage('newer', null, 5); + const newerGate = deferred(); + const newerEntered = deferred(); + const changes: DesktopTranscriptReplicaChange[] = []; const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), @@ -837,10 +1012,8 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is ? { messages, nextCursor: null } : { messages: [appended], nextCursor: null }, loadTranscriptPage: async () => { - // Park catch-up inside the contiguous newer-page await so the test can - // reclaim memory at exactly that point. - signalEntered(); - await newerGate; + newerEntered.resolve(); + await newerGate.promise; return newerPage; }, async close() {}, @@ -849,17 +1022,12 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is maxResidentBytes: 1024 * 1024, onChange: (_replica, change) => changes.push(change), }); - // A large budget keeps the whole bootstrap resident, so the tail is contiguous - // (`hasNewer` false) and `advance` takes the paged catch-up, not the re-anchor. - assert.equal(replica.snapshot().hasNewer, false); - // Advance the watermark contiguously; reclaim memory while the newer page is - // pending. Before the fix `advancing` rejects with `correlation_changed`. const advancing = replica.advance(5); - await newerEntered; + await newerEntered.promise; replica.discard(); assert.equal(replica.resident, false); - releaseNewer(); + newerGate.resolve(); await advancing; const upserts = changes.flatMap((change) => change.durableUpserts.map(({ sequence }) => sequence)); @@ -868,7 +1036,7 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is assert.equal(replica.residentBytes, 0); }); -test('a window opened between catch-up pages can join the change that follows', async () => { +test('a transcript opened between catch-up pages can join the change that follows', async () => { const bootstrap = [0, 1, 2].map((sequence) => ({ identity: sequence, message: assistantMessage(String(sequence), `assistant-${sequence}`), @@ -878,24 +1046,11 @@ test('a window opened between catch-up pages can join the change that follows', message: assistantMessage(String(sequence), `assistant-${sequence}`), })); const secondPage = [{ identity: 6, message: assistantMessage('6', 'assistant-6') }]; - const page = (nextCursor: string | null, throughSequence: number) => ({ - kind: 'page' as const, - sessionId: 'session-1', - direction: 'newer' as const, - throughSequence, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page(null, 2); - const first = page('more', 6); - const second = page(null, 6); - let releaseSecond: () => void = () => {}; - const secondGate = new Promise((resolve) => { releaseSecond = resolve; }); - let signalSecond: () => void = () => {}; - const secondEntered = new Promise((resolve) => { signalSecond = resolve; }); + const bootstrapPage = transcriptPage('newer', null, 2); + const first = transcriptPage('newer', 'more', 6); + const second = transcriptPage('newer', null, 6); + const secondGate = deferred(); + const secondEntered = deferred(); const changes: DesktopTranscriptReplicaChange[] = []; const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), @@ -909,8 +1064,8 @@ test('a window opened between catch-up pages can join the change that follows', : { messages: secondPage, nextCursor: null }, loadTranscriptPage: async (request) => { if (request.cursor === null) return first; - signalSecond(); - await secondGate; + secondEntered.resolve(); + await secondGate.promise; return second; }, async close() {}, @@ -921,13 +1076,13 @@ test('a window opened between catch-up pages can join the change that follows', }); const advancing = replica.advance(6); - await secondEntered; - // The first page is installed; the second is pending. A window opening now - // must be told the watermark its rows actually reach. + await secondEntered.promise; + // The first page is installed; the second is pending. A transcript opening + // now must be told the watermark its rows actually reach. const opened = replica.snapshot(); assert.deepEqual(opened.durable.map(({ sequence }) => sequence), [0, 1, 2, 3, 4, 5]); assert.equal(opened.durableThrough, 5); - releaseSecond(); + secondGate.resolve(); await advancing; const store = transcriptStore(); @@ -936,8 +1091,11 @@ test('a window opened between catch-up pages can join the change that follows', for (const change of changes.slice(1)) { for (const batch of encodeDesktopTranscriptChange(identity, change)) store.accept(batch); } - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [0, 1, 2, 3, 4, 5, 6]); - assert.equal(store.range().hasNewer, false); + assert.deepEqual( + store.snapshot().messages.map(({ id }) => id), + [0, 1, 2, 3, 4, 5, 6].map((sequence) => `assistant-${sequence}`), + ); + assert.equal(store.needsReload(), false); }); test('transfers prepared transcript bytes into active replica accounting', async () => { @@ -992,33 +1150,20 @@ test('does not release resident bytes when preparation accounting rejects them', test('reopens a failed transcript range with a fresh generation', async () => { const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'reloaded', hostEpoch: 'host-2' }; let attempts = 0; const controller = createDesktopTranscriptRangeController(store, async () => { attempts += 1; if (attempts === 1) throw new Error('open failed'); for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', - generation: 'reloaded', - hostEpoch: 'host-2', + beginsAtTurnBoundary: true, + ...identity, durableThrough: null, durable: [], hasOlder: false, - hasNewer: false, - })) - store.accept(batch); - return { - sessionId: 'session-1', - generation: 'reloaded', - hostEpoch: 'host-2', - readThroughMessageId: null, - async acknowledgeTail() {}, - async loadBefore() {}, - async loadAfter() {}, - async loadAround() {}, - async loadLatest() {}, - async close() {}, - }; - }); + })) store.accept(batch); + return transcriptHandle(identity); + }, { onError() {} }); await assert.rejects(() => controller.ready(), /open failed/); await controller.reload(); @@ -1064,57 +1209,6 @@ test('retries a failed transcript recovery after a newer observation becomes rea recovery.close(); }); -test('forwards a larger logical history range without changing batch size', async () => { - const store = transcriptStore(); - // Rows below an open newer edge only install as a command's answer, so this - // snapshot has to be one: it is the window a navigation asked for. - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', - generation: 'generation-1', - hostEpoch: 'host-1', - durableThrough: 4, - durable: [ - { sequence: 1, message: assistantMessage('earlier') }, - { - sequence: 2, - message: { ...assistantMessage('latest', 'assistant-2'), turnId: 'turn-2' }, - }, - { - sequence: 3, - message: { ...assistantMessage('more', 'assistant-3'), turnId: 'turn-2' }, - }, - ], - hasOlder: true, - hasNewer: true, - }, store.navigate())) store.accept(batch); - let request: { anchorSequence: number | null; maxBytes?: number } | undefined; - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId: 'session-1', - generation: 'generation-1', - hostEpoch: 'host-1', - readThroughMessageId: 'assistant-1', - async acknowledgeTail() {}, - async loadBefore(anchorSequence, maxBytes) { - request = { anchorSequence, maxBytes }; - }, - async loadAfter(anchorSequence, maxBytes) { - request = { anchorSequence, maxBytes }; - }, - async loadAround() {}, - async loadLatest() {}, - async close() {}, - })); - - await controller.loadBefore(512 * 1024); - - assert.deepEqual(request, { anchorSequence: 1, maxBytes: 512 * 1024 }, - 'backward reads start at the oldest record the window holds'); - await controller.loadAfter(512 * 1024); - assert.deepEqual(request, { anchorSequence: 3, maxBytes: 512 * 1024 }, - 'forward reads start at the newest record the window holds'); - await controller.close(); -}); - test('waits for the required durable message on the current transcript generation', async () => { const store = transcriptStore(); const identity = { @@ -1123,27 +1217,29 @@ test('waits for the required durable message on the current transcript generatio hostEpoch: 'host-1', }; for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, ...identity, durableThrough: null, durable: [], hasOlder: false, - hasNewer: false, })) store.accept(batch); const waiting = store.waitForDurableMessage('assistant-1', 100); for (const batch of encodeDesktopTranscriptChange(identity, { coversFrom: null, durableThrough: 0, - durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], })) store.accept(batch); + durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], + })) store.accept(batch); assert.equal(await waiting, true); }); -test('the window does not change while an answer is still being assembled', () => { +test('the transcript does not change while a tail change is still being assembled', () => { const store = transcriptStore(); const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], - hasOlder: false, hasNewer: false, + hasOlder: false, })) store.accept(batch); const installed = store.snapshot(); @@ -1154,137 +1250,41 @@ test('the window does not change while an answer is still being assembled', () = message: assistantMessage('x'.repeat(300 * 1024), 'assistant-2'), }], })]; - assert.ok(change.length > 1, 'the answer has to span more than one batch'); + assert.ok(change.length > 1, 'the change has to span more than one batch'); for (const batch of change.slice(0, -1)) assert.equal(store.accept(batch), false); - assert.strictEqual(store.snapshot(), installed, 'the screen is never a half-installed answer'); + assert.strictEqual(store.snapshot(), installed, 'the screen is never a half-installed change'); assert.equal(store.accept(change.at(-1)!), true); - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [1, 2]); -}); - -for (const coversFrom of [7, undefined]) { - test(`tail rows a window cannot join are dropped, and its ${coversFrom === undefined ? 'uncovered' : 'mismatched'} watermark still moves`, () => { - const store = transcriptStore(); - const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], - hasOlder: false, hasNewer: false, - })) store.accept(batch); - assert.equal(store.range().hasNewer, false); - - for (const batch of encodeDesktopTranscriptChange(identity, { - coversFrom, durableThrough: 9, - durableUpserts: [{ sequence: 9, message: assistantMessage('stranded', 'assistant-9') }], - })) store.accept(batch); - - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [1], - 'nothing proves 9 adjacent to what the window holds'); - assert.equal(store.range().durableThrough, 9); - assert.equal(store.range().hasNewer, true, 'so the window knows to read forward itself'); - }); -} - -test('a reset the reader has navigated past moves the watermark and nothing else', () => { - const store = transcriptStore(); - const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], - hasOlder: false, hasNewer: false, - })) store.accept(batch); - - const answer = [...encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 6, - durable: [ - { sequence: 5, message: assistantMessage('x'.repeat(300 * 1024), 'assistant-5') }, - { sequence: 6, message: assistantMessage('jumped', 'assistant-6') }, - ], - hasOlder: true, hasNewer: false, - }, store.navigate())]; - assert.ok(answer.length > 1); - store.accept(answer[0]!); - // The reader asked to be somewhere else before the first answer finished. - store.navigate(); - for (const batch of answer.slice(1)) store.accept(batch); - - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [1]); - assert.equal(store.range().durableThrough, 6); - assert.equal(store.pendingNavigation(), 2, 'the jump the reader is waiting for still stands'); -}); - -test('a fill is issued once per window and again as soon as the window moves', async () => { - const store = transcriptStore(); - let reads = 0; - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - readThroughMessageId: null, - async acknowledgeTail() {}, - async loadBefore(_anchor, maxBytes) { - assert.equal(maxBytes, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - 'a background projection keeps its existing scan budget'); - reads += 1; - }, - async loadAfter() {}, async loadAround() {}, async loadLatest() {}, async close() {}, - })); - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: 2, hasOlder: true, hasNewer: false, - durable: [ - { sequence: 1, message: assistantMessage('first') }, - { sequence: 2, message: assistantMessage('second', 'assistant-2') }, - ], - })) store.accept(batch); - - assert.equal(await controller.loadBefore(), true); - assert.equal(await controller.loadBefore(), false, 'the same window answers the same way'); - assert.equal(reads, 1); - - assert.equal(store.retain(2, 2), true); - assert.equal(await controller.loadBefore(), true); - assert.equal(reads, 2); - await controller.close(); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['assistant-1', 'assistant-2']); }); -test('reports each tail the window reaches once, and none while it is parked', async () => { +test('reports each tail watermark the reader reaches once', async () => { const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; const store = transcriptStore(); const acknowledged: number[] = []; - const readBudgets: number[] = []; - // The visible reader path: only a controller that acknowledges reports a tail. - const controller = createRecoveringDesktopTranscriptRangeController(store, async () => ({ - ...identity, readThroughMessageId: null, + const controller = createDesktopTranscriptRangeController(store, async () => transcriptHandle(identity, { async acknowledgeTail(through) { acknowledged.push(through); }, - async loadBefore(_anchor, maxBytes) { readBudgets.push(maxBytes); }, async loadAfter() {}, async loadAround() {}, - async loadLatest() {}, async close() {}, }), { onError() {} }); const settle = () => new Promise((resolve) => setImmediate(resolve)); - // Opening a Session at the tail: the read marker still moves on open. for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, hasOlder: true, hasNewer: false, + beginsAtTurnBoundary: true, + ...identity, durableThrough: 1, hasOlder: true, durable: [{ sequence: 1, message: assistantMessage('first') }], })) store.accept(batch); await settle(); assert.deepEqual(acknowledged, [1]); - await controller.loadBefore(); - assert.equal(readBudgets.length, 1); - assert.ok(readBudgets[0]! > 0 && readBudgets[0]! < DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - 'the visible reader uses an interactive page budget rather than the IPC ceiling'); for (const batch of encodeDesktopTranscriptChange(identity, { coversFrom: 1, durableThrough: 2, durableUpserts: [{ sequence: 2, message: assistantMessage('second', 'assistant-2') }], })) store.accept(batch); await settle(); - assert.deepEqual(acknowledged, [1, 2], 'a window that joined the tail reports it once'); + assert.deepEqual(acknowledged, [1, 2]); - // A trim reopens the newer edge, so the next change cannot join the window. - assert.equal(store.retain(1, 1), true); - for (const batch of encodeDesktopTranscriptChange(identity, { - coversFrom: 2, durableThrough: 3, - durableUpserts: [{ sequence: 3, message: assistantMessage('third', 'assistant-3') }], - })) store.accept(batch); + for (const batch of earlierBatches(identity, 1, [0], false)) assert.equal(store.accept(batch), true); await settle(); - assert.deepEqual(acknowledged, [1, 2], 'a parked window reports no tail'); + assert.deepEqual(acknowledged, [1, 2], 'earlier history moves no tail watermark'); await controller.close(); }); @@ -1298,12 +1298,106 @@ test('cancels a transcript open that is still waiting for a Host', async () => { openSignal = signal; signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }); }), + { onError() {} }, ); await controller.close(); assert.equal(openSignal?.aborted, true); }); +test('cached fallback remains readable and retries once per observation generation until live', async () => { + const store = transcriptStore(); + const errors: unknown[] = []; + let opens = 0; + let online = false; + let earlierReads = 0; + const controller = createDesktopTranscriptRangeController(store, async () => { + opens += 1; + const identity = { + sessionId: 'session-1', + generation: online ? 'live-generation' : 'cached:generation', + hostEpoch: 'host-1', + }; + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...identity, durableThrough: 1, + durable: [{ sequence: 1, message: assistantMessage(online ? 'live' : 'cached') }], + hasOlder: true, + })) store.accept(batch); + return transcriptHandle(identity, { async loadEarlier() { earlierReads += 1; } }); + }, { onError: (error) => errors.push(error) }); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + await controller.ready(); + await settle(); + assert.equal(opens, 1); + assert.equal(store.range().generation, 'cached:generation'); + await controller.loadEarlier(); + assert.equal(earlierReads, 0, 'a cached transcript has no Host to read earlier history from'); + controller.observationChanged('ready'); + await settle(); + assert.equal(opens, 2); + controller.observationChanged('ready'); + await settle(); + assert.equal(opens, 2); + online = true; + controller.observationChanged('pending'); + controller.observationChanged('ready'); + await settle(); + assert.equal(opens, 3); + assert.equal(store.range().generation, 'live-generation'); + assert.deepEqual(errors, []); + await controller.close(); +}); + +test('a reload that lands on the cached transcript recovers once the Host is back', async () => { + const store = transcriptStore(); + let online = true; + let opens = 0; + const controller = createDesktopTranscriptRangeController(store, async () => { + opens += 1; + const identity = { + sessionId: 'session-1', + generation: online ? `live-${opens}` : `cached:${opens}`, + hostEpoch: 'host-1', + }; + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...identity, durableThrough: 1, + durable: [{ sequence: 1, message: assistantMessage(identity.generation) }], + hasOlder: false, + })) store.accept(batch); + return transcriptHandle(identity); + }, { onError: () => {} }); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + await controller.ready(); + controller.observationChanged('ready'); + online = false; + await assert.rejects(controller.reload(), /waiting for Host reconnection/); + await settle(); + assert.match(store.range().generation, /^cached:/); + online = true; + controller.observationChanged('pending'); + controller.observationChanged('ready'); + await settle(); + await settle(); + assert.match(store.range().generation, /^live-/); + assert.ok(opens >= 3); + await controller.close(); +}); + +test('live transcript open failures without cache still report the original error', async () => { + const failure = new Error('no Host and no cache'); + const errors: unknown[] = []; + const controller = createDesktopTranscriptRangeController( + transcriptStore(), async () => { throw failure; }, + { onError: (error) => errors.push(error) }, + ); + await assert.rejects(controller.ready(), /no Host and no cache/); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(errors, [failure]); + await controller.close(); +}); + function assistantMessage( text: string, id = 'assistant-1', @@ -1322,6 +1416,39 @@ function transcriptStore(): DesktopTranscriptRangeStore { return new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); } +function transcriptHandle( + identity: TranscriptBatchIdentity, + overrides: Partial = {}, +): DesktopTranscriptHandle { + return { + ...identity, + readThroughMessageId: null, + async acknowledgeTail() {}, + async loadEarlier() {}, + async close() {}, + ...overrides, + }; +} + +function earlierBatches( + identity: TranscriptBatchIdentity, + earlierThan: number, + sequences: readonly number[], + hasOlder: boolean, +) { + return encodeDesktopTranscriptBatches(identity, { + durableThrough: null, + durable: sequences.map((sequence) => ({ + sequence, + message: assistantMessage(`${sequence}`, `assistant-${sequence}`), + })), + earlierThan, + hasOlder, + reset: false, + ready: true, + }); +} + function userMessage( text: string, id: string, @@ -1343,13 +1470,13 @@ function transcriptPage( return { kind: 'page' as const, sessionId: 'session-1', + source: 'durable' as const, direction, throughSequence, rawBytes: 1, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor, + endsAtTurnBoundary: true, }; } @@ -1398,97 +1525,3 @@ function continuitySnapshot() { interactions: { pending: [] }, }; } - -test('cached fallback remains readable and retries once per observation generation until live', async () => { - const store = transcriptStore(); - const errors: unknown[] = []; - let opens = 0; - let online = false; - const controller = createRecoveringDesktopTranscriptRangeController(store, async () => { - opens += 1; - const identity = { - sessionId: 'session-1', - generation: online ? 'live-generation' : 'cached:generation', - hostEpoch: 'host-1', - }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, - durable: [{ sequence: 1, message: assistantMessage(online ? 'live' : 'cached') }], - hasOlder: false, hasNewer: false, - })) store.accept(batch); - return { - ...identity, readThroughMessageId: null, - acknowledgeTail: async () => {}, - loadBefore: async () => {}, loadAfter: async () => {}, loadAround: async () => {}, - loadLatest: async () => {}, close: async () => {}, - }; - }, { onError: (error) => errors.push(error) }); - const settle = () => new Promise((resolve) => setImmediate(resolve)); - await controller.ready(); - await settle(); - assert.equal(opens, 1); - assert.equal(store.range().generation, 'cached:generation'); - controller.observationChanged('ready'); - await settle(); - assert.equal(opens, 2); - controller.observationChanged('ready'); - await settle(); - assert.equal(opens, 2); - online = true; - controller.observationChanged('pending'); - controller.observationChanged('ready'); - await settle(); - assert.equal(opens, 3); - assert.equal(store.range().generation, 'live-generation'); - assert.deepEqual(errors, []); - await controller.close(); -}); - -test('a read refused for a Host epoch that moved is superseded, not failed', async () => { - const store = transcriptStore(); - const errors: unknown[] = []; - let opens = 0; - const otherFailure = new Error('the older page failed'); - const controller = createRecoveringDesktopTranscriptRangeController(store, async () => { - opens += 1; - const identity = { sessionId: 'session-1', generation: 'live-generation', hostEpoch: 'host-1' }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, - durable: [{ sequence: 1, message: assistantMessage('live') }], - hasOlder: true, hasNewer: false, - })) store.accept(batch); - return { - ...identity, readThroughMessageId: null, - acknowledgeTail: async () => {}, - loadBefore: async () => { throw otherFailure; }, - loadAfter: async () => {}, - loadAround: async () => { - throw new Error(`Error invoking remote method 'sessions:transcript:load-around': Error: ${DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE}: Desktop transcript host epoch changed; reopen the transcript`); - }, - loadLatest: async () => {}, close: async () => {}, - }; - }, { onError: (error) => errors.push(error) }); - try { - await controller.ready(); - await assert.rejects(controller.loadAround(1), TranscriptReadSupersededError); - await assert.rejects(controller.loadBefore(), (error) => error === otherFailure); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(errors, [], 'a superseded read must not reach the error surface'); - assert.equal(opens, 1, 'the replacement reset carries the new epoch, so nothing is reopened'); - } finally { - await controller.close(); - } -}); - -test('live transcript open failures without cache still report the original error', async () => { - const failure = new Error('no Host and no cache'); - const errors: unknown[] = []; - const controller = createRecoveringDesktopTranscriptRangeController( - transcriptStore(), async () => { throw failure; }, - { onError: (error) => errors.push(error) }, - ); - await assert.rejects(controller.ready(), /no Host and no cache/); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(errors, [failure]); - await controller.close(); -}); diff --git a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts index a77f4dbb35..301f8aeffa 100644 --- a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts +++ b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts @@ -40,7 +40,6 @@ test('reads the newest anchor on the active route', () => { { type: 'assistant' }, usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }), ], - { hasNewer: false }, MODEL, ROUTE, ); @@ -56,7 +55,6 @@ test('scans past an anchorless usage row, which is what manual compaction writes usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }), usage(), ], - { hasNewer: false }, MODEL, ROUTE, ); @@ -69,7 +67,6 @@ test('refuses an anchor from another model', () => { // request the user is not making. const tokens = selectLatestRequestUsage( [usage({ inputTokens: 100_000, modelId: 'model-b', connectionId: 'conn-a' })], - { hasNewer: false }, MODEL, ROUTE, ); @@ -79,7 +76,6 @@ test('refuses an anchor from another model', () => { test('refuses an anchor from another connection', () => { const tokens = selectLatestRequestUsage( [usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-b' })], - { hasNewer: false }, MODEL, ROUTE, ); @@ -89,18 +85,6 @@ test('refuses an anchor from another connection', () => { test('refuses an anchor written before anchors carried their route', () => { const tokens = selectLatestRequestUsage( [usage({ inputTokens: 100, outputTokens: 20 })], - { hasNewer: false }, - MODEL, - ROUTE, - ); - assert.equal(tokens, undefined); -}); - -test('refuses every anchor while the loaded range is not the session tail', () => { - // Browsing history must not report an older range's usage as current. - const tokens = selectLatestRequestUsage( - [usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' })], - { hasNewer: true }, MODEL, ROUTE, ); @@ -109,14 +93,13 @@ test('refuses every anchor while the loaded range is not the session tail', () = test('refuses when there is no active route yet', () => { const anchored = [usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-a' })]; - assert.equal(selectLatestRequestUsage(anchored, undefined, undefined, ROUTE), undefined); - assert.equal(selectLatestRequestUsage(anchored, undefined, MODEL, undefined), undefined); + assert.equal(selectLatestRequestUsage(anchored, undefined, ROUTE), undefined); + assert.equal(selectLatestRequestUsage(anchored, MODEL, undefined), undefined); }); test('refuses a non-positive input count', () => { const tokens = selectLatestRequestUsage( [usage({ inputTokens: 0, modelId: MODEL, connectionId: 'conn-a' })], - { hasNewer: false }, MODEL, ROUTE, ); diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 8bf452bdb4..7c86727227 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -23,7 +23,6 @@ import { afterEach, test } from 'node:test'; import { parseHTML } from 'linkedom'; import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { renderToStaticMarkup } from 'react-dom/server'; import { ChatSurfaceLayout, ChatView, LocaleProvider } from '@maka/ui'; import type { SessionEvent } from '@maka/core/events'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; @@ -46,6 +45,7 @@ import { type WorkbarIngestInput, type WorkbarServices, } from '../../renderer/features/workbar/testing.js'; +import { renderTranscriptMarkup } from './transcript-test-dom.js'; const originalGlobals = { document: globalThis.document, @@ -249,8 +249,8 @@ async function renderOwnershipProbe( stop: () => stop(), deleteQueuedEntry: (entryId: string) => deleteQueuedEntry(entryId), setPermissionMode: (mode: PermissionMode) => setPermissionMode(mode), - transcript() { - return parseHTML(`${renderToStaticMarkup( + async transcript() { + return parseHTML(`${await renderTranscriptMarkup( createElement(LocaleProvider, { locale: 'en', children: createElement(ChatSurfaceLayout, { composer: null, children: createElement(ChatView, { @@ -501,8 +501,8 @@ for (const proof of ['send reply', 'admission event'] as const) { h.emit({ type: 'text_complete', id: 'answer-event', messageId: 'answer', turnId: 'first-turn', ts: 2, text: 'answer to initial question' }); }); - const assertPromptBeforeReply = () => { - const transcript = h.transcript(); + const assertPromptBeforeReply = async () => { + const transcript = await h.transcript(); const turn = transcript.querySelector('[data-transcript-turn-id="first-turn"]'); assert.ok(turn); assert.ok(turn.querySelector('.maka-user-message')?.textContent.startsWith('initial question')); @@ -511,11 +511,11 @@ for (const proof of ['send reply', 'admission event'] as const) { assert.ok(text.indexOf('initial question') < text.indexOf('answer to initial question')); assert.equal(transcript.querySelectorAll('.maka-user-message').length, 1); }; - assertPromptBeforeReply(); + await assertPromptBeforeReply(); await act(async () => { h.hostTurn('first-turn', 'completed'); }); - assertPromptBeforeReply(); + await assertPromptBeforeReply(); await act(async () => { h.hostTurn('successor-turn'); }); - assertPromptBeforeReply(); + await assertPromptBeforeReply(); if (proof === 'admission event') { await act(async () => { receipt.resolve({ ok: true, turnId: 'first-turn' }); @@ -2190,7 +2190,7 @@ for (const proof of ['started receipt', 'admission event'] as const) { }); assert.equal(container.firstElementChild?.getAttribute('data-live-text'), 'new answer'); assert.match( - transcript().querySelector('[data-transcript-turn-id="new-turn"] .maka-user-message')?.textContent ?? '', + (await transcript()).querySelector('[data-transcript-turn-id="new-turn"] .maka-user-message')?.textContent ?? '', /start after settlement/, ); if (proof === 'admission event') { @@ -2243,7 +2243,7 @@ for (const proof of ['admission event', 'ownership recovery'] as const) { turnId: 'turn-b', ts: 3, text: 'reply to raced successor' }); }); await waitUntil(() => h.container.firstElementChild?.getAttribute('data-processing') === 'false'); - const turn = h.transcript().querySelector('[data-transcript-turn-id="turn-b"]'); + const turn = (await h.transcript()).querySelector('[data-transcript-turn-id="turn-b"]'); assert.ok(turn); assert.match(turn.querySelector('.maka-user-message')?.textContent ?? '', /raced successor prompt/); assert.ok(turn.textContent.includes('reply to raced successor')); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts index 77f6ecd7fb..bc7f230424 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts @@ -118,26 +118,25 @@ test('derives turn records from bounded contribution pages', async () => { await client.close(); }); -test('reads the bounded prompt rail index without paging every turn', async () => { +test('reads the bounded prompt rail index, or one Turn, without paging every turn', async () => { + const inputs: unknown[] = []; + const landmark = { turnId: 'turn-50', sequence: 50, lastSequence: 59, label: 'middle' }; const connection = { request: async (operation: string, input: unknown) => { assert.equal(operation, 'session.turn_landmarks.query'); - assert.deepEqual(input, { sessionId: 'session-1', maxLandmarks: 64 }); - return { - sessionId: 'session-1', - throughSequence: 100, - landmarks: [{ turnId: 'turn-50', sequence: 50, label: 'middle' }], - }; + inputs.push(input); + return { sessionId: 'session-1', throughSequence: 100, landmarks: [landmark] }; }, close: async () => undefined, } as unknown as RuntimeHostConnection; const client = new DesktopRuntimeHostClient(connection); - assert.deepEqual(await client.listSessionTurnLandmarks('session-1'), { - sessionId: 'session-1', - throughSequence: 100, - landmarks: [{ turnId: 'turn-50', sequence: 50, label: 'middle' }], - }); + assert.deepEqual((await client.listSessionTurnLandmarks('session-1')).landmarks, [landmark]); + await client.listSessionTurnLandmarks('session-1', 'turn-50'); + assert.deepEqual(inputs, [ + { sessionId: 'session-1', maxLandmarks: 64, turnId: null }, + { sessionId: 'session-1', maxLandmarks: 1, turnId: 'turn-50' }, + ]); await client.close(); }); @@ -195,8 +194,7 @@ function emptyTranscriptPage(sessionId: string) { throughSequence: null, rawBytes: 0, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + endsAtTurnBoundary: true, }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index b4301008f6..1e5ac98107 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -97,33 +97,39 @@ for (const phase of ['connecting', 'seeding'] as const) { } } -test('window transcript reads are observation operations scoped to the renderer', async () => { +test('transcript history IPC forwards open mode, load-earlier, and read-turn to the registry', async () => { const ipc = ipcHarness(); const observations = new RuntimeHostSessionObservationRegistry(); const calls: unknown[] = []; - observations.loadTranscriptAfter = async (request, targetId) => { calls.push({ command: 'after', request, targetId }); }; - observations.loadTranscriptLatest = async (request, targetId) => { calls.push({ command: 'latest', request, targetId }); }; - registerRuntimeHostSessionObservationIpc({ observations, resolveSideConversation: async () => false }, ipc); - const request = { - consumerId: 'guest-consumer', sessionId: 'shared-session', hostEpoch: 'host-1', - anchorSequence: 42, maxBytes: 512 * 1024, navigation: 7, + observations.openTranscript = async (sessionId, consumerId, target, mode) => { + calls.push({ command: 'open', sessionId, consumerId, targetId: target.id, mode }); + return { sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null }; + }; + observations.loadEarlierTranscript = async (consumerId, targetId) => { + calls.push({ command: 'earlier', consumerId, targetId }); }; - await ipc.invoke('sessions:transcript:load-after', request); - await ipc.invoke('sessions:transcript:load-latest', { ...request, anchorSequence: null }); + const turn = [{ type: 'user', id: 'message-1', turnId: 'turn-1', ts: 1, text: 'hello' }]; + observations.readTranscriptTurn = async (sessionId, turnId) => { + calls.push({ command: 'read-turn', sessionId, turnId }); + return turn as never; + }; + registerRuntimeHostSessionObservationIpc({ observations, resolveSideConversation: async () => false }, ipc); + + await ipc.invoke('sessions:transcript:open', 'shared-session', 'guest-consumer', 'history'); + await ipc.invoke('sessions:transcript:load-earlier', 'guest-consumer'); + assert.deepEqual(await ipc.invoke('sessions:transcript:read-turn', 'shared-session', 'turn-1'), turn); + assert.equal(ipc.reconnectableChannels.has('sessions:transcript:read-turn'), true); assert.deepEqual(calls, [ - { command: 'after', request, targetId: 9 }, - { command: 'latest', request: { ...request, anchorSequence: null }, targetId: 9 }, + { command: 'open', sessionId: 'shared-session', consumerId: 'guest-consumer', targetId: 9, mode: 'history' }, + { command: 'earlier', consumerId: 'guest-consumer', targetId: 9 }, + { command: 'read-turn', sessionId: 'shared-session', turnId: 'turn-1' }, ]); await assert.rejects( - ipc.invoke('sessions:transcript:load-after', { ...request, anchorSequence: -1 }), - /Invalid Desktop transcript range anchor/, - ); - // The Renderer owns the window, so every read must name the version it reads for. - await assert.rejects( - ipc.invoke('sessions:transcript:load-after', { ...request, navigation: undefined }), - /Invalid Desktop transcript navigation/, + ipc.invoke('sessions:transcript:open', 'shared-session', 'other-consumer', 'window'), + /Invalid Desktop transcript open mode/, ); - assert.equal(calls.length, 2); + await assert.rejects(ipc.invoke('sessions:transcript:load-earlier', ''), /Transcript consumer/); + assert.equal(calls.length, 3); }); test('treats pending Session observation teardown as IPC cancellation', async () => { @@ -170,16 +176,14 @@ test('treats pending transcript teardown as IPC cancellation', async () => { readThroughMessageId: null, }; }, - async loadTranscriptBefore() {}, - async loadTranscriptAround() {}, - async loadTranscriptAfter() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const ipc = observationIpcHarness(observations); - const opening = ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'); + const opening = ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'); try { await started.promise; assert.deepEqual(observations.trackedSessionIds(), ['session-1']); @@ -215,16 +219,14 @@ for (const teardown of ['forgetSession', 'close'] as const) { readThroughMessageId: null, }; }, - async loadTranscriptBefore() {}, - async loadTranscriptAround() {}, - async loadTranscriptAfter() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const ipc = observationIpcHarness(observations); const observing = ipc.invoke('sessions:observe', 'session-1', 'observer-1'); - const opening = ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'); + const opening = ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'); // Attach rejection handlers before teardown. The late source completion // must not turn the lost observation into readiness or silent cancellation. const results = Promise.allSettled([observing, opening]); @@ -257,10 +259,8 @@ test('preserves genuine Session observation initialization failures', async () = async openTranscript() { throw transcriptFailure; }, - async loadTranscriptBefore() {}, - async loadTranscriptAround() {}, - async loadTranscriptAfter() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); @@ -271,7 +271,7 @@ test('preserves genuine Session observation initialization failures', async () = (error) => error === sessionFailure, ); await assert.rejects( - ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), + ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'), (error) => error === transcriptFailure, ); await observations.close(); @@ -286,14 +286,14 @@ test('releases a transcript registration whose source lacks the window contract' const ipc = observationIpcHarness(observations); await assert.rejects( - ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), + ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'), /transcript source is unavailable/, ); assert.deepEqual(observations.trackedSessionIds(), []); // Reusing the consumer id must reach the same missing-source failure rather // than the duplicate-identity guard, which only a leaked registration trips. await assert.rejects( - ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), + ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'), /transcript source is unavailable/, ); await observations.close(); @@ -313,10 +313,8 @@ test('returns explicit ready results for Session observation IPC', async () => { async openTranscript() { return transcript; }, - async loadTranscriptBefore() {}, - async loadTranscriptAround() {}, - async loadTranscriptAfter() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); @@ -326,7 +324,7 @@ test('returns explicit ready results for Session observation IPC', async () => { kind: 'ready', value: undefined, }); - assert.deepEqual(await ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), { + assert.deepEqual(await ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'), { kind: 'ready', value: transcript, }); @@ -2247,8 +2245,8 @@ function executionClient(overrides: Partial): ExecutionClient { getSession: unavailable, ingestAttachment: unavailable, interruptTurn: unavailable, - listSessionTurnLandmarks: unavailable, listSessionTurns: unavailable, + listSessionTurnLandmarks: unavailable, queryMessageExecutions: unavailable, queryMessages: unavailable, queryTurnResume: unavailable, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 1d2e8c3c99..d0550e32af 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -467,16 +467,17 @@ test('restores transcript consumers across Host replacement', async () => { sessionId: string, consumerId: string, consumer: RuntimeHostTranscriptTarget, + mode?: string, ) { - opens.push(`${generation}:${sessionId}:${consumerId}`); + opens.push(`${generation}:${sessionId}:${consumerId}:${mode}`); consumer.send(`sessions:transcript:${consumerId}`, { deliverySequence: 1, sessionId, generation, hostEpoch: `host-${generation}`, durableThrough: null, - fragments: [], hasOlder: false, - hasNewer: false, + fragments: [], + hasOlder: false, reset: true, ready: true, }); @@ -487,16 +488,14 @@ test('restores transcript consumers across Host replacement', async () => { readThroughMessageId: null, }; }, - async loadTranscriptBefore() {}, - async loadTranscriptAfter() {}, - async loadTranscriptAround() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const first = source('first'); await observations.attach(first, bind('first')); - await observations.openTranscript('session-1', 'consumer-1', target); + await observations.openTranscript('session-1', 'consumer-1', target, 'history'); observations.detach(first); assert.doesNotThrow(() => observations.acknowledgeTranscript('consumer-1', 'first', 1, 18)); const second = source('second'); @@ -514,9 +513,9 @@ test('restores transcript consumers across Host replacement', async () => { assert.equal((await pending).generation, 'third'); assert.deepEqual(opens, [ - 'first:session-1:consumer-1', - 'second:session-1:consumer-1', - 'third:session-1:consumer-2', + 'first:session-1:consumer-1:history', + 'second:session-1:consumer-1:history', + 'third:session-1:consumer-2:tail', ]); assert.deepEqual( batches.map((batch) => batch.generation), @@ -547,10 +546,8 @@ test('does not hold Host observation recovery on transcript replay', async () => async openTranscript() { return transcriptResult(generation); }, - async loadTranscriptBefore() {}, - async loadTranscriptAfter() {}, - async loadTranscriptAround() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); @@ -564,7 +561,7 @@ test('does not hold Host observation recovery on transcript replay', async () => const transcriptReplay = deferred(); let transcriptReplayStarted = false; let transcriptReplayCompleted = false; - let transcriptRangeStarted = false; + let transcriptEarlierStarted = false; let transcriptAcknowledged = false; const replacement = { async observe() { @@ -577,12 +574,10 @@ test('does not hold Host observation recovery on transcript replay', async () => transcriptReplayCompleted = true; return result; }, - async loadTranscriptBefore() { - transcriptRangeStarted = true; + async loadEarlierTranscript() { + transcriptEarlierStarted = true; }, - async loadTranscriptAfter() {}, - async loadTranscriptAround() {}, - async loadTranscriptLatest() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, acknowledgeTranscript() { transcriptAcknowledged = true; @@ -601,30 +596,20 @@ test('does not hold Host observation recovery on transcript replay', async () => assert.deepEqual(await attaching, ['session-1']); assert.equal(attached, true); - const range = observations.loadTranscriptBefore( - { - consumerId: 'consumer-1', - sessionId: 'session-1', - hostEpoch: 'host-second', - anchorSequence: null, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }, - transcriptTarget.id, - ); + const earlier = observations.loadEarlierTranscript('consumer-1', transcriptTarget.id); observations.acknowledgeTranscript('consumer-1', 'second', 1, transcriptTarget.id); await Promise.resolve(); - assert.equal(transcriptRangeStarted, false); + assert.equal(transcriptEarlierStarted, false); assert.equal(transcriptAcknowledged, true); transcriptReplay.resolve(transcriptResult('second')); - await range; - assert.equal(transcriptRangeStarted, true); + await earlier; + assert.equal(transcriptEarlierStarted, true); await waitFor(() => transcriptReplayCompleted); await observations.close(); }); -test('fences transcript range failures to the current registration and Host source', async () => { +test('fences earlier transcript failures to the current registration and Host source', async () => { const observations = new RuntimeHostSessionObservationRegistry(); const target: RuntimeHostTranscriptTarget = { id: 19, @@ -634,7 +619,7 @@ test('fences transcript range failures to the current registration and Host sour }; const source = ( generation: string, - loadTranscriptBefore: () => Promise, + loadEarlierTranscript: () => Promise, ) => ({ async observe() {}, async unobserve() {}, @@ -646,113 +631,77 @@ test('fences transcript range failures to the current registration and Host sour readThroughMessageId: null, }; }, - loadTranscriptBefore, - async loadTranscriptAfter() {}, - async loadTranscriptAround() {}, - async loadTranscriptLatest() {}, + loadEarlierTranscript, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); - const request = (consumerId: string, generation: string) => ({ - consumerId, - sessionId: 'session-1', - hostEpoch: `host-${generation}`, - anchorSequence: null, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }); const closedFailure = deferred(); const first = source('first', () => closedFailure.promise); await observations.attach(first); - await observations.openTranscript('session-1', 'consumer-closed', target); - const closedRange = observations.loadTranscriptBefore( - request('consumer-closed', 'first'), - target.id, - ); + await observations.openTranscript('session-1', 'consumer-closed', target, 'history'); + const closedRead = observations.loadEarlierTranscript('consumer-closed', target.id); await observations.closeTranscript('consumer-closed', target.id); - closedFailure.reject(new Error('closed source rejected its range')); - await assert.doesNotReject(closedRange); + closedFailure.reject(new Error('closed source rejected its read')); + await assert.doesNotReject(closedRead); observations.detach(first); const replacedFailure = deferred(); const second = source('second', () => replacedFailure.promise); await observations.attach(second); - await observations.openTranscript('session-1', 'consumer-replaced', target); - const replacedRange = observations.loadTranscriptBefore( - request('consumer-replaced', 'second'), - target.id, - ); + await observations.openTranscript('session-1', 'consumer-replaced', target, 'history'); + const replacedRead = observations.loadEarlierTranscript('consumer-replaced', target.id); observations.detach(second); - const currentFailure = new Error('current source failed its range'); + const currentFailure = new Error('current source failed its read'); const third = source('third', async () => { throw currentFailure; }); await observations.attach(third); - replacedFailure.reject(new Error('replaced source rejected its range')); - await assert.doesNotReject(replacedRange); + replacedFailure.reject(new Error('replaced source rejected its read')); + await assert.doesNotReject(replacedRead); await assert.rejects( - observations.loadTranscriptBefore( - request('consumer-replaced', 'third'), - target.id, - ), + observations.loadEarlierTranscript('consumer-replaced', target.id), (error) => error === currentFailure, ); await observations.close(); }); -test('fences transcript range failures across same-source replica recovery', async () => { +test('fences earlier transcript failures across same-source replica recovery', async () => { const firstEvents = new AsyncFrameQueue(); const secondEvents = new AsyncFrameQueue(); - const staleRange = deferred(); - const currentFailure = new Error('current replica failed its range'); - const message: StoredMessage = { - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 1, - text: 'current', - modelId: 'test-model', - }; + const staleRead = deferred(); + const currentFailure = new Error('current replica failed its read'); let opens = 0; - let staleRangeStarted = false; - let currentRangeStarted = false; + let staleReadStarted = false; + let currentReadStarted = false; const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => { opens += 1; const first = opens === 1; const events = first ? firstEvents : secondEvents; - const bootstrap: SessionTranscriptPage = { - kind: 'page', - sessionId: 'session-1', - direction: 'older', - throughSequence: 1, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor: 'older', - }; + // One row per page, each its own Turn. The recovery reset reads back down + // to the row the consumer was last given (cursor '4'); cursor '3' is + // reached only by a later load earlier. + const host = historyHost([0, 1, 2, 3, 4].map((index) => turnRow(index, `turn-${index}`)), { pageRows: 1 }); return runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events, - transcriptBootstrap: { durable: bootstrap }, - decodeTranscriptPage: async (page) => ({ - messages: page === bootstrap ? [{ identity: 1, message }] : [], - nextCursor: page === bootstrap ? 'older' : null, - }), - loadTranscriptPage: first - ? () => { - staleRangeStarted = true; - return staleRange.promise; - } - : async () => { - currentRangeStarted = true; - throw currentFailure; - }, + transcriptBootstrap: host.bootstrap, + decodeTranscriptPage: host.decodeTranscriptPage, + loadTranscriptPage: async (input) => { + if (first) { + if (input.cursor !== '3') return host.loadTranscriptPage(input); + staleReadStarted = true; + return staleRead.promise; + } + if (input.cursor !== '3') return host.loadTranscriptPage(input); + currentReadStarted = true; + throw currentFailure; + }, async close() { events.end(); }, @@ -760,18 +709,11 @@ test('fences transcript range failures across same-source replica recovery', asy }, }, emitSessionsChanged() {}, + transcriptHistoryBytes: 1, }); const observations = new RuntimeHostSessionObservationRegistry(); const batches: DesktopTranscriptBatch[] = []; const consumerId = 'consumer-replica-recovery'; - const request = (hostEpoch: string) => ({ - consumerId, - sessionId: 'session-1', - hostEpoch, - anchorSequence: 1, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }); const target: RuntimeHostTranscriptTarget = { id: 20, send(_channel, batch) { @@ -789,9 +731,11 @@ test('fences transcript range failures across same-source replica recovery', asy off() {}, }; await observations.attach(observer); - const opened = await observations.openTranscript('session-1', consumerId, target); - const staleLoad = observations.loadTranscriptBefore(request(opened.hostEpoch), target.id); - await waitFor(() => staleRangeStarted); + const opened = await observations.openTranscript('session-1', consumerId, target, 'history'); + // One answer deeper, so the recovery reset has more than the bootstrap page to walk back down. + await observations.loadEarlierTranscript(consumerId, target.id); + const staleLoad = observations.loadEarlierTranscript(consumerId, target.id); + await waitFor(() => staleReadStarted); firstEvents.push({ kind: 'subscription.closed', @@ -800,18 +744,18 @@ test('fences transcript range failures across same-source replica recovery', asy sequence: 1, reason: 'slow_consumer', }); - // Recovery installs a replacement replica and resets every consumer onto its - // tail; no page read is replayed on its behalf. - await waitFor(() => batches.some((batch) => batch.reset && batch.generation !== opened.generation)); - assert.equal(currentRangeStarted, false); - staleRange.reject(new Error('stale replica rejected its range')); + await waitFor(() => opens === 2); + staleRead.reject(new Error('stale replica rejected its read')); await assert.doesNotReject(staleLoad); + // Recovery resets the consumer onto the replacement replica; the abandoned read is not replayed. + await waitFor(() => batches.some((batch) => batch.ready && batch.generation !== opened.generation)); + assert.equal(currentReadStarted, false); await assert.rejects( - observations.loadTranscriptBefore(request(opened.hostEpoch), target.id), + observations.loadEarlierTranscript(consumerId, target.id), (error) => error === currentFailure, ); - assert.equal(currentRangeStarted, true); + assert.equal(currentReadStarted, true); await observations.close(); await observer.close(); }); @@ -871,9 +815,8 @@ test('broadcasts durable admission and transcript changes from the same message' throughSequence: 0, rawBytes: 1, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + endsAtTurnBoundary: true, }), decodeTranscriptPage: async () => ({ messages: [{ identity: 0, message }], @@ -1033,9 +976,8 @@ test('moves the read marker only as far as the Renderer window reports reaching' throughSequence: input.throughSequence ?? null, rawBytes: 1, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + endsAtTurnBoundary: true, }), // One durable row per catch-up target; the bootstrap page carries none. decodeTranscriptPage: async (page) => { @@ -1132,9 +1074,8 @@ test('keeps a bounded transcript batch window in flight until the renderer ackno throughSequence: 0, rawBytes: 1, fragments: [], - rangeBoundarySequence: 0, - protectedTurnSequence: 0, nextCursor: null, + endsAtTurnBoundary: true, }, }, async close() { @@ -1193,7 +1134,7 @@ test('keeps a bounded transcript batch window in flight until the renderer ackno await observer.close(); }); -test('finishes transcript open and replays a stale range request after replacement', async () => { +test('finishes transcript open on the replacement replica after recovery', async () => { const firstEvents = new AsyncFrameQueue(); const secondEvents = new AsyncFrameQueue(); const message: StoredMessage = { @@ -1205,8 +1146,6 @@ test('finishes transcript open and replays a stale range request after replaceme modelId: 'test-model', }; let opens = 0; - let rangeLoads = 0; - const requestedAnchors: Array = []; const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => { @@ -1224,30 +1163,14 @@ test('finishes transcript open and replays a stale range request after replaceme throughSequence: 0, rawBytes: 1, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: 'older', + endsAtTurnBoundary: true, }, }, decodeTranscriptPage: async (page) => ({ messages: page.rawBytes === 1 ? [{ identity: 0, message }] : [], nextCursor: page.nextCursor, }), - loadTranscriptPage: async (input) => { - rangeLoads += 1; - requestedAnchors.push(input.anchorSequence); - return { - kind: 'page', - sessionId: 'session-1', - direction: input.direction, - throughSequence: input.throughSequence, - rawBytes: 0, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor: null, - }; - }, async close() { events.end(); }, @@ -1257,21 +1180,10 @@ test('finishes transcript open and replays a stale range request after replaceme emitSessionsChanged() {}, }); const batches: DesktopTranscriptBatch[] = []; - let autoAcknowledge = false; const opening = observer.openTranscript('session-1', 'consumer-recovery', { id: 22, send(_channel, batch) { batches.push(batch); - if (autoAcknowledge) { - queueMicrotask(() => - observer.acknowledgeTranscript( - 'consumer-recovery', - batch.generation, - batch.deliverySequence, - 22, - ), - ); - } }, once() {}, off() {}, @@ -1316,60 +1228,6 @@ test('finishes transcript open and replays a stale range request after replaceme assert.equal(opened.error, undefined); assert.equal(opened.value?.generation, batches.at(-1)?.generation); assert.notEqual(opened.value?.generation, staleGeneration); - rangeLoads = 0; - requestedAnchors.length = 0; - autoAcknowledge = true; - // The renderer dispatched this range request before the replacement replica - // was installed; the same Session and Host epoch continue the read against - // the current replica, and the requested slice is what the replica loads. - await assert.doesNotReject(() => - observer.loadTranscriptBefore( - { - consumerId: 'consumer-recovery', - sessionId: 'session-1', - hostEpoch: 'host-1', - anchorSequence: 0, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }, - 22, - ), - ); - assert.equal(rangeLoads, 1); - assert.deepEqual(requestedAnchors, [0]); - // Durable sequence identity is Session- and Host-epoch-scoped: a request - // for a different Session or Host epoch must reject instead of silently - // reading a different slice of the transcript. - await assert.rejects( - () => - observer.loadTranscriptBefore( - { - consumerId: 'consumer-recovery', - sessionId: 'session-1', - hostEpoch: 'other-host', - anchorSequence: 0, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }, - 22, - ), - /Desktop transcript host epoch changed/, - ); - await assert.rejects( - () => - observer.loadTranscriptBefore( - { - consumerId: 'consumer-recovery', - sessionId: 'other-session', - hostEpoch: 'host-1', - anchorSequence: 0, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }, - 22, - ), - /Desktop transcript consumer belongs to another session/, - ); await observer.close(); }); @@ -1390,9 +1248,8 @@ test('coalesces transcript changes into one bounded delta while renderer deliver throughSequence: input.throughSequence, rawBytes: 1, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + endsAtTurnBoundary: true, }), decodeTranscriptPage: async (page) => { if (page.throughSequence === null) return { messages: [], nextCursor: null }; @@ -1465,9 +1322,7 @@ test('coalesces transcript changes into one bounded delta while renderer deliver assert.equal(batches[1]!.reset, false); assert.equal(batches[1]!.durableThrough, 4); assert.equal(batches[1]!.fragments.length, 4); - assert.equal(batches[1]!.navigation, undefined, 'tail growth is a broadcast, not an answer'); assert.equal(batches[1]!.hasOlder, undefined); - assert.equal(batches[1]!.hasNewer, undefined); observer.acknowledgeTranscript( consumerId, batches[1]!.generation, @@ -1477,35 +1332,87 @@ test('coalesces transcript changes into one bounded delta while renderer deliver await observer.close(); }); -test('answers a window page read on its own navigation version and drops a stale one', async () => { +test('delivers history in whole Turns within the budget and continues exactly on load earlier', async () => { const events = new AsyncFrameQueue(); - const record = (sequence: number) => ({ - identity: sequence, - message: { - type: 'assistant' as const, - id: `a-${sequence}`, - turnId: `turn-${sequence}`, - ts: sequence, - text: String(sequence), - modelId: 'test-model', + // Oldest first: a (3 rows), b (1 huge row), c (2 rows), d (1 huge row). The budget is one and a half + // small rows and pages hold two rows, so Turns cross both the budget and the Host page edges. + // Where a page leaves a Turn half-read the answer keeps going, so an answer ends only on a page + // the Host marked as stopping at a Turn boundary. + const huge = 'x'.repeat(4096); + const rows = [ + turnRow(10, 'turn-a'), turnRow(20, 'turn-a'), turnRow(30, 'turn-a'), + turnRow(40, 'turn-b', huge), + turnRow(50, 'turn-c'), turnRow(60, 'turn-c'), + turnRow(70, 'turn-d', huge), + ]; + const smallRowBytes = Buffer.byteLength(JSON.stringify(rows[0]!.message), 'utf8'); + const host = historyHost(rows, { pageRows: 2 }); + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + transcriptBootstrap: host.bootstrap, + loadTranscriptPage: host.loadTranscriptPage, + decodeTranscriptPage: host.decodeTranscriptPage, + async close() { + events.end(); + }, + }), }, + emitSessionsChanged() {}, + transcriptHistoryBytes: Math.floor(smallRowBytes * 1.5), }); - const durablePage = (nextCursor: string | null): SessionTranscriptPage => ({ - kind: 'page', - sessionId: 'session-1', - direction: 'older', - throughSequence: 2, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrap = durablePage(null); - const decoded = new Map>; - nextCursor: string | null; - }>([[bootstrap, { messages: [record(2)], nextCursor: null }]]); + const batches: DesktopTranscriptBatch[] = []; + const consumerId = 'consumer-history'; + await observer.openTranscript('session-1', consumerId, ackingTranscriptTarget(observer, consumerId, 26, batches), 'history'); + + const answer = () => { + const taken = batches.splice(0); + assert.ok(taken.at(-1)?.ready, 'an answer ends with its ready batch'); + assert.equal(taken.filter((batch) => batch.ready).length, 1); + return { + taken, + sequences: taken.flatMap((batch) => durableSequences(batch)).sort((left, right) => left - right), + hasOlder: taken.at(-1)!.hasOlder, + }; + }; + + // The first page (60, 70) is over the budget on its own but cuts Turn c in half, so the read + // continues to the page below it, which the Host marks as whole. + const reset = answer(); + assert.equal(reset.taken[0]!.reset, true); + assert.equal(reset.taken.some((batch) => batch.earlierThan !== undefined), false); + assert.equal(reset.taken.at(-1)!.durableThrough, 70); + assert.deepEqual(reset.sequences, [40, 50, 60, 70]); + assert.equal(reset.hasOlder, true); + + // Turn a spans both remaining pages, so the rest of the history comes back as one answer. + await observer.loadEarlierTranscript(consumerId, 26); + const second = answer(); + assert.equal(second.taken.some((batch) => batch.reset), false); + assert.equal(second.taken[0]!.earlierThan, 40); + assert.deepEqual(second.sequences, [10, 20, 30]); + assert.equal(second.hasOlder, false); + + assert.deepEqual( + [...second.sequences, ...reset.sequences], + rows.map((row) => row.identity), + ); + await observer.loadEarlierTranscript(consumerId, 26); + assert.equal(batches.length, 0, 'nothing older remains to deliver'); + await observer.close(); +}); + +test('reads earlier history past its budget down to the requested Turn in one answer', async () => { + const events = new AsyncFrameQueue(); + const rows = ['a', 'b', 'c', 'd', 'e', 'f'].map((turn, index) => + turnRow((index + 1) * 10, `turn-${turn}`), + ); + const smallRowBytes = Buffer.byteLength(JSON.stringify(rows[0]!.message), 'utf8'); + const host = historyHost(rows, { pageRows: 1 }); const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => @@ -1513,75 +1420,258 @@ test('answers a window page read on its own navigation version and drops a stale snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events, - transcriptBootstrap: { durable: bootstrap }, - loadTranscriptPage: async (request) => { - // `loadAround` probes one row older than its anchor to learn - // whether history precedes it; that probe stays empty here. - const answer = request.direction === 'older' - ? request.maxBytes === 1 - ? { messages: [], nextCursor: null } - : { messages: [record(1)], nextCursor: 'older' } - : request.anchorSequence === 0 - ? { messages: [record(1)], nextCursor: 'newer' } - : { messages: [record(2)], nextCursor: null }; - const page = durablePage(answer.nextCursor); - decoded.set(page, answer); - return page; - }, - decodeTranscriptPage: async (page) => decoded.get(page)!, + transcriptBootstrap: host.bootstrap, + loadTranscriptPage: host.loadTranscriptPage, + decodeTranscriptPage: host.decodeTranscriptPage, async close() { events.end(); }, }), }, emitSessionsChanged() {}, + transcriptHistoryBytes: Math.floor(smallRowBytes * 1.5), }); const batches: DesktopTranscriptBatch[] = []; - const consumerId = 'consumer-window'; - const request = (navigation: number, anchorSequence: number | null) => ({ - consumerId, - sessionId: 'session-1', + const consumerId = 'consumer-located'; + await observer.openTranscript('session-1', consumerId, ackingTranscriptTarget(observer, consumerId, 26, batches), 'history'); + const answer = () => { + const taken = batches.splice(0); + assert.equal(taken.filter((batch) => batch.ready).length, 1); + return taken.flatMap((batch) => durableSequences(batch)).sort((left, right) => left - right); + }; + assert.deepEqual(answer(), [50, 60]); + + await observer.loadEarlierTranscript(consumerId, 26, 20); + assert.deepEqual(answer(), [20, 30, 40], 'one budget alone would stop at 30'); + await observer.close(); +}); + +/** + * Recovery rebuilds what the reader was holding from the number of reads they + * had made, not from the boundary those reads actually reached. A read runs + * past its budget to finish a Turn, so the sum of the budgets buys less than + * the reads delivered, and the difference is history the renderer had and no + * longer has. + */ +test('keeps the history already delivered across a same-session recovery', async () => { + const huge = 'x'.repeat(4096); + const rows = [ + turnRow(10, 'turn-a'), turnRow(20, 'turn-a'), turnRow(30, 'turn-a'), + turnRow(40, 'turn-b', huge), + turnRow(50, 'turn-c'), turnRow(60, 'turn-c'), + turnRow(70, 'turn-d', huge), + ]; + const smallRowBytes = Buffer.byteLength(JSON.stringify(rows[0]!.message), 'utf8'); + const host = historyHost(rows, { pageRows: 2 }); + const queues: AsyncFrameQueue[] = []; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => { + const events = new AsyncFrameQueue(); + queues.push(events); + return runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + transcriptBootstrap: host.bootstrap, + loadTranscriptPage: host.loadTranscriptPage, + decodeTranscriptPage: host.decodeTranscriptPage, + async close() { + events.end(); + }, + }); + }, + }, + emitSessionsChanged() {}, + transcriptHistoryBytes: Math.floor(smallRowBytes * 1.5), + }); + const batches: DesktopTranscriptBatch[] = []; + const consumerId = 'consumer-recovered-history'; + await observer.openTranscript('session-1', consumerId, ackingTranscriptTarget(observer, consumerId, 26, batches), 'history'); + const answer = () => { + const taken = batches.splice(0); + assert.ok(taken.at(-1)?.ready, 'an answer ends with its ready batch'); + return taken.flatMap((batch) => durableSequences(batch)).sort((left, right) => left - right); + }; + + const delivered = [...answer()]; + await observer.loadEarlierTranscript(consumerId, 26); + delivered.push(...answer()); + delivered.sort((left, right) => left - right); + assert.deepEqual(delivered, rows.map((row) => row.identity), 'the reader is holding every row before anything fails'); + + // The Host drops the subscription and the same Session is reopened over the + // same rows: nothing was added, nothing was removed. + queues[0]!.push({ + kind: 'subscription.closed', hostEpoch: 'host-1', - anchorSequence, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation, + subscriptionId: 'subscription-session-1', + sequence: 1, + reason: 'slow_consumer', }); - await observer.openTranscript('session-1', consumerId, { - id: 26, + await waitFor(() => batches.some((batch) => batch.reset === true) && batches.at(-1)?.ready === true); + + assert.deepEqual(answer(), delivered, 'recovery handed back less history than the reader had'); + await observer.close(); +}); + +/** + * A consumer belongs to the connection that made it, and a replacement makes a + * new one. The reader behind it is the same reader, holding the same history, + * so what the registration carries across is where that history reaches. + */ +test('keeps the history already delivered across a replacement of the Host connection', async () => { + const rows = [10, 20, 30, 40].map((identity) => turnRow(identity, `turn-${identity}`)); + const smallRowBytes = Buffer.byteLength(JSON.stringify(rows[0]!.message), 'utf8'); + const sources = () => + new RuntimeHostSessionObserver({ + client: { + openSession: async () => { + const events = new AsyncFrameQueue(); + const host = historyHost(rows, { pageRows: 1 }); + return runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + transcriptBootstrap: host.bootstrap, + loadTranscriptPage: host.loadTranscriptPage, + decodeTranscriptPage: host.decodeTranscriptPage, + async close() { + events.end(); + }, + }); + }, + }, + emitSessionsChanged() {}, + transcriptHistoryBytes: Math.floor(smallRowBytes * 1.5), + }); + const first = sources(); + const second = sources(); + const observations = new RuntimeHostSessionObservationRegistry(); + const batches: DesktopTranscriptBatch[] = []; + const consumerId = 'consumer-connection-replacement'; + const target: RuntimeHostTranscriptTarget = { + id: 31, send(_channel, batch) { batches.push(batch); queueMicrotask(() => - observer.acknowledgeTranscript(consumerId, batch.generation, batch.deliverySequence, 26), + observations.acknowledgeTranscript(consumerId, batch.generation, batch.deliverySequence, 31), ); }, once() {}, off() {}, - }); - batches.splice(0); + }; + const answer = () => { + const taken = batches.splice(0); + assert.ok(taken.at(-1)?.ready, 'an answer ends with its ready batch'); + return taken.flatMap((batch) => durableSequences(batch)).sort((left, right) => left - right); + }; - await observer.loadTranscriptBefore(request(1, 2), 26); - assert.equal(batches.length, 1); - assert.equal(batches[0]!.navigation, 1); - assert.equal(batches[0]!.reset, false, 'extending the window does not replace it'); - assert.equal(batches[0]!.hasOlder, true); - assert.equal(batches[0]!.hasNewer, undefined, 'an older page establishes only its older edge'); - - // The same version extends the window the Renderer already holds. - await observer.loadTranscriptAfter(request(1, 1), 26); - assert.equal(batches.length, 2); - assert.equal(batches[1]!.navigation, 1); - assert.equal(batches[1]!.reset, false); - assert.equal(batches[1]!.hasNewer, false); + await observations.attach(first); + await observations.openTranscript('session-1', consumerId, target, 'history'); + const delivered = [...answer()]; + await observations.loadEarlierTranscript(consumerId, target.id); + delivered.push(...answer()); + delivered.sort((left, right) => left - right); + assert.ok(delivered.length > 1, 'the reader asked for more than its first budget'); + + observations.detach(first); + await first.close(); + await observations.attach(second); + await waitFor(() => batches.some((batch) => batch.reset === true) && batches.at(-1)?.ready === true); - await observer.loadTranscriptAround(request(2, 1), 26); - assert.equal(batches.length, 3); - assert.equal(batches[2]!.navigation, 2); - assert.equal(batches[2]!.reset, true, 'a window-replacing command resets the Renderer'); - assert.equal(batches[2]!.hasOlder, false); - assert.equal(batches[2]!.hasNewer, true); + assert.deepEqual(answer(), delivered, 'the replacement handed back less history than the reader had'); + await observations.close(); + await second.close(); +}); - await observer.loadTranscriptBefore(request(1, 2), 26); - assert.equal(batches.length, 3, 'a version the Renderer already abandoned is dropped silently'); +test('a tail transcript consumer still receives the replica snapshot', async () => { + const events = new AsyncFrameQueue(); + const rows = [turnRow(10, 'turn-a'), turnRow(20, 'turn-b')]; + const host = historyHost(rows, { pageRows: 1 }); + let pageReads = 0; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + transcriptBootstrap: host.bootstrap, + loadTranscriptPage: async (input) => { + pageReads += 1; + return host.loadTranscriptPage(input); + }, + decodeTranscriptPage: host.decodeTranscriptPage, + async close() { + events.end(); + }, + }), + }, + emitSessionsChanged() {}, + transcriptHistoryBytes: 1, + }); + const batches: DesktopTranscriptBatch[] = []; + await observer.openTranscript('session-1', 'consumer-tail', ackingTranscriptTarget(observer, 'consumer-tail', 27, batches)); + + assert.equal(pageReads, 0, 'a tail consumer is answered from the replica, not history reads'); + assert.equal(batches[0]!.reset, true); + assert.equal(batches.at(-1)!.ready, true); + assert.equal(batches.at(-1)!.durableThrough, 20); + assert.equal(batches.at(-1)!.hasOlder, false); + assert.deepEqual(batches.flatMap((batch) => durableSequences(batch)), [10, 20]); + await assert.rejects( + observer.loadEarlierTranscript('consumer-tail', 27), + /history consumer does not exist/, + ); + await observer.close(); +}); + +test('reads every row of one Turn through the Host Turn index', async () => { + const events = new AsyncFrameQueue(); + const rows = [ + turnRow(10, 'turn-a'), + turnRow(20, 'turn-b'), turnRow(30, 'turn-b'), turnRow(40, 'turn-b'), + turnRow(50, 'turn-c'), + ]; + const host = historyHost(rows, { pageRows: 2 }); + const reads: Array = []; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + transcriptBootstrap: host.bootstrap, + loadTranscriptPage: async (input) => { + if (input.direction === 'newer') reads.push(input.throughSequence); + return host.loadTranscriptPage(input); + }, + decodeTranscriptPage: host.decodeTranscriptPage, + async close() { + events.end(); + }, + }), + listSessionTurnLandmarks: async (sessionId, turnId) => ({ + sessionId, + throughSequence: 50, + landmarks: + turnId === 'turn-b' ? [{ turnId, sequence: 20, lastSequence: 40, label: '' }] : [], + }), + }, + emitSessionsChanged() {}, + }); + + assert.deepEqual( + (await observer.readTranscriptTurn('session-1', 'turn-b')).map((message) => message.id), + ['row-20', 'row-30', 'row-40'], + ); + assert.deepEqual( + reads.filter((through) => through !== 40), + [], + 'the read stops where the Turn index says the Turn ends', + ); await observer.close(); }); @@ -1602,9 +1692,8 @@ test('does not let one backpressured transcript consumer block another', async ( throughSequence: input.throughSequence, rawBytes: 1, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + endsAtTurnBoundary: true, }), decodeTranscriptPage: async (page) => { if (page.throughSequence === null) return { messages: [], nextCursor: null }; @@ -1708,9 +1797,8 @@ test('keeps a transcript consumer available after a delivery fails', async () => throughSequence: input.throughSequence, rawBytes: 1, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + endsAtTurnBoundary: true, }), decodeTranscriptPage: async (page) => ({ messages: page.throughSequence === null ? [] : [{ @@ -1770,20 +1858,16 @@ test('keeps a transcript consumer available after a delivery fails', async () => await waitFor(() => failedDeliveries === 1); failDelivery = false; - await assert.doesNotReject( - observer.loadTranscriptAround( - { - consumerId, - sessionId: opened.sessionId, - hostEpoch: opened.hostEpoch, - anchorSequence: 0, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }, - 25, - ), - ); - assert.ok(successfulDeliveries > 1); + const delivered = successfulDeliveries; + events.push({ + kind: 'subscription.transcript_advanced', + hostEpoch: opened.hostEpoch, + subscriptionId: 'subscription-1', + sessionId: 'session-1', + sequence: 2, + throughSequence: 1, + }); + await waitFor(() => successfulDeliveries > delivered); assert.equal(closeCount, 0); await observer.closeTranscript(consumerId, 25); await waitFor(() => closeCount === 1); @@ -2224,9 +2308,8 @@ test("recovers when transcript paging loses the active subscription", async () = throughSequence: 0, rawBytes: 0, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + endsAtTurnBoundary: true, }; }, async close() { @@ -3411,6 +3494,92 @@ async function waitFor(predicate: () => boolean): Promise { await pollFor(predicate, { attempts: 100, message: 'Timed out waiting for observer state' }); } +interface TranscriptRow { + readonly identity: number; + readonly message: StoredMessage; +} + +function turnRow(identity: number, turnId: string, text = `row ${identity}`): TranscriptRow { + return { + identity, + message: { type: 'assistant', id: `row-${identity}`, turnId, ts: identity, text, modelId: 'test-model' }, + }; +} + +/** + * A durable Host transcript cut into pages of `pageRows` rows. Older cursors + * name the index a page starts at; newer cursors are prefixed with `n`. + */ +function historyHost(rows: readonly TranscriptRow[], options: { readonly pageRows: number }) { + const decoded = new Map(); + const throughSequence = rows.at(-1)?.identity ?? null; + const page = (messages: TranscriptRow[], nextCursor: string | null, endsAtTurnBoundary = true) => { + const value: SessionTranscriptPage = { + kind: 'page', + sessionId: 'session-1', + direction: 'older', + throughSequence, + rawBytes: 1, + fragments: [], + nextCursor, + endsAtTurnBoundary, + }; + decoded.set(value, { messages, nextCursor }); + return value; + }; + return { + bootstrap: { durable: page([...rows], null) }, + loadTranscriptPage: async (input: { + readonly direction: 'older' | 'newer'; + readonly cursor: string | null; + readonly anchorSequence: number | null; + }): Promise => { + if (input.direction === 'older') { + const end = input.cursor === null ? rows.length : Number(input.cursor); + const start = Math.max(0, end - options.pageRows); + // The Host hands over mutually overlapping Turns together, so a page + // ends on a boundary only where the row below it starts another Turn. + const whole = + start === 0 || rows[start - 1]!.message.turnId !== rows[start]!.message.turnId; + return page(rows.slice(start, end), start > 0 ? String(start) : null, whole); + } + const start = input.cursor !== null + ? Number(input.cursor.slice(1)) + : rows.findIndex((row) => input.anchorSequence === null || row.identity > input.anchorSequence); + const end = Math.min(rows.length, start + options.pageRows); + return page(rows.slice(start, end), end < rows.length ? `n${end}` : null); + }, + decodeTranscriptPage: async (value: SessionTranscriptPage) => { + const answer = decoded.get(value); + assert.ok(answer, 'decoded a page this Host did not serve'); + return answer; + }, + }; +} + +function ackingTranscriptTarget( + observer: RuntimeHostSessionObserver, + consumerId: string, + id: number, + batches: DesktopTranscriptBatch[], +): RuntimeHostTranscriptTarget { + return { + id, + send(_channel, batch) { + batches.push(batch); + queueMicrotask(() => observer.acknowledgeTranscript(consumerId, batch.generation, batch.deliverySequence, id)); + }, + once() {}, + off() {}, + }; +} + +function durableSequences(batch: DesktopTranscriptBatch): number[] { + return batch.fragments + .filter((fragment) => fragment.byteOffset === 0) + .map((fragment) => fragment.sequence); +} + // #5365: leaving the conversation used to drop the subscription to a Turn the // Host was still running, so coming back made it stream the whole answer again. test('a running Turn keeps its subscription after the last viewer leaves', async () => { diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts index ff5ff646da..820d939427 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts @@ -86,8 +86,7 @@ function emptyPage(sessionId: string): SessionTranscriptPage { throughSequence: null, rawBytes: 0, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + endsAtTurnBoundary: true, }; } diff --git a/apps/desktop/src/main/__tests__/session-local.test.ts b/apps/desktop/src/main/__tests__/session-local.test.ts index 9d96c7dee7..e49bf808a9 100644 --- a/apps/desktop/src/main/__tests__/session-local.test.ts +++ b/apps/desktop/src/main/__tests__/session-local.test.ts @@ -498,7 +498,7 @@ test('cache restoration expires independently of the outbox', async (t) => { }, ], hasOlder: false, - hasNewer: false, + beginsAtTurnBoundary: true, }); assert.equal(store.transcript('authority', 'session-1')?.snapshot.durableThrough, 1); assert.equal(store.transcript('different-authority', 'session-1'), undefined); @@ -551,7 +551,7 @@ test('durable Host evidence retires delivery independently of cache admission an }, ], hasOlder: false, - hasNewer: false, + beginsAtTurnBoundary: true, }; service.cacheTranscript(target.scope, snapshot); if (cacheLoss === 'revision') db.store.enqueue('other-authority', intent('other-message')); diff --git a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts index a32eb391f2..53c4826ae8 100644 --- a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts +++ b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts @@ -24,7 +24,7 @@ import { LocaleProvider } from '@maka/ui'; import type { StoredMessage } from '@maka/core/session'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { useAppShellSessionWorkspace } from '../../renderer/use-app-shell-session-workspace.js'; -import { createRecoveringDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; +import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; /** @@ -78,7 +78,7 @@ describe('session workspace action identity', () => { const row = (id: string): StoredMessage => ({ id, type: 'user', text: id, turnId: id, ts: 1 }); const a = [row('a-message')]; const c = [row('c-message')]; - const reader = (id: string) => createRecoveringDesktopTranscriptRangeController( + const reader = (id: string) => createDesktopTranscriptRangeController( new DesktopTranscriptRangeStore(id), async () => { throw new Error('unexpected read'); }, { onError() {} }, ); const readerA = reader(sessionA); @@ -137,28 +137,20 @@ describe('session workspace action identity', () => { act(() => workspace.setActiveId(sessionA)); assert.deepEqual(workspace.retiredSessionIds([{ id: sessionB }]), [sessionA]); - // Publication is scheduled outside the current React lifecycle. - // A retired queued source may not replace the displayed - // Session or publish its reader. + // A retired source may not replace the displayed Session or publish its reader. act(() => workspace.setActiveId(sessionC)); for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, sessionId: 'c', generation: 'publication', hostEpoch: 'host', - durableThrough: 0, durable: c.map((message, sequence) => ({ sequence, message })), hasOlder: false, hasNewer: false, + durableThrough: 0, durable: c.map((message, sequence) => ({ sequence, message })), hasOlder: false, })) readerC.store.accept(batch); - let blocked = true; - let idle!: () => void; - const detach = workspace.sessionUiController.transcriptViewportNavigation.attachCommitScheduler(sessionC, { - subscribeToReaderScroll: () => () => {}, - commitRange: (commit) => { if (blocked) idle = commit; else commit(); }, - }); let publications = 0; + const retiredSelection = workspace.captureSelection(); + act(() => workspace.clearOwnedSessionState(sessionC)); await act(async () => workspace.publishTranscript( - sessionC, readerC, workspace.captureSelection(), () => { publications += 1; }, + sessionC, readerC, retiredSelection, () => { publications += 1; }, )); - assert.equal(workspace.activeId, sessionB); - act(() => workspace.clearOwnedSessionState(sessionC)); - await act(async () => { blocked = false; idle(); }); - assert.equal(publications, 0, 'retirement revokes queued publication'); + assert.equal(publications, 0, 'retirement revokes publication'); assert.equal(workspace.activeId, sessionB); assert.equal(workspace.transcriptRangeRef.current, undefined); await act(async () => { @@ -170,7 +162,6 @@ describe('session workspace action identity', () => { assert.equal((workspace.messages as StoredMessage[])[0]?.id, 'c-message'); assert.equal(workspace.publishedTranscriptRange?.sessionId, sessionC); assert.equal(workspace.transcriptRangeRef.current, readerC); - await act(async () => detach()); }); it('keeps every action identity fixed across re-renders', () => { diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 5b869d8239..67391480ec 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -20,7 +20,6 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { createElement, type ReactNode } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { parseHTML } from 'linkedom'; import type { SessionEvent } from '@maka/core/events'; import { @@ -36,9 +35,10 @@ import { createAppShellSessionEventHandlers, } from '../../renderer/app-shell-session-events.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { renderTranscriptMarkup } from './transcript-test-dom.js'; -function renderWithLocale(child: ReactNode): string { - return renderToStaticMarkup( +function renderWithLocale(child: ReactNode): Promise { + return renderTranscriptMarkup( createElement(LocaleProvider, { locale: 'zh-CN', children: createElement(ChatSurfaceLayout, { composer: null, children: child }), @@ -63,7 +63,7 @@ async function waitFor(predicate: () => boolean, message: string): Promise await pollFor(predicate, { timeoutMs: 3_000, pollMs: 10, message }); } -function renderLiveTurn(liveTurn: LiveTurnProjection): string { +function renderLiveTurn(liveTurn: LiveTurnProjection): Promise { return renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', @@ -88,14 +88,14 @@ function renderLiveTurn(liveTurn: LiveTurnProjection): string { } describe('single live-turn handoff', () => { - it('keeps activity in the process row before the session or Turn arrives', () => { + it('keeps activity in the process row before the session or Turn arrives', async () => { const session: NonNullable[0]['activeSession']> = { id: 'session-1', name: 'pending', status: 'running' as const, backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask' as const, }; for (const activeSession of [undefined, session]) { - const markup = renderWithLocale(createElement(ChatView, { + const markup = await renderWithLocale(createElement(ChatView, { activeSession, messages: [], transientMessages: [{ @@ -114,8 +114,8 @@ describe('single live-turn handoff', () => { } }); - it('renders a transient user message without manufacturing a Turn', () => { - const markup = renderWithLocale(createElement(ChatView, { + it('renders a transient user message without manufacturing a Turn', async () => { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'active', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, @@ -139,8 +139,8 @@ describe('single live-turn handoff', () => { assert.match(markup, />send now { - const markup = renderWithLocale(createElement(ChatView, { + it('does not flash the empty-chat Maka hero before a first transient message', async () => { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'pending', status: 'active', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, @@ -162,8 +162,8 @@ describe('single live-turn handoff', () => { assert.doesNotMatch(markup, /maka-hero-empty-chat/); }); - it('shows a loading transient before its real live Turn answer', () => { - const markup = renderWithLocale(createElement(ChatView, { + it('shows a loading transient before its real live Turn answer', async () => { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'running', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, @@ -199,8 +199,8 @@ describe('single live-turn handoff', () => { assert.equal((markup.match(/data-transcript-turn-id="turn-1"/g) ?? []).length, 1); }); - it('keeps an unresolved Message independent of a Turn without an admission binding', () => { - const markup = renderWithLocale(createElement(ChatView, { + it('keeps an unresolved Message independent of a Turn without an admission binding', async () => { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'running', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, @@ -236,8 +236,8 @@ describe('single live-turn handoff', () => { assert.equal((markup.match(/data-transient-message-id=/g) ?? []).length, 1); }); - it('renders one ordered timeline: thinking before its tool and answer', () => { - const markup = renderLiveTurn({ + it('renders one ordered timeline: thinking before its tool and answer', async () => { + const markup = await renderLiveTurn({ turnId: 'turn-1', steps: [{ stepId: 'assistant-1', @@ -262,9 +262,9 @@ describe('single live-turn handoff', () => { assert.equal((markup.match(/data-turn-id=/g) ?? []).length, 1); }); - it('keeps a completed live answer as the only visible owner until settle', () => { + it('keeps a completed live answer as the only visible owner until settle', async () => { const finalText = 'one visible answer'; - const markup = renderWithLocale(createElement(ChatView, { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'streaming', lastMessageAt: 1, status: 'active', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, @@ -291,9 +291,9 @@ describe('single live-turn handoff', () => { assert.equal(markup.split(finalText).length - 1, 1); }); - it('keeps an incomplete live answer as the only owner after early persistence', () => { + it('keeps an incomplete live answer as the only owner after early persistence', async () => { const text = 'persisted before a slow tool finishes'; - const markup = renderWithLocale(createElement(ChatView, { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'streaming', lastMessageAt: 1, status: 'running', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts b/apps/desktop/src/main/__tests__/transcript-history-read.test.ts similarity index 62% rename from apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts rename to apps/desktop/src/main/__tests__/transcript-history-read.test.ts index 349a2f32b5..00f3bea995 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-history-read.test.ts @@ -22,27 +22,30 @@ import { Buffer } from 'node:buffer'; import test from 'node:test'; import { markPersisted } from '@maka/core/persisted-value'; import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; -import { SESSION_CONTINUITY_SCHEMA_VERSION } from '@maka/runtime-host/protocol'; +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionTranscriptPage, +} from '@maka/runtime-host/protocol'; import { ClientSessionSubscription } from '../../../../../packages/runtime-host/dist/client/session-subscription.js'; import { createSessionTranscriptBootstrap, readSessionTranscriptPage, updateSubscriberTranscriptHighWater, } from '../../../../../packages/runtime-host/dist/server/session-transcript-pager.js'; -import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../../preload/transcript-contract.js'; +import { DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES } from '../../preload/transcript-contract.js'; import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; -import { openTranscriptNavigationLedger } from './transcript-navigation-test-fixture.js'; +import { openTranscriptLedger } from './transcript-ledger-test-fixture.js'; const PAGE_BYTES = 128 * 1024; -const HOST_EPOCH = 'transcript-navigation-host'; -const SUBSCRIPTION_ID = 'transcript-navigation-subscription'; +const HOST_EPOCH = 'transcript-history-host'; +const SUBSCRIPTION_ID = 'transcript-history-subscription'; test('keeps both Turns reachable when an oversized ledger Turn is followed by a new durable tail', async () => { const source = transcriptFixture(); assert.ok(source.first.reduce((bytes, message) => bytes + Buffer.byteLength(JSON.stringify(message)), 0) - > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - const ledger = await openTranscriptNavigationLedger([...source.first, ...source.second]); + > DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES); + const ledger = await openTranscriptLedger([...source.first, ...source.second]); let opened: Awaited> | undefined; try { const firstThrough = await ledger.appendThrough('completed-a'); @@ -50,9 +53,7 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a assertSourcePayloads(first, source.first); opened = await openReplica(ledger, firstThrough); const { replica, subscription, state } = opened; - assertRecords(replica, first); - assert.equal(replica.snapshot().hasOlder, false, 'sparse first-row sequence does not imply older history'); - assert.equal(replica.snapshot().hasNewer, false, 'unused low watermark bits do not imply a newer row'); + assertTailOf(replica, first); const completeThrough = await ledger.appendThrough('completed-b'); assert.ok(completeThrough !== null); @@ -71,30 +72,27 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a await replica.advance(completeThrough); assertRecords(replica, second); - // A window read answers the Renderer without touching Main's tail cache. - const older = await replica.loadBefore(second[0]!.sequence, PAGE_BYTES); - assert.ok(older); - assert.deepEqual(older.durable, first, - 'older paging returns the complete oversized Turn adjacent to the anchor'); - assert.equal(older.hasOlder, false); - assert.equal(older.hasNewer, undefined, 'an older page establishes only its older edge'); + // A history read walks older pages through the watermark without touching + // Main's tail cache, and returns every row of the oversized Turn. + const history: { sequence: number; message: StoredMessage }[] = []; + let cursor: string | null = null; + do { + const page = await replica.readOlderPage(completeThrough, cursor); + history.unshift(...page.durable); + cursor = page.nextCursor; + } while (cursor !== null); + assert.deepEqual(history, complete); assertRecords(replica, second); - for (let attempt = 0; attempt < 2; attempt += 1) { - // An oversized Turn fills a client range on its own, so a reset anchored - // on the oldest row ends at that range boundary rather than at the tail. - // Reachability is carried by the newer edge and the page behind it. - const around = await replica.loadAround(first[0]!.sequence, PAGE_BYTES); - assert.ok(around); - assert.deepEqual(around.durable, first); - assert.equal(around.hasOlder, false); - assert.equal(around.hasNewer, true); - const newer = await replica.loadAfter(first.at(-1)!.sequence, PAGE_BYTES); - assert.ok(newer); - assert.deepEqual(newer.durable, second, 'the page past the boundary reaches the current tail'); - assert.equal(newer.hasNewer, false); - assertRecords(replica, second); + for (const [turnId, records] of [['a', first], ['b', second]] as const) { + assert.deepEqual( + await replica.readTurn(turnId, extentOf(records), 16 * 1024 * 1024), + records.map(({ message }) => message), + 'a Turn read stops at the next Turn and keeps the oversized payload whole', + ); } + await assert.rejects(replica.readTurn('a', extentOf(first), PAGE_BYTES), RangeError); + assertRecords(replica, second); } finally { opened?.replica.close(); await opened?.subscription.close(); @@ -105,7 +103,7 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a for (const checkpoint of ['running-b', 'result-b'] as const) { test(`serves the running second Turn through durable pages at ${checkpoint}`, async () => { const source = transcriptFixture(); - const ledger = await openTranscriptNavigationLedger([...source.first, ...source.second]); + const ledger = await openTranscriptLedger([...source.first, ...source.second]); let opened: Awaited> | undefined; try { const firstThrough = await ledger.appendThrough('completed-a'); @@ -133,7 +131,6 @@ for (const checkpoint of ['running-b', 'result-b'] as const) { await replica.advance(throughSequence); const second = (await ledger.durableRecords()).filter(({ message }) => message.turnId === 'b'); assertRecords(replica, second); - assert.equal(replica.snapshot().hasNewer, false); } finally { opened?.replica.close(); await opened?.subscription.close(); @@ -142,8 +139,81 @@ for (const checkpoint of ['running-b', 'result-b'] as const) { }); } -type Ledger = Awaited>; -async function openReplica(ledger: Ledger, throughSequence: number | null) { +/** + * The Host writes a nested Turn's rows between its parent's, so the two share a + * stretch of the Session's ordinals (see `session-transcript-reader.test.ts`, + * "serves every row of a Turn nested inside another"). A targeted read + * of the outer Turn has to read through that stretch, not stop at it. + */ +test('reads a Turn through the rows of a nested one', async () => { + const outer = (id: string, ts: number): StoredMessage => + ({ type: 'assistant', id, turnId: 'outer', ts, text: `outer ${id}`, modelId: 'fixture-model' }); + const source: StoredMessage[] = [ + { type: 'user', id: 'user-outer', turnId: 'outer', ts: 1, text: 'Outer question' }, + outer('outer-before', 2), + { type: 'user', id: 'user-inner', turnId: 'inner', ts: 3, text: 'Inner question' }, + { type: 'assistant', id: 'inner-answer', turnId: 'inner', ts: 4, text: 'inner answer', modelId: 'fixture-model' }, + { type: 'turn_state', id: 'completed-inner', turnId: 'inner', ts: 5, status: 'completed' }, + outer('outer-after', 6), + { type: 'turn_state', id: 'completed-outer', turnId: 'outer', ts: 7, status: 'completed' }, + ]; + const ledger = await openTranscriptLedger(source); + let opened: Awaited> | undefined; + try { + const throughSequence = await ledger.appendThrough('completed-outer'); + const records = await ledger.durableRecords(); + opened = await openReplica(ledger, throughSequence); + const read = await opened.replica.readTurn( + 'outer', + extentOf(records.filter(({ message }) => message.turnId === 'outer')), + PAGE_BYTES, + ); + assert.deepEqual( + read.map(({ id }) => id), + records.filter(({ message }) => message.turnId === 'outer').map(({ message }) => message.id), + 'the nested Turn ended the read of the Turn around it', + ); + } finally { + opened?.replica.close(); + await opened?.subscription.close(); + await ledger.close(); + } +}); + +test('refuses a Turn read whose continuation never advances', async () => { + const source = transcriptFixture(); + const ledger = await openTranscriptLedger(source.first); + let opened: Awaited> | undefined; + try { + const throughSequence = await ledger.appendThrough('completed-a'); + const records = await ledger.durableRecords(); + let pages = 0; + opened = await openReplica(ledger, throughSequence, (request) => { + pages += 1; + return { + kind: 'page', sessionId: ledger.sessionId, direction: request.direction, + throughSequence: request.throughSequence, rawBytes: 0, fragments: [], + nextCursor: 'stalled', endsAtTurnBoundary: false, + }; + }); + await assert.rejects( + opened.replica.readTurn('a', extentOf(records), PAGE_BYTES), + /empty continuation/, + ); + assert.equal(pages, 1); + } finally { + opened?.replica.close(); + await opened?.subscription.close(); + await ledger.close(); + } +}); + +type Ledger = Awaited>; +async function openReplica( + ledger: Ledger, + throughSequence: number | null, + hostPage?: (request: Parameters[0]) => SessionTranscriptPage, +) { const { reader, sessionId } = ledger; const opened = await createSessionTranscriptBootstrap({ reader, sessionId, subscriptionId: SUBSCRIPTION_ID, throughSequence, @@ -165,12 +235,25 @@ async function openReplica(ledger: Ledger, throughSequence: number | null) { snapshot: subscription.snapshot, transcript: Promise.resolve([]), events: subscription, transcriptBootstrap: opened.bootstrap, decodeTranscriptPage: (page, maxBytes, accountBytes) => subscription.decodeTranscriptPage(page, decodeMessage, maxBytes, accountBytes), - loadTranscriptPage: (request) => subscription.loadTranscriptPage(request), + loadTranscriptPage: (request) => + hostPage ? Promise.resolve(hostPage(request)) : subscription.loadTranscriptPage(request), close: () => subscription.close(), })); return { replica, subscription, state: opened.state }; } +/** The bootstrap page is byte-bounded, so the tail cache may start inside the oversized Turn. */ +function assertTailOf(replica: DesktopTranscriptReplica, records: readonly { sequence: number; message: StoredMessage }[]) { + const { durable, hasOlder } = replica.snapshot(); + assert.ok(durable.length > 0); + assert.deepEqual(durable, records.slice(records.length - durable.length)); + assert.equal(hasOlder, durable.length < records.length, 'older history is reported exactly when rows are missing'); +} + +function extentOf(records: readonly { sequence: number }[]) { + return { sequence: records[0]!.sequence, lastSequence: records.at(-1)!.sequence }; +} + function assertRecords(replica: DesktopTranscriptReplica, records: readonly { sequence: number; message: StoredMessage }[]) { assert.deepEqual(replica.snapshot().durable, records, 'every selected Turn row and its full payload survives paging'); } @@ -220,5 +303,5 @@ function transcriptFixture() { ]; // One complete tool payload crosses both page and resident-range budgets. // The record count is incidental; the next Turn starts live and ends durably. - return { first: turn('a', DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + PAGE_BYTES), second: turn('b', 256) }; + return { first: turn('a', DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + PAGE_BYTES), second: turn('b', 256) }; } diff --git a/apps/desktop/src/main/__tests__/transcript-identity.test.ts b/apps/desktop/src/main/__tests__/transcript-identity.test.ts index 4c43ae0398..696d212eb7 100644 --- a/apps/desktop/src/main/__tests__/transcript-identity.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-identity.test.ts @@ -31,8 +31,8 @@ function batch(overrides: Partial = {}): DesktopTranscri generation: 'generation-1', hostEpoch: 'host-1', durableThrough: null, - fragments: [], hasOlder: false, - hasNewer: false, + fragments: [], + hasOlder: false, reset: false, ready: true, deliverySequence: 1, diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts b/apps/desktop/src/main/__tests__/transcript-ledger-test-fixture.ts similarity index 97% rename from apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts rename to apps/desktop/src/main/__tests__/transcript-ledger-test-fixture.ts index af4fafdb8b..72e1949a8d 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts +++ b/apps/desktop/src/main/__tests__/transcript-ledger-test-fixture.ts @@ -36,7 +36,7 @@ const FIXTURE_EPOCH = Date.UTC(2026, 0, 2, 3, 4, 5); * Legacy-shaped input keeps the payload fixture legible, but every page is * projected by the production RuntimeEvent reader. */ -export async function openTranscriptNavigationLedger(messages: readonly StoredMessage[]) { +export async function openTranscriptLedger(messages: readonly StoredMessage[]) { const base = await mkdtemp(join(tmpdir(), 'maka-transcript-navigation-')); const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); const owner = await tryAcquireInteractiveRootOwner(capability); @@ -111,7 +111,7 @@ export async function openTranscriptNavigationLedger(messages: readonly StoredMe direction: 'newer', maxMessages: 1_000, maxStoredBytes: 16 * 1024 * 1024, }); assert.equal(result.nextPosition, null, 'the assertion sweep must include every durable row'); - return result.records; + return result.records.map(({ sequence, message }) => ({ sequence, message })); }, async close() { await stores!.sessionStore.close?.(); diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts deleted file mode 100644 index e5109dee23..0000000000 --- a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts +++ /dev/null @@ -1,547 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { deferred } from '@maka/core/test-only/async-primitives'; -import type { StoredMessage } from '@maka/core/session'; -import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol'; -import type { DesktopTranscriptBatch, DesktopTranscriptHandle, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; -import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; -import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; -import { RuntimeHostSessionObserver } from '../runtime-host-session-observer.js'; -import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; - -const PAGE_BYTES = 128 * 1024; - -for (const kind of ['before', 'around'] as const) { - test(`an invalidated ${kind} page neither answers its window nor touches the tail`, async () => { - const entered = deferred(); - const release = deferred(); - const installed: number[][] = []; - const bootstrap = page(1); - const pending = page(1); - const older = record(0); - const latest = record(1); - const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - durable: bootstrap, - }, - decodeTranscriptPage: async (candidate) => ({ - messages: candidate === bootstrap ? [latest] : [older], - nextCursor: candidate === bootstrap ? 'older' : null, - }), - loadTranscriptPage: async () => { - entered.resolve(); - await release.promise; - return pending; - }, - async close() {}, - }), { onChange: (_replica, change) => installed.push(change.durableUpserts.map(({ sequence }) => sequence)) }); - let current = true; - const isCurrent = () => current; - const loading = kind === 'before' - ? replica.loadBefore(1, PAGE_BYTES, isCurrent) - : replica.loadAround(0, PAGE_BYTES, isCurrent); - await entered.promise; - // The Renderer replaced its window while this page was in flight. - current = false; - release.resolve(); - assert.equal(await loading, undefined); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [1]); - assert.deepEqual(installed, []); - replica.close(); - }); -} - -test('a global cache trim empties the tail without publishing or reading history', async () => { - const bootstrap = page(1); - const decoded = new Map>([[bootstrap, record(1)]]); - const requests: number[] = []; - const changes: number[] = []; - const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - durable: bootstrap, - }, - decodeTranscriptPage: async (candidate) => ({ messages: [decoded.get(candidate)!], nextCursor: null }), - loadTranscriptPage: async (request) => { - assert.ok(request.throughSequence !== null); - requests.push(request.throughSequence); - const candidate = page(request.throughSequence); - decoded.set(candidate, record(request.throughSequence)); - return candidate; - }, - async close() {}, - }), { maxResidentBytes: 64, onChange: (_replica, change) => changes.push(change.durableUpserts.length) }); - try { - await replica.advance(2); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2], - 'catch-up keeps the newest Turn and drops the oldest'); - const published = changes.length; - requests.length = 0; - - replica.trimDurable(0); - - assert.deepEqual(replica.snapshot().durable, []); - assert.equal(changes.length, published, 'a cache trim is not a transcript change'); - assert.deepEqual(requests, [], 'a cache trim reads nothing back'); - } finally { - replica.close(); - } -}); - -test('a tail the global cache trim emptied is read back before it answers follow latest', async () => { - const bootstrap = page(1); - const tail = page(1); - const reads: Array<{ direction: string; anchorSequence: number | null }> = []; - const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - durable: bootstrap, - }, - decodeTranscriptPage: async (candidate) => ({ - messages: [record(1)], nextCursor: candidate === bootstrap ? 'older' : null, - }), - loadTranscriptPage: async (request) => { - reads.push({ direction: request.direction, anchorSequence: request.anchorSequence }); - return tail; - }, - async close() {}, - })); - try { - replica.trimDurable(0); - assert.deepEqual(replica.snapshot().durable, [], 'global memory pressure empties the tail'); - - await replica.refillTail(PAGE_BYTES); - - assert.deepEqual(reads, [{ direction: 'older', anchorSequence: 2 }], - 'the refill reads the newest page, not history'); - const snapshot = replica.snapshot(); - assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [1]); - assert.equal(snapshot.durableThrough, replica.durableThrough); - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - for (const batch of encodeDesktopTranscriptSnapshot(snapshot)) store.accept(batch); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - assert.equal(store.range().hasNewer, false, 'the reader is at the tail, not short of it'); - - reads.length = 0; - await replica.refillTail(PAGE_BYTES); - assert.deepEqual(reads, [], 'a cache holding the whole transcript answers on its own'); - } finally { - replica.close(); - } -}); - -test('return to latest answers with a tail after reclaim emptied the cache', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const eventsClosed = deferred(); - const bootstrap = page(1); - const tail = page(1); - const reads: Array<{ direction: string; anchorSequence: number | null }> = []; - const observer = new RuntimeHostSessionObserver({ - client: { openSession: async () => runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, - transcriptBootstrap: { - durable: bootstrap, - }, - // What global reclaim leaves behind: the watermark stands, the rows are gone. - decodeTranscriptPage: async (candidate) => candidate === bootstrap - ? { messages: [], nextCursor: 'older' } - : { messages: [record(1)], nextCursor: null }, - loadTranscriptPage: async (request) => { - reads.push({ direction: request.direction, anchorSequence: request.anchorSequence }); - return tail; - }, - async close() { eventsClosed.resolve(); }, - }) }, - emitSessionsChanged() {}, - }); - await observer.openTranscript('session-1', 'consumer-1', { - id: 1, once() {}, off() {}, - send(_channel, batch) { - store.accept(batch); - queueMicrotask(() => observer.acknowledgeTranscript('consumer-1', batch.generation, batch.deliverySequence, 1)); - }, - }); - assert.deepEqual(store.snapshot().messages, [], 'the window opens on the emptied cache'); - - const navigation = store.navigate(); - await observer.loadTranscriptLatest({ - consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', - anchorSequence: null, maxBytes: PAGE_BYTES, navigation, - }, 1); - - assert.deepEqual(reads, [{ direction: 'older', anchorSequence: 2 }]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - assert.equal(store.range().hasNewer, false, 'the return-to-latest affordance is gone because the rows arrived'); - await observer.close(); -}); - -test('a superseded fragmented reset cannot clear or complete the next navigation', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - acceptSnapshot(store, undefined, 'generation-1', [record(1)]); - store.navigate(); - const stale = [...encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, - durable: [{ sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }], - hasOlder: false, hasNewer: true, - }, 1)]; - assert.equal(store.accept(stale[0]!), false); - store.navigate(); - acceptSnapshot(store, 2, 'generation-2', [record(1)]); - const committed = store.snapshot(); - for (const batch of stale) assert.equal(store.accept(batch), false); - assert.strictEqual(store.snapshot(), committed); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - // Even a reset carrying the current navigation cannot resurrect a retired replica. - acceptSnapshot(store, 2, 'generation-1', [record(0)]); - assert.strictEqual(store.snapshot(), committed); -}); - -test('a replica replacement is admitted whole, however far the window has navigated', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - acceptSnapshot(store, undefined, 'generation-1', [record(1)]); - store.navigate(); - const replacement = [...encodeDesktopTranscriptSnapshot({ - ...identity, generation: 'generation-2', durableThrough: 1, - durable: [ - { sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }, - { sequence: 1, message: record(1).message }, - ], - hasOlder: false, hasNewer: false, - })]; - assert.ok(replacement.length > 1, 'the replacement has to span more than its reset batch'); - for (const batch of replacement) store.accept(batch); - assert.equal(store.range().ready, true); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-0', 'message-1']); -}); - -test('a fill landing under a pending jump joins the window it was anchored on', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const arrive = deferred(); - const handle: DesktopTranscriptHandle = { - ...identity, readThroughMessageId: null, acknowledgeTail: async () => {}, - async loadBefore(anchor) { - for (const batch of encodeDesktopTranscriptPage(identity, { - durableThrough: 1, durable: [{ sequence: 0, message: record(0).message }], hasOlder: false, - }, { direction: 'older', anchor })) store.accept(batch); - }, - async loadAfter() { assert.fail('unexpected'); }, - async loadAround(_anchor, _bytes, navigation) { - await arrive.promise; - acceptSnapshot(store, navigation, 'generation-1', [record(9)]); - }, - async loadLatest() { assert.fail('unexpected'); }, - async close() {}, - }; - const controller = createDesktopTranscriptRangeController(store, async () => handle); - acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2)]); - const navigation = controller.loadAround(9); - await Promise.resolve(); - - assert.equal(await controller.loadBefore(), true); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), - ['message-0', 'message-1', 'message-2'], - 'the fill is anchored on an edge of the window still on screen, and reaches it'); - - arrive.resolve(); - await navigation; - // The jump replaces the window whole, so the fill leaves no trace in it. - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-9']); - await controller.close(); -}); - -test('a fill that left an edge where it found it is not asked again', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - let reads = 0; - const handle: DesktopTranscriptHandle = { - ...identity, readThroughMessageId: null, acknowledgeTail: async () => {}, - // The window still has history, but this answer reaches none of it: the - // Host read past a retired generation, or the page came back refused. - async loadBefore() { reads += 1; }, - async loadAfter() { assert.fail('unexpected'); }, - async loadAround() { assert.fail('unexpected'); }, - async loadLatest() { assert.fail('unexpected'); }, - async close() {}, - }; - const controller = createDesktopTranscriptRangeController(store, async () => handle); - acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2)]); - assert.equal(store.range().hasOlder, true); - - await controller.loadBefore(); - await controller.loadBefore(); - await controller.loadBefore(); - assert.equal(reads, 1, 'the same edge is not read twice'); - - // Anything that moves the edge makes it worth asking again. - assert.equal(store.retain(2, 2), true); - await controller.loadBefore(); - assert.equal(reads, 2); - await controller.close(); -}); - -test('a fill anchored on the window a navigation replaced cannot splice onto it', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - acceptSnapshot(store, undefined, 'generation-1', [record(8), record(9)]); - const navigating = store.navigate(); - acceptSnapshot(store, navigating, 'generation-1', [record(1), record(2)]); - for (const batch of encodeDesktopTranscriptPage(identity, { - durableThrough: 9, durable: [{ sequence: 9, message: record(9).message }], hasNewer: false, - }, { direction: 'newer', anchor: 9 })) store.accept(batch); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1', 'message-2']); -}); - -test('a navigation outlives the band trimming the window it was issued under', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2)]); - const navigating = store.navigate(); - assert.equal(store.retain(2, 2), true); - const replacement = [...encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, - durable: [ - { sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }, - { sequence: 1, message: record(1).message }, - ], - hasOlder: false, hasNewer: false, - }, navigating)]; - assert.ok(replacement.length > 1, 'the replacement has to span more than its reset batch'); - for (const batch of replacement) store.accept(batch); - assert.equal(store.range().ready, true); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-0', 'message-1']); - // The fill the band left in flight is anchored on an edge nothing here has. - for (const batch of encodeDesktopTranscriptPage(identity, { - durableThrough: 3, durable: [{ sequence: 3, message: record(3).message }], hasNewer: false, - }, { direction: 'newer', anchor: 2 })) store.accept(batch); - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [0, 1]); - assert.equal(store.range().hasNewer, true, 'the watermark it carried still moved'); -}); - -test('a page anchored on an edge the band has since dropped is refused', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2), record(3)]); - assert.equal(store.retain(3, 3), true); - for (const batch of encodeDesktopTranscriptPage(identity, { - durableThrough: 3, durable: [{ sequence: 0, message: record(0).message }], - hasOlder: false, - }, { direction: 'older', anchor: 1 })) store.accept(batch); - // Installing it would leave 1..2 missing between the answer and the window, - // and no edge cursor can name a hole in the middle. - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-3']); - assert.equal(store.range().hasOlder, true); -}); - -test('follow latest invalidates an in-flight history navigation before open resolves', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const opening = deferred(); - const requests: Array<{ command: 'around' | 'latest'; anchor: number | null; navigation: number }> = []; - const handle = (generation: string): DesktopTranscriptHandle => ({ - ...identity, generation, readThroughMessageId: null, acknowledgeTail: async () => {}, - async loadBefore() { assert.fail('an obsolete history request was replayed'); }, - async loadAfter() { assert.fail('an obsolete newer request was replayed'); }, - async loadAround(anchor, _bytes, navigation) { - requests.push({ command: 'around', anchor, navigation }); - acceptSnapshot(store, navigation, generation, [record(0)]); - }, - async loadLatest(navigation) { - requests.push({ command: 'latest', anchor: null, navigation }); - acceptSnapshot(store, navigation, generation, [record(1)]); - }, - async close() {}, - }); - const controller = createDesktopTranscriptRangeController(store, async () => opening.promise); - const history = controller.loadAround(0); - const latest = controller.loadLatest(); - opening.resolve(handle('generation-1')); - await Promise.all([history, latest]); - assert.deepEqual(requests, [{ command: 'latest', anchor: null, navigation: 2 }]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - await controller.close(); -}); - -test('a rejected older navigation cannot fail the newer latest command', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const historyEntered = deferred(); - let rejectHistory!: (error: Error) => void; - const historyResult = new Promise((_resolve, reject) => { rejectHistory = reject; }); - const controller = createDesktopTranscriptRangeController(store, async () => ({ - ...identity, readThroughMessageId: null, acknowledgeTail: async () => {}, - async loadBefore() {}, async loadAfter() {}, - async loadAround(anchor) { - assert.equal(anchor, 0); - historyEntered.resolve(); - await historyResult; - }, - async loadLatest(navigation) { - acceptSnapshot(store, navigation, identity.generation, [record(1)]); - }, - async close() {}, - })); - const history = controller.loadAround(0); - await historyEntered.promise; - await controller.loadLatest(); - rejectHistory(new Error('the obsolete range failed')); - await assert.doesNotReject(history); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - await controller.close(); -}); - -test('superseded batches remain ACKable and cannot reset the latest window while delivery drains', { timeout: 10_000 }, async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const firstOldBatch = deferred(); - const eventsClosed = deferred(); - const bootstrap = page(1); - const historyPage = page(1); - const latestPage = page(1); - const old = record(0); - const largeOld = { ...old, message: { ...old.message, text: 'A'.repeat(700 * 1024) } as StoredMessage }; - const latest = record(1); - const blocked: DesktopTranscriptBatch[] = []; - let releaseAcks = false; - const observer = new RuntimeHostSessionObserver({ - client: { openSession: async () => runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, - transcriptBootstrap: { - durable: bootstrap, - }, - decodeTranscriptPage: async (candidate) => ({ - messages: candidate === historyPage ? [largeOld] : [latest], - nextCursor: candidate === historyPage ? 'newer' : 'older', - }), - loadTranscriptPage: async (request) => request.direction === 'newer' ? historyPage : latestPage, - async close() { eventsClosed.resolve(); }, - }) }, - emitSessionsChanged() {}, - }); - const ack = (batch: DesktopTranscriptBatch) => observer.acknowledgeTranscript('consumer-1', batch.generation, batch.deliverySequence, 1); - await observer.openTranscript('session-1', 'consumer-1', { - id: 1, once() {}, off() {}, - send(_channel, batch) { - store.accept(batch); - if (batch.navigation === 1 && !releaseAcks) { - blocked.push(batch); - firstOldBatch.resolve(); - } else queueMicrotask(() => ack(batch)); - }, - }); - const request: DesktopTranscriptRangeRequest = { - consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', - anchorSequence: 0, maxBytes: PAGE_BYTES, navigation: 1, - }; - store.navigate(); - const history = observer.loadTranscriptAround(request, 1); - await firstOldBatch.promise; - store.navigate(); - const following = observer.loadTranscriptLatest({ ...request, navigation: 2, anchorSequence: null }, 1); - releaseAcks = true; - for (const batch of blocked) ack(batch); - await Promise.all([history, following]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - const snapshot = store.snapshot(); - // Replaying the reset of the answer the reader navigated away from: it names - // a navigation that is over, so it cannot reinstall the window it was read for. - for (const batch of blocked.filter(({ reset }) => reset)) { - assert.equal(store.accept(batch), false); - } - assert.strictEqual(store.snapshot(), snapshot); - await observer.close(); -}); - -test('a fill in flight does not discard the replacement it was issued under', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const entered = deferred(); - const release = deferred(); - const eventsClosed = deferred(); - const bootstrap = page(1); - const historyPage = page(1); - let gated = true; - const observer = new RuntimeHostSessionObserver({ - client: { openSession: async () => runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, - transcriptBootstrap: { - durable: bootstrap, - }, - decodeTranscriptPage: async (candidate) => ({ - messages: [candidate === bootstrap ? record(1) : record(0)], nextCursor: null, - }), - loadTranscriptPage: async () => { - if (gated) { - gated = false; - entered.resolve(); - await release.promise; - } - return historyPage; - }, - async close() { eventsClosed.resolve(); }, - }) }, - emitSessionsChanged() {}, - }); - await observer.openTranscript('session-1', 'consumer-1', { - id: 1, once() {}, off() {}, - send(_channel, batch) { - store.accept(batch); - queueMicrotask(() => observer.acknowledgeTranscript('consumer-1', batch.generation, batch.deliverySequence, 1)); - }, - }); - const request: DesktopTranscriptRangeRequest = { - consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', - anchorSequence: 0, maxBytes: PAGE_BYTES, navigation: 1, - }; - store.navigate(); - const navigation = observer.loadTranscriptAround(request, 1); - await entered.promise; - // A fill issued while the jump is still reading names the same navigation: - // it extends the window, and nothing about it abandons the jump. - const filling = observer.loadTranscriptBefore({ ...request, anchorSequence: 1 }, 1); - release.resolve(); - await Promise.all([navigation, filling]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-0']); - await observer.close(); -}); - -const identity = { sessionId: 'session-1', hostEpoch: 'host-1', generation: 'generation-1' }; -function acceptSnapshot(store: DesktopTranscriptRangeStore, navigation: number | undefined, generation: string, records: Array>) { - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, generation, durableThrough: 1, - durable: records.map(({ identity: sequence, message }) => ({ sequence, message })), - hasOlder: true, hasNewer: false, - }, navigation)) store.accept(batch); -} -function record(identity: number) { - const message: StoredMessage = { type: 'assistant', id: `message-${identity}`, turnId: `turn-${identity}`, ts: 1, text: String(identity), modelId: 'test' }; - return { identity, message }; -} -function page(throughSequence: number): SessionTranscriptPage { - return { kind: 'page', sessionId: 'session-1', direction: 'older', throughSequence, - rawBytes: 1, fragments: [], rangeBoundarySequence: null, protectedTurnSequence: null, nextCursor: null }; -} -function continuitySnapshot() { - return { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, - session: { sessionId: 'session-1', metadataRevision: 1, status: 'running' as const, createdAt: 1, isArchived: false }, - projectionRevision: 1, rootTurn: null, goal: null, - queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] } }; -} diff --git a/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts b/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts deleted file mode 100644 index f0c4b0a0a5..0000000000 --- a/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import type { StoredMessage } from '@maka/core/session'; -import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { - encodeDesktopTranscriptChange, - encodeDesktopTranscriptPage, - encodeDesktopTranscriptSnapshot, -} from '../desktop-transcript-ipc.js'; - -const identity = { sessionId: 'session-1', hostEpoch: 'host-1', generation: 'generation-1' }; -const message = (sequence: number, text = String(sequence)): StoredMessage => - ({ type: 'assistant', id: `message-${sequence}`, turnId: `turn-${sequence}`, ts: 1, text, modelId: 'test' }); - -test('a Turn completing at the tail does not splice into a window parked far from it', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const navigation = store.navigate(); - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 20, - durable: [{ sequence: 5, message: message(5) }, { sequence: 6, message: message(6) }], - hasOlder: true, hasNewer: true, - }, navigation)) store.accept(batch); - // 21 completes; the tail broadcast carries its durable row. - for (const batch of encodeDesktopTranscriptChange(identity, { - coversFrom: 20, durableThrough: 21, - durableUpserts: [{ sequence: 21, message: message(21, 'partial and completed') }], - })) store.accept(batch); - // 7..20 are not in the window, so 21 cannot join its durable range contiguously. - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [5, 6]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-5', 'message-6']); - - for (const batch of encodeDesktopTranscriptPage(identity, { - durableThrough: 21, hasOlder: true, hasNewer: false, - durable: Array.from({ length: 15 }, (_, index) => ({ - sequence: index + 7, - message: message(index + 7, index === 14 ? 'partial and completed' : undefined), - })), - }, { direction: 'newer', anchor: 6 })) store.accept(batch); - const ids = store.snapshot().messages.map(({ id }) => id); - assert.deepEqual(ids.slice(-2), ['message-20', 'message-21']); - assert.equal(ids.length, 17, 'reading forward brings 21 in once'); -}); diff --git a/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts b/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts deleted file mode 100644 index de518085da..0000000000 --- a/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { deferred } from '@maka/core/test-only/async-primitives'; -import type { StoredMessage } from '@maka/core/session'; -import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol'; -import type { DesktopTranscriptBatch, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; -import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { RuntimeHostSessionObserver } from '../runtime-host-session-observer.js'; -import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; - -// Drives the real observer, replica and Renderer store with a fake Host whose -// jump read (loadAround) is held until a fill has been queued behind it. -const PAGE_BYTES = 128 * 1024; -const THROUGH = 20; - -async function harness() { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const eventsClosed = deferred(); - const aroundEntered = deferred(); - const releaseAround = deferred(); - const bootstrap = page(); - const decoded = new Map>; nextCursor: string | null }>([ - [bootstrap, { messages: [record(18), record(19), record(20)], nextCursor: 'older' }], - ]); - const observer = new RuntimeHostSessionObserver({ - client: { openSession: async () => runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, - transcriptBootstrap: { durable: bootstrap }, - decodeTranscriptPage: async (candidate) => decoded.get(candidate)!, - loadTranscriptPage: async (request) => { - const candidate = page(); - if (request.direction === 'newer') { - aroundEntered.resolve(); - await releaseAround.promise; - decoded.set(candidate, { messages: [record(5), record(6)], nextCursor: 'newer' }); - } else if (request.maxBytes === 1) { - decoded.set(candidate, { messages: [record(4)], nextCursor: 'older' }); - } else { - decoded.set(candidate, { messages: [record(request.anchorSequence! - 1)], nextCursor: 'older' }); - } - return candidate; - }, - async close() { eventsClosed.resolve(); }, - }) }, - emitSessionsChanged() {}, - }); - const ack = (batch: DesktopTranscriptBatch) => observer.acknowledgeTranscript('consumer-1', batch.generation, batch.deliverySequence, 1); - await observer.openTranscript('session-1', 'consumer-1', { - id: 1, once() {}, off() {}, - send(_channel, batch) { store.accept(batch); queueMicrotask(() => ack(batch)); }, - }); - const request = (navigation: number, anchorSequence: number): DesktopTranscriptRangeRequest => ({ - consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', anchorSequence, maxBytes: PAGE_BYTES, navigation, - }); - const sequences = () => store.durableEntries().map(({ sequence }) => sequence); - return { store, observer, request, sequences, aroundEntered, releaseAround }; -} - -test('a fill issued while a jump is pending does not splice the old edge onto the new window', async () => { - const h = await harness(); - try { - assert.deepEqual(h.sequences(), [18, 19, 20]); - const navigation = h.store.navigate(); - const jump = h.observer.loadTranscriptAround(h.request(navigation, 5), 1); - await h.aroundEntered.promise; - // Anchored on the window still on screen (18), under the jump's navigation. - const fill = h.observer.loadTranscriptBefore(h.request(navigation, h.store.range().oldestSequence!), 1); - h.releaseAround.resolve(); - await Promise.all([jump, fill]); - assert.deepEqual(h.sequences(), [5, 6]); - } finally { - await h.observer.close(); - } -}); - -test('a trim and a fill while a jump is pending do not make Main drop the jump', async () => { - const h = await harness(); - try { - const navigation = h.store.navigate(); - const jump = h.observer.loadTranscriptAround(h.request(navigation, 5), 1); - await h.aroundEntered.promise; - assert.equal(h.store.retain(19, 20), true); - const fill = h.observer.loadTranscriptBefore(h.request(navigation, h.store.range().oldestSequence!), 1); - h.releaseAround.resolve(); - await Promise.all([jump, fill]); - assert.ok(h.sequences().includes(5), `the jump target never arrived: ${JSON.stringify(h.sequences())}`); - } finally { - await h.observer.close(); - } -}); - -function record(identity: number) { - const message: StoredMessage = { type: 'assistant', id: `message-${identity}`, turnId: `turn-${identity}`, ts: 1, text: String(identity), modelId: 'test' }; - return { identity, message }; -} -function page(): SessionTranscriptPage { - return { kind: 'page', sessionId: 'session-1', direction: 'older', throughSequence: THROUGH, - rawBytes: 1, fragments: [], rangeBoundarySequence: null, protectedTurnSequence: null, nextCursor: null }; -} -function continuitySnapshot() { - return { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, - session: { sessionId: 'session-1', metadataRevision: 1, status: 'running' as const, createdAt: 1, isArchived: false }, - projectionRevision: 1, rootTurn: null, goal: null, - queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] } }; -} diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index cb683a6fad..07bd13194d 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -21,14 +21,14 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement, createRef, type ComponentProps } from 'react'; import { deferred } from '@maka/core/test-only/async-primitives'; +import type { StoredMessage } from '@maka/core/session'; import type { DesktopTranscriptHandle } from '../../preload/transcript-contract.js'; -import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { encodeDesktopTranscriptBatches, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { createAppShellSessionUiStateController, TranscriptReadingPositionController, type TranscriptReadingPositionCommands, - TranscriptReadSupersededError, } from '../../renderer/features/conversation/index.js'; import { createTranscriptRestoreLifecycle, @@ -39,250 +39,202 @@ import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; afterEach(cleanupFakeDom); -test('a resident navigation supersedes a pending history replacement without rereading an idle window', async () => { - const sessionId = JSON.stringify(['host-1', 'session-1']); - const store = new DesktopTranscriptRangeStore(sessionId); - const pending = deferred(); - const reads: number[] = []; - const publish = (sequence: number, navigation?: number) => { - const turnId = sequence === 10 ? 'a' : 'b'; - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: 20, durable: [{ sequence, message: { - type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - } }], hasOlder: true, hasNewer: true, - }, navigation)) store.accept(batch); +const SESSION_ID = JSON.stringify(['host-1', 'session-1']); +const IDENTITY = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +function answer(turnId: string): StoredMessage { + return { type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture' }; +} + +function handle(overrides: Partial = {}): DesktopTranscriptHandle { + return { + sessionId: SESSION_ID, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, + acknowledgeTail: async () => {}, + loadEarlier: async () => assert.fail('unexpected earlier history read'), + close: async () => {}, + ...overrides, }; - publish(20); - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, - acknowledgeTail: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, - close: async () => {}, loadLatest: async () => assert.fail('unexpected tail navigation'), - async loadAround(sequence, _bytes, navigation) { - reads.push(sequence); - if (sequence === 10) await pending.promise; - publish(sequence, navigation); +} + +async function restoreFromEarlierHistory(lookupTurn?: (sessionId: string, turnId: string) => Promise) { + const store = new DesktopTranscriptRangeStore(SESSION_ID); + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...IDENTITY, durableThrough: 30, durable: [{ sequence: 30, message: answer('c') }], hasOlder: true, + })) store.accept(batch); + const reads: (number | undefined)[] = []; + const controller = createDesktopTranscriptRangeController(store, async () => handle({ + async loadEarlier(throughSequence) { + reads.push(throughSequence); + for (const batch of encodeDesktopTranscriptBatches(IDENTITY, { + durableThrough: 30, + durable: [{ sequence: 10, message: answer('a') }, { sequence: 20, message: answer('b') }], + hasOlder: false, earlierThan: 30, reset: false, ready: true, + })) store.accept(batch); }, - })); + }), { onError: (error) => assert.fail(String(error)) }); + let anchor: { turnId: string } | undefined = { turnId: 'a' }; + const unavailable: string[] = []; const lifecycle = createTranscriptRestoreLifecycle(); - const navigate = (turnId: string, sequence: number, nonce: number) => restoreSessionTranscriptRange({ - lifecycle, sessionId, controller, searchTarget: { sessionId, turnId, sequence, nonce }, - isCurrent: () => true, setReadingAnchor() {}, onError: (error) => assert.fail(String(error)), + const restore = () => restoreSessionTranscriptRange({ + lifecycle, sessionId: SESSION_ID, controller, readingAnchor: { turnId: 'a' }, + isCurrent: () => true, + lookupTurn, + setReadingAnchor: (_sessionId, next) => { anchor = next; }, + onRestoreUnavailable: (_sessionId, turnId) => { unavailable.push(turnId); }, + onError: (error) => assert.fail(String(error)), }); - const settle = () => new Promise((resolve) => setImmediate(resolve)); try { - navigate('b', 20, 1); - await settle(); - assert.deepEqual(reads, [], 'an idle resident target needs no read'); - navigate('a', 10, 2); - await settle(); - navigate('b', 20, 3); - await settle(); - pending.resolve(); + await controller.ready(); + restore(); + for (let tick = 0; tick < 4; tick += 1) await settle(); + restore(); await settle(); - assert.deepEqual(store.snapshot().messages.map((message) => message.turnId), ['b'], - 'the late A response must not replace the newer B navigation'); + return { reads, anchor, unavailable, turns: store.snapshot().messages.map(({ turnId }) => turnId) }; } finally { - pending.resolve(); await controller.close(); } +} + +test('a bookmark older than the loaded history is read down to in one request located by the Turn index', async () => { + const lookups: string[] = []; + const result = await restoreFromEarlierHistory(async (_sessionId, turnId) => { + lookups.push(turnId); + return 10; + }); + assert.deepEqual(lookups, ['a']); + assert.deepEqual(result.reads, [10], 'a restored bookmark reads nothing more'); + assert.deepEqual(result.turns, ['a', 'b', 'c']); + assert.deepEqual(result.anchor, { turnId: 'a' }); + assert.deepEqual(result.unavailable, []); +}); + +test('a bookmark the Turn index does not know, or cannot be asked about, is unavailable without reading history', async () => { + for (const lookupTurn of [async () => undefined, undefined]) { + const result = await restoreFromEarlierHistory(lookupTurn); + assert.deepEqual(result.reads, []); + assert.equal(result.anchor, undefined); + assert.deepEqual(result.unavailable, ['a']); + } }); -test('sending before transcript open completes supersedes the queued bookmark without delaying admission', { timeout: 5_000 }, async () => { - const sessionId = JSON.stringify(['host-1', 'session-1']); - const store = new DesktopTranscriptRangeStore(sessionId); +test('sending before transcript open completes cancels the queued bookmark without delaying admission', { timeout: 5_000 }, async () => { + const store = new DesktopTranscriptRangeStore(SESSION_ID); const opening = deferred(); - const controller = createDesktopTranscriptRangeController(store, () => opening.promise); + const controller = createDesktopTranscriptRangeController(store, () => opening.promise, { + onError: (error) => assert.fail(String(error)), + }); const lifecycle = createTranscriptRestoreLifecycle(); - const requests: Array<{ sequence: number | null; navigation: number }> = []; - const publish = (sequence: number | null, navigation: number) => { - requests.push({ sequence, navigation }); - const turnId = sequence === null ? 'b' : 'a'; - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: 20, - durable: [{ sequence: sequence ?? 20, message: { - type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - } }], hasOlder: true, hasNewer: sequence !== null, - }, navigation)) store.accept(batch); - }; - const handle: DesktopTranscriptHandle = { - sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, - acknowledgeTail: async () => {}, - loadBefore: async () => {}, loadAfter: async () => {}, close: async () => {}, - async loadAround(sequence, _maxBytes, navigation) { publish(sequence, navigation); }, - async loadLatest(navigation) { publish(null, navigation); }, - }; + let reads = 0; const restore = () => restoreSessionTranscriptRange({ - lifecycle, sessionId, controller, readingAnchor: { turnId: 'a', sequence: 10 }, + lifecycle, sessionId: SESSION_ID, controller, readingAnchor: { turnId: 'a' }, isCurrent: () => true, - setReadingAnchor: () => assert.fail('the cancelled bookmark must not be restored'), + setReadingAnchor: () => assert.fail('the cancelled bookmark must not be settled'), onError: (error) => assert.fail(String(error)), }); try { restore(); assert.throws(() => store.range(), /not initialized/); let pins = 0; - assert.equal(await prepareTranscriptForSend({ - sessionId, currentSessionId: { current: sessionId }, controller: { current: controller }, + assert.equal(prepareTranscriptForSend({ + sessionId: SESSION_ID, currentSessionId: { current: SESSION_ID }, cancel: (target) => lifecycle.cancel(target), followLatest: () => { pins += 1; }, }), true, 'local admission must finish while transcript open is still pending'); assert.equal(pins, 1); - assert.equal(requests.length, 0); - opening.resolve(handle); - await new Promise((resolve) => setImmediate(resolve)); - restore(); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(requests.map(({ sequence, navigation }) => - [sequence, navigation]), [[null, 2]]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['answer-b']); - const latest = store.snapshot(); + opening.resolve(handle({ loadEarlier: async () => { reads += 1; } })); for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: 20, - durable: [{ sequence: 10, message: { - type: 'assistant', id: 'answer-a', turnId: 'a', text: 'a', ts: 1, modelId: 'fixture', - } }], hasOlder: false, hasNewer: true, - }, 1)) assert.equal(store.accept(batch), false); - assert.strictEqual(store.snapshot(), latest, 'a late history response must not replace the latest range'); + beginsAtTurnBoundary: true, + ...IDENTITY, durableThrough: 20, durable: [{ sequence: 20, message: answer('b') }], hasOlder: true, + })) store.accept(batch); + await settle(); + restore(); + await settle(); + assert.equal(reads, 0, 'the cancelled bookmark must not load earlier history'); } finally { - opening.resolve(handle); await controller.close(); } }); -test('a failed return to the tail reports to its own Session', async () => { - const fixture = controllerFixture(); - const errors: string[] = []; - fixture.props.onNavigationError = (error) => { errors.push(String(error)); }; - fixture.controller.loadLatest = async () => { throw new Error('tail read failed'); }; - await fixture.render(); - - await fixture.commands.current!.returnToLatest(); - assert.deepEqual(errors, ['Error: tail read failed']); -}); - -test('an old Session return to the tail cannot report against the new Session', async () => { +test('loading earlier history only reaches the current Session controller', async () => { const fixture = controllerFixture(); - const first = deferred(); - fixture.props.onNavigationError = () => assert.fail('a superseded Session must not report'); - fixture.controller.loadLatest = () => first.promise; + let first = 0; + let second = 0; + fixture.controller.loadEarlier = async () => { first += 1; }; await fixture.render(); - const returningFirst = fixture.commands.current!.returnToLatest(); + await fixture.commands.current!.loadEarlier(); + assert.equal(first, 1); fixture.props.currentSessionId.current = 'session-2'; + await fixture.commands.current!.loadEarlier(); + assert.equal(first, 1, 'a superseded Session cannot load history'); + fixture.props.sessionId = 'session-2'; fixture.props.rangeController.current = { ...fixture.controller, - store: { ...fixture.controller.store, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, - loadLatest: async () => {}, + store: { ...fixture.controller.store, range: () => ({ sessionId: 'session-2', hasOlder: true, ready: true }) }, + loadEarlier: async () => { second += 1; }, }; await fixture.render(); - await fixture.commands.current!.returnToLatest(); - - first.reject(new Error('superseded tail request failed')); - await returningFirst; + await fixture.commands.current!.loadEarlier(); + assert.deepEqual([first, second], [1, 1]); }); -test('filling an edge leaves an outstanding jump alone', async () => { +test('captured reading anchors belong to the current Session and preparing a send clears them', async () => { const fixture = controllerFixture(); - let cleared = 0; - fixture.props.searchTarget = { sessionId: 'session-1', nonce: 1, turnId: 'turn-1' } as never; - fixture.props.clearSearchTarget = () => { cleared += 1; }; - const failure = new Error('the older read failed'); - fixture.controller.loadBefore = async () => { throw failure; }; + let followed: string[] = []; + fixture.props.sessionUi.transcriptViewportNavigation.subscribe((sessionId) => { followed = [...followed, sessionId]; }); await fixture.render(); + const anchors = fixture.props.sessionUi.transcriptReadingAnchorBySessionRef; - // The jump is still in flight; the band asking for the edge it is scrolling - // towards decides nothing, so it must not answer for the reader. - await assert.rejects(fixture.commands.current!.prefetchHistory('older'), failure); - assert.equal(cleared, 0); -}); + fixture.commands.current!.captureAnchor('turn-1'); + assert.deepEqual(anchors.current['session-1'], { turnId: 'turn-1' }); -for (const known of [true, false]) { -test(`a bookmark ${known ? 'survives' : 'cannot outlive'} a Host epoch change`, async () => { - const fixture = controllerFixture(); - const landmarks = known ? [{ turnId: 'turn-t', sequence: 77, label: 'T' }] : []; - let range = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; - fixture.controller.store.range = () => range; - fixture.props.listTurnLandmarks = async () => ({ throughSequence: 80, landmarks }); - const loaded: number[] = []; - fixture.controller.loadAround = async (sequence: number) => { loaded.push(sequence); }; - await fixture.render(); - fixture.props.sessionUi.setTranscriptReadingAnchor('session-1', { turnId: 'turn-t', sequence: 10 }); + assert.equal(fixture.commands.current!.prepareSend('session-2'), false); + assert.deepEqual(followed, []); + assert.equal(fixture.commands.current!.prepareSend('session-1'), true); + assert.deepEqual(followed, ['session-1']); + assert.equal(anchors.current['session-1'], undefined); - range = { sessionId: 'session-1', generation: 'generation-2', hostEpoch: 'host-2' }; - fixture.props.messages = []; - await fixture.render(); - await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); - - // The old epoch's sequence names a different row, so only the Turn resolved - // in the new epoch may be navigated to. - assert.deepEqual(loaded, known ? [77] : []); - assert.deepEqual( - fixture.props.sessionUi.transcriptReadingAnchorBySessionRef.current['session-1'], - { turnId: 'turn-t', sequence: known ? 77 : 10 }, - ); -}); -} - -test('a read superseded by a Host epoch change leaves the bookmark alone', async () => { - const sessionId = 'session-1'; - const lifecycle = createTranscriptRestoreLifecycle(); - const controller = { - store: { - sessionId, - range: () => ({ sessionId }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => null, - snapshot: () => ({ messages: [] }), - }, - loadAround: async () => { - throw new TranscriptReadSupersededError('Desktop transcript host epoch changed; reopen the transcript'); - }, - }; - restoreSessionTranscriptRange({ - lifecycle, sessionId, controller, readingAnchor: { turnId: 'turn-t', sequence: 10 }, - isCurrent: () => true, - setReadingAnchor: () => assert.fail('a superseded read must not clear the bookmark'), - onRestoreUnavailable: () => assert.fail('a superseded read decides nothing about the bookmark'), - onError: (error) => assert.fail(String(error)), - }); - await new Promise((resolve) => setImmediate(resolve)); + fixture.props.currentSessionId.current = 'session-2'; + fixture.commands.current!.captureAnchor('turn-2'); + assert.equal(anchors.current['session-1'], undefined, 'a superseded Session cannot capture an anchor'); }); -test('retaining the reader window trims the store to the visible Turns', async () => { +test('a Turn index read that failed is read again when the transcript reopens', async () => { const fixture = controllerFixture(); - const retained: Array<[number | null, number | null]> = []; - fixture.controller.store.sequenceForTurn = (turnId: string, edge?: 'first' | 'last') => - turnId === 'first' ? 10 : turnId === 'last' ? (edge === 'last' ? 21 : 20) : null; - fixture.controller.store.retain = (oldest, newest) => { - retained.push([oldest, newest]); - return true; + let generation = 'generation-1'; + fixture.controller.store.range = () => ({ sessionId: 'session-1', hasOlder: true, ready: true, generation }); + let reads = 0; + const indexes: unknown[] = []; + fixture.props.listTurnLandmarks = async () => { + reads += 1; + if (reads === 1) throw new Error('Host reconnecting'); + return { landmarks: [{ turnId: 'turn-1', sequence: 10, lastSequence: 19, label: 'First' }] }; }; + fixture.props.setTurnIndex = (index) => { indexes.push(index); }; await fixture.render(); + await act(settle); + assert.deepEqual(indexes, []); - fixture.commands.current!.retainWindow({ firstTurnId: 'first', lastTurnId: 'last' }); - assert.deepEqual(retained, [[10, 21]]); + generation = 'generation-2'; + await fixture.render(); + await act(settle); + assert.equal(reads, 2); + assert.deepEqual(indexes, [{ + sessionId: 'session-1', + turns: [{ turnId: 'turn-1', sequence: 10, lastSequence: 19, label: 'First' }], + }]); }); function controllerFixture() { const { root } = installReactRenderer(); const commands = createRef(); const controller = { - loadAround: async (_sequence: number) => {}, - loadBefore: async () => true, - loadAfter: async () => true, - loadLatest: async () => {}, + loadEarlier: async () => {}, store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - retain: (_oldest: number | null, _newest: number | null) => false, - pendingNavigation: () => undefined, - sequenceForTurn: (_turnId: string, _edge?: 'first' | 'last'): number | null => null, - newestDurableUserSequence: () => null, - snapshot: () => ({ messages: [] }), + range: () => ({ sessionId: 'session-1', hasOlder: true, ready: true }), + snapshot: () => ({ messages: [] as StoredMessage[] }), }, }; const props: ComponentProps = { @@ -294,11 +246,10 @@ function controllerFixture() { searchTarget: undefined, clearSearchTarget: () => {}, sessionUi: createAppShellSessionUiStateController(), - turnIndex: undefined, + landmarkSessionId: 'session-1', + listTurnLandmarks: async () => ({ landmarks: [] }), setTurnIndex: () => {}, - listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), onRestoreError: (error) => assert.fail(String(error)), - onNavigationError: (error) => assert.fail(String(error)), }; return { commands, controller, props, diff --git a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts index 32a04d154a..dadef652a4 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -23,7 +23,6 @@ import { act, createElement, createRef, Fragment, useRef, useState, type Compone import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import type { StoredMessage } from '@maka/core/session'; -import { deferred } from '@maka/core/test-only/async-primitives'; import { TranscriptScrollAuthorityProvider, TranscriptScrollButton, @@ -37,6 +36,8 @@ import { type TranscriptReadingPositionCommands, } from '../../renderer/features/conversation/index.js'; +type VirtualizerHandle = NonNullable[0]['virtualizerRef']['current']>; + const cleanups: Array<() => Promise> = []; afterEach(async () => { while (cleanups.length) await cleanups.pop()!(); }); @@ -48,9 +49,8 @@ test('preparing a send follows the new prompt and streaming growth, then lets th assert.equal(fixture.sessionUi.transcriptReadingAnchorBySessionRef.current['session-a']?.turnId, 'history'); let prepared: boolean | undefined; - await act(async () => { prepared = await fixture.commands.current!.prepareSend('session-a'); }); + await act(() => { prepared = fixture.commands.current!.prepareSend('session-a'); }); assert.equal(prepared, true); - assert.equal(fixture.latestReads(), 1); assert.equal(fixture.pinned(), true); assert.equal(fixture.scroller.scrollTop, 2400); @@ -68,10 +68,8 @@ test('preparing a send follows the new prompt and streaming growth, then lets th assert.equal(fixture.scroller.scrollTop, 500); }); -test('reading a live Turn without a durable sequence bookmarks its Turn identity', async () => { +test('reading a live Turn bookmarks its Turn identity', async () => { const fixture = viewportFixture(); - const sequenceForTurn = fixture.controller.store.sequenceForTurn; - fixture.controller.store.sequenceForTurn = (turnId) => turnId === 'latest' ? null : sequenceForTurn(turnId); await fixture.render(); await fixture.readAt(1900); @@ -79,18 +77,12 @@ test('reading a live Turn without a durable sequence bookmarks its Turn identity assert.deepEqual(fixture.sessionUi.transcriptReadingAnchorBySessionRef.current['session-a'], { turnId: 'latest' }); }); -test('a send is accepted before latest history loads and its old completion cannot move a new Session viewport', async () => { +test('a send prepared for an old Session cannot move the new Session viewport', async () => { const fixture = viewportFixture(); - const latest = deferred(); - fixture.controller.loadLatest = () => latest.promise; await fixture.render(); - await fixture.readAt(1000); - await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - assert.equal(fixture.pinned(), true, 'local admission must not wait for the latest range'); - await fixture.switchSession('session-b'); await fixture.readAt(900); - await act(async () => { latest.resolve(); }); + await act(() => { assert.equal(fixture.commands.current!.prepareSend('session-a'), false); }); assert.equal(fixture.pinned(), false); assert.equal(fixture.scroller.scrollTop, 900); @@ -98,30 +90,13 @@ test('a send is accepted before latest history loads and its old completion cann assert.equal(fixture.scroller.scrollTop, 900); }); -for (const direction of ['older', 'newer'] as const) { - test(`filling the ${direction} edge supersedes the background range load of an accepted send`, async () => { - const fixture = viewportFixture(); - const latest = deferred(); - fixture.controller.loadLatest = () => latest.promise; - await fixture.render(); - await fixture.readAt(1000); - await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - await fixture.fillEdge(direction); - const readerTop = fixture.scroller.scrollTop; - await act(async () => { latest.resolve(); }); - - assert.equal(fixture.pinned(), false); - assert.equal(fixture.scroller.scrollTop, readerTop); - }); -} - test('a prepared send cancels an outstanding bookmark frame before it can scroll to history', async () => { const fixture = viewportFixture(); - fixture.sessionUi.setTranscriptReadingAnchor('session-a', { turnId: 'history', sequence: 0 }); + fixture.sessionUi.setTranscriptReadingAnchor('session-a', { turnId: 'history' }); await fixture.render(); assert.equal(fixture.pinned(), false); - await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); + await act(() => { assert.equal(fixture.commands.current!.prepareSend('session-a'), true); }); await fixture.flushFrames(); await fixture.append('new-question', 200); @@ -131,10 +106,7 @@ test('a prepared send cancels an outstanding bookmark frame before it can scroll test('the return-to-latest button consumes a pending bookmark frame and permits subsequent reader navigation', async () => { const fixture = viewportFixture({ returnButton: true }); - const latest = deferred(); - fixture.controller.loadLatest = () => latest.promise; - fixture.controller.store.range = () => ({ sessionId: 'session-a', hasNewer: true }); - fixture.sessionUi.setTranscriptReadingAnchor('session-a', { turnId: 'history', sequence: 0 }); + fixture.sessionUi.setTranscriptReadingAnchor('session-a', { turnId: 'history' }); await fixture.render(); assert.equal(fixture.pinned(), false); @@ -144,41 +116,8 @@ test('the return-to-latest button consumes a pending bookmark frame and permits assert.equal(fixture.scroller.scrollTop, 2400); await fixture.readAt(1000); - await act(async () => { latest.resolve(); }); await fixture.append('later-content', 200); - assert.equal(fixture.pinned(), false, 'the reader can leave while the latest range is pending'); - assert.equal(fixture.scroller.scrollTop, 1000); -}); - -test('geometry changes from the latest range do not cancel the background load of an accepted send', async () => { - const fixture = viewportFixture(); - const latest = deferred(); - fixture.controller.loadLatest = () => latest.promise; - await fixture.render(); - await fixture.readAt(1000); - await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - - await fixture.replaceRangeFromHost(); - await act(async () => { latest.resolve(); }); - - assert.deepEqual(fixture.visibleTurns(), ['latest-b']); - assert.equal(fixture.pinned(), true); - await fixture.append('new-question', 200); - assert.equal(fixture.scroller.scrollTop, 400); -}); - -test('the reader can leave an accepted send while its latest range is still loading', async () => { - const fixture = viewportFixture(); - const latest = deferred(); - fixture.controller.loadLatest = () => latest.promise; - await fixture.render(); - await fixture.readAt(1900); - await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - - await fixture.readAt(1000); - await act(async () => { latest.resolve(); }); - - assert.equal(fixture.pinned(), false); + assert.equal(fixture.pinned(), false, 'the reader can leave the tail again'); assert.equal(fixture.scroller.scrollTop, 1000); }); @@ -230,30 +169,26 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { IS_REACT_ACT_ENVIRONMENT: true, }); const messages: StoredMessage[] = []; + const turns: Array<{ id: string; start: number }> = []; const addTurn = (id: string, start: number, size: number) => { const article = document.createElement('article'); article.dataset.turnId = id; article.getBoundingClientRect = () => rectangle(start - top, size); - article.scrollIntoView = () => { scroller.scrollTop = start; }; scroller.append(article); + turns.push({ id, start }); messages.push({ id, type: 'user', turnId: id, ts: 1, text: id }); }; addTurn('history', 0, 1800); addTurn('latest', 1800, 1200); - let reads = 0; + const virtualizer = { + findItemIndex: (offset: number) => Math.max(0, turns.filter((turn) => turn.start <= offset).length - 1), + getItemOffset: (index: number) => turns[index]?.start ?? 0, + scrollToIndex: (index: number) => { scroller.scrollTop = turns[index]?.start ?? 0; }, + } as unknown as VirtualizerHandle; const controller = { - loadAround: async () => {}, loadBefore: async () => true, loadAfter: async () => true, - loadLatest: async () => { reads += 1; }, + loadEarlier: async () => {}, store: { - sessionId: 'session-a', - range: () => ({ sessionId: 'session-a' }), - retain: () => false, - pendingNavigation: () => undefined, - sequenceForTurn: (turnId: string) => { - const sequence = messages.findIndex((message) => message.turnId === turnId); - return sequence < 0 ? null : sequence; - }, - newestDurableUserSequence: () => 1, + range: () => ({ sessionId: 'session-a', hasOlder: false, ready: true }), snapshot: () => ({ messages }), }, }; @@ -263,25 +198,25 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { commands, sessionId: 'session-a', currentSessionId: { current: 'session-a' }, rangeController: { current: controller }, messages, sessionUi, searchTarget: undefined, clearSearchTarget: () => {}, - turnIndex: undefined, setTurnIndex: () => {}, - listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), - onRestoreError: (error) => assert.fail(String(error)), onNavigationError: (error) => assert.fail(String(error)), + landmarkSessionId: null, listTurnLandmarks: async () => ({ landmarks: [] }), setTurnIndex: () => {}, + onRestoreError: (error) => assert.fail(String(error)), }; let authority: TranscriptScrollAuthority | undefined; function Harness() { const scrollRef = useRef(scroller); + const virtualizerRef = useRef(virtualizer); authority = useTranscriptScrollAuthority(); const anchor = sessionUi.transcriptReadingAnchorBySessionRef.current[props.sessionId!]; useChatScroll({ - scrollRef, sessionId: props.sessionId, messages: props.messages, + scrollRef, virtualizerRef, sessionId: props.sessionId, + turnIds: props.messages.map((message) => message.turnId!), + measureStartMargin: () => 0, restoreTarget: anchor, viewportNavigation: sessionUi.transcriptViewportNavigation, onReadingAnchorChange: (turnId) => commands.current?.captureAnchor(turnId), behavior: 'auto', }); return createElement(Fragment, null, createElement(TranscriptReadingPositionController, props), - options.returnButton ? createElement(TranscriptScrollButton, { - onActivate: () => commands.current?.returnToLatest(), - }) : null, + options.returnButton ? createElement(TranscriptScrollButton) : null, ); } const root = createRoot(mount); @@ -289,18 +224,12 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { const render = () => act(() => root.render(createElement(TranscriptScrollAuthorityProvider, null, createElement(Harness)))); return { scroller, controller, sessionUi, commands, render, - pinned: () => authority!.getSnapshot().pinned, latestReads: () => reads, - visibleTurns: () => props.messages.map((message) => message.turnId), + pinned: () => authority!.getSnapshot().pinned, async clickReturnToLatest() { const button = mount.querySelector('button'); assert.ok(button); await act(() => { button.dispatchEvent(new window.Event('click', { bubbles: true })); }); }, - async fillEdge(edge: 'older' | 'newer') { - // A reader who scrolls to an edge releases the pin, and the band fills - // that edge behind them. - await act(async () => { authority!.releasePin(); await commands.current!.prefetchHistory(edge); }); - }, async readAt(offset: number) { await act(() => { const input = new window.Event('wheel'); @@ -318,22 +247,12 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { await render(); await act(() => { for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); }); }, - async replaceRangeFromHost() { - // The browser clamps the old offset when a much shorter range arrives. - // ResizeObserver changes awayFromTail, without a reader scroll gesture. - height = 800; - messages.splice(0); - scroller.replaceChildren(); - addTurn('latest-b', 0, 800); - scroller.scrollTop = scroller.scrollTop; - props.messages = [...messages]; - await render(); - await act(() => { for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); }); - }, async switchSession(sessionId: string) { props.sessionId = sessionId; props.currentSessionId.current = sessionId; - props.rangeController.current = { ...controller, store: { ...controller.store, sessionId, range: () => ({ sessionId }) } }; + props.rangeController.current = { + ...controller, store: { ...controller.store, range: () => ({ sessionId, hasOlder: false, ready: true }) }, + }; await render(); }, async flushFrames() { diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts b/apps/desktop/src/main/__tests__/transcript-tail-restore.test.ts similarity index 57% rename from apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts rename to apps/desktop/src/main/__tests__/transcript-tail-restore.test.ts index a9125af9a4..f23d9ef6b9 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-tail-restore.test.ts @@ -24,7 +24,7 @@ import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage, } from '@maka/runtime-host/protocol'; -import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../../preload/transcript-contract.js'; +import { DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES } from '../../preload/transcript-contract.js'; import { createTranscriptRestoreLifecycle, restoreSessionTranscriptRange, @@ -32,24 +32,21 @@ import { import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; -const PAGE_BYTES = 128 * 1024; - test('a history page reaches an oversized earlier Turn without disturbing the tail', async () => { const fixture = await oversizedHistoryFixture(); try { assert.deepEqual(sequences(fixture.replica), [2, 3]); - const page = await fixture.replica.loadBefore(2, PAGE_BYTES); + const page = await fixture.replica.readOlderPage(3, 'more'); - assert.ok(page); assert.deepEqual(page.durable.map(({ sequence }) => sequence), [0, 1]); assert.equal(page.durable[1]?.message.id, 'assistant-a', 'the oversized earlier answer reaches the Renderer whole'); - assert.equal(page.hasOlder, false); + assert.equal(page.nextCursor, null); assert.deepEqual( sequences(fixture.replica), [2, 3], - 'a window read answers the Renderer and leaves the Main tail alone', + 'a history read answers its consumer and leaves the Main tail alone', ); } finally { fixture.replica.close(); @@ -77,119 +74,44 @@ test('tail catch-up evicts only the oldest Turns and always keeps the newest com } }); -test('a completed resident bookmark does not reload after streaming settlement evicts its Turn', async () => { - const fixture = await oversizedHistoryFixture({ live: true }); +test('a completed loaded bookmark does not load earlier history after later notifications', async () => { const lifecycle = createTranscriptRestoreLifecycle(); - let loaded = 0; - const controller = { - loadAround: async (sequence: number) => { - loaded += 1; - await fixture.replica.loadAround(sequence, PAGE_BYTES); - }, - store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - pendingNavigation: () => undefined, - sequenceForTurn: (turnId: string) => fixture.replica.snapshot().durable - .find(({ message }) => message.turnId === turnId)?.sequence ?? null, - newestDurableUserSequence: () => 2, - snapshot: () => ({ messages: fixture.replica.messages() }), - }, - }; + const history = restoreHistory([{ turnId: 'turn-a' }]); const restore = () => restoreSessionTranscriptRange({ lifecycle, sessionId: 'session-1', - readingAnchor: { turnId: 'turn-a', sequence: 0 }, - controller, + readingAnchor: { turnId: 'turn-a' }, + controller: history.controller, isCurrent: () => true, setReadingAnchor: () => {}, onError: (error) => assert.fail(String(error)), }); - try { - restore(); - await settleRestore(); - assert.equal(loaded, 0, 'an already resident bookmark completes without a range read'); - - await fixture.replica.advance(3); - restore(); - await settleRestore(); - await fixture.replica.advance(4); - restore(); - await settleRestore(); - - assert.equal(loaded, 0, 'message notifications cannot revive the completed bookmark command'); - assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); - assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-b-later'); - } finally { - fixture.replica.close(); - } -}); - -test('reopening a bookmark at the current Turn retains content persisted later in that same Turn', async () => { - const fixture = await oversizedHistoryFixture(); - try { - assert.deepEqual(sequences(fixture.replica), [2, 3]); - await fixture.replica.advance(4); - assert.equal(fixture.replica.durableThrough, 4, 'the Host has persisted the final answer segment'); - - // Reopening an observed Session reuses its resident replica. The renderer - // starts a fresh restore lifecycle, and the bookmark is already resident. - restoreSessionTranscriptRange({ - lifecycle: createTranscriptRestoreLifecycle(), - sessionId: 'session-1', - readingAnchor: { turnId: 'turn-b', sequence: 2 }, - controller: { - loadAround: async (sequence) => { await fixture.replica.loadAround(sequence, PAGE_BYTES); }, - store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - pendingNavigation: () => undefined, - sequenceForTurn: (turnId) => fixture.replica.snapshot().durable - .find(({ message }) => message.turnId === turnId)?.sequence ?? null, - newestDurableUserSequence: () => 2, - snapshot: () => ({ messages: fixture.replica.messages() }), - }, - }, - isCurrent: () => true, - setReadingAnchor: () => {}, - onError: (error) => assert.fail(String(error)), - }); - await settleRestore(); - - assert.equal( - fixture.replica.messages().some(({ id }) => id === 'assistant-b-later'), - true, - 'restoring the visible Turn must not silently omit its later persisted answer segment', - ); - } finally { - fixture.replica.close(); - } + restore(); + await settleRestore(); + history.messages = [{ turnId: 'turn-b' }]; + restore(); + await settleRestore(); + assert.equal(history.loads, 0, 'a loaded bookmark completes without reading history, and stays completed'); }); test('repeated message notifications share one pending restore and cancellation preserves the newer bookmark', async () => { const lifecycle = createTranscriptRestoreLifecycle(); let finishLoad!: () => void; const loading = new Promise((resolve) => { finishLoad = resolve; }); - let reads = 0; - let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'turn-a', sequence: 0 }; + let anchor: { turnId: string } | undefined = { turnId: 'turn-a' }; let unavailable: string | undefined; + const history = restoreHistory([], async () => { + await loading; + history.hasOlder = false; + history.messages = []; + }); const options = { lifecycle, sessionId: 'session-1', - readingAnchor: { turnId: 'turn-a', sequence: 0 }, - controller: { - setReadingAnchor: async () => {}, - loadAround: async () => { reads += 1; await loading; }, - store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => 2, - snapshot: () => ({ messages: ['old restored range'] }), - }, - }, + readingAnchor: { turnId: 'turn-a' }, + controller: history.controller, isCurrent: () => true, + lookupTurn: history.lookupTurn, setReadingAnchor: (_sessionId: string, next: typeof anchor) => { anchor = next; }, onRestoreUnavailable: (_sessionId: string, turnId: string) => { unavailable = turnId; }, onError: (error: unknown) => assert.fail(String(error)), @@ -197,109 +119,115 @@ test('repeated message notifications share one pending restore and cancellation restoreSessionTranscriptRange(options); restoreSessionTranscriptRange(options); await settleRestore(); - assert.equal(reads, 1); + assert.equal(history.loads, 1); lifecycle.cancel('session-1'); - anchor = { turnId: 'turn-b', sequence: 2 }; + anchor = { turnId: 'turn-b' }; finishLoad(); await settleRestore(); restoreSessionTranscriptRange(options); await settleRestore(); - assert.equal(reads, 1, 'cancellation must not recapture the bookmark in the same activation'); - assert.deepEqual(anchor, { turnId: 'turn-b', sequence: 2 }); + assert.equal(history.loads, 1, 'cancellation must not recapture the bookmark in the same activation'); + assert.deepEqual(anchor, { turnId: 'turn-b' }); assert.equal(unavailable, undefined, 'a cancelled restore cannot declare the newer bookmark unavailable'); }); test('switching away and back creates a fresh restore while clearing search does not replay a bookmark', async () => { const lifecycle = createTranscriptRestoreLifecycle(); - const reads: number[] = []; + const history = restoreHistory([]); const options = { lifecycle, sessionId: 'session-1', profileId: 'profile-1', - readingAnchor: { turnId: 'turn-a', sequence: 0 }, - controller: { - setReadingAnchor: async () => {}, - loadAround: async (sequence: number) => { reads.push(sequence); }, - store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => 2, - snapshot: () => ({ messages: [] as string[] }), - }, - }, + readingAnchor: { turnId: 'turn-a' }, + controller: history.controller, isCurrent: () => true, + lookupTurn: history.lookupTurn, setReadingAnchor: () => {}, onError: (error: unknown) => assert.fail(String(error)), }; restoreSessionTranscriptRange(options); await settleRestore(); - restoreSessionTranscriptRange({ ...options, searchTarget: { - sessionId: 'session-1', turnId: 'turn-b', sequence: 2, nonce: 1, - } }); + restoreSessionTranscriptRange({ ...options, searchTarget: { sessionId: 'session-1', turnId: 'turn-b', nonce: 1 } }); await settleRestore(); restoreSessionTranscriptRange(options); await settleRestore(); - assert.deepEqual(reads, [0, 2]); + assert.equal(history.loads, 2); restoreSessionTranscriptRange({ ...options, sessionId: 'other-session', controller: undefined }); restoreSessionTranscriptRange(options); await settleRestore(); - assert.deepEqual(reads, [0, 2, 0], 'a later session activation may restore the saved bookmark again'); + assert.equal(history.loads, 3, 'a later session activation may restore the saved bookmark again'); restoreSessionTranscriptRange({ ...options, profileId: 'profile-2' }); await settleRestore(); - assert.deepEqual(reads, [0, 2, 0, 0], 'changing Hosts also creates a fresh activation'); + assert.equal(history.loads, 4, 'changing Hosts also creates a fresh activation'); }); test('effect teardown followed by setup lets only the replacement restore settle its bookmark', async () => { const lifecycle = createTranscriptRestoreLifecycle(); const loads: Array<() => void> = []; - let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'turn-a', sequence: 0 }; + let anchor: { turnId: string } | undefined = { turnId: 'turn-a' }; let unavailable: string | undefined; + const history = restoreHistory([], () => new Promise((resolve) => { + loads.push(() => { + history.hasOlder = false; + history.messages = []; + resolve(); + }); + })); const options = { lifecycle, sessionId: 'session-1', - readingAnchor: { turnId: 'turn-a', sequence: 0 }, - controller: { - setReadingAnchor: async () => {}, - loadAround: () => new Promise((resolve) => { loads.push(resolve); }), - store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => 2, - snapshot: () => ({ messages: ['replacement range'] }), - }, - }, + readingAnchor: { turnId: 'turn-a' }, + controller: history.controller, isCurrent: () => true, + lookupTurn: history.lookupTurn, setReadingAnchor: (_sessionId: string, next: typeof anchor) => { anchor = next; }, onRestoreUnavailable: (_sessionId: string, turnId: string) => { unavailable = turnId; }, onError: (error: unknown) => assert.fail(String(error)), }; restoreSessionTranscriptRange(options); - assert.equal(loads.length, 1, 'the first command admits its navigation synchronously'); lifecycle.deactivate(); restoreSessionTranscriptRange(options); - assert.equal(loads.length, 2, 'StrictMode replay must admit a replacement navigation'); - - loads[0]!(); await settleRestore(); - assert.deepEqual(anchor, { turnId: 'turn-a', sequence: 0 }); - assert.equal(unavailable, undefined, 'the deactivated command cannot settle after replacement'); - loads[1]!(); + assert.equal(loads.length, 1, 'only the StrictMode replacement reads history'); + assert.deepEqual(anchor, { turnId: 'turn-a' }); + assert.equal(unavailable, undefined); + loads[0]!(); await settleRestore(); assert.equal(anchor, undefined); assert.equal(unavailable, 'turn-a', 'only the replacement restore settles its unavailable target'); }); -async function settleRestore(): Promise { - await new Promise((resolve) => setImmediate(resolve)); -} +test('a bookmark read down to across a reopen is looked up again rather than declared unavailable', async () => { + const lifecycle = createTranscriptRestoreLifecycle(); + let anchor: { turnId: string } | undefined = { turnId: 'turn-a' }; + let unavailable: string | undefined; + const history = restoreHistory([], async () => { + // The first answer lands on a range that reopened, which drops it. + if (history.loads === 1) history.generation = 'generation-2'; + else history.messages = [{ turnId: 'turn-a' }]; + }); + restoreSessionTranscriptRange({ + lifecycle, + sessionId: 'session-1', + readingAnchor: { turnId: 'turn-a' }, + controller: history.controller, + isCurrent: () => true, + lookupTurn: history.lookupTurn, + setReadingAnchor: (_sessionId, next) => { anchor = next; }, + onRestoreUnavailable: (_sessionId, turnId) => { unavailable = turnId; }, + onError: (error) => assert.fail(String(error)), + }); + await settleRestore(); + await settleRestore(); + + assert.equal(history.loads, 2); + assert.equal(unavailable, undefined); + assert.deepEqual(anchor, { turnId: 'turn-a' }); +}); test('a second Turn reached by advancing evicts the oversized first Turn', async () => { const fixture = await oversizedHistoryFixture({ live: true }); @@ -313,12 +241,44 @@ test('a second Turn reached by advancing evicts the oversized first Turn', async assert.equal(answer?.type, 'assistant'); assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'Second answer, persisted completely.'); assert.equal(fixture.replica.snapshot().hasOlder, true); - assert.equal(fixture.replica.snapshot().hasNewer, false); } finally { fixture.replica.close(); } }); +async function settleRestore(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +/** A loaded transcript whose earlier history is read by `load`; every read changes the snapshot. */ +function restoreHistory(initial: Array<{ turnId: string }>, load: () => Promise = async () => {}) { + let snapshot = { messages: initial }; + const history = { + loads: 0, + hasOlder: true, + generation: 'generation-1', + lookupTurn: async () => 0, + get messages() { return snapshot.messages; }, + set messages(messages: Array<{ turnId: string }>) { snapshot = { messages }; }, + controller: { + store: { + range: () => ({ + sessionId: 'session-1', + hasOlder: history.hasOlder, + ready: true, + generation: history.generation, + }), + snapshot: () => snapshot, + }, + loadEarlier: async () => { + history.loads += 1; + await load(); + }, + }, + }; + return history; +} + function sequences(replica: DesktopTranscriptReplica): number[] { return replica.snapshot().durable.map(({ sequence }) => sequence); } @@ -326,12 +286,12 @@ function sequences(replica: DesktopTranscriptReplica): number[] { async function oversizedHistoryFixture(options: { live?: boolean } = {}) { const records = [ message('user', 'user-a', 'turn-a', 'First question.'), - message('assistant', 'assistant-a', 'turn-a', 'A'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1)), + message('assistant', 'assistant-a', 'turn-a', 'A'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1)), message('user', 'user-b', 'turn-b', 'Second question.'), message('assistant', 'assistant-b', 'turn-b', 'Second answer, persisted completely.'), message('assistant', 'assistant-b-later', 'turn-b', 'A later durable answer segment.'), message('user', 'user-c', 'turn-c', 'Third question while the reader stays in the second Turn.'), - message('assistant', 'assistant-c', 'turn-c', 'C'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1)), + message('assistant', 'assistant-c', 'turn-c', 'C'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1)), ].map((message, identity) => ({ identity, message })); const decodedPages = new Map(); const page = (input: { @@ -339,7 +299,6 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) { through: number; records: typeof records; hasMore: boolean; - protectedSequence: number | null; }): SessionTranscriptPage => { const result: SessionTranscriptPage = { kind: 'page', @@ -348,11 +307,8 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) { throughSequence: input.through, rawBytes: input.records.reduce((bytes, record) => bytes + Buffer.byteLength(JSON.stringify(record.message)), 0), fragments: [], - rangeBoundarySequence: input.direction === 'older' - ? input.records[0]?.identity ?? null - : input.records.at(-1)?.identity ?? null, - protectedTurnSequence: input.protectedSequence, nextCursor: input.hasMore ? 'more' : null, + endsAtTurnBoundary: true, }; decodedPages.set(result, { messages: input.records, nextCursor: result.nextCursor }); return result; @@ -363,9 +319,7 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) { through, records: options.live ? records.slice(0, 2) : records.slice(2, 4), hasMore: !options.live, - protectedSequence: options.live ? 0 : 2, }); - const requests: Array<{ direction: string; anchorSequence: number | null; throughSequence: number | null }> = []; const handle = runtimeHostSessionFixture({ snapshot: { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, @@ -388,22 +342,17 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) { }, loadTranscriptPage: async (request) => { const through = request.throughSequence ?? 4; - const anchor = request.anchorSequence ?? null; - requests.push({ direction: request.direction, anchorSequence: anchor, throughSequence: through }); - const history = request.direction === 'older' ? anchor === 2 : anchor === null; - return page({ - direction: request.direction, - through, - records: history ? records.slice(0, 2) - : request.direction === 'older' ? records.slice(2, through + 1) - : records.slice((anchor ?? -1) + 1, through + 1), - hasMore: history ? request.direction === 'newer' : request.direction === 'older', - protectedSequence: history ? 0 : through >= 5 ? 5 : 2, - }); + if (request.direction === 'older') { + return request.cursor === null + ? page({ direction: 'older', through, records: records.slice(2, through + 1), hasMore: true }) + : page({ direction: 'older', through, records: records.slice(0, 2), hasMore: false }); + } + const anchor = request.anchorSequence ?? -1; + return page({ direction: 'newer', through, records: records.slice(anchor + 1, through + 1), hasMore: false }); }, async close() {}, }); - return { replica: await DesktopTranscriptReplica.prepare(handle), requests }; + return { replica: await DesktopTranscriptReplica.prepare(handle) }; } function message( diff --git a/apps/desktop/src/main/__tests__/transcript-test-dom.ts b/apps/desktop/src/main/__tests__/transcript-test-dom.ts new file mode 100644 index 0000000000..ea67a01957 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-test-dom.ts @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The virtualized transcript mounts Turns only after its scroller reports a + * size, so static server markup contains none. This client-renders on LinkeDOM + * with a ResizeObserver that gives the scroller a tall viewport, so every Turn + * mounts. Globals and the shared LinkeDOM prototype are restored afterwards. + */ + +import { act, type ReactElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; + +const GLOBAL_KEYS = [ + 'CSS', + 'Element', + 'HTMLElement', + 'IS_REACT_ACT_ENVIRONMENT', + 'IntersectionObserver', + 'MutationObserver', + 'Node', + 'ResizeObserver', + 'cancelAnimationFrame', + 'document', + 'getComputedStyle', + 'matchMedia', + 'requestAnimationFrame', + 'window', +] as const; + +const GEOMETRY_KEYS = ['offsetParent', 'scrollTop', 'scrollHeight', 'clientHeight'] as const; + +/** Client-rendered markup of `element`, with every Turn of a transcript mounted. */ +export async function renderTranscriptMarkup(element: ReactElement): Promise { + const originals = new Map( + GLOBAL_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ); + const { document, window } = parseHTML('
'); + const prototype = window.HTMLElement.prototype; + const prototypeOriginals = new Map( + GEOMETRY_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(prototype, key)]), + ); + const scrollTops = new WeakMap(); + Object.defineProperties(prototype, { + offsetParent: { configurable: true, get(this: HTMLElement) { return this.parentElement; } }, + scrollTop: { + configurable: true, + get(this: HTMLElement) { return scrollTops.get(this) ?? 0; }, + set(this: HTMLElement, value: number) { scrollTops.set(this, Math.max(0, value)); }, + }, + scrollHeight: { configurable: true, get: () => 0 }, + clientHeight: { configurable: true, get: () => 0 }, + }); + class MeasuringResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + queueMicrotask(() => { + const height = target.hasAttribute('data-chat-scroll-container') ? 100_000 : 100; + this.callback( + [{ target, contentRect: { height, width: 800 } } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + }); + } + unobserve(): void {} + disconnect(): void {} + } + class InertObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + takeRecords(): [] { return []; } + } + Object.assign(window, { ResizeObserver: MeasuringResizeObserver }); + Object.assign(globalThis, { + CSS: { escape: String, supports: () => false }, + Element: window.Element, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + IntersectionObserver: InertObserver, + MutationObserver: InertObserver, + Node: window.Node, + ResizeObserver: MeasuringResizeObserver, + cancelAnimationFrame: () => undefined, + document, + getComputedStyle: () => ({ overflowY: 'visible' }), + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 0, + window, + }); + const container = document.querySelector('#root')!; + const root = createRoot(container); + try { + await act(async () => { root.render(element); }); + // Viewport measurement mounts the Turns; their own measurement settles them. + await act(async () => {}); + await act(async () => {}); + return container.innerHTML; + } finally { + await act(async () => { root.unmount(); }); + for (const [key, descriptor] of prototypeOriginals) { + if (descriptor) Object.defineProperty(prototype, key, descriptor); + else delete (prototype as unknown as Record)[key]; + } + for (const [key, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + } +} diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index fb5cb24477..d64678990a 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -103,21 +103,6 @@ test('keeps transient messages ordered independently from a sparse durable tail' assert.deepEqual(projected.map((message) => message.id), ['message-1']); }); -test('keeps a transient message out of a sparse historical range', () => { - const live = { ...transient, id: 'message-live', text: 'latest prompt' }; - const pending = new Map([[live.id, live]]); - const historical: StoredMessage[] = [ - { type: 'user', id: 'message-old', turnId: 'turn-old', ts: 1, text: 'old prompt' }, - ]; - - const projected = reconcileTransientMessages(pending, historical, { - includeTransient: false, - }); - - assert.deepEqual(projected, []); - assert.equal(pending.has('message-live'), true); -}); - test('uses the Host queue snapshot order for already-present transient messages', () => { const localSecond = { ...transient, diff --git a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts index 7ba1ebae65..90f653fef3 100644 --- a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts @@ -33,6 +33,7 @@ import { import { ChatSurfaceLayout, LocaleProvider } from '@maka/ui'; import { WorkHubConversation, WorkHubDelegationStatus } from '../../renderer/features/workhub/testing.js'; import { getWorkHubRailCopy } from "../../renderer/locales/workhub-copy.js"; +import { renderTranscriptMarkup } from './transcript-test-dom.js'; import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session'; test('durable task results restore Host-scoped work links without treating failed or unrelated tools as delegations', () => { @@ -187,8 +188,8 @@ test("focus display is derived from the selected Session ID, not delegation prio }); -test('a shared coordination turn keeps every Work label without assigning one Work color to the whole turn', () => { - const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', children: null }, +test('a shared coordination turn keeps every Work label without assigning one Work color to the whole turn', async () => { + const markup = await renderTranscriptMarkup(createElement(LocaleProvider, { locale: 'en', children: null }, createElement(ChatSurfaceLayout, { composer: null, children: null }, createElement(WorkHubConversation, { activeSession: { id: 'coordination', name: 'WorkHub', status: 'active', labels: [], isFlagged: false, isArchived: false, hasUnread: false, backend: 'ai-sdk', llmConnectionSlug: 'test', connectionLocked: false, model: 'test', permissionMode: 'ask' }, messages: [{ type: 'user', id: 'user', turnId: 'shared', text: 'Do both tasks', ts: 1 }], diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index da7431f17f..a4431252ac 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -27,12 +27,11 @@ import { build } from 'esbuild'; import { deferred, waitFor } from '@maka/core/test-only/async-primitives'; import type { StoredMessage } from '@maka/core/session'; import type { MakaBridge } from '../../preload/bridge-contract.js'; -import type { DesktopTranscriptBatch, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; +import type { DesktopTranscriptBatch } from '../../preload/transcript-contract.js'; import { createDesktopWorkHubServices } from '../../renderer/platform/desktop/create-workhub-services.js'; -import type { WorkHubTranscriptSnapshot } from '../../renderer/features/workhub/index.js'; import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; import type { WorkHubPrepareAttachmentsResult } from '../../shared/workhub-conversation.js'; -import { encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { encodeDesktopTranscriptBatches, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { AttachmentIngestBlockedError } from '@maka/core/attachments'; import type { AttachmentRef } from '@maka/core/events'; import { MESSAGE_QUEUE_MAX_ENTRIES } from '@maka/runtime-host/protocol'; @@ -125,125 +124,7 @@ test('WorkHub upload references round-trip through idle answers, both queue mode await assert.rejects(services.enqueueMessage(sessionId, 'foreign', 'read this', foreign, 'next_turn'), /another Host or Session/); }); -test('WorkHub projects the exact delegated Turn status and bounded assistant result', async (t) => { - const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); - Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); - t.after(() => { - if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); - else Reflect.deleteProperty(globalThis, 'window'); - }); - const sessionId = desktopSessionKey({ hostId: 'owner-host', sessionId: 'target-session' }); - const result: StoredMessage = { - type: 'assistant', id: 'answer', turnId: 'owned-turn', ts: 3, - modelId: 'model', text: 'The delegated task finished with this exact result.', - }; - const services = createDesktopWorkHubServices({ - attachments: {}, - sessions: { - async list() { - return [{ - id: sessionId, name: 'Target task', isFlagged: false, isArchived: false, - labels: [], hasUnread: false, status: 'active', runningTurnIds: [], revision: 1, - }]; - }, - async listTurns() { - return [{ turnId: 'owned-turn', firstSequence: 1, status: 'completed', statusSource: 'recorded' }]; - }, - async queryMessageExecutions() { - return { resolutions: [{ messageId: 'delegated-message', state: 'owned', turnId: 'owned-turn', runId: 'run' }] }; - }, - }, - transcripts: { - async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { - const snapshot = { - sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 1, hasOlder: false, hasNewer: false, - }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, durable: [{ sequence: 1, message: result }], - })) onBatch({ ...batch, deliverySequence: 1 }); - return { - ...snapshot, readThroughMessageId: result.id, - loadBefore: async () => undefined, loadAfter: async () => undefined, - loadAround: async () => undefined, close: async () => undefined, - }; - }, - }, - } as unknown as Parameters[0]); - - assert.deepEqual(await services.delegationFeedback([{ - id: 'delegation-record', targetSessionId: sessionId, - targetMessageId: 'delegated-message', targetTurnId: 'initial-turn', - }]), [{ - id: 'delegation-record', state: 'completed', - resultPreview: 'The delegated task finished with this exact result.', - }]); -}); - -test('delegation feedback does not advance the target Session read marker', async (t) => { - const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); - Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); - t.after(() => { - if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); - else Reflect.deleteProperty(globalThis, 'window'); - }); - const sessionId = desktopSessionKey({ hostId: 'owner-host', sessionId: 'target-session' }); - const result: StoredMessage = { - type: 'assistant', id: 'answer', turnId: 'owned-turn', ts: 3, - modelId: 'model', text: 'The delegated task finished with this exact result.', - }; - const later: StoredMessage = { - type: 'user', id: 'later', turnId: 'next-turn', ts: 4, text: 'A later turn nobody has read.', - }; - const acknowledged: number[] = []; - const services = createDesktopWorkHubServices({ - attachments: {}, - sessions: { - async list() { - return [{ - id: sessionId, name: 'Target task', isFlagged: false, isArchived: false, - labels: [], hasUnread: true, status: 'active', runningTurnIds: [], revision: 1, - }]; - }, - async listTurns() { - return [{ turnId: 'owned-turn', firstSequence: 1, status: 'completed', statusSource: 'recorded' }]; - }, - async queryMessageExecutions() { - return { resolutions: [{ messageId: 'delegated-message', state: 'owned', turnId: 'owned-turn', runId: 'run' }] }; - }, - }, - transcripts: { - async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { - // The real open answers over IPC, so its first batches reach a consumer - // that is already listening. - await Promise.resolve(); - const snapshot = { - sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 2, hasOlder: false, hasNewer: false, - }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, - durable: [{ sequence: 1, message: result }, { sequence: 2, message: later }], - })) onBatch({ ...batch, deliverySequence: 1 }); - return { - ...snapshot, readThroughMessageId: later.id, - async acknowledgeTail(through: number) { acknowledged.push(through); }, - loadBefore: async () => undefined, loadAfter: async () => undefined, - loadAround: async () => undefined, close: async () => undefined, - }; - }, - }, - } as unknown as Parameters[0]); - - assert.equal((await services.delegationFeedback([{ - id: 'delegation-record', targetSessionId: sessionId, - targetMessageId: 'delegated-message', targetTurnId: 'initial-turn', - }]))[0]?.resultPreview, result.text); - await new Promise((resolve) => { setImmediate(resolve); }); - assert.deepEqual(acknowledged, [], 'a result projection is not a reader of the target Session'); -}); - -test('WorkHub proves a long historical Turn tail before caching its final result', async (t) => { +test('WorkHub projects a delegated Turn result from one Turn read without opening the target transcript', async (t) => { const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); t.after(() => { @@ -257,21 +138,16 @@ test('WorkHub proves a long historical Turn tail before caching its final result }; const final: StoredMessage = { type: 'assistant', id: 'final', turnId: 'owned-turn', ts: 2, - modelId: 'model', text: 'Final answer after the historical Turn boundary.', - }; - const next: StoredMessage = { - type: 'user', id: 'next', turnId: 'next-turn', ts: 3, text: 'Later turn', + modelId: 'model', text: 'The delegated task finished with this exact result.', }; - let deliverySequence = 0; - let opens = 0; - let loadAfters = 0; + const turnReads: string[] = []; const services = createDesktopWorkHubServices({ attachments: {}, sessions: { async list() { return [{ id: sessionId, name: 'Target task', isFlagged: false, isArchived: false, - labels: [], hasUnread: false, status: 'active', runningTurnIds: [], revision: 1, + labels: [], hasUnread: true, status: 'active', runningTurnIds: [], revision: 1, }]; }, async listTurns() { @@ -282,44 +158,13 @@ test('WorkHub proves a long historical Turn tail before caching its final result }, }, transcripts: { - async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { - opens += 1; - const emit = ( - navigation: number | undefined, - durable: Array<{ sequence: number; message: StoredMessage }>, - hasOlder: boolean, - hasNewer: boolean, - ) => { - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 4, hasOlder, hasNewer, durable, - }, navigation)) onBatch({ ...batch, deliverySequence: ++deliverySequence }); - }; - emit(undefined, [{ sequence: 4, message: { ...next, id: 'tail', ts: 4 } }], true, false); - return { - sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 4, hasOlder: true, hasNewer: false, readThroughMessageId: 'tail', - loadBefore: async () => undefined, - async loadAround(_sequence: number | null, _maxBytes: number | undefined, navigation: number) { - emit(navigation, [{ sequence: 1, message: intermediate }], false, true); - }, - async loadAfter(anchor: number | null, _maxBytes: number | undefined, navigation: number) { - loadAfters += 1; - assert.equal(anchor, 1); - // An extension splices onto the window; only a navigation replaces it. - for (const batch of encodeDesktopTranscriptPage({ - sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - navigation, - }, { - durableThrough: 4, hasNewer: true, - durable: [ - { sequence: 2, message: final }, - { sequence: 3, message: next }, - ], - }, { direction: 'newer', anchor })) onBatch({ ...batch, deliverySequence: ++deliverySequence }); - }, - close: async () => undefined, - }; + async readTurn(requestedSessionId: string, turnId: string) { + assert.equal(requestedSessionId, sessionId); + turnReads.push(turnId); + return [intermediate, final]; + }, + async open() { + assert.fail('a result projection is not a reader of the target Session'); }, }, } as unknown as Parameters[0]); @@ -328,10 +173,11 @@ test('WorkHub proves a long historical Turn tail before caching its final result targetMessageId: 'delegated-message', targetTurnId: 'initial-turn', }; + assert.deepEqual(await services.delegationFeedback([reference]), [{ + id: 'delegation-record', state: 'completed', resultPreview: final.text, + }]); assert.equal((await services.delegationFeedback([reference]))[0]?.resultPreview, final.text); - assert.equal((await services.delegationFeedback([reference]))[0]?.resultPreview, final.text); - assert.equal(opens, 1); - assert.equal(loadAfters, 1); + assert.deepEqual(turnReads, ['owned-turn'], 'the final result is cached'); }); test('WorkHub batches more than the message execution query limit per target Session', async (t) => { @@ -398,31 +244,30 @@ test('WorkHub does not infer live running when the Session catalog is unavailabl }); // Keep the real preload in this consumer regression; the IPC stub models the -// observer's authoritative reset reply to a latest command. -test('WorkHub tail navigation converges through the preload with a fragmented sparse tail', { timeout: 5_000 }, async (t) => { +// observer's earlier-history answer to a load-earlier command. +test('WorkHub loads earlier history through the preload with a fragmented answer', { timeout: 5_000 }, async (t) => { const owner = { hostId: 'owner-host', targetEpoch: 'owner-epoch', profileId: 'local', profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready', }; const sessionId = desktopSessionKey({ hostId: owner.hostId, sessionId: 'coordination' }); - const snapshot = { - sessionId: 'coordination', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 8, hasOlder: true, hasNewer: false, + const identity = { sessionId: 'coordination', generation: 'generation-1', hostEpoch: 'epoch-1' }; + const tail: StoredMessage = { + type: 'user', id: 'tail-message', turnId: 'tail-turn', ts: 8, text: 'Tail coordination record', }; - const message: StoredMessage = { - type: 'user', id: 'latest-message', turnId: 'latest-turn', ts: 7, - text: 'Latest coordination record '.repeat(8_000), + const earlier: StoredMessage = { + type: 'user', id: 'earlier-message', turnId: 'earlier-turn', ts: 7, + text: 'Earlier coordination record '.repeat(8_000), }; - const requests: DesktopTranscriptRangeRequest[] = []; - const projections: string[][] = []; + const earlierReads: unknown[] = []; + const projections: Array<{ ids: string[]; hasOlder: boolean }> = []; const partialProjectionCounts: number[] = []; let bridge: MakaBridge | undefined; let consumerId: string; let deliverySequence = 0; let deliverDirect: ((batch: DesktopTranscriptBatch) => void) | undefined; const listeners = new Map void>(); - let finishResponse!: () => void; - const responseDelivered = new Promise((resolve) => { finishResponse = resolve; }); + const responseDelivered = deferred(); const deliver = (batch: Omit) => { listeners.get(`sessions:transcript:${consumerId}`)?.({}, owner, { ...batch, deliverySequence: ++deliverySequence, @@ -438,40 +283,47 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp if (channel === 'session-local:transcript') return null; if (channel === 'sessions:transcript:open') { consumerId = args[2] as string; - for (const batch of encodeDesktopTranscriptSnapshot({ ...snapshot, durable: [] })) { - deliver(batch); - } - return { kind: 'ready', value: { ...snapshot, readThroughMessageId: null } }; + assert.equal(args[3], 'history'); + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...identity, durableThrough: 8, hasOlder: true, + durable: [{ sequence: 8, message: tail }], + })) deliver(batch); + return { kind: 'ready', value: { ...identity, readThroughMessageId: null } }; } - if (channel === 'sessions:transcript:load-latest') { - const request = args[1] as DesktopTranscriptRangeRequest; - requests.push(request); + if (channel === 'sessions:transcript:load-earlier') { + earlierReads.push(args[1]); + assert.equal(args[1], consumerId); // Bound a regressed request loop so the test reports its cause. - if (requests.length >= 3) return new Promise(() => {}); + if (earlierReads.length >= 2) return new Promise(() => {}); await new Promise((resolve) => setImmediate(resolve)); try { - for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, - durable: [{ sequence: 7, message }], - }, request.navigation)) { + for (const batch of encodeDesktopTranscriptBatches(identity, { + durableThrough: 8, durable: [{ sequence: 7, message: earlier }], + earlierThan: 8, hasOlder: false, reset: false, ready: true, + })) { deliver(batch); if (!batch.ready) { partialProjectionCounts.push(projections.length); // A batch from another replica generation must not publish a - // partial valid snapshot or clear the load guard. + // partial answer or complete it. deliverDirect?.({ - ...batch, generation: 'unrelated-generation', reset: false, fragments: [], ready: true, + ...batch, generation: 'unrelated-generation', fragments: [], ready: true, deliverySequence: ++deliverySequence, }); partialProjectionCounts.push(projections.length); } } } finally { - finishResponse(); + responseDelivered.resolve(); } return; } - if (channel === 'sessions:transcript:ack' || channel === 'sessions:transcript:close') return; + if ( + channel === 'sessions:transcript:ack' || + channel === 'sessions:transcript:acknowledge-tail' || + channel === 'sessions:transcript:close' + ) return; throw new Error(`Unexpected channel: ${channel}`); }, }; @@ -501,28 +353,32 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp ...bridge, transcripts: { ...bridge.transcripts, - open(requestedSessionId, handler, registerCancellation) { + open(requestedSessionId, handler, registerCancellation, mode) { deliverDirect = handler; - return bridge!.transcripts.open(requestedSessionId, handler, registerCancellation); + return bridge!.transcripts.open(requestedSessionId, handler, registerCancellation, mode); }, }, }); const handle = await services.openTranscript( sessionId, - (snapshot) => projections.push(snapshot.messages.map((message) => message.id)), + (snapshot) => projections.push({ ids: snapshot.messages.map((message) => message.id), hasOlder: snapshot.hasOlder }), new AbortController().signal, (error) => { throw error; }, ); try { await waitFor(() => projections.length === 1, { timeoutMs: 5_000 }); - await handle.loadLatest(); - await responseDelivered; + await handle.loadEarlier(); + await responseDelivered.promise; await new Promise((resolve) => setImmediate(resolve)); - assert.equal(requests.length, 1); - assert.equal(requests[0]!.navigation, 1); - assert.equal(requests[0]!.anchorSequence, null); - assert.deepEqual(partialProjectionCounts, [1, 1]); - assert.deepEqual(projections, [[], ['latest-message']]); + assert.equal(earlierReads.length, 1); + assert.ok(partialProjectionCounts.length > 0, 'the answer has to span more than one batch'); + assert.ok(partialProjectionCounts.every((count) => count === 1)); + assert.deepEqual(projections, [ + { ids: ['tail-message'], hasOlder: true }, + { ids: ['earlier-message', 'tail-message'], hasOlder: false }, + ]); + await handle.loadEarlier(); + assert.equal(earlierReads.length, 1, 'nothing is read once no earlier history remains'); } finally { await handle.close(); } @@ -564,23 +420,20 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] throw new Error('transient initial open failure'); } const cached = attempt === 1; - const snapshot = { + const identity = { sessionId: 'coordination', generation: cached ? 'cached:epoch-1' : `live-${attempt}`, - hostEpoch: 'epoch-1', durableThrough: 1, hasOlder: false, hasNewer: false, + hostEpoch: 'epoch-1', }; - const deliver = (navigation?: number) => { - for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, - durable: [{ sequence: 1, message: { type: 'user', id: cached ? 'cached-message' : 'live-message', turnId: 'turn-1', ts: 1, text: cached ? 'Cached history' : 'Live history' } }], - }, navigation)) onBatch({ ...batch, deliverySequence: 1 }); - }; - deliver(); + for (const batch of encodeDesktopTranscriptSnapshot({ + beginsAtTurnBoundary: true, + ...identity, durableThrough: 1, hasOlder: false, + durable: [{ sequence: 1, message: { type: 'user', id: cached ? 'cached-message' : 'live-message', turnId: 'turn-1', ts: 1, text: cached ? 'Cached history' : 'Live history' } }], + })) onBatch({ ...batch, deliverySequence: 1 }); const unavailable = async () => { throw new Error('Reconnect the Host to load uncached history'); }; return { - ...snapshot, readThroughMessageId: null, + ...identity, readThroughMessageId: null, acknowledgeTail: async () => {}, - loadBefore: unavailable, loadAfter: unavailable, loadLatest: unavailable, - loadAround: cached ? unavailable : async (_sequence, _maxBytes, navigation) => deliver(navigation), + loadEarlier: unavailable, close: async () => { closedCount++; }, }; }, @@ -605,77 +458,3 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] } }); } - -test('WorkHub fills and trims its transcript window through the reader band', async (t) => { - const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); - Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); - t.after(() => { - if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); - else Reflect.deleteProperty(globalThis, 'window'); - }); - const sessionId = desktopSessionKey({ hostId: 'owner-host', sessionId: 'coordination' }); - const identity = { sessionId: 'coordination', generation: 'generation-1', hostEpoch: 'epoch-1' }; - const row = (sequence: number, turnId: string): { sequence: number; message: StoredMessage } => ({ - sequence, message: { type: 'user', id: `message-${sequence}`, turnId, ts: sequence, text: `Record ${sequence}` }, - }); - let deliverySequence = 0; - let newerReads = 0; - let olderReads = 0; - let snapshots: WorkHubTranscriptSnapshot[] = []; - const services = createDesktopWorkHubServices({ - attachments: {}, - transcripts: { - async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 4, hasOlder: true, hasNewer: true, - durable: [row(2, 'turn-a'), row(3, 'turn-b')], - })) onBatch({ ...batch, deliverySequence: ++deliverySequence }); - return { - ...identity, durableThrough: 4, hasOlder: true, hasNewer: true, readThroughMessageId: 'message-3', - acknowledgeTail: async () => {}, - loadBefore: async () => { olderReads += 1; }, - loadAround: async () => {}, - loadLatest: async () => {}, - async loadAfter(anchor: number | null, _maxBytes: number | undefined, navigation: number) { - newerReads += 1; - assert.equal(anchor, 3); - for (const batch of encodeDesktopTranscriptPage( - { ...identity, navigation }, - { durableThrough: 4, hasNewer: false, durable: [row(4, 'turn-c')] }, - { direction: 'newer', anchor }, - )) onBatch({ ...batch, deliverySequence: ++deliverySequence }); - }, - close: async () => undefined, - }; - }, - } satisfies Pick, - } as unknown as Parameters[0]); - const handle = await services.openTranscript( - sessionId, - (snapshot) => { snapshots.push(snapshot); }, - new AbortController().signal, - (error) => { throw error; }, - ); - const latest = () => snapshots.at(-1)!; - try { - await waitFor(() => latest()?.ready === true, { timeoutMs: 5_000 }); - assert.deepEqual(latest().messages.map(({ turnId }) => turnId), ['turn-a', 'turn-b']); - assert.equal(await handle.prefetchHistory('newer'), true); - assert.deepEqual(latest().messages.map(({ turnId }) => turnId), ['turn-a', 'turn-b', 'turn-c']); - assert.equal(latest().hasNewer, false); - assert.equal(await handle.prefetchHistory('newer'), false, 'a window at the tail has no newer edge to read'); - assert.equal(newerReads, 1); - assert.equal(await handle.prefetchHistory('older'), true); - assert.equal(await handle.prefetchHistory('older'), false, 'the same window answers an older read the same way'); - assert.equal(olderReads, 1); - snapshots = []; - handle.retain({ firstTurnId: 'turn-b', lastTurnId: 'turn-c' }); - assert.deepEqual(latest().messages.map(({ turnId }) => turnId), ['turn-b', 'turn-c']); - assert.equal(latest().hasOlder, true, 'a trimmed edge becomes history again'); - // A trim moves the window, so the edge it re-opened is worth asking again. - assert.equal(await handle.prefetchHistory('older'), true); - assert.equal(olderReads, 2); - } finally { - await handle.close(); - } -}); diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index 381986c074..4e0beb845a 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -44,11 +44,8 @@ async function mountController(failFirstRead = false, overrides: Partial void; let onExecution: Parameters[4]; let observe!: Parameters[1]; - let loadLatestCount = 0; - const prefetched: Array<'older' | 'newer'> = []; - const retained: Array<{ firstTurnId: string; lastTurnId: string }> = []; + let earlierLoads = 0; let admission = deferred<{ turnId: string }>(); - const latestRead = deferred(); const requests: Array[1]> = []; let rootTurn: { turnId: string; runId: string; status: 'running' | 'cancelled' | 'completed' } | undefined; const queueMutations: unknown[][] = []; @@ -102,12 +99,10 @@ async function mountController(failFirstRead = false, overrides: Partial {}, - prefetchHistory: async (edge: 'older' | 'newer') => { prefetched.push(edge); return true; }, - retain: (window: { firstTurnId: string; lastTurnId: string }) => { retained.push(window); }, - loadLatest: () => { loadLatestCount += 1; return latestRead.promise; }, + loadEarlier: async () => { earlierLoads += 1; }, close: async () => {}, }; }, @@ -143,14 +138,13 @@ async function mountController(failFirstRead = false, overrides: Partial(); }, admit(turnId: string) { rootTurn = { turnId, runId: `run:${turnId}`, status: 'running' }; projectExecution(); }, loseObservation() { projectExecution(false); }, - get loadLatestCount() { return loadLatestCount; }, - prefetched, retained, + get earlierLoads() { return earlierLoads; }, emit(event: Parameters[0]) { observe(event); }, - publish(messages: StoredMessage[]) { publish({ messages, ready: true, hasOlder: false, hasNewer: false }); }, + publish(messages: StoredMessage[]) { publish({ messages, ready: true, hasOlder: false }); }, }; } @@ -253,8 +247,7 @@ test('WorkHub shows the submitted prompt before admission and keeps it until its await act(async () => { sent = h.controller.send(text, attachments); }); assert.deepEqual(h.controller.transientMessages.map((message) => message.text), [text]); assert.deepEqual(h.controller.transientMessages[0]!.attachments, attachments); - assert.equal(h.requests.length, 1, 'the pending history read must not delay admission'); - assert.equal(h.loadLatestCount, 1); + assert.equal(h.requests.length, 1); assert.deepEqual(followed, [h.sessionId]); const turnId = h.requests[0]!.turnId; assert.equal(h.controller.liveTurn?.turnId, turnId, 'waiting feedback starts before admission'); @@ -271,38 +264,25 @@ test('WorkHub shows the submitted prompt before admission and keeps it until its assert.equal(h.controller.transientMessages.length, 0); assert.deepEqual(h.controller.transcript.messages.map((message) => message.id), ['canonical-user-id']); unsubscribe(); - h.latestRead.resolve(); }); -test('WorkHub holds transcript and live handoff together until publication is admitted', async () => { +test('WorkHub hands transcript and live content off together when publication commits', async () => { const h = await mountController(); let sent!: Promise; - await act(() => { sent = h.controller.send('held prompt', []); }); + await act(() => { sent = h.controller.send('handoff prompt', []); }); const turnId = h.requests[0]!.turnId; await act(async () => { h.admission.resolve({ turnId }); await sent; }); await act(() => h.emit({ type: 'text_delta', id: 'delta', turnId, messageId: 'answer', ts: 1, text: 'Answer' })); - let held = true; - let idle!: () => void; - const detach = h.controller.viewportNavigation.attachCommitScheduler(h.sessionId, { - subscribeToReaderScroll: () => () => {}, - commitRange(commit) { if (held) idle = commit; else commit(); }, - }); const messages: StoredMessage[] = [ - { type: 'user', id: 'user', turnId, text: 'held prompt', ts: 1 }, + { type: 'user', id: 'user', turnId, text: 'handoff prompt', ts: 1 }, { type: 'assistant', id: 'answer', turnId, text: 'Answer', ts: 2, modelId: 'fixture' }, ]; await act(() => h.publish(messages)); await act(() => h.emit({ type: 'complete', id: 'done', turnId, ts: 3, stopReason: 'end_turn' })); await act(() => h.controller.streamingSettled('answer')); - assert.equal(h.controller.transcript.messages.length, 0); - assert.equal(h.controller.transientMessages.length, 1); - assert.ok(h.controller.liveTurn?.steps.some((step) => step.stepId === 'answer')); - await act(() => { held = false; idle(); }); assert.deepEqual(h.controller.transcript.messages, messages); assert.equal(h.controller.transientMessages.length, 0); assert.ok(!h.controller.liveTurn?.steps.some((step) => step.stepId === 'answer')); - detach(); - h.latestRead.resolve(); }); test('WorkHub marks a failed submission and preserves its retry identity', async () => { @@ -319,7 +299,6 @@ test('WorkHub marks a failed submission and preserves its retry identity', async await act(async () => { assert.equal(await h.controller.send('retry this prompt', []), false); }); assert.equal(h.requests[1]!.turnId, turnId); assert.equal(h.controller.transientMessages.length, 1); - h.latestRead.resolve(); }); test('a lost admission response cannot erase confirmed WorkHub activity', async () => { @@ -332,7 +311,6 @@ test('a lost admission response cannot erase confirmed WorkHub activity', async assert.equal(h.controller.liveTurn?.turnId, turnId); assert.equal(h.controller.liveTurn?.unconfirmed, undefined); assert.equal(h.controller.busy, true); - h.latestRead.resolve(); }); @@ -406,7 +384,6 @@ test('WorkHub carries Stop through deferred or uncertain admission for the origi await act(async () => h.emit({ type: 'text_delta', id: 'later', turnId: 'later-turn', messageId: 'later-answer', ts: 3, text: 'Later work' })); assert.equal(h.interrupts.length, 1, 'the intent cannot transfer to a later Turn'); } - h.latestRead.resolve(); cleanupFakeDom(); } }); @@ -474,7 +451,6 @@ test('an unknown WorkHub submission converges through the original Host admissio await act(async () => { h.admit(nextTurn); h.admission.resolve({ turnId: nextTurn }); await next; }); assert.equal(h.interrupts.length, 0, 'an unadmitted attempt cannot leave Stop on a later Turn'); } - h.latestRead.resolve(); cleanupFakeDom(); } }); @@ -495,7 +471,6 @@ test('Retry reopens a failed initial WorkHub read after Session resolution', asy assert.equal(h.controller.transcript.ready, true); assert.equal(h.controller.error, undefined); await act(async () => { h.admission.resolve({ turnId }); await sent; }); - h.latestRead.resolve(); }); @@ -522,7 +497,6 @@ test('WorkHub steering keeps the current Turn and Stop authority and reconciles assert.deepEqual(h.controller.transientMessages, []); await act(async () => { await h.controller.stop(); }); assert.equal(h.interrupts[0]?.turnId, turnId); - h.latestRead.resolve(); }); test('uncertain steering retains its identity across Turn completion and rejection preserves the active Turn', async () => { @@ -553,7 +527,6 @@ test('uncertain steering retains its identity across Turn completion and rejecti await act(async () => { assert.equal(await h.controller.send('change direction', [], 'steer'), true); }); assert.equal(h.steers[2]![1], messageId); assert.equal(h.requests.length, 0, 'retry must recover the steering receipt even after its Turn ends'); - h.latestRead.resolve(); }); @@ -568,7 +541,6 @@ test('Host retraction resolves an uncertain WorkHub attempt before the next draf h.setSteerResult('admitted'); await act(async () => { assert.equal(await h.controller.send('new direction', [], 'steer'), true); }); assert.notEqual(h.steers[1]![1], messageId); - h.latestRead.resolve(); }); test('steering observed before its admission response renders once and outranks an uncertain receipt', async () => { @@ -580,7 +552,6 @@ test('steering observed before its admission response renders once and outranks assert.deepEqual(h.controller.transientMessages, []); assert.equal(h.controller.error, undefined); assert.equal(h.controller.liveTurn?.turnId, 'active-turn'); - h.latestRead.resolve(); }); @@ -604,7 +575,6 @@ test('WorkHub Host queue owns restored, consumed and retracted rows without tran await act(() => h.emit({ type: 'queue_update', id: 'removed', turnId: 'active-turn', ts: 4, steering: [], followup: [], steeringEntries: [] })); assert.deepEqual(h.controller.messageQueue.entries, []); assert.deepEqual(h.controller.transientMessages, [], 'withdrawal needs no separate admission event'); - h.latestRead.resolve(); }); test('WorkHub sends queue edits, withdrawal and both queue orders to the Host and waits for its projection', async () => { @@ -628,7 +598,6 @@ test('WorkHub sends queue edits, withdrawal and both queue orders to the Host an queueRevision: 10, steering: ['edited second'], followup: [], steeringEntries: [{ ...entries[1]!, content: { text: 'edited second' } }] })); assert.deepEqual(h.controller.messageQueue.entries.map((entry) => entry.content.text), ['edited second']); assert.deepEqual(h.controller.transientMessages, []); - h.latestRead.resolve(); }); @@ -673,7 +642,6 @@ test('WorkHub defaults to follow-up and moves each message into its admitted suc await act(() => h.publish([{ type: 'user', id: first, turnId: 'successor', text: 'first follow-up', attachments, ts: 2 }])); assert.deepEqual(h.controller.transientMessages, []); assert.deepEqual(h.controller.messageQueue.entries.map((entry) => entry.messageId), [second]); - h.latestRead.resolve(); }); test('restored follow-ups transfer edited Host content once, regardless of transcript arrival order', async () => { @@ -697,7 +665,6 @@ test('restored follow-ups transfer edited Host content once, regardless of trans if (!transcriptFirst) await act(publish); await act(admit); assert.deepEqual(h.controller.transientMessages, [], 'late admission cannot recreate a published user row'); - h.latestRead.resolve(); cleanupFakeDom(); } }); @@ -709,33 +676,25 @@ test('withdrawing a queued follow-up never transfers it into the conversation', await act(() => h.emit({ type: 'message_admission', id: 'retracted', turnId: 'active', messageId: entry.messageId, ts: 2, outcome: 'retracted' })); assert.deepEqual(h.controller.messageQueue.entries, []); assert.deepEqual(h.controller.transientMessages, []); - h.latestRead.resolve(); }); -test('a queued follow-up returns the window to the tail so its own retry guard can clear', async () => { +test('an uncertain queued follow-up blocks the next one until its row is observed', async () => { const h = await mountController(); await act(() => { h.admit('active-turn'); h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' }); }); h.setSteerResult('unknown'); await act(async () => { assert.equal(await h.controller.send('queued while parked in history', []), false); }); - const messageId = h.steers[0]![1]; - assert.equal(h.loadLatestCount, 1, 'an uncertain enqueue still has to reach the tail to be observed'); - await act(async () => { assert.equal(await h.controller.send('a different follow-up', []), false); }); + const messageId = h.steers[0]![1]; await act(async () => { assert.equal(await h.controller.send('a different follow-up', []), false); }); assert.equal(h.steers.length, 1, 'an unobserved attempt refuses the next follow-up'); await act(() => h.publish([{ type: 'user', id: messageId, turnId: 'successor', text: 'queued while parked in history', ts: 2 }])); h.setSteerResult('admitted'); await act(async () => { assert.equal(await h.controller.send('a different follow-up', []), true); }); assert.equal(h.steers.length, 2, 'the observed row releases the guard'); - h.latestRead.resolve(); }); -test('the transcript band reaches WorkHub’s window', async () => { +test('loading earlier history reaches WorkHub’s transcript', async () => { const h = await mountController(); - assert.equal(await h.controller.prefetchHistory('older'), true); - assert.equal(await h.controller.prefetchHistory('newer'), true); - assert.deepEqual(h.prefetched, ['older', 'newer']); - h.controller.retainWindow({ firstTurnId: 'turn-b', lastTurnId: 'turn-c' }); - assert.deepEqual(h.retained, [{ firstTurnId: 'turn-b', lastTurnId: 'turn-c' }]); - h.latestRead.resolve(); + await h.controller.loadEarlier(); + assert.equal(h.earlierLoads, 1); }); test('follow-up admission before an uncertain response keeps its successor placement', async () => { @@ -747,7 +706,6 @@ test('follow-up admission before an uncertain response keeps its successor place assert.equal(h.controller.transientMessages[0]?.transientPlacement, 'current_turn'); assert.equal(h.controller.transientMessages[0]?.hostTurnId, 'successor'); assert.equal(h.controller.error, undefined); - h.latestRead.resolve(); }); @@ -778,7 +736,6 @@ test('Stop retires only Host-confirmed queued messages even without retraction e assert.deepEqual(h.controller.messageQueue.entries.map((entry) => entry.messageId), ['retained']); assert.deepEqual(h.controller.transientMessages.filter((message) => message.id !== turnId), []); assert.equal(h.controller.stopPending, false); - h.latestRead.resolve(); cleanupFakeDom(); } }); diff --git a/apps/desktop/src/main/desktop-transcript-ipc.ts b/apps/desktop/src/main/desktop-transcript-ipc.ts index a3aa995b2d..c8b07f9f6b 100644 --- a/apps/desktop/src/main/desktop-transcript-ipc.ts +++ b/apps/desktop/src/main/desktop-transcript-ipc.ts @@ -21,76 +21,59 @@ import type { StoredMessage } from '@maka/core/session'; import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, type DesktopTranscriptBatchPayload, - type DesktopTranscriptExtension, type DesktopTranscriptFragment, } from '../preload/transcript-contract.js'; import type { DesktopSequencedTranscriptMessage, DesktopTranscriptReplicaChange, - DesktopTranscriptReplicaPage, DesktopTranscriptReplicaSnapshot, } from './desktop-transcript-replica.js'; -interface TranscriptBatchIdentity { - readonly navigation?: number; +export interface TranscriptBatchIdentity { readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; } -interface TranscriptBatchContent { +/** One part of an answer; `ready` marks its last part. */ +export interface TranscriptBatchContent { readonly durableThrough: number | null; readonly durable: readonly DesktopSequencedTranscriptMessage[]; readonly hasOlder?: boolean; - readonly hasNewer?: boolean; - readonly extends?: DesktopTranscriptExtension; + readonly beginsAtTurnBoundary?: boolean; + readonly earlierThan?: number; readonly coversFrom?: number | null; readonly reset: boolean; + readonly ready: boolean; } export function encodeDesktopTranscriptSnapshot( snapshot: DesktopTranscriptReplicaSnapshot, - navigation?: number, ): Iterable { - return encodeDesktopTranscriptBatches({ ...snapshot, navigation }, { + return encodeDesktopTranscriptBatches(snapshot, { durableThrough: snapshot.durableThrough, durable: snapshot.durable, hasOlder: snapshot.hasOlder, - hasNewer: snapshot.hasNewer, + beginsAtTurnBoundary: snapshot.beginsAtTurnBoundary, reset: true, - }); -} - -export function encodeDesktopTranscriptPage( - identity: TranscriptBatchIdentity, - page: DesktopTranscriptReplicaPage, - extension: DesktopTranscriptExtension, -): Iterable { - return encodeDesktopTranscriptBatches(identity, { - durableThrough: page.durableThrough, - durable: page.durable, - hasOlder: page.hasOlder, - hasNewer: page.hasNewer, - extends: extension, - reset: false, + ready: true, }); } export function encodeDesktopTranscriptChange( identity: TranscriptBatchIdentity, - // A merge that had to drop rows carries no `coversFrom` at all: it claims - // nothing about adjacency and only moves the watermark. - change: Omit & { readonly coversFrom?: number | null }, + change: DesktopTranscriptReplicaChange, ): Iterable { return encodeDesktopTranscriptBatches(identity, { durableThrough: change.durableThrough, durable: change.durableUpserts, coversFrom: change.coversFrom, reset: false, + ready: true, }); } -function* encodeDesktopTranscriptBatches( +export function* encodeDesktopTranscriptBatches( identity: TranscriptBatchIdentity, content: TranscriptBatchContent, ): Iterable { @@ -109,19 +92,21 @@ function* encodeDesktopTranscriptBatches( rawBytes += bytes; fragment = fragments.next(); } + const last = fragment.done === true; yield { - ...(identity.navigation === undefined ? {} : { navigation: identity.navigation }), - ...(content.extends === undefined ? {} : { extends: content.extends }), + ...(content.earlierThan === undefined ? {} : { earlierThan: content.earlierThan }), ...(content.coversFrom === undefined ? {} : { coversFrom: content.coversFrom }), sessionId: identity.sessionId, generation: identity.generation, hostEpoch: identity.hostEpoch, durableThrough: content.durableThrough, fragments: batchFragments, - ...(content.hasOlder === undefined ? {} : { hasOlder: content.hasOlder }), - ...(content.hasNewer === undefined ? {} : { hasNewer: content.hasNewer }), + ...(content.hasOlder === undefined || !(last && content.ready) ? {} : { hasOlder: content.hasOlder }), + ...(content.beginsAtTurnBoundary === undefined || !(last && content.ready) + ? {} + : { beginsAtTurnBoundary: content.beginsAtTurnBoundary }), reset: content.reset && first, - ready: fragment.done === true, + ready: last && content.ready, }; first = false; } diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index 4187605510..ff347c6a6c 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -25,11 +25,12 @@ import { } from '@maka/runtime-host/adapter'; import { RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; import { - SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + SESSION_TRANSCRIPT_PAGE_MAX_BYTES, type SessionTranscriptPage, } from '@maka/runtime-host/protocol'; import { - DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES, DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, } from '../preload/transcript-contract.js'; import type { DesktopRuntimeHostSession } from './runtime-host-client.js'; @@ -58,15 +59,20 @@ export interface DesktopTranscriptReplicaSnapshot { readonly durableThrough: number | null; readonly durable: readonly DesktopSequencedTranscriptMessage[]; readonly hasOlder: boolean; - readonly hasNewer: boolean; + /** + * Whether the oldest Turn in the tail has all its rows here. The tail is + * bounded by bytes, so it can begin inside a Turn — what it holds of that + * Turn then says nothing about how much of it exists. + */ + readonly beginsAtTurnBoundary: boolean; } -/** A durable page read on behalf of one Renderer window; never installed here. */ -export interface DesktopTranscriptReplicaPage { - readonly durableThrough: number; +/** One durable page read for a history consumer; never installed here. Rows ascend. */ +export interface DesktopTranscriptHistoryPage { readonly durable: readonly DesktopSequencedTranscriptMessage[]; - readonly hasOlder?: boolean; - readonly hasNewer?: boolean; + readonly nextCursor: string | null; + /** Whether the Host holds nothing more of the Turns this page carries rows of. */ + readonly endsAtTurnBoundary: boolean; } /** @@ -86,9 +92,8 @@ interface ResidentMessage extends DesktopSequencedTranscriptMessage { /** * Main's view of one Session transcript: the durable tail the projector needs - * and a pass-through pager for the - * Renderer's own window. The Renderer decides what it holds; this class only - * keeps the tail current and answers page reads. + * and pass-through reads of older history. This class only keeps the tail + * current and answers those reads. */ export class DesktopTranscriptReplica { readonly sessionId: string; @@ -108,6 +113,7 @@ export class DesktopTranscriptReplica { #durableThrough: number | null; #targetThrough: number | null; #hasOlder: boolean; + #beginsAtTurnBoundary: boolean; #resident = true; #residentExternallyAccounted = true; #closed = false; @@ -123,15 +129,16 @@ export class DesktopTranscriptReplica { this.generation = options.generation ?? randomUUID(); this.hostEpoch = handle.hostEpoch; this.#maxResidentBytes = - options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES; + options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES; this.#maxResidentTurns = options.maxResidentTurns ?? DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS; - this.#maxMessageBytes = options.maxMessageBytes ?? SESSION_TRANSCRIPT_RANGE_MAX_BYTES; + this.#maxMessageBytes = options.maxMessageBytes ?? DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES; this.#accountPreparationBytes = options.accountPreparationBytes ?? (() => undefined); this.#onChange = options.onChange ?? (() => undefined); this.#durableThrough = handle.transcriptBootstrap.durable.throughSequence; this.#targetThrough = this.#durableThrough; this.#hasOlder = handle.transcriptBootstrap.durable.nextCursor !== null; + this.#beginsAtTurnBoundary = handle.transcriptBootstrap.durable.endsAtTurnBoundary; } static async prepare( @@ -144,12 +151,7 @@ export class DesktopTranscriptReplica { replica.#installDurable(durable.messages); replica.#hasOlder = durable.nextCursor !== null; }); - replica.#evictToBudget( - undefined, - handle.transcriptBootstrap.durable.protectedTurnSequence ?? - replica.#durableThrough ?? - undefined, - ); + replica.#evictToBudget(); return replica; } catch (error) { replica.close(); @@ -190,7 +192,7 @@ export class DesktopTranscriptReplica { durableThrough: this.#durableThrough, durable: this.#orderedDurable(false), hasOlder: this.#hasOlder, - hasNewer: false, + beginsAtTurnBoundary: this.#beginsAtTurnBoundary, }; } @@ -219,153 +221,83 @@ export class DesktopTranscriptReplica { return latest?.message.id ?? null; } - loadBefore( - anchorSequence: number | null, - maxBytes: number, - isCurrent: () => boolean = () => true, - ): Promise { - return this.#enqueue(() => this.#loadPage('older', anchorSequence, maxBytes, isCurrent)); - } - - loadAfter( - anchorSequence: number | null, - maxBytes: number, - isCurrent: () => boolean = () => true, - ): Promise { - return this.#enqueue(() => this.#loadPage('newer', anchorSequence, maxBytes, isCurrent)); - } - - async #loadPage( - direction: 'older' | 'newer', - anchorSequence: number | null, - maxBytes: number, - isCurrent: () => boolean, - ): Promise { - if (!this.#isLive() || !isCurrent()) return undefined; - const throughSequence = this.#durableThrough; - if (throughSequence === null) return undefined; + /** One page older than `cursor`, or the newest page through `throughSequence` when it is null. */ + async readOlderPage( + throughSequence: number, + cursor: string | null, + ): Promise { + this.#assertLive(); const page = await this.#handle.loadTranscriptPage({ - direction, + direction: 'older', throughSequence, - cursor: null, - anchorSequence, - maxBytes, + cursor, + anchorSequence: null, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, }); return this.#withDecodedPage(page, (decoded) => { - if (!this.#isLive() || !isCurrent()) return undefined; + this.#assertLive(); this.#acceptRange(decoded.messages); - if ( - anchorSequence !== null && - decoded.messages.length > 0 && - !(direction === 'older' - ? this.#matchesCoverageStep(anchorSequence, decoded.messages.at(-1)!.identity + 1) - : this.#matchesCoverageStep(decoded.messages[0]!.identity, anchorSequence + 1)) - ) { - throw correlationError(`Desktop transcript ${direction} page did not meet its anchor`); - } return { - durableThrough: throughSequence, durable: decoded.messages.map((entry) => ({ sequence: entry.identity, message: entry.message, })), - ...(direction === 'older' - ? { hasOlder: decoded.nextCursor !== null } - : { hasNewer: decoded.nextCursor !== null }), + nextCursor: decoded.nextCursor, + endsAtTurnBoundary: page.endsAtTurnBoundary, }; }); } /** - * Reads the newest page back into the tail cache when global reclaim has - * trimmed it below a tail. Follow-tail is answered from this cache, so - * without the refill a reader returning to latest is shown whatever reclaim - * happened to leave — down to nothing. + * Every durable row of one Turn this replica's watermark covers, read + * forward across the extent the Host's Turn index gives it. Only the Turn's + * own rows count toward `maxBytes`: a nested Turn's rows sit inside the + * extent of the Turn around it. */ - refillTail(maxBytes: number, isCurrent: () => boolean = () => true): Promise { - return this.#enqueue(async () => { - if (!this.#isLive() || !isCurrent() || !this.#tailIsShort()) return; - const throughSequence = this.#durableThrough; - if (throughSequence === null) return; - const page = await this.#handle.loadTranscriptPage({ - direction: 'older', - throughSequence, - cursor: null, - anchorSequence: throughSequence + 1, - maxBytes, - }); - await this.#withDecodedPage(page, (decoded) => { - if (!this.#isLive() || !isCurrent()) return; - this.#acceptRange(decoded.messages); - this.#installDurable(decoded.messages); - this.#hasOlder = decoded.nextCursor !== null; - this.#evictToBudget( - undefined, - page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, - ); - }); - }); - } - - /** - * Whether the cache holds less than the tail it is meant to hold. `#hasOlder` - * settles the case a Turn count cannot: a short Session whose whole durable - * transcript is resident is never short, however few Turns that is. - */ - #tailIsShort(): boolean { - if (!this.#hasOlder) return false; - const turns = new Set(); - for (const entry of this.#durable.values()) turns.add(residentTurnKey(entry)); - return turns.size < this.#maxResidentTurns; - } - - loadAround( - sequence: number, + async readTurn( + turnId: string, + extent: { readonly sequence: number; readonly lastSequence: number }, maxBytes: number, - isCurrent: () => boolean = () => true, - ): Promise { - return this.#enqueue(async () => { - if (!this.#isLive() || !isCurrent()) return undefined; - const throughSequence = this.#durableThrough; - if (throughSequence === null || sequence > throughSequence) return undefined; - const page = await this.#handle.loadTranscriptPage({ - direction: 'newer', - throughSequence, - cursor: null, - anchorSequence: sequence === 0 ? null : sequence - 1, - maxBytes, - }); - if (!this.#isLive() || !isCurrent()) return undefined; - // A durable sequence is an event ordinal times its stride, so the oldest - // row of a Session is at no fixed number and `sequence > 0` cannot answer - // whether anything precedes the anchor. Ask for one row older instead. - const older = await this.#handle.loadTranscriptPage({ - direction: 'older', - throughSequence, - cursor: null, - anchorSequence: sequence, - maxBytes: 1, - }); - return this.#withDecodedPage(page, (decoded) => { - if (!this.#isLive() || !isCurrent()) return undefined; - this.#acceptRange(decoded.messages); - if (decoded.messages.length > 0 && decoded.messages[0]!.identity !== sequence) { - throw correlationError('Desktop transcript range did not meet its anchor'); - } - return { - sessionId: this.sessionId, - generation: this.generation, - hostEpoch: this.hostEpoch, - durableThrough: throughSequence, - durable: decoded.messages.map((entry) => ({ - sequence: entry.identity, - message: entry.message, - })), - hasOlder: older.fragments.length > 0, - hasNewer: decoded.nextCursor !== null, - }; - }); - }); + ): Promise { + this.#assertLive(); + const firstSequence = extent.sequence; + const throughSequence = + this.#durableThrough === null ? null : Math.min(this.#durableThrough, extent.lastSequence); + const durable: StoredMessage[] = []; + let bytes = 0; + let cursor: string | null = null; + let nextSequence = firstSequence; + if (throughSequence !== null && firstSequence <= throughSequence) { + do { + const page: SessionTranscriptPage = await this.#handle.loadTranscriptPage({ + direction: 'newer', + throughSequence, + cursor, + anchorSequence: cursor === null && firstSequence > 0 ? firstSequence - 1 : null, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + }); + await this.#withDecodedPage(page, (decoded) => { + this.#assertLive(); + if (decoded.messages.length === 0 && decoded.nextCursor !== null) { + throw correlationError('Desktop transcript Turn read returned an empty continuation'); + } + this.#acceptRange(decoded.messages); + const first = decoded.messages[0]; + if (first && !this.#matchesCoverageStep(first.identity, nextSequence)) { + throw correlationError('Desktop transcript Turn read has a sequence gap'); + } + nextSequence = (decoded.messages.at(-1)?.identity ?? nextSequence - 1) + 1; + for (const { message } of decoded.messages) { + if (messageTurnId(message) !== turnId) continue; + bytes += encodedMessageBytes(message); + if (bytes > maxBytes) throw new RangeError('Desktop transcript Turn exceeds its read limit'); + durable.push(message); + } + cursor = decoded.nextCursor; + }); + } while (cursor !== null); + } + return durable; } advance(throughSequence: number): Promise { @@ -431,7 +363,7 @@ export class DesktopTranscriptReplica { throughSequence: target, cursor, anchorSequence: cursor === null ? anchorSequence : null, - maxBytes: 512 * 1024, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, }); await this.#withDecodedPage(page, (decoded) => { // A concurrent `discard()` (LRU reclaim for another observed session) @@ -452,10 +384,7 @@ export class DesktopTranscriptReplica { nextSequence = decoded.messages.at(-1)!.identity + 1; } this.#installDurable(decoded.messages); - this.#evictToBudget( - undefined, - page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, - ); + this.#evictToBudget(); // The watermark moves with every page, not only at the end: a window // opening mid-catch-up takes a snapshot whose rows must agree with the // `durableThrough` it names, or the next change cannot join it. @@ -536,27 +465,25 @@ export class DesktopTranscriptReplica { } /** - * Evicts whole Turns from the oldest edge until the tail fits. The protected - * Turn and everything newer stay even when they alone exceed the budget: the - * projector needs the newest Turn complete. Global pressure calls with no - * protection and may empty the tail. + * Evicts whole Turns from the oldest edge until the tail fits. The newest + * Turn stays even when it alone exceeds the budget: the projector needs it + * complete. Global pressure passes a budget and may empty the tail. */ - #evictToBudget( - budget: number | undefined = undefined, - protectedSequence?: number, - ): void { + #evictToBudget(budget?: number): void { const residentBudget = budget ?? this.#maxResidentBytes; const turns = new Map(); - for (const sequence of [...this.#durable.keys()].sort((left, right) => left - right)) { + const sequences = [...this.#durable.keys()].sort((left, right) => left - right); + for (const sequence of sequences) { const key = residentTurnKey(this.#durable.get(sequence)!); const group = turns.get(key); if (group) group.push(sequence); else turns.set(key, [sequence]); } - const protectedEntry = protectedSequence === undefined - ? undefined - : this.#durable.get(protectedSequence); - const protectedKey = protectedEntry === undefined ? undefined : residentTurnKey(protectedEntry); + // A trailing Session note is not the Turn the projector needs to keep whole. + const keys = [...turns.keys()]; + const protectedKey = budget === undefined + ? [...keys].reverse().find((key) => key.startsWith('turn:')) ?? keys.at(-1) + : undefined; let residentTurns = turns.size; for (const [key, sequences] of turns) { if (this.#residentBytes <= residentBudget && residentTurns <= this.#maxResidentTurns) return; @@ -569,6 +496,9 @@ export class DesktopTranscriptReplica { } residentTurns -= 1; this.#hasOlder = true; + // Eviction groups rows by their owner, which is not where a Turn ends + // when another Turn's rows are written between them. + this.#beginsAtTurnBoundary = false; } } @@ -638,6 +568,11 @@ export class DesktopTranscriptReplica { if (this.#closed) throw new Error('Desktop transcript replica is closed'); } + #assertLive(): void { + this.#assertOpen(); + this.#assertResident(); + } + #assertResident(): void { if (!this.#resident) { throw new Error('Desktop transcript replica was evicted'); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 5be7c0ed42..a242c86692 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -34,6 +34,7 @@ import { E2E_FIXTURE_NOW, LONG_SIDEBAR_PROJECT_ID, LONG_SIDEBAR_PROJECT_NAME, + LARGE_HISTORY_SESSION_PREFIX, LONG_SIDEBAR_SESSION_PREFIX, PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_SESSION_ID, @@ -41,6 +42,7 @@ import { writeSession, } from './e2e-fixture/seed-helpers.js'; import { + largeHistorySessions, partialHistoryMessages, partialHistorySession, promptRailMessages, @@ -64,6 +66,7 @@ const E2E_FIXTURE_SCENARIOS = new Set([ 'turn-narrative-browser', 'chat-prompt-rail', 'chat-partial-history', + 'chat-large-history', 'settings-data', 'settings-bots-onboarding', 'settings-general', @@ -175,6 +178,8 @@ export function getE2eFixtureState(fixture: E2eFixture | null): E2eFixtureState return { ...state, activeSessionId: PROMPT_RAIL_SESSION_ID, workbarCollapsed: true }; case 'chat-partial-history': return { ...state, activeSessionId: PARTIAL_HISTORY_SESSION_ID, workbarCollapsed: true }; + case 'chat-large-history': + return { ...state, activeSessionId: `${LARGE_HISTORY_SESSION_PREFIX}1`, workbarCollapsed: true }; case 'settings-data': return { ...state, activeSessionId: TURN_SESSION_ID, openSettingsSection: 'data' }; case 'settings-bots-onboarding': @@ -237,6 +242,11 @@ export async function seedE2eFixture(input: { partialHistoryMessages(now), ); } + if (scenario === 'chat-large-history') { + for (const seed of largeHistorySessions(now)) { + await writeSession(input.workspaceRoot, seed.header, seed.messages); + } + } if (scenario === 'sidebar-search-modal-open') { for (const seed of longSidebarSessions(now)) { await writeSession(input.workspaceRoot, seed.header, seed.messages); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index 763b91ada0..50e49c557a 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -20,6 +20,9 @@ import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { header, + LARGE_HISTORY_REPLY_BYTES, + LARGE_HISTORY_SESSION_PREFIX, + LARGE_HISTORY_TURN_COUNTS, PARTIAL_HISTORY_SESSION_ID, PROMPT_RAIL_PROMPT_COUNT, PROMPT_RAIL_SESSION_ID, @@ -125,6 +128,10 @@ export function promptRailSession(now: number): SessionHeader { * A plain multi-prompt conversation: no tools, no thinking, no usage rows. * The transcript perf suite measures against this, so every turn is just a * prompt and a reply long enough to push the transcript past the scrollport. + * + * Reply length has to vary the way a real transcript's does. A uniform reply + * lets the virtualizer estimate every unmounted row correctly, which is the one + * case where mounting a row above the reader cannot move them. */ export function promptRailMessages(now: number): StoredMessage[] { const messages: StoredMessage[] = []; @@ -143,12 +150,69 @@ export function promptRailMessages(now: number): StoredMessage[] { id: `msg-prompt-rail-assistant-${index}`, turnId, ts: ts + 1_000, - text: `第 ${index} 段回答。`.repeat(40), + text: promptRailReply(index), modelId: 'glm-5.1', }); } return messages; } + +function promptRailReply(index: number): string { + const prose = `第 ${index} 段回答。`.repeat(4 + ((index * 7) % 60)); + if (index % 5 !== 0) return prose; + const code = Array.from( + { length: 6 + ((index * 3) % 40) }, + (_, line) => ` const step${line} = await pipeline.run(${index}, ${line});`, + ).join('\n'); + return `${prose}\n\n\`\`\`ts\n${code}\n\`\`\`\n\n收尾说明。`; +} +/** + * Transcripts past the real 64 MiB read budget, for measuring what holding + * history costs. Prose rather than whitespace: these are weighed, not looked + * at, and collapsed padding would understate the renderer's share. + */ +export function largeHistorySessions( + now: number, +): { header: SessionHeader; messages: StoredMessage[] }[] { + const reply = '这一步的实现、验证与遗留问题记录如下。'.repeat( + Math.ceil(LARGE_HISTORY_REPLY_BYTES / 54), + ); + return LARGE_HISTORY_TURN_COUNTS.map((turns, seat) => { + const id = `${LARGE_HISTORY_SESSION_PREFIX}${seat + 1}`; + const messages: StoredMessage[] = []; + for (let index = 1; index <= turns; index += 1) { + const turnId = `turn-${id}-${index}`; + const ts = now - (turns + 1 - index) * 60_000; + messages.push({ + type: 'user', + id: `msg-${id}-user-${index}`, + turnId, + ts, + text: `第 ${index} 个问题:请概括这一阶段的实现进展。`, + }); + messages.push({ + type: 'assistant', + id: `msg-${id}-assistant-${index}`, + turnId, + ts: ts + 1_000, + text: `第 ${index} 阶段。${reply}`, + modelId: 'glm-5.1', + }); + } + return { + header: header({ + id, + name: `大历史内存基准会话 ${seat + 1}`, + connection: 'zai-live', + model: 'glm-5.1', + now, + lastMessageAt: now - (seat + 1) * 60_000, + }), + messages, + }; + }); +} + export function partialHistorySession(now: number): SessionHeader { return header({ id: PARTIAL_HISTORY_SESSION_ID, diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 1e1cd6a535..9821709a7e 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -35,8 +35,30 @@ export const E2E_FIXTURE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0); export const TURN_SESSION_ID = 'e2e-fixture-turn'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; export const PARTIAL_HISTORY_SESSION_ID = 'e2e-fixture-partial-history'; -/** Exceeds both the 64-tick rail and the bounded active transcript range. */ -export const PROMPT_RAIL_PROMPT_COUNT = 120; +/** The transcript history budget the partial-history fixture runs with, so its Session loads in chunks. */ +export const PARTIAL_HISTORY_TRANSCRIPT_BYTES = 1024 * 1024; +export const LARGE_HISTORY_SESSION_PREFIX = 'e2e-fixture-large-history-'; +/** + * The large-history fixture is three Sessions, each past the real 64 MiB read + * budget, so every open stops short and the rest is reached through + * load-earlier. Each Turn is moderate rather than enormous, because that is + * where per-message object and index overhead dominates the byte count. + * + * The first is deep enough to need several rounds of load-earlier — one round + * cannot show whether repeated loading stays linear, and repeated loading is + * what searching back to an early Turn does. The other two are there to be + * different Sessions: a renderer that kept one transcript per Session visited + * would survive any number of returns to the same one. + */ +export const LARGE_HISTORY_DEEP_TURN_COUNT = 12_000; +export const LARGE_HISTORY_TURN_COUNT = 4_000; +export const LARGE_HISTORY_TURN_COUNTS = [ + LARGE_HISTORY_DEEP_TURN_COUNT, + LARGE_HISTORY_TURN_COUNT, + LARGE_HISTORY_TURN_COUNT, +] as const; +export const LARGE_HISTORY_REPLY_BYTES = 18 * 1024; +export const PROMPT_RAIL_PROMPT_COUNT = 500; export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-'; export const LONG_SIDEBAR_SESSION_COUNT = 60; export const LONG_SIDEBAR_PROJECT_ID = 'e2e-fixture-project'; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index ed39b5753e..2265eb18a3 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -128,6 +128,7 @@ import { resolveE2eFixture, seedE2eFixture, } from "./e2e-fixture.js"; +import { PARTIAL_HISTORY_TRANSCRIPT_BYTES } from "./e2e-fixture/seed-helpers.js"; import { createKeepSystemAwakeController } from "./keep-system-awake.js"; import { isDarkAppearance } from "./theme-source.js"; import { @@ -1269,6 +1270,9 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( }, emitSessionsChanged, cacheTranscript: (scope, snapshot) => sessionLocal.cacheTranscript(scope, snapshot), + ...(e2eFixture?.scenario === "chat-partial-history" + ? { transcriptHistoryBytes: PARTIAL_HISTORY_TRANSCRIPT_BYTES } + : {}), completeDesktopInteractionTurn, createSessionCopyCleanup: ({ removeSession, resumeSessionCopy }) => createSessionCopyCleanupAuthority({ diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 0a5731f857..95acf56dff 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1700,11 +1700,13 @@ export class DesktopRuntimeHostClient { async listSessionTurnLandmarks( sessionId: string, + turnId: string | null = null, ): Promise> { this.#assertOpen(); return this.request('session.turn_landmarks.query', { sessionId, - maxLandmarks: 64, + maxLandmarks: turnId === null ? 64 : 1, + turnId, }); } diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 041300e768..5f5a56a286 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -159,6 +159,7 @@ export interface DesktopRuntimeHostCandidateDeps { sessionId: string, ) => void | Promise; readonly e2eInteractions?: RuntimeHostSessionExecutionIpcDeps["e2eInteractions"]; + readonly transcriptHistoryBytes?: number; readonly renderer?: { send(channel: string, scope: DesktopTargetScope, payload: unknown): void; }; @@ -657,6 +658,7 @@ export async function createDesktopRuntimeHostCandidate( }; const sessionObserver = new RuntimeHostSessionObserver({ client, + transcriptHistoryBytes: deps.transcriptHistoryBytes, cacheTranscript: (snapshot) => { if (target.access === 'owner') deps.cacheTranscript?.(scope, snapshot); }, diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 5b156b094c..8f9ff5ff60 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -73,7 +73,7 @@ import { type RuntimeHostTranscriptTarget, } from "./runtime-host-session-observer.js"; import type { - DesktopTranscriptRangeRequest, + DesktopTranscriptOpenMode, DesktopTranscriptTailAcknowledgement, } from '../preload/transcript-contract.js'; import type { DesktopSessionStopResult } from '../preload/bridge-contract.js'; @@ -229,12 +229,10 @@ export interface RuntimeHostSessionObservationIpcDeps { observations: Pick< RuntimeHostSessionObservationRegistry, | 'acknowledgeTranscriptTail' - | 'loadTranscriptAround' - | 'loadTranscriptBefore' - | 'loadTranscriptAfter' - | 'loadTranscriptLatest' + | 'loadEarlierTranscript' | 'observe' | 'openTranscript' + | 'readTranscriptTurn' >; resolveSideConversation(sessionId: string): Promise; } @@ -261,39 +259,33 @@ export function registerRuntimeHostSessionObservationIpc( ); ipcMain.handle( 'sessions:transcript:open', - async (event, sessionId: unknown, consumerId: unknown) => + async (event, sessionId: unknown, consumerId: unknown, mode: unknown, resumeFrom: unknown) => observationIpcResult( deps.observations.openTranscript( requiredId(sessionId, 'Session'), requiredId(consumerId, 'Transcript consumer'), event.sender as RuntimeHostTranscriptTarget, + normalizeTranscriptOpenMode(mode), + optionalSequence(resumeFrom, 'Desktop transcript resume position'), ), ), ); - ipcMain.handle('sessions:transcript:load-before', async (event, input: unknown) => { - await deps.observations.loadTranscriptBefore( - normalizeTranscriptRangeRequest(input), - event.sender.id, - ); - }); - ipcMain.handle('sessions:transcript:load-around', async (event, input: unknown) => { - await deps.observations.loadTranscriptAround( - normalizeTranscriptRangeRequest(input), - event.sender.id, - ); - }); - ipcMain.handle('sessions:transcript:load-after', async (event, input: unknown) => { - await deps.observations.loadTranscriptAfter( - normalizeTranscriptRangeRequest(input), - event.sender.id, - ); - }); - ipcMain.handle('sessions:transcript:load-latest', async (event, input: unknown) => { - await deps.observations.loadTranscriptLatest( - normalizeTranscriptRangeRequest(input), - event.sender.id, - ); - }); + ipcMain.handle( + 'sessions:transcript:load-earlier', + async (event, consumerId: unknown, throughSequence: unknown) => { + await deps.observations.loadEarlierTranscript( + requiredId(consumerId, 'Transcript consumer'), + event.sender.id, + optionalSequence(throughSequence, 'Desktop transcript earlier target'), + ); + }, + ); + handleReconnectableRead( + ipcMain, + 'sessions:transcript:read-turn', + (_event, sessionId: unknown, turnId: unknown) => + deps.observations.readTranscriptTurn(requiredId(sessionId, 'Session'), requiredId(turnId, 'Turn')), + ); ipcMain.handle('sessions:transcript:acknowledge-tail', async (event, input: unknown) => { await deps.observations.acknowledgeTranscriptTail( normalizeTranscriptTailAcknowledgement(input), @@ -394,8 +386,11 @@ export function registerRuntimeHostSessionExecutionIpc( handleReconnectableRead( ipcMain, 'sessions:listTurnLandmarks', - async (_event, sessionId: unknown) => - deps.client.listSessionTurnLandmarks(requiredId(sessionId, 'Session')), + async (_event, sessionId: unknown, turnId: unknown) => + deps.client.listSessionTurnLandmarks( + requiredId(sessionId, 'Session'), + turnId === null ? null : requiredId(turnId, 'Turn'), + ), ); handleReconnectableRead( ipcMain, @@ -883,35 +878,15 @@ export function registerRuntimeHostSessionExecutionIpc( }; } -function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRangeRequest { - if (!input || typeof input !== 'object' || Array.isArray(input)) { - throw new Error('Invalid Desktop transcript range request'); - } - const value = input as Record; - const anchorSequence = value.anchorSequence; - const maxBytes = value.maxBytes; - if ( - anchorSequence !== null && - (!Number.isSafeInteger(anchorSequence) || (anchorSequence as number) < 0) - ) { - throw new Error('Invalid Desktop transcript range anchor'); - } - if (!Number.isSafeInteger(maxBytes)) { - throw new Error('Invalid Desktop transcript range byte limit'); - } - if ( - !Number.isSafeInteger(value.navigation) || (value.navigation as number) < 0 - ) { - throw new Error('Invalid Desktop transcript navigation'); - } - return { - consumerId: requiredId(value.consumerId, 'Transcript consumer'), - sessionId: requiredId(value.sessionId, 'Session'), - hostEpoch: requiredId(value.hostEpoch, 'Host epoch'), - anchorSequence: anchorSequence as number | null, - maxBytes: maxBytes as number, - navigation: value.navigation as number, - }; +function normalizeTranscriptOpenMode(mode: unknown): DesktopTranscriptOpenMode { + if (mode === 'tail' || mode === 'history') return mode; + throw new Error('Invalid Desktop transcript open mode'); +} + +function optionalSequence(value: unknown, label: string): number | undefined { + if (value === undefined || value === null) return undefined; + if (Number.isSafeInteger(value) && (value as number) >= 0) return value as number; + throw new Error(`Invalid ${label}`); } function normalizeTranscriptTailAcknowledgement( diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts index 354e774905..bfd4c4f2f1 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { StoredMessage } from '@maka/core/session'; import { RuntimeHostOperationError } from "@maka/runtime-host/client"; import type { RuntimeHostSessionObserver, @@ -25,8 +26,8 @@ import type { RuntimeHostTranscriptTarget, } from "./runtime-host-session-observer.js"; import type { + DesktopTranscriptOpenMode, DesktopTranscriptOpenResult, - DesktopTranscriptRangeRequest, DesktopTranscriptTailAcknowledgement, } from '../preload/transcript-contract.js'; @@ -37,11 +38,9 @@ type SessionObservationSource = Pick >; @@ -50,11 +49,9 @@ type TranscriptSource = Required< RuntimeHostSessionObserver, | 'acknowledgeTranscriptTail' | 'closeTranscript' - | 'loadTranscriptAround' - | 'loadTranscriptBefore' - | 'loadTranscriptAfter' - | 'loadTranscriptLatest' + | 'loadEarlierTranscript' | 'openTranscript' + | 'readTranscriptTurn' > >; @@ -78,10 +75,8 @@ function requireTranscriptSource( if ( !source?.openTranscript || !source.acknowledgeTranscriptTail || - !source.loadTranscriptBefore || - !source.loadTranscriptAfter || - !source.loadTranscriptAround || - !source.loadTranscriptLatest || + !source.loadEarlierTranscript || + !source.readTranscriptTurn || !source.closeTranscript ) { throw new Error('Runtime Host transcript source is unavailable'); @@ -114,12 +109,20 @@ interface SessionObservationRegistration { interface TranscriptRegistration { readonly sessionId: string; + readonly mode: DesktopTranscriptOpenMode; readonly target: RuntimeHostTranscriptTarget; readonly destroyedListener: () => void; readonly ready: TranscriptReadiness; restore: ObservationReadiness | undefined; restoreOpened: boolean; lifecycle: 'pending' | 'active'; + /** + * The oldest sequence this consumer has been given. A consumer dies with + * the connection that made it; the reader it feeds does not, so a + * replacement has to reopen onto the history the reader is holding rather + * than onto a fresh budget. + */ + deliveredFrom: number | null; } interface TranscriptReadiness { @@ -341,10 +344,39 @@ export class RuntimeHostSessionObservationRegistry { await this.#remove(observerId); } + /** + * The consumer's target, with the oldest sequence it is handed recorded on + * the registration — which outlives the connection the consumer belongs to. + * A reset replaces what the consumer holds, so it starts the count again. + */ + #trackDelivered( + registration: TranscriptRegistration, + target: RuntimeHostTranscriptTarget, + ): RuntimeHostTranscriptTarget { + return { + get id() { + return target.id; + }, + send: (channel, payload) => { + if (payload.reset) registration.deliveredFrom = null; + for (const { sequence } of payload.fragments) { + if (registration.deliveredFrom === null || sequence < registration.deliveredFrom) { + registration.deliveredFrom = sequence; + } + } + target.send(channel, payload); + }, + once: (event, listener) => target.once(event, listener), + off: (event, listener) => target.off(event, listener), + }; + } + async openTranscript( sessionId: string, consumerId: string, target: RuntimeHostTranscriptTarget, + mode: DesktopTranscriptOpenMode = 'tail', + resumeFrom?: number, ): Promise { this.#assertOpen(); if (this.#transcripts.has(consumerId)) { @@ -357,12 +389,14 @@ export class RuntimeHostSessionObservationRegistry { void ready.promise.catch(() => undefined); const registration: TranscriptRegistration = { sessionId, + mode, target, destroyedListener, ready, restore: undefined, restoreOpened: false, lifecycle: 'pending', + deliveredFrom: resumeFrom ?? null, }; this.#transcripts.set(consumerId, registration); target.once('destroyed', destroyedListener); @@ -373,7 +407,9 @@ export class RuntimeHostSessionObservationRegistry { const result = await transcriptSource.openTranscript( sessionId, consumerId, - this.#bindTarget(target), + this.#trackDelivered(registration, this.#bindTarget(target)), + mode, + resumeFrom, ); if (this.#source === source && this.#transcripts.get(consumerId) === registration) { registration.lifecycle = 'active'; @@ -391,40 +427,19 @@ export class RuntimeHostSessionObservationRegistry { return registration.ready.promise; } - async loadTranscriptBefore( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - await this.#runTranscriptOperation(request, (source) => - source.loadTranscriptBefore(request, targetId), - ); - } - - async loadTranscriptAround( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - await this.#runTranscriptOperation(request, (source) => - source.loadTranscriptAround(request, targetId), - ); - } - - async loadTranscriptAfter( - request: DesktopTranscriptRangeRequest, + async loadEarlierTranscript( + consumerId: string, targetId?: number, + throughSequence?: number, ): Promise { - await this.#runTranscriptOperation(request, (source) => - source.loadTranscriptAfter(request, targetId), + await this.#runTranscriptOperation({ consumerId }, (source) => + source.loadEarlierTranscript(consumerId, targetId, throughSequence), ); } - async loadTranscriptLatest( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - await this.#runTranscriptOperation(request, (source) => - source.loadTranscriptLatest(request, targetId), - ); + readTranscriptTurn(sessionId: string, turnId: string): Promise { + this.#assertOpen(); + return requireTranscriptSource(this.#source).readTranscriptTurn(sessionId, turnId); } async acknowledgeTranscriptTail( @@ -572,7 +587,9 @@ export class RuntimeHostSessionObservationRegistry { const result = await transcriptSource.openTranscript( registration.sessionId, consumerId, - this.#bindTarget(registration.target), + this.#trackDelivered(registration, this.#bindTarget(registration.target)), + registration.mode, + registration.deliveredFrom ?? undefined, ); if ( this.#source === source && diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 407a61a592..660eb7b7b0 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -27,25 +27,24 @@ import { isRuntimeHostTerminalTurn as isTerminalTurn, projectRuntimeHostInteractionRequest, } from "@maka/runtime-host/adapter"; -import { - SESSION_TRANSCRIPT_RANGE_MAX_BYTES, - type InteractionAnsweredSnapshot, - type InteractionPendingSnapshot, - type SessionDomainChange, - type SessionContinuitySnapshot, - type SubscriptionFrame, +import type { + InteractionAnsweredSnapshot, + InteractionPendingSnapshot, + SessionDomainChange, + SessionContinuitySnapshot, + SubscriptionFrame, } from "@maka/runtime-host/protocol"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES, - DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE, - DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES, + DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, type DesktopTranscriptBatch, type DesktopTranscriptBatchPayload, + type DesktopTranscriptOpenMode, type DesktopTranscriptOpenResult, - type DesktopTranscriptRangeRequest, type DesktopTranscriptTailAcknowledgement, } from '../preload/transcript-contract.js'; import { @@ -60,13 +59,21 @@ import { type DesktopTranscriptReplicaSnapshot, } from './desktop-transcript-replica.js'; import { + encodeDesktopTranscriptBatches, encodeDesktopTranscriptChange, - encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot, } from './desktop-transcript-ipc.js'; type SessionObserverClient = Pick & - Partial>; + Partial< + Pick< + DesktopRuntimeHostClient, + | 'listSessionTurns' + | 'listSessionTurnLandmarks' + | 'setSessionReadMarker' + | 'queryMessageExecutions' + > + >; const TRANSCRIPT_DELIVERY_TIMEOUT_MS = 30_000; const TRANSCRIPT_DELIVERY_WINDOW = 4; @@ -103,6 +110,7 @@ export interface RuntimeHostSessionObserverDeps { ) => void; emitSubscriptionRecovered?: (sessionId: string) => void; recoverConnectionClosed?: boolean; + transcriptHistoryBytes?: number; now?: () => number; } @@ -131,21 +139,13 @@ interface TranscriptConsumer { readonly consumerId: string; readonly target: RuntimeHostTranscriptTarget; generation: string; - /** The standing replacement; a read naming an older one has been abandoned. */ - navigation: number; deliverySequence: number; deliveryBytes: number; deliveryTask?: Promise; resetRequested: boolean; - /** - * Set only when the reset answers a navigation command, and stamped on that - * snapshot so the window can tell its own answer from a replacement it did - * not ask for. A reset from recovery or from an error carries no version: - * the window applies it to whatever it holds, under any navigation. - */ - resetNavigation?: number; - /** Page answers queued behind the delivery loop so they never interleave with a change. */ - readonly pendingPages: PendingTranscriptPage[]; + /** An earlier read to send, reading down to `floor` when it is set. */ + earlierRequested: false | { readonly floor: number | null }; + readonly history?: TranscriptHistory; pendingChange?: PendingTranscriptChange; readonly pendingDeliveries: Map; } +/** Where a history consumer's delivered history ends. */ +interface TranscriptHistory { + throughSequence: number | null; + started: boolean; + cursor: string | null; + /** The oldest sequence delivered so far; a reset reads down to it again. */ + oldestSequence: number | null; +} + interface PendingTranscriptChange { - coversFrom: number | null | undefined; + readonly coversFrom: number | null; durableThrough: number | null; readonly durableUpserts: Map; encodedBytes: number; } -interface PendingTranscriptPage { - readonly navigation: number; - readonly generation: string; - readonly batches: Iterable; - readonly encodedBytes: number; -} - interface PendingTranscriptUpsert { readonly entry: DesktopSequencedTranscriptMessage; readonly encodedBytes: number; @@ -225,6 +227,7 @@ export class RuntimeHostSessionObserver { ) => void; readonly #emitSubscriptionRecovered: (sessionId: string) => void; readonly #recoverConnectionClosed: boolean; + readonly #transcriptHistoryBytes: number; readonly #now: () => number; #closed = false; #transcriptAccessClock = 0; @@ -248,6 +251,7 @@ export class RuntimeHostSessionObserver { this.#emitSubscriptionRecovered = deps.emitSubscriptionRecovered ?? (() => undefined); this.#recoverConnectionClosed = deps.recoverConnectionClosed ?? false; + this.#transcriptHistoryBytes = deps.transcriptHistoryBytes ?? DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES; this.#now = deps.now ?? Date.now; } @@ -255,6 +259,13 @@ export class RuntimeHostSessionObserver { sessionId: string, consumerId: string, target: RuntimeHostTranscriptTarget, + mode: DesktopTranscriptOpenMode = 'tail', + /** + * The oldest sequence the reader behind this consumer already holds. A + * consumer is new after the connection is replaced; the reader is not, so + * the first answer reads back down to here instead of to one budget. + */ + resumeFrom?: number, ): Promise { this.#assertOpen(); if ( @@ -306,11 +317,20 @@ export class RuntimeHostSessionObserver { consumerId, target, generation: replica.generation, - navigation: 0, deliverySequence: 0, deliveryBytes: 0, resetRequested: false, - pendingPages: [], + earlierRequested: false, + ...(mode === 'history' + ? { + history: { + throughSequence: null, + started: false, + cursor: null, + oldestSequence: resumeFrom ?? null, + }, + } + : {}), pendingDeliveries: new Map(), }; state.transcriptConsumers.set(consumerId, consumer); @@ -342,176 +362,51 @@ export class RuntimeHostSessionObserver { } } - async loadTranscriptBefore( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, false, async (replica, isCurrent) => { - const page = await replica.loadBefore( - request.anchorSequence, - requireTranscriptRangeBytes(request.maxBytes), - isCurrent, - ); - return page && { - batches: encodeDesktopTranscriptPage(this.#pageIdentity(replica, request), page, { - direction: 'older', anchor: request.anchorSequence, - }), - bytes: page.durable, - }; - }); - } - - async loadTranscriptAfter( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, false, async (replica, isCurrent) => { - const page = await replica.loadAfter( - request.anchorSequence, - requireTranscriptRangeBytes(request.maxBytes), - isCurrent, - ); - return page && { - batches: encodeDesktopTranscriptPage(this.#pageIdentity(replica, request), page, { - direction: 'newer', anchor: request.anchorSequence, - }), - bytes: page.durable, - }; - }); - } - - async loadTranscriptAround( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - if (request.anchorSequence === null) { - throw new Error('Desktop transcript around request requires an anchor'); - } - const sequence = request.anchorSequence; - await this.#runTranscriptRangeOperation(request, targetId, true, async (replica, isCurrent) => { - const snapshot = await replica.loadAround( - sequence, - requireTranscriptRangeBytes(request.maxBytes), - isCurrent, - ); - return snapshot && { - batches: encodeDesktopTranscriptSnapshot(snapshot, request.navigation), - bytes: snapshot.durable, - }; - }); - } - - async loadTranscriptLatest( - request: DesktopTranscriptRangeRequest, + /** + * Delivers the next history budget older than what the consumer holds, and + * past it down to `throughSequence` when given, in one answer. + */ + async loadEarlierTranscript( + consumerId: string, targetId?: number, + throughSequence?: number, ): Promise { - const { state, replica, consumer } = this.#admitTranscriptNavigation(request, targetId, true); - if (!consumer) return; - // This answer is the tail cache, and global reclaim trims that cache even - // while the Session is open (`#touchReplica`). Refill it first or a reader - // returning to latest is answered with less than a tail. - await replica.refillTail( - requireTranscriptRangeBytes(request.maxBytes), - this.#transcriptReadIsCurrent(state, replica, consumer, request), - ); - if (!this.#transcriptReadIsCurrent(state, replica, consumer, request)()) return; - consumer.resetRequested = true; - consumer.resetNavigation = request.navigation; + const state = this.#transcriptConsumers.get(consumerId); + const consumer = state?.transcriptConsumers.get(consumerId); + if (!state || !consumer?.history) { + throw new Error('Desktop transcript history consumer does not exist'); + } + if (targetId !== undefined && consumer.target.id !== targetId) { + throw new Error('Desktop transcript consumer belongs to another renderer'); + } + const floors = [consumer.earlierRequested ? consumer.earlierRequested.floor : null, throughSequence] + .filter((floor): floor is number => typeof floor === 'number'); + consumer.earlierRequested = { floor: floors.length === 0 ? null : Math.min(...floors) }; await this.#scheduleTranscriptDelivery(state, consumer); + // The loop may have been finishing when the request arrived. + if (consumer.earlierRequested) await this.#scheduleTranscriptDelivery(state, consumer); this.#touchReplica(state); } - #pageIdentity(replica: DesktopTranscriptReplica, request: DesktopTranscriptRangeRequest) { - return { - sessionId: replica.sessionId, - generation: replica.generation, - hostEpoch: replica.hostEpoch, - navigation: request.navigation, - }; - } - - /** - * Main's navigation number is a cancellation hint and nothing more: dropping - * it would leave the system correct, because the Renderer decides what its - * window can splice from the anchors the answers carry. What it buys is not - * reading and shipping pages for a window the reader has already left. - */ - #admitTranscriptNavigation( - request: DesktopTranscriptRangeRequest, - targetId: number | undefined, - replaces: boolean, - ): { state: ObservedSessionState; replica: DesktopTranscriptReplica; consumer?: TranscriptConsumer } { - const { state, replica, consumer } = this.#requireTranscriptConsumer(request, targetId); - const navigation = request.navigation; - if (!Number.isSafeInteger(navigation) || navigation < 0) throw new Error('Invalid transcript navigation version'); - if (navigation < consumer.navigation) return { state, replica }; - if (replaces && navigation > consumer.navigation) { - consumer.navigation = navigation; - // Every queued answer was read for a window this replacement discards. - consumer.pendingPages.splice(0).forEach((page) => - this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes), - ); - } - return { state, replica, consumer }; - } - - /** Asked before a read is started and again before its answer is sent. */ - #deliversTranscriptPage(consumer: TranscriptConsumer, navigation: number): boolean { - return navigation >= consumer.navigation; - } - - /** Whether a Host read still belongs to the window that asked for it. */ - #transcriptReadIsCurrent( - state: ObservedSessionState, - replica: DesktopTranscriptReplica, - consumer: TranscriptConsumer, - request: DesktopTranscriptRangeRequest, - ): () => boolean { - return () => - state.replica === replica && - state.transcriptConsumers.get(request.consumerId) === consumer && - this.#deliversTranscriptPage(consumer, request.navigation); - } - - async #runTranscriptRangeOperation( - request: DesktopTranscriptRangeRequest, - targetId: number | undefined, - replaces: boolean, - operation: ( - replica: DesktopTranscriptReplica, - isCurrent: () => boolean, - ) => Promise< - | { batches: Iterable; bytes: readonly { readonly message: StoredMessage }[] } - | undefined - >, - ): Promise { - const { state, replica, consumer } = this.#admitTranscriptNavigation(request, targetId, replaces); - if (!consumer) return; - const isCurrent = this.#transcriptReadIsCurrent(state, replica, consumer, request); - let answer: Awaited>; + /** Every message of one Turn, whether or not any consumer holds it. */ + async readTranscriptTurn(sessionId: string, turnId: string): Promise { + this.#assertOpen(); + const state = this.#state(sessionId); + state.pendingTranscriptConsumers += 1; try { - answer = await operation(replica, isCurrent); - } catch (error) { - // A replaced replica's failure is not a failure of the current window. - if (!isCurrent()) return; - throw error; - } - if (!answer || !isCurrent()) return; - const encodedBytes = answer.bytes.reduce( - (total, { message }) => total + encodedTranscriptMessageBytes(message), - 0, - ); - if (!this.#adjustTranscriptDeliveryBytes(consumer, encodedBytes)) { - throw new Error('Desktop transcript delivery capacity was reached'); + await state.subscriptionOwner.waitUntilReady(); + if (!state.replica?.resident) await state.subscriptionOwner.refresh(); + const replica = state.replica; + if (!replica?.resident) throw new Error('Desktop transcript replica is unavailable'); + const landmark = (await this.#client.listSessionTurnLandmarks?.(sessionId, turnId)) + ?.landmarks[0]; + if (!landmark) return replica.messagesForTurn(turnId); + return await replica.readTurn(turnId, landmark, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES); + } finally { + state.pendingTranscriptConsumers -= 1; + this.#touchReplica(state); + void this.#closeIfIdle(state); } - consumer.pendingPages.push({ - navigation: request.navigation, - generation: replica.generation, - batches: answer.batches, - encodedBytes, - }); - await this.#scheduleTranscriptDelivery(state, consumer); - if (isCurrent()) this.#touchReplica(state); } async closeTranscript(consumerId: string, targetId?: number): Promise { @@ -1377,38 +1272,31 @@ export class RuntimeHostSessionObserver { while (state.transcriptConsumers.get(consumer.consumerId) === consumer) { if (consumer.resetRequested) { consumer.resetRequested = false; - const resetNavigation = consumer.resetNavigation; - consumer.resetNavigation = undefined; this.#clearPendingTranscriptChange(consumer); const replica = state.replica; if (!replica?.resident || state.closing) return; + consumer.generation = replica.generation; + if (consumer.history) { + await this.#sendTranscriptHistory(state, consumer, consumer.history, replica, true); + continue; + } const deliveryBytes = resetDeliveryWorkingSetBytes(replica.residentBytes); if (!this.#adjustTranscriptDeliveryBytes(consumer, deliveryBytes)) { throw new Error('Desktop transcript delivery capacity was reached'); } - consumer.generation = replica.generation; try { - await this.#sendTranscriptBatches( - consumer, - encodeDesktopTranscriptSnapshot(replica.snapshot(), resetNavigation), - ); + await this.#sendTranscriptBatches(consumer, encodeDesktopTranscriptSnapshot(replica.snapshot())); } finally { this.#adjustTranscriptDeliveryBytes(consumer, -deliveryBytes); } continue; } - const page = consumer.pendingPages.shift(); - if (page) { - try { - if ( - this.#deliversTranscriptPage(consumer, page.navigation) && - page.generation === consumer.generation && - state.replica?.generation === consumer.generation - ) { - await this.#sendTranscriptBatches(consumer, page.batches); - } - } finally { - this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes); + if (consumer.earlierRequested) { + const { floor } = consumer.earlierRequested; + consumer.earlierRequested = false; + const replica = state.replica; + if (consumer.history && replica?.resident && replica.generation === consumer.generation) { + await this.#sendTranscriptHistory(state, consumer, consumer.history, replica, false, floor); } continue; } @@ -1461,17 +1349,108 @@ export class RuntimeHostSessionObserver { } /** - * Coalesces tail growth for one consumer. A merged change can only keep rows - * while each change starts where the last one ended; where it does not, the - * rows go and the merge carries nothing but the watermark, which is enough - * for the window to learn it has fallen behind and read forward itself. + * One history answer: a reset reads the newest whole Turns through the tail + * watermark; an earlier read continues below what was delivered. Each answer + * stops at a Turn boundary once it reaches its byte budget and its floor. + * + * A reset replaces everything the reader holds, so its floor is the oldest + * sequence this consumer was already given. Stopping at one budget would + * take back history the reader had loaded. */ + async #sendTranscriptHistory( + state: ObservedSessionState, + consumer: TranscriptConsumer, + history: TranscriptHistory, + replica: DesktopTranscriptReplica, + reset: boolean, + floor: number | null = null, + ): Promise { + let earlierThan: number | undefined; + if (reset) { + const snapshot = replica.snapshot(); + floor = history.oldestSequence; + Object.assign(history, { + throughSequence: snapshot.durableThrough, + started: false, + cursor: null, + oldestSequence: null, + }); + } else { + if (history.oldestSequence === null || !historyHasOlder(history)) return; + earlierThan = history.oldestSequence; + } + const budget = this.#transcriptHistoryBytes; + const identity = { sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch }; + const isCurrent = () => + state.replica === replica && + state.transcriptConsumers.get(consumer.consumerId) === consumer && + !consumer.resetRequested; + let first = true; + /** Whether the oldest Turn in what has been read so far is whole. */ + let beginsAtTurnBoundary = true; + const send = async (durable: readonly DesktopSequencedTranscriptMessage[], ready: boolean) => { + if (!ready && durable.length === 0) return; + await this.#sendTranscriptBatches( + consumer, + encodeDesktopTranscriptBatches(identity, { + durableThrough: history.throughSequence, + durable, + hasOlder: historyHasOlder(history), + beginsAtTurnBoundary, + ...(earlierThan === undefined ? {} : { earlierThan }), + reset: reset && first, + ready, + }), + ); + first = false; + }; + let bytes = 0; + while (history.throughSequence !== null && historyHasOlder(history)) { + let page: Awaited>; + try { + page = await replica.readOlderPage(history.throughSequence, history.cursor); + } catch (error) { + if (!isCurrent()) return; + throw error; + } + if (!isCurrent()) return; + history.started = true; + history.cursor = page.nextCursor; + beginsAtTurnBoundary = page.endsAtTurnBoundary; + const rows = page.durable; + const rowsBytes = rows.reduce( + (total, entry) => total + encodedTranscriptMessageBytes(entry.message), + 0, + ); + if (!this.#adjustTranscriptDeliveryBytes(consumer, rowsBytes)) { + throw new Error('Desktop transcript delivery capacity was reached'); + } + bytes += rowsBytes; + if (rows.length > 0) history.oldestSequence = rows[0]!.sequence; + try { + await send(rows, false); + } finally { + this.#adjustTranscriptDeliveryBytes(consumer, -rowsBytes); + } + if (!isCurrent()) return; + const reachedFloor = + floor === null || (history.oldestSequence !== null && history.oldestSequence <= floor); + // The Host cuts its pages by bytes, so where an answer may end is the + // Host's to say: a page that leaves a Turn half-read is read past, + // however much of the budget has already been spent. + if (bytes >= budget && reachedFloor && page.endsAtTurnBoundary) break; + } + if (isCurrent()) await send([], true); + } + + /** Coalesces tail growth for one consumer; a change that does not join the pending one needs a reset. */ #mergeTranscriptChange( consumer: TranscriptConsumer, change: DesktopTranscriptReplicaChange, ): boolean { if (consumer.resetRequested) return true; const existing = consumer.pendingChange; + if (existing && existing.durableThrough !== change.coversFrom) return false; const pending = existing ?? { coversFrom: change.coversFrom, durableThrough: change.durableThrough, @@ -1479,15 +1458,8 @@ export class RuntimeHostSessionObserver { encodedBytes: 0, }; let byteDelta = 0; - const joins = !existing || - (pending.coversFrom !== undefined && pending.durableThrough === change.coversFrom); - if (!joins) { - for (const { encodedBytes } of pending.durableUpserts.values()) byteDelta -= encodedBytes; - pending.durableUpserts.clear(); - pending.coversFrom = undefined; - } pending.durableThrough = change.durableThrough; - for (const entry of joins ? change.durableUpserts : []) { + for (const entry of change.durableUpserts) { const previous = pending.durableUpserts.get(entry.sequence); if (previous) byteDelta -= previous.encodedBytes; const encodedBytes = encodedTranscriptMessageBytes(entry.message); @@ -1603,42 +1575,6 @@ export class RuntimeHostSessionObserver { await Promise.all(deliveries); } - #requireTranscriptConsumer( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): { - state: ObservedSessionState; - replica: DesktopTranscriptReplica; - consumer: TranscriptConsumer; - } { - const state = this.#transcriptConsumers.get(request.consumerId); - const consumer = state?.transcriptConsumers.get(request.consumerId); - const replica = state?.replica; - if (!state || !consumer || !replica) { - throw new Error('Desktop transcript consumer does not exist'); - } - if (targetId !== undefined && consumer.target.id !== targetId) { - throw new Error('Desktop transcript consumer belongs to another renderer'); - } - // Durable transcript sequence identities belong to the Session and the - // Runtime Host epoch, not to a Desktop replica generation. Recovery may - // install a replacement replica (new generation, same session and host - // epoch) after the renderer dispatches a range request; continue that - // read against the current replica so navigation completes across - // reconnect. When the Host itself is replaced, sequence identity is not - // preserved, so reject the stale request instead of silently reading a - // different slice. - if (state.sessionId !== request.sessionId) { - throw new Error('Desktop transcript consumer belongs to another session'); - } - if (replica.hostEpoch !== request.hostEpoch) { - throw new Error( - `${DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE}: Desktop transcript host epoch changed; reopen the transcript`, - ); - } - return { state, replica, consumer }; - } - #detachTranscriptConsumer( state: ObservedSessionState, consumer: TranscriptConsumer, @@ -1647,10 +1583,8 @@ export class RuntimeHostSessionObserver { state.transcriptConsumers.delete(consumer.consumerId); this.#transcriptConsumers.delete(consumer.consumerId); consumer.resetRequested = false; + consumer.earlierRequested = false; this.#clearPendingTranscriptChange(consumer); - consumer.pendingPages.splice(0).forEach((page) => - this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes), - ); for (const pending of consumer.pendingDeliveries.values()) { pending.reject(new Error('Desktop transcript consumer was closed')); } @@ -1719,20 +1653,13 @@ function encodedTranscriptMessageBytes(message: StoredMessage): number { return Buffer.byteLength(JSON.stringify(message), 'utf8'); } -function requireTranscriptRangeBytes(value: number): number { - if ( - !Number.isSafeInteger(value) || - value < 1 || - value > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES - ) { - throw new Error('Invalid Desktop transcript range byte limit'); - } - return value; +function historyHasOlder(history: TranscriptHistory): boolean { + return history.started ? history.cursor !== null : history.throughSequence !== null; } function resetDeliveryWorkingSetBytes(residentBytes: number): number { return ( - Math.min(residentBytes, SESSION_TRANSCRIPT_RANGE_MAX_BYTES) + + Math.min(residentBytes, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES) + Math.min( residentBytes, (TRANSCRIPT_DELIVERY_WINDOW * 2 + 1) * DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 296b2158e3..cd7b5f4cf1 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -76,7 +76,7 @@ import type { } from '@maka/core/runtime-inputs'; import type { PlanSessionState } from '@maka/core/plan'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; -import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; +import type { SessionChangedEvent, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { E2eFixtureState } from '@maka/core/e2e-fixture'; import type { @@ -116,6 +116,7 @@ import type { DeepResearchChangedEvent, DeepResearchClientProgress } from '@maka import type { DesktopTranscriptBatch, DesktopTranscriptHandle, + DesktopTranscriptOpenMode, } from './transcript-contract.js'; import type { PetPackManifestV1 } from '@maka/core/pet'; import type { @@ -1256,7 +1257,11 @@ export interface MakaBridge { }) => void, ): () => void; listTurns(sessionId: string): Promise; - listTurnLandmarks(sessionId: string): Promise>; + /** Sampled prompt-rail landmarks, or where the one Turn `turnId` sits. */ + listTurnLandmarks( + sessionId: string, + turnId?: string | null, + ): Promise>; compact(sessionId: string): Promise>; resumeLatest(sessionId: string): Promise< | { disposition: 'started'; runId: string; turnId: string } @@ -1351,10 +1356,16 @@ export interface MakaBridge { abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; }; transcripts: { + /** Every message of one Turn, read from the Host rather than from any open transcript. */ + readTurn(sessionId: string, turnId: string): Promise; + /** Opens the whole transcript unless a consumer asks for the tail alone. */ open( sessionId: string, handler: (batch: DesktopTranscriptBatch) => void, registerCancellation?: (cancel: () => void) => void, + mode?: DesktopTranscriptOpenMode, + /** The oldest sequence the reader already holds; the first answer reads back down to it. */ + resumeFrom?: number, ): Promise; }; externalSessions: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e97f86935d..fe85e8ef5b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -99,10 +99,10 @@ import { type DesktopHostExternalSessionCatalogItem, } from './external-session-catalog.js'; import { - DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, assertDesktopTranscriptBatch, type DesktopTranscriptBatch, type DesktopTranscriptHandle, + type DesktopTranscriptOpenMode, type DesktopTranscriptOpenResult, } from './transcript-contract.js'; import { @@ -176,6 +176,7 @@ import type { SessionCatalogSummary, SessionChangedEvent, SessionSummary, + StoredMessage, TurnRecord, } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -275,6 +276,7 @@ import { projectDesktopDailyReviewSummary, projectDesktopSessionEvent, projectDesktopSessionSummary, + projectDesktopStoredMessage, projectDesktopTurnRecord, projectDesktopUsageStats, projectDesktopUsageActivity, @@ -2282,8 +2284,8 @@ const makaBridge = { ) as TurnRecord[]; return turns.map((turn) => projectDesktopTurnRecord(session.scope, turn)); }, - listTurnLandmarks(sessionId) { - return invokeProjectedSessionRuntimeHost('sessions:listTurnLandmarks', sessionId); + listTurnLandmarks(sessionId, turnId = null) { + return invokeProjectedSessionRuntimeHost('sessions:listTurnLandmarks', sessionId, turnId); }, regenerateTurn(sessionId: string, input: RegenerateTurnInput): Promise { return invokeSessionRuntimeHost('sessions:regenerateTurn', sessionId, input); @@ -2544,10 +2546,22 @@ const makaBridge = { }, }, transcripts: { + async readTurn(sessionId: string, turnId: string): Promise { + const session = await runtimeHostSessionRef(sessionId); + const messages = await ipcRenderer.invoke( + 'sessions:transcript:read-turn', + session.scope, + session.sessionId, + turnId, + ) as StoredMessage[]; + return messages.map((message) => projectDesktopStoredMessage(session.scope, message)); + }, async open( sessionId: string, handler: (batch: DesktopTranscriptBatch) => void, registerCancellation?: (cancel: () => void) => void, + mode: DesktopTranscriptOpenMode = 'history', + resumeFrom?: number, ): Promise { const consumerId = crypto.randomUUID(); const channel = `sessions:transcript:${consumerId}`; @@ -2613,6 +2627,8 @@ const makaBridge = { session.scope, session.sessionId, consumerId, + mode, + resumeFrom ?? null, ) as Promise>, }; }); @@ -2639,8 +2655,7 @@ const makaBridge = { return { ...cachedIdentity, sessionId, readThroughMessageId: null, acknowledgeTail: unavailable, - loadBefore: unavailable, loadAfter: unavailable, loadAround: unavailable, - loadLatest: unavailable, + loadEarlier: unavailable, close: async () => {}, }; } @@ -2654,29 +2669,6 @@ const makaBridge = { const opened = openResult.value; if (closed) throw new Error('Desktop transcript open was cancelled'); identity ??= { generation: opened.generation, hostEpoch: opened.hostEpoch }; - const range = ( - operation: - | 'sessions:transcript:load-before' - | 'sessions:transcript:load-after' - | 'sessions:transcript:load-around' - | 'sessions:transcript:load-latest', - anchorSequence: number | null, - maxBytes: number, - navigation: number, - ): Promise => { - const currentIdentity = identity; - if (!currentIdentity) { - throw new Error('Desktop transcript identity is unavailable'); - } - return ipcRenderer.invoke(operation, consumerScope, { - consumerId, - sessionId: opened.sessionId, - hostEpoch: currentIdentity.hostEpoch, - anchorSequence, - maxBytes, - navigation, - }) as Promise; - }; return { ...opened, sessionId, @@ -2692,14 +2684,13 @@ const makaBridge = { through, }) as Promise; }, - loadBefore: (anchorSequence, maxBytes, navigation) => - range('sessions:transcript:load-before', anchorSequence, maxBytes, navigation), - loadAfter: (anchorSequence, maxBytes, navigation) => - range('sessions:transcript:load-after', anchorSequence, maxBytes, navigation), - loadAround: (sequence, maxBytes, navigation) => - range('sessions:transcript:load-around', sequence, maxBytes, navigation), - loadLatest: (navigation) => - range('sessions:transcript:load-latest', null, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, navigation), + loadEarlier: (throughSequence) => + ipcRenderer.invoke( + 'sessions:transcript:load-earlier', + consumerScope, + consumerId, + throughSequence ?? null, + ) as Promise, async close() { if (closed) return; requestClose(); diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts index 94de166fb5..3b3042f467 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -18,16 +18,13 @@ */ export const DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES = 128 * 1024; -export const DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES = 512 * 1024; -/** Turns the Main tail cache keeps for the projector and for the tail the Renderer opens with. */ +export const DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES = 512 * 1024; +/** Turns the Main tail cache keeps for the projector and for tail-only consumers. */ export const DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS = 10; -/** - * Main rejects a read whose Host epoch moved under it. `ipcRenderer.invoke` - * carries nothing across but the Error's message, so both sides name the - * rejection by this code rather than by matching prose. - */ -export const DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE = 'DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED'; +export const DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES = 16 * 1024 * 1024; export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; +/** Projected message bytes of whole Turns one history read delivers: the first read and each "load earlier". */ +export const DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES = 64 * 1024 * 1024; export interface DesktopTranscriptFragment { readonly sequence: number; @@ -41,15 +38,14 @@ export interface DesktopTranscriptFragment { * cannot be read off durable sequence numbers: they advance by a stride, so * only the Host read that produced a row proves what it is contiguous with. * - * - `extends` names the edge a page read started from. - * - `coversFrom` names the watermark a tail change read forward from; absent - * means the batch claims no contiguity and only moves the watermark. - * - `navigation` appears on the reset answering `loadAround` / `loadLatest`, - * which replaces the window outright instead of splicing onto it. + * - `reset` starts a replacement of everything the consumer holds. + * - `earlierThan` starts earlier history that ends just before that sequence. + * - `coversFrom` names the watermark a tail change read forward from. + * + * One answer spans batches until `ready`; `hasOlder` is final only there. */ export interface DesktopTranscriptBatchPayload { - readonly navigation?: number; - readonly extends?: DesktopTranscriptExtension; + readonly earlierThan?: number; readonly coversFrom?: number | null; readonly sessionId: string; readonly generation: string; @@ -57,16 +53,16 @@ export interface DesktopTranscriptBatchPayload { readonly durableThrough: number | null; readonly fragments: readonly DesktopTranscriptFragment[]; readonly hasOlder?: boolean; - readonly hasNewer?: boolean; + /** + * Whether the oldest Turn in this answer has all its rows in it. A + * byte-bounded answer can begin inside a Turn, and no local rule tells the + * consumer that it did. + */ + readonly beginsAtTurnBoundary?: boolean; readonly reset: boolean; readonly ready: boolean; } -export interface DesktopTranscriptExtension { - readonly direction: 'older' | 'newer'; - readonly anchor: number | null; -} - export interface DesktopTranscriptBatch extends DesktopTranscriptBatchPayload { readonly deliverySequence: number; } @@ -78,19 +74,16 @@ export interface DesktopTranscriptOpenResult { readonly readThroughMessageId: string | null; } -export interface DesktopTranscriptRangeRequest { - readonly navigation: number; - readonly consumerId: string; - readonly sessionId: string; - readonly hostEpoch: string; - readonly anchorSequence: number | null; - readonly maxBytes: number; -} +/** + * Tail-only consumers get the Main tail cache; history consumers get the + * newest whole Turns up to the history budget and may ask for earlier ones. + */ +export type DesktopTranscriptOpenMode = 'tail' | 'history'; /** - * The Renderer reporting that its window now holds every durable row through + * The Renderer reporting that it now holds every durable row through * `through`. Main cannot derive this: a consumer only proves the Session is - * open, and a tail change a parked window refuses moves no window. + * open, and a change still assembling in the Renderer moves no reader. */ export interface DesktopTranscriptTailAcknowledgement { readonly consumerId: string; @@ -101,10 +94,8 @@ export interface DesktopTranscriptTailAcknowledgement { export interface DesktopTranscriptHandle extends DesktopTranscriptOpenResult { acknowledgeTail(through: number): Promise; - loadBefore(anchorSequence: number | null, maxBytes: number, navigation: number): Promise; - loadAfter(anchorSequence: number | null, maxBytes: number, navigation: number): Promise; - loadAround(sequence: number, maxBytes: number, navigation: number): Promise; - loadLatest(navigation: number): Promise; + /** One budget of earlier history, continuing in the same answer down to `throughSequence`. */ + loadEarlier(throughSequence?: number): Promise; close(): Promise; } @@ -115,8 +106,7 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB const batch = value as Record; if ( typeof batch.sessionId !== 'string' || - (batch.navigation !== undefined && !isSequence(batch.navigation)) || - !isExtension(batch.extends) || + (batch.earlierThan !== undefined && !isSequence(batch.earlierThan)) || (batch.coversFrom !== undefined && batch.coversFrom !== null && !isSequence(batch.coversFrom)) || !isSequence(batch.deliverySequence) || typeof batch.generation !== 'string' || @@ -124,7 +114,8 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB (batch.durableThrough !== null && !isSequence(batch.durableThrough)) || !Array.isArray(batch.fragments) || (batch.hasOlder !== undefined && typeof batch.hasOlder !== 'boolean') || - (batch.hasNewer !== undefined && typeof batch.hasNewer !== 'boolean') || + (batch.beginsAtTurnBoundary !== undefined && + typeof batch.beginsAtTurnBoundary !== 'boolean') || typeof batch.reset !== 'boolean' || typeof batch.ready !== 'boolean' ) { @@ -164,11 +155,3 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB function isSequence(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) >= 0; } - -function isExtension(value: unknown): boolean { - if (value === undefined) return true; - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const extension = value as Record; - return (extension.direction === 'older' || extension.direction === 'newer') && - (extension.anchor === null || isSequence(extension.anchor)); -} diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index ad4225ffa8..4e34674ac5 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -168,7 +168,7 @@ export function createAppShellChatActions(deps: { removeTransientMessage: (sessionId: string, messageId: string) => void; transcriptRangeRef: RefBox; isMessagePublished: (message: StoredMessage) => boolean; - onFollowLatest: (sessionId: string) => Promise; + onFollowLatest: (sessionId: string) => boolean; /** #646: arm the "正在处理…" indicator locally at send() — the model-wait * window opens before any SessionEvent arrives (turn_started is not one). */ setInteractionBySession: InteractionQueueUpdater; @@ -436,7 +436,7 @@ export function createAppShellChatActions(deps: { void refreshSessions().catch(() => undefined); return true; } - if (!await onFollowLatest(initialSessionId)) return false; + if (!onFollowLatest(initialSessionId)) return false; optimisticSessionId = initialSessionId; optimisticMessageId = messageId; publishTransientUserMessage(initialSessionId, { diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index c3d13c4f99..e309fa4581 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -402,25 +402,16 @@ export function useActiveSessionEvents(options: { now: Date.now(), }), })); - const openTranscript = (signal: AbortSignal) => - window.maka.transcripts.open( - activeId, - (batch) => { - if (disposed) return; - try { - transcript.accept(batch); - } catch (error) { - applyReadError(activeId, error); - } - }, - (cancel) => { - if (signal.aborted) cancel(); - else signal.addEventListener('abort', cancel, { once: true }); - }, - ); - const controller = desktopTranscript.createRecoveringDesktopTranscriptRangeController( + const controller = desktopTranscript.createDesktopTranscriptRangeController( transcript, - openTranscript, + desktopTranscript.openDesktopTranscriptHistory(window.maka.transcripts.open, activeId, (batch) => { + if (disposed) return; + try { + transcript.accept(batch); + } catch (error) { + applyReadError(activeId, error); + } + }), { onError: (error) => { if (!disposed) applyReadError(activeId, error); }, }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f14b2b9cf1..f57da4bb34 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -414,11 +414,7 @@ function AppShellContent({ const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const transcriptReadingCommands = useRef(null); - const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ - sessionId: string; - throughSequence: number | null; - turns: readonly { turnId: string; sequence: number; label: string }[]; - }>(); + const [transcriptTurnIndex, setTranscriptTurnIndex] = useState(); const [petCompletionNonce, setPetCompletionNonce] = useState(0); const [navigationState, setNavigationState] = useState(() => readNavigationState()); const navSelection = navigationState.selection; @@ -1444,7 +1440,7 @@ function AppShellContent({ updateTransientMessage, removeTransientMessage, transcriptRangeRef, - onFollowLatest: (sessionId) => transcriptReadingCommands.current?.prepareSend(sessionId) ?? Promise.resolve(true), + onFollowLatest: (sessionId) => transcriptReadingCommands.current?.prepareSend(sessionId) ?? true, isMessagePublished, setInteractionBySession: sessionUiController.setInteractionBySession, onInteractionChanged: markInteractionChanged, @@ -2253,19 +2249,15 @@ function AppShellContent({ rangeController={transcriptRangeRef} messages={messages} searchTarget={searchScrollTarget} - landmarkSessionId={ownerActiveId ?? null} clearSearchTarget={() => setSearchScrollTarget(null)} sessionUi={sessionUiController} - turnIndex={transcriptTurnIndex} + landmarkSessionId={ownerActiveId ?? null} + listTurnLandmarks={(sessionId, turnId) => window.maka.sessions.listTurnLandmarks(sessionId, turnId)} setTurnIndex={setTranscriptTurnIndex} - listTurnLandmarks={(sessionId) => window.maka.sessions.listTurnLandmarks(sessionId)} onRestoreError={(error, sessionId) => sessionUiController.setMessageLoadErrorBySession((current) => ({ ...current, [sessionId]: localizedShellErrorMessage(error, desktopConversationCopy.actions.operationFailedFallback, uiLocale), }))} - onNavigationError={(error, sessionId) => showSessionError(sessionId, - desktopConversationCopy.actions.messageReadFailedTitle, - localizedShellErrorMessage(error, desktopConversationCopy.actions.operationFailedFallback, uiLocale))} /> transcriptReadingCommands.current?.returnToLatest() - : undefined} hidden={workHubActive || !sessionsSelected} composer={ <> @@ -2511,7 +2500,7 @@ function AppShellContent({ activeModel={activeModel} activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} - latestRequestUsageTokens={selectLatestRequestUsage(messages, activeTranscriptRange, activeModel, activeSessionForModelControls)} + latestRequestUsageTokens={selectLatestRequestUsage(messages, activeModel, activeSessionForModelControls)} onOpenContextUsage={() => commands.toggleTool('inspector')} LiveContextUsageProbe={LiveContextUsageProbe} contextUsageSessionId={ownerActiveId} @@ -2595,11 +2584,10 @@ function AppShellContent({ sessionUiController={sessionUiController} activeSessionId={activeId} activeTurn={Conversation.chatTurnActivity(activeExecution)} - hasOlderHistory={activeTranscriptRange?.hasOlder} - hasNewerHistory={activeTranscriptRange?.hasNewer} - onPrefetchHistory={(edge) => - transcriptReadingCommands.current?.prefetchHistory(edge) ?? Promise.resolve(false)} - onRetainWindow={(band) => transcriptReadingCommands.current?.retainWindow(band)} + hasEarlierHistory={activeTranscriptRange?.hasOlder} + onLoadEarlierHistory={() => transcriptReadingCommands.current?.loadEarlier()} + transcriptTurnIndex={activeId && transcriptTurnIndex?.sessionId === activeId ? transcriptTurnIndex.turns : undefined} + onLoadTranscriptTurn={(turn) => transcriptReadingCommands.current?.loadEarlier(turn.sequence)} liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} transientMessages={transientMessages} @@ -2649,14 +2637,6 @@ function AppShellContent({ onReadingAnchorChange={activeId ? (turnId) => transcriptReadingCommands.current?.captureAnchor(turnId) : undefined} - transcriptTurnIndex={ - transcriptTurnIndex && transcriptTurnIndex.sessionId === activeId - ? transcriptTurnIndex.turns - : undefined - } - onLoadTranscriptTurn={activeId - ? (target) => openSessionInChat(activeId, target.turnId, target.sequence) - : undefined} scrollBehavior={readScrollMotionBehavior()} branchBanner={branchBanner} onBranchBannerClick={openSessionInChat} diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts index 62635a5cee..ce32b827fe 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts @@ -47,13 +47,11 @@ export interface LatestRequestUsageAnchor { export function selectLatestRequestUsage( messages: readonly { type: string; lastRequestAnchor?: LatestRequestUsageAnchor }[], - /** `hasNewer` means the loaded range is not the session tail. */ - range: { hasNewer?: boolean } | undefined, model: string | undefined, route: { llmConnectionId?: string } | undefined, ): number | undefined { const connectionId = route?.llmConnectionId; - if (range?.hasNewer || model === undefined || connectionId === undefined) return undefined; + if (model === undefined || connectionId === undefined) return undefined; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (message?.type !== 'token_usage') continue; diff --git a/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts b/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts index b06bea0c6e..4b89d962ed 100644 --- a/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts +++ b/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts @@ -70,9 +70,7 @@ export function mergeTransientMessageProjection( export function reconcileTransientMessages( transient: Map, durable: readonly StoredMessage[], - options: { includeTransient?: boolean } = {}, ): TransientUserMessage[] { for (const message of durable) transient.delete(message.id); - if (transient.size === 0 || options.includeTransient === false) return []; return [...transient.values()]; } diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index 9cef7a1e5d..11299b5c55 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -62,9 +62,7 @@ interface ChatMessageSurfaceProps extends Omit< | 'liveTurns' | 'shellRunUpdates' | 'goalIndicator' - | 'onPrefetchHistory' - | 'onRetainWindow' ->, Required, 'onPrefetchHistory' | 'onRetainWindow'>> { +> { /** * #1985: the live projection and the shell-run records are the only session * UI state that changes per streamed token, and this surface is their only diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx index f8391b0f7f..25006709bd 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx @@ -17,42 +17,33 @@ * under the License. */ -import { useEffect, useImperativeHandle, useRef, useState, type Dispatch, type Ref, type SetStateAction } from 'react'; +import { useEffect, useImperativeHandle, useState, type Ref } from 'react'; import type { StoredMessage } from '@maka/core/session'; import type { AppShellSessionUiStateController } from '../model/session-ui-state.js'; import { - captureTranscriptReadingAnchor, createTranscriptRestoreLifecycle, currentTranscriptRange, - newestDurablePromptSequence, prepareTranscriptForSend, - refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, - TranscriptReadSupersededError, } from './transcript-reading-position.js'; -type RangeController = NonNullable>[0]['controller']> & { - readonly store: { - retain(oldestSequence: number | null, newestSequence: number | null): boolean; - snapshot(): object; - }; - loadBefore(maxBytes?: number): Promise; - loadAfter(maxBytes?: number): Promise; - loadLatest(): Promise; -}; +type RangeController = NonNullable>[0]['controller']>; + +export interface TranscriptTurnLandmark { + readonly turnId: string; + readonly sequence: number; + readonly label: string; +} -interface TurnIndex { - sessionId: string; - throughSequence: number | null; - turns: readonly { turnId: string; sequence: number; label: string }[]; +export interface TranscriptTurnIndex { + readonly sessionId: string; + readonly turns: readonly TranscriptTurnLandmark[]; } export interface TranscriptReadingPositionCommands { - prepareSend(sessionId: string): Promise; + prepareSend(sessionId: string): boolean; captureAnchor(turnId?: string): void; - returnToLatest(): Promise; - prefetchHistory(edge: 'older' | 'newer'): Promise; - retainWindow(window: { firstTurnId: string; lastTurnId: string }): void; + loadEarlier(throughSequence?: number): Promise; } /** The conversation owns restoration lifetime; the shell supplies explicit ports. */ @@ -60,29 +51,24 @@ export function TranscriptReadingPositionController(props: { commands: Ref; sessionId?: string; profileId?: string; - landmarkSessionId?: string | null; currentSessionId: { current: string | undefined }; rangeController: { current: RangeController | undefined }; messages: readonly StoredMessage[]; searchTarget: Parameters[0]['searchTarget']; clearSearchTarget(): void; sessionUi: AppShellSessionUiStateController; - turnIndex: TurnIndex | undefined; - setTurnIndex: Dispatch>; - listTurnLandmarks: Parameters>[0]['list']; + /** The Session whose Turn index this viewer may read; `null` for none. */ + landmarkSessionId: string | null; + listTurnLandmarks( + sessionId: string, + turnId: string | null, + ): Promise<{ readonly landmarks: readonly TranscriptTurnLandmark[] }>; + setTurnIndex(index: TranscriptTurnIndex | undefined): void; onRestoreError(error: unknown, sessionId: string): void; - onNavigationError(error: unknown, sessionId: string): void; }) { const [lifecycle] = useState(createTranscriptRestoreLifecycle); - const lastLiveGeneration = useRef< - { sessionId: string; generation: string; hostEpoch: string } | undefined - >(undefined); const isCurrent = (sessionId: string, controller: object) => props.currentSessionId.current === sessionId && props.rangeController.current === controller; - const reportNavigationError = (error: unknown, sessionId: string, controller: object) => { - if (error instanceof TranscriptReadSupersededError) return; - if (isCurrent(sessionId, controller)) props.onNavigationError(error, sessionId); - }; const cancel = (sessionId: string, clearAnchor = false) => { lifecycle.cancel(sessionId); if (props.searchTarget?.sessionId === sessionId) props.clearSearchTarget(); @@ -94,8 +80,7 @@ export function TranscriptReadingPositionController(props: { useImperativeHandle(props.commands, () => ({ prepareSend(sessionId) { return prepareTranscriptForSend({ - sessionId, currentSessionId: props.currentSessionId, - controller: props.rangeController, cancel, + sessionId, currentSessionId: props.currentSessionId, cancel, followLatest: props.sessionUi.transcriptViewportNavigation.followLatest, }); }, @@ -103,71 +88,42 @@ export function TranscriptReadingPositionController(props: { const { sessionId } = props; if (!sessionId || props.currentSessionId.current !== sessionId) return; props.sessionUi.setTranscriptRestoreUnavailable(sessionId, undefined); - captureTranscriptReadingAnchor({ - sessionId, currentSessionId: props.currentSessionId.current, turnId, - controller: props.rangeController.current, - setAnchor: props.sessionUi.setTranscriptReadingAnchor, - }); - }, - retainWindow(window) { - const controller = props.rangeController.current; - const { sessionId } = props; - if (!controller || !sessionId || !isCurrent(sessionId, controller)) return; - if (currentTranscriptRange(controller, sessionId) === undefined) return; - try { - controller.store.retain( - controller.store.sequenceForTurn(window.firstTurnId, 'first'), - controller.store.sequenceForTurn(window.lastTurnId, 'last'), - ); - } catch { - // A stale range has no window to trim. - } - }, - /** - * Deliberately not `returnToLatest`: that one cancels restoration and - * clears the search target, because a reader who asks to go somewhere has - * decided where to be. Filling decides nothing, so it must leave an - * outstanding jump alone — the page it is waiting for can still be in - * flight. - * - * Safe to ask on every frame the geometry wants it: the range controller - * refuses a read against a window it has already read, and answers whether - * it issued one. - */ - async prefetchHistory(edge) { - const controller = props.rangeController.current; - const { sessionId } = props; - if (!controller || !sessionId || !isCurrent(sessionId, controller)) return false; - return edge === 'older' ? controller.loadBefore() : controller.loadAfter(); + props.sessionUi.setTranscriptReadingAnchor(sessionId, turnId ? { turnId } : undefined); }, - async returnToLatest() { + async loadEarlier(throughSequence) { const controller = props.rangeController.current; const { sessionId } = props; if (!controller || !sessionId || !isCurrent(sessionId, controller)) return; - cancel(sessionId, true); - try { - await controller.loadLatest(); - } catch (error) { - reportNavigationError(error, sessionId, controller); - } + await controller.loadEarlier(throughSequence); }, })); - const newestPrompt = newestDurablePromptSequence(props.rangeController.current, props.sessionId); - const landmarkSessionId = props.landmarkSessionId === null - ? undefined - : props.landmarkSessionId ?? props.sessionId; - useEffect(() => refreshTranscriptTurnLandmarks({ - sessionId: landmarkSessionId, - newestDurablePromptSequence: newestPrompt, - current: props.turnIndex, - list: props.listTurnLandmarks, - isCurrent: (sessionId) => props.currentSessionId.current === sessionId, - setIndex: props.setTurnIndex, - }), [props.sessionId, landmarkSessionId, newestPrompt, props.turnIndex]); useEffect(() => () => { lifecycle.deactivate(); }, [props.sessionId, props.profileId, lifecycle]); + const landmarkSessionId = props.landmarkSessionId === props.sessionId ? props.landmarkSessionId : null; + // New Turns land in the resident tail, so the index is read once per Session + // and only once some history lies outside the resident range. + const range = currentTranscriptRange(props.rangeController.current, props.sessionId); + const hasOlder = range?.hasOlder ?? false; + // A reopen reads the index again, so a lookup that failed does not stay failed. + const generation = range?.ready ? range.generation : undefined; + useEffect(() => { + // The shell shows an index only for the Session it names, so a reread keeps + // the previous one on screen until it answers. + if (!landmarkSessionId || !hasOlder) { + props.setTurnIndex(undefined); + return; + } + let disposed = false; + void props.listTurnLandmarks(landmarkSessionId, null).then( + (snapshot) => { + if (!disposed) props.setTurnIndex({ sessionId: landmarkSessionId, turns: snapshot.landmarks }); + }, + () => undefined, + ); + return () => { disposed = true; }; + }, [landmarkSessionId, hasOlder, generation]); useEffect(() => restoreSessionTranscriptRange({ lifecycle, sessionId: props.sessionId, @@ -178,48 +134,14 @@ export function TranscriptReadingPositionController(props: { : undefined, controller: props.rangeController.current, isCurrent, + lookupTurn: landmarkSessionId + ? async (sessionId, turnId) => + (await props.listTurnLandmarks(sessionId, turnId)).landmarks + .find((landmark) => landmark.turnId === turnId)?.sequence + : undefined, setReadingAnchor: props.sessionUi.setTranscriptReadingAnchor, onRestoreUnavailable: props.sessionUi.setTranscriptRestoreUnavailable, onError: props.onRestoreError, }), [props.sessionId, props.profileId, props.messages, props.searchTarget?.nonce]); - useEffect(() => { - const controller = props.rangeController.current; - const { sessionId } = props; - const range = currentTranscriptRange(controller, sessionId); - if (!controller || !sessionId || !range?.generation || !range.hostEpoch) return; - if (range.generation.startsWith('cached:')) return; - const previous = lastLiveGeneration.current; - lastLiveGeneration.current = { sessionId, generation: range.generation, hostEpoch: range.hostEpoch }; - if (!previous || previous.sessionId !== sessionId || previous.generation === range.generation) return; - const anchor = props.sessionUi.transcriptReadingAnchorBySessionRef.current[sessionId]; - if (!anchor || controller.store.sequenceForTurn(anchor.turnId) !== null) return; - const navigate = (sequence: number) => { - void controller.loadAround(sequence).catch((error) => { - reportNavigationError(error, sessionId, controller); - }); - }; - if (previous.hostEpoch === range.hostEpoch) { - if (anchor.sequence !== undefined) navigate(anchor.sequence); - return; - } - // Sequences only name the same rows within one Host epoch, so a bookmark - // carried across one has to be found again by Turn. The landmark index in - // hand still names the epoch that is gone, hence the refresh first. - if (landmarkSessionId !== sessionId) return; - const { turnId } = anchor; - let disposed = false; - void props.listTurnLandmarks(sessionId).then((snapshot) => { - if (disposed || !isCurrent(sessionId, controller)) return; - props.setTurnIndex({ sessionId, throughSequence: snapshot.throughSequence, turns: snapshot.landmarks }); - // A reader who has gone somewhere else since owns the position now. - if (props.sessionUi.transcriptReadingAnchorBySessionRef.current[sessionId]?.turnId !== turnId) return; - const landmark = snapshot.landmarks.find((turn) => turn.turnId === turnId); - // A Turn the new epoch does not name leaves the reader where the reset put them. - if (!landmark) return; - props.sessionUi.setTranscriptReadingAnchor(sessionId, { turnId, sequence: landmark.sequence }); - navigate(landmark.sequence); - }, () => undefined); - return () => { disposed = true; }; - }, [props.sessionId, props.messages]); return null; } diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts index d30182f7ae..9791c1e864 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts @@ -19,53 +19,31 @@ import type { TranscriptReadingAnchor } from '../model/session-ui-state.js'; -interface TranscriptRangeStore { - readonly sessionId: string; - range(): { - readonly sessionId: string; - readonly hasNewer?: boolean; - readonly generation?: string; - readonly hostEpoch?: string; - }; - sequenceForTurn(turnId: string, edge?: 'first' | 'last'): number | null; - pendingNavigation(): number | undefined; - newestDurableUserSequence(): number | null; - snapshot(): { readonly messages: readonly Message[] }; -} - interface TranscriptRangeController { - readonly store: TranscriptRangeStore; - loadAround(sequence: number): Promise; -} - -/** - * A read the Host refused because the window it was stamped for belongs to a - * Runtime Host epoch that is gone. Nothing the reader asked for failed: the - * replacement reset carries the new epoch, and the cross-epoch re-anchor finds - * the bookmarked Turn in it. The platform adapter that speaks to the Host - * raises this; every reading-position path treats it as a read that was - * superseded rather than one that went wrong. - */ -export class TranscriptReadSupersededError extends Error { - constructor(message: string, options?: { cause?: unknown }) { - super(message, options); - this.name = 'TranscriptReadSupersededError'; - } + readonly store: { + range(): { + readonly sessionId: string; + readonly hasOlder: boolean; + readonly ready: boolean; + readonly generation?: string; + }; + snapshot(): { readonly messages: readonly Message[] }; + }; + loadEarlier(throughSequence?: number): Promise; } interface SearchTarget { readonly sessionId: string; readonly turnId: string; - readonly sequence?: number; readonly nonce?: number; } interface TranscriptRestoreCommand { - target: TranscriptReadingAnchor; + readonly target: TranscriptReadingAnchor; readonly fromSearch: boolean; completed: boolean; - controller?: object; - attempt?: object; + loading?: object; + loaded?: boolean; } /** A bookmark survives navigation; a command to restore it does not. */ @@ -99,19 +77,12 @@ export function createTranscriptRestoreLifecycle() { profileId: input.profileId, searchKey, command: input.sessionId && target - ? { target: { turnId: target.turnId, sequence: target.sequence }, fromSearch: Boolean(search), completed: false } + ? { target: { turnId: target.turnId }, fromSearch: Boolean(search), completed: false } : undefined, }; } const command = activation?.command; - if (!command || command.completed) return; - if (command.target.sequence === undefined) { - const target = search ?? input.readingAnchor; - if (target?.turnId === command.target.turnId && target.sequence !== undefined) { - command.target = { ...command.target, sequence: target.sequence }; - } - } - return command; + return command && !command.completed ? command : undefined; }, isCurrent(command: TranscriptRestoreCommand): boolean { return activation?.command === command && !command.completed; @@ -131,29 +102,16 @@ export function createTranscriptRestoreLifecycle() { export type TranscriptRestoreLifecycle = ReturnType; -export async function prepareTranscriptForSend(options: { +export function prepareTranscriptForSend(options: { sessionId: string; currentSessionId: { current: string | undefined }; - controller: { current: (TranscriptRangeController & { loadLatest(): Promise }) | undefined }; cancel(sessionId: string, clearAnchor: boolean): void; followLatest(sessionId: string): void; -}): Promise { +}): boolean { const { sessionId } = options; if (options.currentSessionId.current !== sessionId) return false; options.cancel(sessionId, true); - const controller = options.controller.current; options.followLatest(sessionId); - if (!controller || controller.store.sessionId !== sessionId) return true; - // Invalidate pending history immediately, but keep local Message admission - // independent of an unopened, slow or offline transcript. The explicit pin - // happens once; a late page must not reclaim the viewport from the reader. - void (async () => { - try { - await controller.loadLatest(); - } catch { - // Catch-up failure must not prevent the Message from being saved locally. - } - })(); return true; } @@ -169,19 +127,6 @@ export function currentTranscriptRange( - controller: TranscriptRangeController | undefined, - sessionId: string | undefined, -): number | null { - try { - return controller && controller.store.range().sessionId === sessionId - ? controller.store.newestDurableUserSequence() - : null; - } catch { - return null; - } -} - export function transcriptRestoreTarget( anchor: TranscriptReadingAnchor | undefined, unavailableTurnId: string | undefined, @@ -197,42 +142,11 @@ export function transcriptRestoreTarget( : undefined; } -export function refreshTranscriptTurnLandmarks(options: { - readonly sessionId?: string; - readonly newestDurablePromptSequence: number | null; - readonly current?: { readonly sessionId: string; readonly throughSequence: number | null }; - readonly list: (sessionId: string) => Promise<{ readonly throughSequence: number | null; readonly landmarks: readonly T[] }>; - readonly isCurrent: (sessionId: string) => boolean; - readonly setIndex: (index: { sessionId: string; throughSequence: number | null; turns: readonly T[] } | undefined) => void; -}): (() => void) | undefined { - const { sessionId } = options; - if (!sessionId) { - options.setIndex(undefined); - return; - } - if ( - options.current?.sessionId === sessionId && - (options.newestDurablePromptSequence === null || - (options.current.throughSequence !== null && - options.newestDurablePromptSequence <= options.current.throughSequence)) - ) return; - let disposed = false; - void options.list(sessionId).then( - (snapshot) => { - if (disposed || !options.isCurrent(sessionId)) return; - options.setIndex({ - sessionId, - throughSequence: snapshot.throughSequence, - turns: snapshot.landmarks, - }); - }, - () => undefined, - ); - return () => { - disposed = true; - }; -} - +/** + * Finds the target Turn in the loaded transcript. A Turn outside it is located + * through the Host Turn index and read down to in one request; a Turn the index + * does not know, or a Session without index access, is unavailable. + */ export function restoreSessionTranscriptRange(options: { readonly lifecycle: TranscriptRestoreLifecycle; readonly sessionId?: string; @@ -241,6 +155,8 @@ export function restoreSessionTranscriptRange(options: { readonly readingAnchor?: TranscriptReadingAnchor; readonly controller?: TranscriptRangeController; readonly isCurrent: (sessionId: string, controller: TranscriptRangeController) => boolean; + /** Where the Turn starts; `undefined` when the index does not know it. */ + readonly lookupTurn?: (sessionId: string, turnId: string) => Promise; readonly setReadingAnchor: ( sessionId: string, anchor: TranscriptReadingAnchor | undefined, @@ -250,110 +166,49 @@ export function restoreSessionTranscriptRange(options: { }): void { const { controller, sessionId } = options; const command = options.lifecycle.request(options); - if (!command || !controller || !sessionId || !options.isCurrent(sessionId, controller)) return; - if (command.controller === controller && command.attempt) return; - if (!command.fromSearch && command.target.sequence === undefined) { - const { turnId } = command.target; - try { - const sequence = controller.store.range().sessionId === sessionId - ? controller.store.sequenceForTurn(turnId) - : null; - if (sequence !== null) { - command.target = { turnId, sequence }; - options.setReadingAnchor(sessionId, command.target); - } - } catch { - // A stale range cannot enrich the anchor, but also cannot invalidate it. - } - } - const target = command.target; - if (command.fromSearch && target.sequence === undefined) return; - const restoringReadingAnchor = !command.fromSearch; - const attempt = {}; - command.controller = controller; - command.attempt = attempt; - const current = (): boolean => options.lifecycle.isCurrent(command) - && command.attempt === attempt && options.isCurrent(sessionId, controller); - if (!current()) { - command.attempt = undefined; + if (!command || command.loading || !controller || !sessionId || !options.isCurrent(sessionId, controller)) return; + const range = currentTranscriptRange(controller, sessionId); + if (!range?.ready) return; + const { turnId } = command.target; + if (controller.store.snapshot().messages.some((message) => + message !== null && typeof message === 'object' && 'turnId' in message && message.turnId === turnId, + )) { + command.completed = true; return; } - let admitted: Promise; - try { - const residentSequence = currentTranscriptRange(controller, sessionId) - ? controller.store.sequenceForTurn(target.turnId) - : null; - // A resident target needs no page unless an older replacement is still - // pending. In that case this navigation must supersede the old read too. - admitted = (residentSequence !== null && controller.store.pendingNavigation() === undefined) - || target.sequence === undefined - ? Promise.resolve() - : controller.loadAround(target.sequence); - } catch (error) { - admitted = Promise.reject(error); - } - void admitted - .then(() => { - if (!current() || controller.store.range().sessionId !== sessionId) return false; - const residentSequence = controller.store.sequenceForTurn(target.turnId); - if (residentSequence !== null) { - if (restoringReadingAnchor && target.sequence === undefined) { - options.setReadingAnchor(sessionId, { turnId: target.turnId, sequence: residentSequence }); - } - return false; - } - if (controller.store.snapshot().messages.some((message) => - message !== null && typeof message === 'object' && - 'turnId' in message && message.turnId === target.turnId, - )) { - // A live row can be on screen before the transcript assigns it a - // sequence. Its bookmark is already visible, so there is nothing to - // page in. - return false; - } - return restoringReadingAnchor; - }) - .then((unavailable) => { - if (!current()) return; - command.completed = true; - if (unavailable) { - options.setReadingAnchor(sessionId, undefined); - options.onRestoreUnavailable?.(sessionId, target.turnId); - } - }) - .catch((error) => { - if (error instanceof TranscriptReadSupersededError) { - // The sequence this command carries names a row of the epoch that is - // gone, so retrying it would land somewhere else entirely. Consume the - // command and leave the position to the cross-epoch re-anchor. - command.completed = true; - return; - } - if (current()) options.onError(error, sessionId); - }) - .finally(() => { - if (command.attempt === attempt) command.attempt = undefined; - }); -} - -export function captureTranscriptReadingAnchor(options: { - readonly sessionId?: string; - readonly currentSessionId?: string; - readonly turnId?: string; - readonly controller?: TranscriptRangeController; - readonly setAnchor: (sessionId: string, anchor: TranscriptReadingAnchor | undefined) => void; -}): void { - const { sessionId, turnId } = options; - if (!sessionId || options.currentSessionId !== sessionId) return; - if (!turnId) { - options.setAnchor(sessionId, undefined); + const { lookupTurn } = options; + if (range.hasOlder && !command.loaded && lookupTurn) { + const loading = {}; + command.loading = loading; + const current = () => options.lifecycle.isCurrent(command) && options.isCurrent(sessionId, controller); + const settle = () => { + if (command.loading !== loading) return false; + command.loading = undefined; + return current(); + }; + void lookupTurn(sessionId, turnId) + .then((sequence) => { + if (sequence === undefined || !current()) return; + return controller.loadEarlier(sequence); + }) + .then( + () => { + // A range that reopened meanwhile dropped the answer, so it says + // nothing about whether the Turn can be reached. + if (currentTranscriptRange(controller, sessionId)?.generation === range.generation) { + command.loaded = true; + } + if (settle()) restoreSessionTranscriptRange(options); + }, + (error: unknown) => { + if (settle()) options.onError(error, sessionId); + }, + ); return; } - try { - if (options.controller?.store.range().sessionId !== sessionId) return; - const sequence = options.controller.store.sequenceForTurn(turnId) ?? undefined; - options.setAnchor(sessionId, sequence === undefined ? { turnId } : { turnId, sequence }); - } catch { - // A stale range says nothing new about the reader's current intent. + command.completed = true; + if (!command.fromSearch) { + options.setReadingAnchor(sessionId, undefined); + options.onRestoreUnavailable?.(sessionId, turnId); } } diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts index 80ce52fab1..cb92ff9bcc 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts @@ -24,7 +24,7 @@ import { currentTranscriptRange } from './transcript-reading-position.js'; import { createAppShellSessionUiStateController, type AppShellSessionUiStateController } from '../model/session-ui-state.js'; interface TranscriptSource { - range(): { readonly sessionId: string; readonly hasOlder: boolean; readonly hasNewer: boolean }; + range(): { readonly sessionId: string; readonly hasOlder: boolean }; snapshot(): { readonly messages: readonly StoredMessage[]; readonly ready: boolean }; } @@ -35,8 +35,7 @@ export type TranscriptPublisher = ( onReady: () => void, ) => void; -/** The rendered messages and gap flags are a single publication. The source - * may advance during reader input, but only the scroll authority admits it. */ +/** The rendered messages and the earlier-history flag are a single publication. */ export function useAppShellSessionUiState< Controller extends { readonly store: TranscriptSource }, Session extends SessionSummary & { localState?: string; shared?: boolean }, @@ -84,12 +83,10 @@ export function useAppShellSessionUiState< isCurrent: () => boolean, onReady: () => void, ) { - controller.transcriptViewportNavigation.commitRange(sessionId, () => { - if (!isCurrent()) return; - const snapshot = rangeController.store.snapshot(); - if (!snapshot.ready || !commitTranscript(sessionId, [...snapshot.messages], rangeController)) return; - onReady(); - }); + if (!isCurrent()) return; + const snapshot = rangeController.store.snapshot(); + if (!snapshot.ready || !commitTranscript(sessionId, [...snapshot.messages], rangeController)) return; + onReady(); }, })); diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 37a6c454f4..9677a94998 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -22,8 +22,6 @@ import { transcriptRestoreTarget, } from './controller/transcript-reading-position.js'; -export { TranscriptReadSupersededError } from './controller/transcript-reading-position.js'; - export const transcriptReadingPosition = { currentRange: currentTranscriptRange, restoreTarget: transcriptRestoreTarget, @@ -32,6 +30,7 @@ export const transcriptReadingPosition = { export { TranscriptReadingPositionController, type TranscriptReadingPositionCommands, + type TranscriptTurnIndex, } from './controller/transcript-reading-position-controller.js'; export { diff --git a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts index 24dbad1852..e25f0cf865 100644 --- a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts @@ -61,7 +61,6 @@ export interface SessionPendingClaim { export interface TranscriptReadingAnchor { readonly turnId: string; - readonly sequence?: number; } const SESSION_UI_MAP_KEYS = [ @@ -253,12 +252,8 @@ function createTranscriptReadingAnchorRegistry() { registry.clear(sessionId); return; } - const next = previous?.turnId === anchor.turnId && - previous.sequence !== undefined && anchor.sequence === undefined - ? previous - : anchor; - if (next === previous) return; - ref.current = { ...ref.current, [sessionId]: next }; + if (previous?.turnId === anchor.turnId) return; + ref.current = { ...ref.current, [sessionId]: anchor }; }, }; } diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index fa076070f5..bd9c0549c9 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -20,6 +20,5 @@ export { createTranscriptRestoreLifecycle, prepareTranscriptForSend, - refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, } from './controller/transcript-reading-position.js'; diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts index 0d42a80c97..9fa91f96d2 100644 --- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts +++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts @@ -44,7 +44,6 @@ import type { WorkHubServices, WorkHubTranscript, WorkHubTranscriptSnapshot } fr const emptyTranscript: WorkHubTranscriptSnapshot = { messages: [], hasOlder: false, - hasNewer: false, ready: false, }; interface SendAttempt { @@ -405,22 +404,19 @@ export function useWorkHubController(onSubmit?: () => void) { const queued = pendingQueued.current; if (queued?.sessionId === sessionId && snapshot.messages.some((message) => message.type === 'user' && message.id === queued.messageId)) queued.observed = true; - viewportNavigation.commitRange(sessionId, () => { - if (disposed) return; - transcriptRef.current = snapshot; - setTranscript(snapshot); - if (snapshot.ready && observationPhase === 'ready') setReadError(undefined); - setMessagePresentation((previous) => ({ ...previous, transientMessages: previous.transientMessages.filter((pending) => - !snapshot.messages.some((message) => message.type === 'user' && - (message.id === pending.id || (pending.id === pending.hostTurnId && message.turnId === pending.hostTurnId))), - ) })); - const settled = snapshot.messages.filter((message) => - message.type === 'assistant' && settledBeforePublication.current.delete(message.id)); - setLiveTurns((previous) => { - let next = previous; - for (const message of settled) if (next) next = settleLiveTurnBufferStep(next, message.id); - return next ? reconcileLiveTurnBuffer(next, snapshot.messages) : next; - }); + transcriptRef.current = snapshot; + setTranscript(snapshot); + if (snapshot.ready && observationPhase === 'ready') setReadError(undefined); + setMessagePresentation((previous) => ({ ...previous, transientMessages: previous.transientMessages.filter((pending) => + !snapshot.messages.some((message) => message.type === 'user' && + (message.id === pending.id || (pending.id === pending.hostTurnId && message.turnId === pending.hostTurnId))), + ) })); + const settled = snapshot.messages.filter((message) => + message.type === 'assistant' && settledBeforePublication.current.delete(message.id)); + setLiveTurns((previous) => { + let next = previous; + for (const message of settled) if (next) next = settleLiveTurnBufferStep(next, message.id); + return next ? reconcileLiveTurnBuffer(next, snapshot.messages) : next; }); }, transcriptAbort.signal, readFailed); void opening @@ -475,12 +471,6 @@ export function useWorkHubController(onSubmit?: () => void) { ts: Date.now(), transientPlacement: attempt.placement, pendingSteering: attempt.placement === 'current_turn', }] })); viewportNavigation.followLatest(target); - // A queued message becomes visible only where the tail is, and its own - // retry guard waits on seeing it. Issue the read before admission so an - // uncertain enqueue — the case that arms the guard — is covered too. - void range.current?.loadLatest().catch((reason: unknown) => { - if (currentSessionId.current === target) report(reason); - }); const result = await services.enqueueMessage(target, attempt.messageId, text, attachments, attempt.placement); if (result === 'rejected' && pendingQueued.current === attempt) { pendingQueued.current = undefined; @@ -506,10 +496,6 @@ export function useWorkHubController(onSubmit?: () => void) { attachments: [...attachments], transientPlacement: 'current_turn', }] })); viewportNavigation.followLatest(target); - // Return a historical range to the tail without delaying message admission. - void range.current?.loadLatest().catch((reason: unknown) => { - if (currentSessionId.current === target) report(reason); - }); const result = await services.answer(attempt.sessionId, attempt.input); return acceptAnswer(attempt, result); } catch (reason) { @@ -650,11 +636,7 @@ export function useWorkHubController(onSubmit?: () => void) { void send(attempt.input.text, attempt.input.attachments ?? []); } else retryResolution.current(); }, - prefetchHistory: (edge: 'older' | 'newer') => - range.current?.prefetchHistory(edge) ?? Promise.resolve(false), - retainWindow: (window: { firstTurnId: string; lastTurnId: string }) => - range.current?.retain(window), - loadLatest: () => range.current?.loadLatest(), + loadEarlier: () => range.current?.loadEarlier(), report, streamingSettled(messageId?: string) { if (!messageId || currentSessionId.current !== sessionId) return; diff --git a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts index 54f2e272c2..b37de22b6e 100644 --- a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts +++ b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts @@ -19,7 +19,7 @@ import type { UiCatalog } from '@maka/core/ui-locale'; export const workHubLiveCopy = { - en: { filterConversation: 'Filter conversation by Work', clearConversationFilter: 'Show all conversations', noWorkConversation: 'No conversations for this Work in this part of history.', olderConversations: 'Earlier history', newerConversations: 'Later history', navigationGesture: 'Click to locate conversations; click again to filter; click once more to show all', attachmentLimit: 'Attachment count or size exceeds the limit', attachmentUploadFailed: 'Attachment upload did not return a reference', reviewAttachments: 'Please review the attachments.', sendFailed: 'Could not send', + en: { filterConversation: 'Filter conversation by Work', clearConversationFilter: 'Show all conversations', noWorkConversation: 'No conversations for this Work in this part of history.', navigationGesture: 'Click to locate conversations; click again to filter; click once more to show all', attachmentLimit: 'Attachment count or size exceeds the limit', attachmentUploadFailed: 'Attachment upload did not return a reference', reviewAttachments: 'Please review the attachments.', sendFailed: 'Could not send', retrySteering: 'Retry the original text and attachments with Shift+Enter to resolve the previous submission first.', retryFollowup: 'Retry the original text and attachments with Enter to resolve the previous submission first.', sendUnknown: 'The Host has not confirmed this message. Retry checks the same submission.', @@ -45,7 +45,7 @@ export const workHubLiveCopy = { delegationCompleted: 'Completed', delegationFailed: 'Failed', delegationAborted: 'Aborted', delegationRecovering: 'Recovering', openWork: 'Open task', openResult: 'Open result', }, - 'zh-CN': { filterConversation: '筛选此 Work 的对话', clearConversationFilter: '显示全部对话', noWorkConversation: '这段历史中没有此 Work 的对话。', olderConversations: '更早的历史', newerConversations: '更新的历史', navigationGesture: '点击定位对话;再点筛选;再次点击显示全部', attachmentLimit: '附件数量或大小超过限制', attachmentUploadFailed: '附件上传失败', reviewAttachments: '请查看附件。', sendFailed: '发送失败', + 'zh-CN': { filterConversation: '筛选此 Work 的对话', clearConversationFilter: '显示全部对话', noWorkConversation: '这段历史中没有此 Work 的对话。', navigationGesture: '点击定位对话;再点筛选;再次点击显示全部', attachmentLimit: '附件数量或大小超过限制', attachmentUploadFailed: '附件上传失败', reviewAttachments: '请查看附件。', sendFailed: '发送失败', retrySteering: '请先保留原文和附件,用 Shift+Enter 重试并确认上次提交结果。', retryFollowup: '请先保留原文和附件,用 Enter 重试并确认上次提交结果。', sendUnknown: 'Host 尚未确认这条消息。重试会核对原提交。', @@ -71,7 +71,7 @@ export const workHubLiveCopy = { delegationCompleted: '已完成', delegationFailed: '失败', delegationAborted: '已中止', delegationRecovering: '正在恢复', openWork: '打开任务', openResult: '打开结果', }, - 'zh-TW': { filterConversation: '篩選此 Work 的對話', clearConversationFilter: '顯示全部對話', noWorkConversation: '這段歷史中沒有此 Work 的對話。', olderConversations: '更早的歷史', newerConversations: '更新的歷史', navigationGesture: '點擊定位對話;再點篩選;再次點擊顯示全部', attachmentLimit: '附件數量或大小超過限制', attachmentUploadFailed: '附件上傳失敗', reviewAttachments: '請查看附件。', sendFailed: '傳送失敗', + 'zh-TW': { filterConversation: '篩選此 Work 的對話', clearConversationFilter: '顯示全部對話', noWorkConversation: '這段歷史中沒有此 Work 的對話。', navigationGesture: '點擊定位對話;再點篩選;再次點擊顯示全部', attachmentLimit: '附件數量或大小超過限制', attachmentUploadFailed: '附件上傳失敗', reviewAttachments: '請查看附件。', sendFailed: '傳送失敗', retrySteering: '請先保留原文和附件,用 Shift+Enter 重試並確認上次提交結果。', retryFollowup: '請先保留原文和附件,用 Enter 重試並確認上次提交結果。', sendUnknown: 'Host 尚未確認這則訊息。重試會核對原提交。', diff --git a/apps/desktop/src/renderer/features/workhub/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts index 4b9e20af47..66577ffa0a 100644 --- a/apps/desktop/src/renderer/features/workhub/ports.ts +++ b/apps/desktop/src/renderer/features/workhub/ports.ts @@ -36,16 +36,11 @@ import type { export interface WorkHubTranscriptSnapshot { readonly messages: readonly StoredMessage[]; readonly hasOlder: boolean; - readonly hasNewer: boolean; readonly ready: boolean; } export interface WorkHubTranscript { observationChanged(phase: 'pending' | 'ready'): void; - /** Fills the window at an edge the reader approaches; resolves to whether a read was issued. */ - prefetchHistory(edge: 'older' | 'newer'): Promise; - /** Trims the window to the Turns the reader's band still covers. */ - retain(window: { firstTurnId: string; lastTurnId: string }): void; - loadLatest(): Promise; + loadEarlier(): Promise; close(): Promise; } export interface WorkHubServices extends WorkHubWorkspaceServices { diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx index b036bf1d3c..e0bf5b5fc1 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useContext, useMemo, useState, type ComponentProps, type CSSProperties } from 'react'; +import { useContext, useMemo, type ComponentProps, type CSSProperties } from 'react'; import { ChatView, useUiLocale } from '@maka/ui'; import type { UiLocale } from '@maka/core/ui-locale'; import { Button, Link, Text } from '@astryxdesign/core'; @@ -53,16 +53,6 @@ export function WorkHubConversation(props: ComponentProps & { w const workHubIdentityHue = useWorkHubIdentityHue(assignments.map((work) => work.targetSessionId)); const locale = useUiLocale(); const copy = workHubLiveCopy[locale]; - const [loadingHistory, setLoadingHistory] = useState(false); - const [historyError, setHistoryError] = useState(false); - async function loadHistory(edge: 'older' | 'newer') { - if (loadingHistory) return; - setLoadingHistory(true); - setHistoryError(false); - try { await chat.onPrefetchHistory?.(edge); } - catch { setHistoryError(true); } - finally { setLoadingHistory(false); } - } // A coordination turn can delegate to several Works. Keep every label and // leave its shared bar neutral rather than attributing the entire turn to one. const worksByTurn = useMemo(() => { @@ -132,9 +122,6 @@ export function WorkHubConversation(props: ComponentProps & { w {selected &&
{selected.name}
} & { w transientMessages={selected ? chat.transientMessages?.filter((message) => message.hostTurnId && matchingTurns.has(message.hostTurnId)) : chat.transientMessages} activeTurn={activeTurn} emptyOverride={selected ?

{copy.noWorkConversation}

: chat.emptyOverride} - onRetainWindow={selected ? undefined : chat.onRetainWindow} - onPrefetchHistory={selected ? undefined : chat.onPrefetchHistory} turnDecorations={turnDecorations} promptRailDecorations={promptRailDecorations} onPromptRailHighlight={(turnId) => highlight.highlight(turnId ? workByTurn.get(turnId) : undefined)} diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx index 589b5d5e57..f5f1d44cad 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx @@ -295,7 +295,6 @@ export function WorkHubRoot() { {(controller.error || control?.error) && ( @@ -346,7 +345,7 @@ export function WorkHubRoot() { onModelChange={controller.changeModel} modelSwitchAvailability={controller.configuringModel ? { available: false, pending: true, reason: 'pending' } : undefined} contextUsage={session ? { - usageTokens: liveContextUsage?.usageTokens ?? selectLatestRequestUsage(transcript.messages, transcript, session.model, session), + usageTokens: liveContextUsage?.usageTokens ?? selectLatestRequestUsage(transcript.messages, session.model, session), declaredContextWindow: modelChoice?.declaredContextWindow, meteredContextWindow: liveContextUsage?.contextWindow, metadataContextWindow: modelChoice?.contextWindow, @@ -382,10 +381,8 @@ export function WorkHubRoot() { scrollBehavior="auto" onNew={() => composer.current?.focus()} messages={[...transcript.messages]} - hasOlderHistory={transcript.hasOlder} - hasNewerHistory={transcript.hasNewer} - onPrefetchHistory={controller.prefetchHistory} - onRetainWindow={controller.retainWindow} + hasEarlierHistory={transcript.hasOlder} + onLoadEarlierHistory={controller.loadEarlier} transientMessages={controller.transientMessages} viewportNavigation={controller.viewportNavigation} liveTurns={controller.liveTurns} diff --git a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts index 4f6bba8de9..8a00b6ced9 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts @@ -32,72 +32,13 @@ import { import { DesktopTranscriptRangeStore, createDesktopTranscriptRangeController, - createRecoveringDesktopTranscriptRangeController, + openDesktopTranscriptHistory, } from './desktop-transcript-range-store.js'; -import type { TurnRecord } from '@maka/core/session'; import { MESSAGE_QUEUE_MAX_ENTRIES, type TurnMessageExecutionResolution, } from '@maka/runtime-host/protocol'; -const WORKHUB_RESULT_SCAN_MAX_PAGES = 16; - -async function readDelegatedTurnResult( - bridge: Pick, - sessionId: string, - turn: TurnRecord, -): Promise { - const store = new DesktopTranscriptRangeStore(sessionId); - const controller = createDesktopTranscriptRangeController(store, (signal) => - bridge.transcripts.open( - sessionId, - (batch) => { - if (signal.aborted) return; - store.accept(batch); - }, - (cancel) => { - if (signal.aborted) cancel(); - else signal.addEventListener('abort', cancel, { once: true }); - }, - ), - ); - try { - await controller.ready(); - let preview: string | undefined; - let lastTargetSequence: number | undefined; - const boundaryEstablished = () => { - const entries = store.durableEntries(); - const currentPreview = workHubTurnResultPreview( - entries.map(({ message }) => message), - turn.turnId, - ); - if (currentPreview) preview = currentPreview; - for (const entry of entries) { - if (entry.message.turnId === turn.turnId) { - lastTargetSequence = Math.max(lastTargetSequence ?? entry.sequence, entry.sequence); - } - } - const crossedIntoLaterTurn = lastTargetSequence !== undefined && entries.some( - (entry) => entry.sequence > lastTargetSequence! && entry.message.turnId !== turn.turnId, - ); - return lastTargetSequence !== undefined && ( - crossedIntoLaterTurn || !store.range().hasNewer - ); - }; - if (!boundaryEstablished() && lastTargetSequence === undefined && turn.firstSequence !== undefined) { - await controller.loadAround(turn.firstSequence); - } - for (let page = 0; page <= WORKHUB_RESULT_SCAN_MAX_PAGES; page += 1) { - if (boundaryEstablished()) return preview; - if (!store.range().hasNewer || page === WORKHUB_RESULT_SCAN_MAX_PAGES) return undefined; - await controller.loadAfter(); - } - return undefined; - } finally { - await controller.close(); - } -} - export function createDesktopWorkHubServices( bridge: Pick< MakaBridge, @@ -204,7 +145,10 @@ export function createDesktopWorkHubServices( resultPreview = delegatedResultCache.get(cacheKey); if (!resultPreview) { try { - resultPreview = await readDelegatedTurnResult(bridge, sessionId, turn); + resultPreview = workHubTurnResultPreview( + await bridge.transcripts.readTurn(sessionId, turn.turnId), + turn.turnId, + ); if (resultPreview) { delegatedResultCache.set(cacheKey, resultPreview); if (delegatedResultCache.size > 100) { @@ -260,21 +204,10 @@ export function createDesktopWorkHubServices( }, async openTranscript(sessionId, handler, cancellation, onError) { const store = new DesktopTranscriptRangeStore(sessionId); - // Every window change commits through the store, a trim included, so - // this is the whole of what the surface hears. const unsubscribe = store.subscribe(() => handler(store.snapshot())); - const controller = createRecoveringDesktopTranscriptRangeController(store, (signal) => - bridge.transcripts.open( - sessionId, - (batch) => { - if (signal.aborted) return; - store.accept(batch); - }, - (cancel) => { - if (signal.aborted) cancel(); - else signal.addEventListener('abort', cancel, { once: true }); - }, - ), + const controller = createDesktopTranscriptRangeController( + store, + openDesktopTranscriptHistory(bridge.transcripts.open, sessionId, (batch) => store.accept(batch)), { onError }, ); const cancel = () => { unsubscribe(); void controller.close(); }; @@ -282,17 +215,7 @@ export function createDesktopWorkHubServices( if (cancellation.aborted) cancel(); return { observationChanged: controller.observationChanged, - prefetchHistory: (edge) => - edge === 'older' ? controller.loadBefore() : controller.loadAfter(), - retain: ({ firstTurnId, lastTurnId }) => { - // A Turn the band named but the window no longer holds yields null, - // which leaves that side of the window unbounded rather than empty. - controller.store.retain( - controller.store.sequenceForTurn(firstTurnId, 'first'), - controller.store.sequenceForTurn(lastTurnId, 'last'), - ); - }, - loadLatest: () => controller.loadLatest(), + loadEarlier: () => controller.loadEarlier(), close: () => { cancellation.removeEventListener('abort', cancel); unsubscribe(); diff --git a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts index 5f154c3fc8..2e57ad0610 100644 --- a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts @@ -19,199 +19,31 @@ import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; import { markPersisted } from '@maka/core/persisted-value'; -import { - DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE, - type DesktopTranscriptBatchPayload, - type DesktopTranscriptExtension, - type DesktopTranscriptFragment, - type DesktopTranscriptHandle, +import type { MakaBridge } from '../../../preload/bridge-contract.js'; +import type { + DesktopTranscriptBatch, + DesktopTranscriptBatchPayload, + DesktopTranscriptFragment, + DesktopTranscriptHandle, } from '../../../preload/transcript-contract.js'; -import { TranscriptReadSupersededError } from '../../features/conversation/index.js'; import { projectDesktopStoredMessage } from '../../../shared/desktop-session-projection.js'; import { parseDesktopSessionKey } from '../../../shared/runtime-host-identity.js'; -// Interactive reads render real rows. Keep a page smaller than the IPC fragment -// ceiling so short replies cannot turn one read into dozens of mounted Turns. -// The Host still completes a boundary Turn that exceeds this page budget. -const TRANSCRIPT_READING_PAGE_BYTES = 16 * 1024; - /** - * The Renderer's window onto one Session transcript. `loadAround` and - * `loadLatest` replace the window and mint a navigation number; `loadBefore` - * and `loadAfter` extend it from an edge. Main answers the request and - * otherwise only broadcasts tail growth. + * The Renderer's copy of one Session transcript, opened in history mode: it + * only grows, by tail changes and by explicit `loadEarlier` reads. */ export interface DesktopTranscriptRangeController { readonly store: DesktopTranscriptRangeStore; ready(): Promise; waitForDurableMessage(messageId: string, timeoutMs: number): Promise; - /** Resolves to whether a read was actually issued. */ - loadBefore(maxBytes?: number): Promise; - loadAfter(maxBytes?: number): Promise; - loadAround(sequence: number, maxBytes?: number): Promise; - loadLatest(): Promise; + /** One budget of earlier history, or everything down to `throughSequence` in one answer. */ + loadEarlier(throughSequence?: number): Promise; reload(): Promise; + observationChanged(phase: 'pending' | 'ready'): void; close(): Promise; } -/** - * `acknowledgesTail` is what a reader that renders the transcript says about - * itself. A consumer opened only to project rows reaches the tail just as a - * reader does, and acknowledging from there would mark the Session read on - * behalf of nobody, so the default is to stay silent. - */ -export function createDesktopTranscriptRangeController( - store: DesktopTranscriptRangeStore, - open: (signal: AbortSignal) => Promise, - options: { readonly acknowledgesTail?: boolean } = {}, -): DesktopTranscriptRangeController { - let closed = false; - let openController = new AbortController(); - let handle = open(openController.signal); - const extending: { - older?: { anchor: number | null; task: Promise }; - newer?: { anchor: number | null; task: Promise }; - } = {}; - /** The window a read was issued against; the same window answers the same way. */ - const spent: { older?: object; newer?: object } = {}; - const current = async () => { - if (closed) throw new Error('Desktop transcript range is closed'); - return handle; - }; - const command = async ( - replace: boolean, - run: (value: DesktopTranscriptHandle, navigation: number) => Promise, - ) => { - // Mint before awaiting an open handle or any in-flight page. Main uses the - // number only to cancel work a newer navigation has made pointless, so an - // extension issued while a navigation is still in flight names that - // navigation: it is the one Main is already reading for. - const navigation = replace ? store.navigate() : store.pendingNavigation() ?? store.navigation(); - const opening = handle; - const isCurrent = () => !closed && opening === handle && - (!replace || store.pendingNavigation() === navigation); - try { - const value = await current(); - if (!isCurrent()) return; - await run(value, navigation); - } catch (error) { - if (isCurrent()) throw error; - } - }; - /** The row a read at this edge would anchor on, or undefined with no edge to read. */ - const edgeAt = (edge: 'older' | 'newer'): { anchor: number | null } | undefined => { - let range: DesktopTranscriptRangeState; - try { - range = store.range(); - } catch { - return undefined; - } - if (edge === 'older' ? !range.hasOlder : !range.hasNewer) return undefined; - return { anchor: edge === 'older' ? range.oldestSequence : range.newestSequence }; - }; - const extend = async (edge: 'older' | 'newer', maxBytes: number): Promise => { - const at = edgeAt(edge); - if (!at) return false; - // Sharing a read only holds while the edge it was anchored on does. - const pending = extending[edge]; - if (pending && pending.anchor === at.anchor) { - await pending.task; - return false; - } - // A read issued against this exact window answers it the same way again. - // Every commit mints a fresh snapshot object, so a window that moved at all - // — by a page, a trim, a navigation or tail growth — is worth asking again. - const window = store.snapshot(); - if (spent[edge] === window) return false; - spent[edge] = window; - const task = command(false, (value, navigation) => - edge === 'older' - ? value.loadBefore(at.anchor, maxBytes, navigation) - : value.loadAfter(at.anchor, maxBytes, navigation), - ).finally(() => { - if (extending[edge]?.task === task) extending[edge] = undefined; - }); - extending[edge] = { anchor: at.anchor, task }; - await task; - return true; - }; - /** - * Main marks the Session read from these and from nothing else, so the window - * reports every watermark it actually reaches, once. A window with newer - * history beyond it reports nothing: the reader is parked off the tail. - */ - let acknowledged: number | undefined; - const acknowledgeTail = () => { - let range: DesktopTranscriptRangeState; - try { - range = store.range(); - } catch { - return; - } - // A cached window's watermark is a fact about the local cache, not about - // the live replica an acknowledgement moves. - if ( - !range.ready || range.hasNewer || range.durableThrough === null || - range.generation.startsWith('cached:') - ) return; - const through = range.durableThrough; - if (acknowledged === through) return; - acknowledged = through; - void (async () => { - try { - await (await current()).acknowledgeTail(through); - } catch { - if (acknowledged === through) acknowledged = undefined; - } - })(); - }; - const unsubscribe = options.acknowledgesTail ? store.subscribe(acknowledgeTail) : () => {}; - return { - store, - async ready() { await current(); }, - async waitForDurableMessage(messageId, timeoutMs) { - await current(); - return store.waitForDurableMessage(messageId, timeoutMs); - }, - loadBefore(maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { - return extend('older', maxBytes); - }, - loadAfter(maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { - return extend('newer', maxBytes); - }, - loadAround(sequence, maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { - return command(true, (value, navigation) => value.loadAround(sequence, maxBytes, navigation)); - }, - loadLatest() { - return command(true, (value, navigation) => value.loadLatest(navigation)); - }, - async reload() { - const previous = handle; - // The replacement consumer has heard nothing yet. - acknowledged = undefined; - openController.abort(); - const replacement = previous - .then((value) => value.close()) - .catch(() => undefined) - .then(() => { - if (closed) throw new Error('Desktop transcript range is closed'); - openController = new AbortController(); - return open(openController.signal); - }); - handle = replacement; - await replacement; - }, - async close() { - if (closed) return; - closed = true; - unsubscribe(); - openController.abort(); - await handle.then((value) => value.close()).catch(() => undefined); - }, - }; -} - export interface DesktopTranscriptReconnectRecovery { transcriptFailed(error: unknown): void; observationChanged(phase: 'pending' | 'ready'): void; @@ -282,68 +114,146 @@ export function createDesktopTranscriptReconnectRecovery(options: { }; } -export interface RecoveringDesktopTranscriptRangeController - extends DesktopTranscriptRangeController { - observationChanged(phase: 'pending' | 'ready'): void; -} - -function isHostEpochChanged(error: unknown): error is Error { - return error instanceof Error && - error.message.includes(`${DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE}:`); +/** The `open` a range controller takes, reading a Session's history through the bridge. */ +export function openDesktopTranscriptHistory( + open: MakaBridge['transcripts']['open'], + sessionId: string, + accept: (batch: DesktopTranscriptBatch) => void, +): (signal: AbortSignal, resumeFrom?: number) => Promise { + return (signal, resumeFrom) => open( + sessionId, + (batch) => { + if (!signal.aborted) accept(batch); + }, + (cancel) => { + if (signal.aborted) cancel(); + else signal.addEventListener('abort', cancel, { once: true }); + }, + 'history', + resumeFrom, + ); } -export function createRecoveringDesktopTranscriptRangeController( +export function createDesktopTranscriptRangeController( store: DesktopTranscriptRangeStore, - open: (signal: AbortSignal) => Promise, - options: { - onError(error: unknown): void; - }, -): RecoveringDesktopTranscriptRangeController { - // Every visible transcript reader recovers; a projection does not. So this is - // the one place that claims tail acknowledgement on a reader's behalf. - const controller = createDesktopTranscriptRangeController(store, open, { acknowledgesTail: true }); - const cached = () => { + /** `resumeFrom` is the oldest sequence held, which a reopen must read back down to. */ + open: (signal: AbortSignal, resumeFrom?: number) => Promise, + options: { onError(error: unknown): void }, +): DesktopTranscriptRangeController { + let closed = false; + let openController = new AbortController(); + let handle = open(openController.signal); + const current = async () => { + if (closed) throw new Error('Desktop transcript range is closed'); + return handle; + }; + const range = () => { try { - const range = store.range(); - return range.ready && range.generation.startsWith('cached:'); + return store.range(); } catch { - return false; + return undefined; } }; + const cached = () => { + const current = range(); + return current?.ready === true && current.generation.startsWith('cached:'); + }; const requireLive = () => { if (cached()) throw new Error('The cached transcript is waiting for Host reconnection'); }; - /** - * A read the Host refused because its epoch moved under the request says - * nothing about the reader: the replacement replica has already asked every - * consumer to reset, and that reset carries the new epoch, so reopening here - * would only throw the answer away. Retype it so the reading position treats - * the read as superseded instead of failed. - */ - const superseding = (run: () => Promise): Promise => run().catch((error: unknown) => { - if (!isHostEpochChanged(error)) throw error; - throw new TranscriptReadSupersededError(error.message, { cause: error }); - }); + /** Main marks the Session read from these alone, so each watermark the reader reaches is reported once. */ + let acknowledged: number | undefined; + const acknowledgeTail = () => { + const held = range(); + if (!held?.ready || held.durableThrough === null || held.generation.startsWith('cached:')) return; + const through = held.durableThrough; + if (acknowledged === through) return; + acknowledged = through; + void (async () => { + try { + await (await current()).acknowledgeTail(through); + } catch { + if (acknowledged === through) acknowledged = undefined; + } + })(); + }; + const reopen = async () => { + const previous = handle; + acknowledged = undefined; + openController.abort(); + const held = range(); + const resumeFrom = held?.ready ? held.oldestSequence ?? undefined : undefined; + const replacement = previous + .then((value) => value.close()) + .catch(() => undefined) + .then(() => { + if (closed) throw new Error('Desktop transcript range is closed'); + openController = new AbortController(); + return open(openController.signal, resumeFrom); + }); + handle = replacement; + await replacement; + requireLive(); + }; const recovery = createDesktopTranscriptReconnectRecovery({ - async reload() { - await controller.reload(); - requireLive(); - }, + reload: reopen, onError(error) { if (!cached()) options.onError(error); }, }); - void controller.ready().then(requireLive).catch(recovery.transcriptFailed); + // A reopen can land on the cached transcript; recovery must still hear of it. + const reload = () => reopen().catch((error: unknown) => { + recovery.transcriptFailed(error); + throw error; + }); + let gapReload: Promise | undefined; + const unsubscribe = store.subscribe(() => { + acknowledgeTail(); + if (!store.needsReload() || gapReload || closed) return; + gapReload = reload() + .catch(() => undefined) + .finally(() => { gapReload = undefined; }); + }); + void handle.then(requireLive).catch(recovery.transcriptFailed); + let earlier: Promise | undefined; + const loadEarlier = (throughSequence?: number): Promise => { + if (earlier) { + return throughSequence === undefined ? earlier : earlier.then(() => loadEarlier(throughSequence)); + } + const held = range(); + if (!held?.ready || !held.hasOlder || cached()) return Promise.resolve(); + if ( + throughSequence !== undefined && + held.oldestSequence !== null && + held.oldestSequence <= throughSequence + ) return Promise.resolve(); + const reading = handle; + const task = current() + .then((value) => value.loadEarlier(throughSequence)) + .catch((error: unknown) => { + if (!closed && reading === handle) options.onError(error); + }) + .finally(() => { earlier = undefined; }); + earlier = task; + return task; + }; return { - ...controller, - loadBefore: (maxBytes = TRANSCRIPT_READING_PAGE_BYTES) => superseding(() => controller.loadBefore(maxBytes)), - loadAfter: (maxBytes = TRANSCRIPT_READING_PAGE_BYTES) => superseding(() => controller.loadAfter(maxBytes)), - loadAround: (sequence, maxBytes = TRANSCRIPT_READING_PAGE_BYTES) => superseding(() => controller.loadAround(sequence, maxBytes)), - loadLatest: () => superseding(() => controller.loadLatest()), + store, + async ready() { await current(); }, + async waitForDurableMessage(messageId, timeoutMs) { + await current(); + return store.waitForDurableMessage(messageId, timeoutMs); + }, + loadEarlier, + reload, observationChanged: recovery.observationChanged, async close() { + if (closed) return; + closed = true; recovery.close(); - await controller.close(); + unsubscribe(); + openController.abort(); + await handle.then((value) => value.close()).catch(() => undefined); }, }; } @@ -365,9 +275,13 @@ export interface DesktopTranscriptRangeState { readonly hostEpoch: string; readonly durableThrough: number | null; readonly oldestSequence: number | null; - readonly newestSequence: number | null; + /** Earlier durable history exists that the Renderer has not loaded. */ readonly hasOlder: boolean; - readonly hasNewer: boolean; + /** + * Whether the oldest Turn held has all its rows. An answer is bounded by + * bytes, so it can begin inside a Turn, and no local rule says that it did. + */ + readonly beginsAtTurnBoundary: boolean; readonly ready: boolean; } @@ -375,66 +289,54 @@ export interface DesktopTranscriptRangeSnapshot extends DesktopTranscriptRangeSt readonly messages: readonly StoredMessage[]; } -/** - * An immutable window value. `through` is the newer-side watermark the Host has - * proved this window reaches; `hasNewerAtThrough` says whether rows at or below - * it are still missing, which no watermark comparison can tell. - */ -interface TranscriptWindow { +/** An immutable transcript value: durable rows in sequence order. */ +interface TranscriptValue { readonly rows: ReadonlyMap; readonly order: readonly number[]; readonly hasOlder: boolean; + readonly beginsAtTurnBoundary: boolean; readonly through: number | null; - readonly hasNewerAtThrough: boolean; - readonly newestUserSequence: number | null; } /** - * One answer under construction. Batches accumulate here so that `#window` + * One answer under construction. Batches accumulate here so that the value * changes exactly once per answer, from one complete value to the next. */ interface TranscriptAssembly { - readonly kind: 'replace' | 'extend' | 'tail'; + readonly kind: 'reset' | 'earlier' | 'tail'; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; - readonly navigation: number | undefined; - readonly extension: DesktopTranscriptExtension | undefined; + readonly earlierThan: number | undefined; readonly coversFrom: number | null | undefined; - /** Decided once: rows the window cannot join are not worth decoding. */ - readonly collects: boolean; durableThrough: number | null; hasOlder: boolean | undefined; - hasNewer: boolean | undefined; + beginsAtTurnBoundary: boolean | undefined; readonly fragments: Map; readonly rows: Map; } -const EMPTY_WINDOW: TranscriptWindow = { +const EMPTY_VALUE: TranscriptValue = { rows: new Map(), order: [], hasOlder: false, + beginsAtTurnBoundary: true, through: null, - hasNewerAtThrough: false, - newestUserSequence: null, }; export class DesktopTranscriptRangeStore { readonly sessionId: string; readonly #hostId: string; readonly #expectedSessionId: string; - #window: TranscriptWindow = EMPTY_WINDOW; - /** The Host's durable watermark, which the window may not reach. */ - #durableThrough: number | null = null; + #value: TranscriptValue = EMPTY_VALUE; #assembly: TranscriptAssembly | undefined; - #pendingNavigation: number | undefined; - #navigations = 0; readonly #retiredGenerations = new Set(); #sourceSessionId: string | undefined; #generation: string | undefined; #liveGeneration: string | undefined; #hostEpoch: string | undefined; #ready = false; + #needsReload = false; #snapshot: DesktopTranscriptRangeSnapshot | undefined; readonly #durableWaiters = new Set<() => void>(); readonly #listeners = new Set<() => void>(); @@ -446,35 +348,9 @@ export class DesktopTranscriptRangeStore { this.#expectedSessionId = sessionId; } - /** Names the replacement a navigation is about to ask for. */ - navigate(): number { - this.#navigations += 1; - this.#pendingNavigation = this.#navigations; - return this.#pendingNavigation; - } - - /** The replacement that has been asked for and has not landed yet. */ - pendingNavigation(): number | undefined { - return this.#pendingNavigation; - } - - /** The navigation the window currently sits on. */ - navigation(): number { - return this.#navigations; - } - - /** - * Whether this batch is worth assembling at all. Only Host identity decides - * that; whether its rows reach the window is settled once the whole answer is - * in hand, against the anchor the answer carries. - */ #accepts(batch: DesktopTranscriptBatchPayload): boolean { if (this.#retiredGenerations.has(batch.generation)) return false; - if (batch.reset) { - return batch.navigation === undefined || batch.navigation === this.#pendingNavigation; - } - // A continuation belongs to the answer in flight, whose identity the - // window only adopts once that answer lands. + if (batch.reset) return true; const identity = this.#assembly ?? { sessionId: this.#sourceSessionId, generation: this.#generation, @@ -490,118 +366,86 @@ export class DesktopTranscriptRangeStore { if (batch.reset && batch.sessionId !== this.#expectedSessionId) { throw new Error('Desktop transcript belongs to a different Session'); } - let assembly = this.#assembly; - // A reset is by construction the first batch of its answer, so it starts a - // fresh one even where its identity matches what is in flight. - if (assembly && (batch.reset || !continuesAssembly(assembly, batch))) assembly = undefined; + let assembly = batch.reset ? undefined : this.#assembly; if (!assembly) { - const kind = batch.reset ? 'replace' : batch.extends ? 'extend' : 'tail'; assembly = { - kind, + kind: batch.reset ? 'reset' : batch.earlierThan !== undefined ? 'earlier' : 'tail', sessionId: batch.sessionId, generation: batch.generation, hostEpoch: batch.hostEpoch, - navigation: batch.navigation, - extension: batch.extends, + earlierThan: batch.earlierThan, coversFrom: batch.coversFrom, - // A tail answer this window cannot join contributes nothing but its watermark. - collects: kind !== 'tail' || this.#joinsTail(batch.coversFrom), durableThrough: batch.durableThrough, hasOlder: undefined, - hasNewer: undefined, + beginsAtTurnBoundary: undefined, fragments: new Map(), rows: new Map(), }; this.#assembly = assembly; } if (batch.hasOlder !== undefined) assembly.hasOlder = batch.hasOlder; - if (batch.hasNewer !== undefined) assembly.hasNewer = batch.hasNewer; + if (batch.beginsAtTurnBoundary !== undefined) { + assembly.beginsAtTurnBoundary = batch.beginsAtTurnBoundary; + } assembly.durableThrough = batch.durableThrough; - for (const fragment of batch.fragments) this.#acceptFragment(assembly, fragment, assembly.collects); + for (const fragment of batch.fragments) this.#acceptFragment(assembly, fragment); if (!batch.ready) return false; this.#assembly = undefined; return this.#apply(assembly); } - /** - * Installs one complete answer. The Host facts it carries land whatever the - * window does with its rows; the rows land only where the answer's anchor is - * still the edge it was read from, because nothing else proves them adjacent. - */ #apply(answer: TranscriptAssembly): boolean { - const durableThrough = this.#durableThrough; - const window = this.#window; - if ( - answer.durableThrough !== null && - (durableThrough === null || answer.durableThrough > durableThrough) - ) { - this.#durableThrough = answer.durableThrough; - } - const installed = this.#install(answer); - if (installed) this.#window = installed; - if (installed && answer.kind === 'replace') this.#adoptHostIdentity(answer); - if (answer.navigation !== undefined && answer.navigation === this.#pendingNavigation) { - this.#pendingNavigation = undefined; + const value = this.#value; + const wasReady = this.#ready; + const next = this.#install(answer); + if (next) { + this.#value = next; + if (answer.kind === 'reset') { + this.#adoptHostIdentity(answer); + this.#ready = true; + this.#needsReload = false; + } + } else if (answer.kind === 'tail') { + this.#needsReload = true; } - const ready = this.#ready || (answer.kind === 'replace' && installed !== undefined); - const changed = this.#durableThrough !== durableThrough || this.#window !== window || ready !== this.#ready; - this.#ready = ready; + const changed = this.#value !== value || this.#ready !== wasReady || (!next && answer.kind === 'tail'); if (changed) this.#commit(); for (const notify of this.#durableWaiters) notify(); return changed; } - /** The next window value, or `undefined` where this answer reaches no edge of it. */ - #install(answer: TranscriptAssembly): TranscriptWindow | undefined { - const window = this.#window; - if (answer.kind === 'replace') { - if (answer.navigation !== undefined && answer.navigation !== this.#pendingNavigation) { - return undefined; - } - return sameWindow(window, makeWindow( + /** The next value, or `undefined` where the answer does not continue what is held. */ + #install(answer: TranscriptAssembly): TranscriptValue | undefined { + const value = this.#value; + if (answer.kind === 'reset') { + return makeValue( answer.rows, answer.hasOlder ?? false, + answer.beginsAtTurnBoundary ?? true, answer.durableThrough, - answer.hasNewer ?? false, - )); + ); } - if (answer.kind === 'extend') { - const extension = answer.extension!; - if (extension.direction === 'older') { - if (extension.anchor !== (window.order[0] ?? null)) return undefined; - return sameWindow(window, makeWindow( - mergeRows(window, answer.rows), - answer.hasOlder ?? window.hasOlder, - window.through, - window.hasNewerAtThrough, - )); - } - if (extension.anchor !== (window.order.at(-1) ?? null)) return undefined; - // A page read before the tail grew cannot close the newer edge: rows that - // landed meanwhile are not in it, so the edge stays open. - const regressed = answer.durableThrough !== null && window.through !== null && - answer.durableThrough < window.through; - return sameWindow(window, makeWindow( - mergeRows(window, answer.rows), - window.hasOlder, - answer.durableThrough ?? window.through, - (answer.hasNewer ?? false) || regressed, - )); + if (answer.kind === 'earlier') { + if (!this.#ready || answer.earlierThan !== value.order[0]) return undefined; + return makeValue( + mergeRows(value.rows, answer.rows), + answer.hasOlder ?? value.hasOlder, + answer.beginsAtTurnBoundary ?? value.beginsAtTurnBoundary, + value.through, + ); } - if (!this.#joinsTail(answer.coversFrom)) return undefined; - return sameWindow(window, makeWindow( - mergeRows(window, answer.rows), - window.hasOlder, - answer.durableThrough ?? window.through, - false, - )); + if (!this.#ready || answer.coversFrom !== value.through) return undefined; + return makeValue( + mergeRows(value.rows, answer.rows), + value.hasOlder, + value.beginsAtTurnBoundary, + answer.durableThrough ?? value.through, + ); } - /** Whether a read that started at `coversFrom` continues this window's newer edge. */ - #joinsTail(coversFrom: number | null | undefined): boolean { - return coversFrom !== undefined && - coversFrom === this.#window.through && - !this.#window.hasNewerAtThrough; + /** Set when a tail change did not continue what is held; the controller reopens. */ + needsReload(): boolean { + return this.#needsReload; } /** Fires after every committed change to `snapshot()`. */ @@ -610,34 +454,6 @@ export class DesktopTranscriptRangeStore { return () => { this.#listeners.delete(listener); }; } - /** - * Drops durable rows outside `[oldestSequence, newestSequence]`. Either edge - * that lost rows becomes a history edge again. An extension already in flight - * carries the anchor it was read from, so a trim needs no announcement: the - * anchor no longer matches an edge, and the answer is refused on arrival. - */ - retain(oldestSequence: number | null, newestSequence: number | null): boolean { - const window = this.#window; - let droppedOlder = false; - let droppedNewer = false; - const kept = window.order.filter((sequence) => { - const older = oldestSequence !== null && sequence < oldestSequence; - const newer = newestSequence !== null && sequence > newestSequence; - droppedOlder ||= older; - droppedNewer ||= newer; - return !older && !newer; - }); - if (!droppedOlder && !droppedNewer) return false; - this.#window = makeWindow( - new Map(kept.map((sequence) => [sequence, window.rows.get(sequence)!])), - window.hasOlder || droppedOlder, - droppedNewer ? kept.at(-1) ?? null : window.through, - window.hasNewerAtThrough || droppedNewer, - ); - this.#commit(); - return true; - } - #commit(): void { this.#snapshot = this.#createSnapshot(); for (const listener of [...this.#listeners]) listener(); @@ -648,57 +464,29 @@ export class DesktopTranscriptRangeStore { return this.#snapshot; } - durableEntries(): ReadonlyArray<{ readonly sequence: number; readonly message: StoredMessage }> { - const window = this.#window; - return window.order.map((sequence) => ({ - sequence, - message: structuredClone(window.rows.get(sequence)!.message), - })); - } - range(): DesktopTranscriptRangeState { if (!this.#sourceSessionId || !this.#generation || !this.#hostEpoch) { throw new Error('Desktop transcript range is not initialized'); } - const window = this.#window; return { sessionId: this.sessionId, generation: this.#generation, hostEpoch: this.#hostEpoch, - durableThrough: this.#durableThrough, - oldestSequence: window.order[0] ?? null, - newestSequence: window.order.at(-1) ?? null, - hasOlder: window.hasOlder, - hasNewer: this.#hasNewer(), + durableThrough: this.#value.through, + oldestSequence: this.#value.order[0] ?? null, + hasOlder: this.#value.hasOlder, + beginsAtTurnBoundary: this.#value.beginsAtTurnBoundary, ready: this.#ready, }; } - /** The window has newer history whenever anything durable lies beyond it. */ - #hasNewer(): boolean { - const window = this.#window; - return window.hasNewerAtThrough || - (this.#durableThrough !== null && - (window.through === null || this.#durableThrough > window.through)); - } - hasDurableMessage(messageId: string): boolean { - for (const record of this.#window.rows.values()) { + for (const record of this.#value.rows.values()) { if (record.message.id === messageId) return true; } return false; } - newestDurableUserSequence(): number | null { - return this.#window.newestUserSequence; - } - - sequenceForTurn(turnId: string, edge: 'first' | 'last' = 'first'): number | null { - const window = this.#window; - const order = edge === 'first' ? window.order : [...window.order].reverse(); - return order.find((sequence) => window.rows.get(sequence)?.message.turnId === turnId) ?? null; - } - waitForDurableMessage(messageId: string, timeoutMs: number): Promise { if (this.hasDurableMessage(messageId)) return Promise.resolve(true); return new Promise((resolve) => { @@ -734,11 +522,7 @@ export class DesktopTranscriptRangeStore { this.#hostEpoch = batch.hostEpoch; } - #acceptFragment( - assembly: TranscriptAssembly, - fragment: DesktopTranscriptFragment, - collects: boolean, - ): void { + #acceptFragment(assembly: TranscriptAssembly, fragment: DesktopTranscriptFragment): void { const sequence = fragment.sequence; let pending = assembly.fragments.get(sequence); if (!pending) { @@ -766,7 +550,6 @@ export class DesktopTranscriptRangeStore { pending.receivedBytes += bytes.byteLength; if (pending.receivedBytes < pending.totalBytes) return; assembly.fragments.delete(sequence); - if (!collects) return; const encoded = new TextDecoder('utf-8', { fatal: true }).decode(pending.bytes); const message = freezeTranscriptValue(projectDesktopStoredMessage( { hostId: this.#hostId }, @@ -776,34 +559,18 @@ export class DesktopTranscriptRangeStore { } #createSnapshot(): DesktopTranscriptRangeSnapshot { - const window = this.#window; - const messages = Object.freeze( - window.order.map((sequence) => window.rows.get(sequence)!.message), - ); - return Object.freeze({ - ...this.range(), - messages, - }); + const range = this.range(); + const value = this.#value; + const messages = Object.freeze(value.order.map((sequence) => value.rows.get(sequence)!.message)); + return Object.freeze({ ...range, messages }); } } -function continuesAssembly( - assembly: TranscriptAssembly, - batch: DesktopTranscriptBatchPayload, -): boolean { - return assembly.sessionId === batch.sessionId && - assembly.generation === batch.generation && - assembly.hostEpoch === batch.hostEpoch && - assembly.navigation === batch.navigation && - assembly.extension?.direction === batch.extends?.direction && - assembly.extension?.anchor === batch.extends?.anchor; -} - function mergeRows( - window: TranscriptWindow, + current: ReadonlyMap, rows: ReadonlyMap, ): Map { - const merged = new Map(window.rows); + const merged = new Map(current); for (const [sequence, record] of rows) { const existing = merged.get(sequence); if (existing && existing.encoded !== record.encoded) { @@ -814,36 +581,19 @@ function mergeRows( return merged; } -function makeWindow( +function makeValue( rows: ReadonlyMap, hasOlder: boolean, + beginsAtTurnBoundary: boolean, through: number | null, - hasNewerAtThrough: boolean, -): TranscriptWindow { - const order = [...rows.keys()].sort((left, right) => left - right); - let newestUserSequence: number | null = null; - for (const sequence of order) { - if (rows.get(sequence)!.message.type === 'user') newestUserSequence = sequence; - } - return { rows, order, hasOlder, through, hasNewerAtThrough, newestUserSequence }; -} - -/** Keeps the current value where the candidate says the same thing, so that a - * fresh snapshot object always means a window that actually moved. */ -function sameWindow(current: TranscriptWindow, candidate: TranscriptWindow): TranscriptWindow { - if ( - current.hasOlder !== candidate.hasOlder || - current.through !== candidate.through || - current.hasNewerAtThrough !== candidate.hasNewerAtThrough || - current.order.length !== candidate.order.length - ) return candidate; - for (const [index, sequence] of current.order.entries()) { - if (candidate.order[index] !== sequence) return candidate; - if (current.rows.get(sequence)!.encoded !== candidate.rows.get(sequence)!.encoded) { - return candidate; - } - } - return current; +): TranscriptValue { + return { + rows, + order: [...rows.keys()].sort((left, right) => left - right), + hasOlder, + beginsAtTurnBoundary, + through, + }; } function freezeTranscriptValue(value: T): T { diff --git a/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts b/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts index 91c2d62b8d..7508a2babc 100644 --- a/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts +++ b/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts @@ -19,7 +19,6 @@ import type { StoredMessage } from '@maka/core/session'; import type { MakaBridge } from '../../../preload/bridge-contract.js'; -import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../../../preload/transcript-contract.js'; import { DesktopTranscriptRangeStore } from './desktop-transcript-range-store.js'; const COMMITTED_ASSISTANT_SETTLE_TIMEOUT_MS = 480; @@ -31,8 +30,7 @@ export interface RefreshMessagesOptions { } export type TranscriptSettlementSource = { - transcripts: Pick; - sessions: Pick; + transcripts: Pick; }; export async function readSettledMessages( @@ -54,6 +52,7 @@ export async function readSettledMessagesFrom( notify = resolve; }); let nextChange = changed(); + let tailRevision = 0; let cancelOpen = () => {}; let rejectCancellation!: (error: Error) => void; const cancellation = new Promise((_resolve, reject) => { @@ -78,6 +77,7 @@ export async function readSettledMessagesFrom( sessionId, (batch) => { if (!store.accept(batch)) return; + tailRevision += 1; notify(); nextChange = changed(); }, @@ -85,6 +85,7 @@ export async function readSettledMessagesFrom( cancelOpen = close; if (cancelled) close(); }, + 'tail', ); void opening.catch(() => undefined); let handle: Awaited | undefined; @@ -92,48 +93,67 @@ export async function readSettledMessagesFrom( handle = await Promise.race([opening, cancellation]); globalThis.clearTimeout(openTimeout); const requiredTurnId = options.requiredTurnId; - let retainedDurable: ReturnType | undefined; - if ( - requiredTurnId !== undefined && - !transcriptRecordsTerminalTurn(store.snapshot().messages, requiredTurnId) - ) { - retainedDurable = store.durableEntries(); - const readHandle = handle; - const recoverTurn = async () => { - // Main now reads sequence-anchored ranges, not Turn identities. Resolve - // the Host's existing Turn index, then extend only this bounded window - // until the requested terminal record arrives or settlement times out. - const turns = await source.sessions.listTurns(sessionId); - const firstSequence = turns.find((turn) => turn.turnId === requiredTurnId)?.firstSequence; - if (firstSequence === undefined || cancelled || Date.now() >= deadline) return; - await readHandle.loadAround(firstSequence, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, store.navigate()); - let previousSequence: number | null = null; - while (!cancelled && Date.now() < deadline) { - if (transcriptRecordsTerminalTurn(store.snapshot().messages, requiredTurnId)) return; - const range = store.range(); - if (!range.hasNewer || range.newestSequence === previousSequence) return; - previousSequence = range.newestSequence; - await readHandle.loadAfter(range.newestSequence, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, store.navigation()); - } - }; - void recoverTurn().catch(() => undefined); - } + /* + * The tail is byte-bounded and can begin inside a Turn, so what it holds + * of that Turn says nothing about how much of it exists — a terminal row + * in the tail is evidence about execution, not about coverage. Only a + * targeted read walks a whole Turn. + */ + const reads = new Map(); + const readTurn = (turnId: string, done?: (read: TurnRead) => boolean): TurnRead => { + let read = reads.get(turnId); + if (!read) { + read = { messages: [], returned: false, pending: false, revision: -1 }; + reads.set(turnId, read); + } + // Re-read only for a tail that has moved since the last one: the Turn may + // have been running then and ended since. + const current = read; + if (!current.pending && current.revision !== tailRevision && !done?.(current)) { + current.revision = tailRevision; + current.pending = true; + void source.transcripts.readTurn(sessionId, turnId).then((messages) => { + if (cancelled) return; + current.messages = messages; + current.returned = true; + }).catch(() => undefined).finally(() => { + current.pending = false; + notify(); + nextChange = changed(); + }); + } + return current; + }; while (true) { const snapshot = store.snapshot(); + // The Turn the tail begins inside, when it begins inside one. Its earlier + // rows are missing here, and nothing in the tail says which they are — so + // a caller that named no Turn still gets a whole one. + const cutTurnId = snapshot.beginsAtTurnBoundary + ? undefined + : snapshot.messages[0]?.turnId; + const required = + requiredTurnId === undefined + ? undefined + : readTurn(requiredTurnId, (read) => + transcriptRecordsTerminalTurn(read.messages, requiredTurnId), + ); + const cut = + cutTurnId === undefined || cutTurnId === requiredTurnId + ? undefined + : readTurn(cutTurnId, (read) => read.returned); + const tailIds = new Set(snapshot.messages.map((message) => message.id)); + const messages = [...(cut?.messages ?? []), ...(required?.messages ?? [])] + .filter((message) => !tailIds.has(message.id)) + .concat(snapshot.messages); const requiredMessageId = options.requiredAssistantMessageId; const settled = snapshot.ready && (requiredMessageId === undefined || store.hasDurableMessage(requiredMessageId)) && - (requiredTurnId === undefined || - transcriptRecordsTerminalTurn(snapshot.messages, requiredTurnId)); - if (settled || Date.now() >= deadline) { - return { - messages: retainedDurable - ? mergeTranscriptRanges(retainedDurable, store.durableEntries(), snapshot.messages) - : [...snapshot.messages], - settled, - }; - } + (required === undefined || + transcriptRecordsTerminalTurn(required.messages, requiredTurnId!)) && + (cut === undefined || cut.returned); + if (settled || Date.now() >= deadline) return { messages, settled }; await Promise.race([ nextChange, cancellation, @@ -150,18 +170,13 @@ export async function readSettledMessagesFrom( } } -function mergeTranscriptRanges( - retained: ReturnType, - current: ReturnType, - currentMessages: readonly StoredMessage[], -): StoredMessage[] { - const durableBySequence = new Map(retained.map(({ sequence, message }) => [sequence, message])); - for (const { sequence, message } of current) durableBySequence.set(sequence, message); - const durable = [...durableBySequence] - .sort(([left], [right]) => left - right) - .map(([, message]) => message); - const durableIds = new Set(durable.map((message) => message.id)); - return durable.concat(currentMessages.filter((message) => !durableIds.has(message.id))); +/** One Turn's rows as the Host last returned them, and whether a read is in flight. */ +interface TurnRead { + messages: readonly StoredMessage[]; + returned: boolean; + pending: boolean; + /** The tail this read was started against; a moved tail may hold more. */ + revision: number; } function transcriptRecordsTerminalTurn( diff --git a/apps/desktop/src/renderer/session-workspace-actions.ts b/apps/desktop/src/renderer/session-workspace-actions.ts index ff42337a00..b9731efe21 100644 --- a/apps/desktop/src/renderer/session-workspace-actions.ts +++ b/apps/desktop/src/renderer/session-workspace-actions.ts @@ -101,14 +101,7 @@ export function createSessionWorkspaceActions(deps: { ): TransientUserMessage[] { const pending = transientMessagesBySessionRef.current.get(sessionId); if (!pending || pending.size === 0) return []; - let includeTransient = true; - try { - const range = transcriptRangeRef.current?.store.range(); - includeTransient = range?.sessionId !== sessionId || !range.hasNewer; - } catch { - // An unopened transcript has no historical range to hide the live tail from. - } - const projected = reconcileTransientMessages(pending, durable, { includeTransient }); + const projected = reconcileTransientMessages(pending, durable); if (pending.size === 0) { transientMessagesBySessionRef.current.delete(sessionId); } diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index aaaf355b0e..5fa7735d58 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2041,12 +2041,18 @@ function dockOffered(): boolean { * real one does. What it cannot do is scroll, so cases that need the reader to * move set `scrollTop` themselves. */ -/** Storybook input is synthetic, so supply its native scroll result explicitly. */ -function scrollAsReader(root: HTMLElement, top: number): void { +/** + * Storybook input is synthetic, so supply its native scroll result explicitly. + * Returns how far the scroller actually went, read before anything can react to + * it: what the reader asked for, which is what their eyes then expect to see. + */ +function scrollAsReader(root: HTMLElement, top: number): number { const deltaY = top - root.scrollTop; - if (deltaY === 0) return; + if (deltaY === 0) return 0; + const before = root.scrollTop; root.dispatchEvent(new WheelEvent('wheel', { deltaY, bubbles: true })); root.scrollTo({ top, behavior: 'instant' }); + return before - root.scrollTop; } function wheelUp(target: Element): void { @@ -2084,54 +2090,11 @@ function transcriptTurns(from: number, count: number, mixed = false): StoredMess }).flat(); } -const PARTIAL_HISTORY_INDEX = Array.from({ length: 8 }, (_, index) => ({ - turnId: `turn-scroll-${index + 1}`, - sequence: index + 1, - label: `第 ${index + 1} 个问题`, -})); - -function PartialHistoryHarness() { - const [range, setRange] = useState({ from: 5, count: 4 }); - const [target, setTarget] = useState<{ turnId: string; nonce: number }>(); - return ( - { - setRange({ from: loaded.sequence, count: 4 }); - setTarget((previous) => ({ turnId: loaded.turnId, nonce: (previous?.nonce ?? 0) + 1 })); - }, - scrollTargetTurn: target, - hasOlderHistory: range.from > 1, - hasNewerHistory: range.from + range.count <= PARTIAL_HISTORY_INDEX.length, - // A fill extends the window; it never replaces what the reader jumped - // to. Like the Host's range controller it answers `false` for a window - // it has already read and settles a frame later: the transcript chains - // the next band check on `true`, so a fill that says `true` without - // having laid anything out is asked again in the same microtask, forever. - onPrefetchHistory: async (edge) => { - if (edge !== 'newer') return false; - const count = PARTIAL_HISTORY_INDEX.length - range.from + 1; - if (count <= range.count) return false; - setRange({ ...range, count }); - await painted(2); - return true; - }, - }} - /> - ); -} - - -// Real path: selecting a prompt outside the loaded transcript range. The -// transcript shows Turns and nothing else — a range boundary is not a thing to -// read — and every inactive prompt-rail tick uses one neutral treatment. +// Real path: selecting a prompt the reader has scrolled far away from. The +// transcript shows Turns and nothing else, and every inactive prompt-rail tick +// uses one neutral treatment. export const PartialHistoryNotice: Story = { - render: () => , + render: () => , play: async ({ canvasElement }) => { const firstPrompt = canvasElement.querySelector( '.maka-prompt-rail-tick[data-prompt-turn-id="turn-scroll-1"]', @@ -2373,21 +2336,20 @@ export const SubmittedPromptSettlesWithoutReversing: Story = { /** Lets a play function drive props React owns. One story renders per page. */ let appendTurn: (() => void) | undefined; -/** Every older fill the transcript asked for, oldest resident turn first. */ +/** Every earlier-history load the transcript asked for, oldest resident turn first. */ const historyLoads: string[] = []; const HISTORY_BATCH = 4; -// More than any story here consumes, so no story reaches the end of history. -const HISTORY_BATCHES_AVAILABLE = 8; - /** A settled transcript with a turn the play function can make arrive. */ function SettledTranscriptHarness({ turns, composer, + mixed, }: { turns: number; composer?: Partial; + mixed?: boolean; }) { const [extra, setExtra] = useState(0); useEffect(() => { @@ -2396,76 +2358,49 @@ function SettledTranscriptHarness({ appendTurn = undefined; }; }, []); - return ; + return ; } /** - * The history seam is two props: `hasOlderHistory`, and a loader that prepends. - * The loader settles a frame later, the way a page fetched over IPC does — the - * transcript reads the band again as soon as a page settles, so a loader that - * settled before its turns were laid out would be asked for the next page - * against the geometry of the previous one. + * The history seam is `hasEarlierHistory` and a loader that prepends + * `HISTORY_BATCH` Turns. The loader settles a frame later, the way an answer + * delivered over IPC does. */ -function HistoryHarness({ turns, bounded = false, olderTurns = HISTORY_BATCH * HISTORY_BATCHES_AVAILABLE, mixed = false }: { turns: number; bounded?: boolean; olderTurns?: number; mixed?: boolean }) { - const [range, setRange] = useState({ from: 0, count: turns }); - const [viewportNavigation] = useState(createTranscriptViewportNavigation); +function HistoryHarness({ + turns, + olderTurns = 0, + mixed = false, +}: { turns: number; olderTurns?: number; mixed?: boolean }) { + const [from, setFrom] = useState(0); useEffect(() => { historyLoads.length = 0; }, []); return ( -olderTurns, - hasNewerHistory: bounded && range.from + range.count < turns, - onRetainWindow: bounded ? ({ firstTurnId, lastTurnId }) => { - // The real scroll hook chooses the retained band. This fixture only - // supplies the requested slice, standing in for the transcript store. - const from = Number(firstTurnId.replace('turn-scroll-', '')); - const last = Number(lastTurnId.replace('turn-scroll-', '')); - viewportNavigation.commitRange(activeSession!.id, () => setRange({ - from, count: last - from + 1, - })); - } : undefined, - onPrefetchHistory: async (edge) => { - if (bounded && edge === 'newer') { - viewportNavigation.commitRange(activeSession!.id, () => setRange((current) => ({ - ...current, - count: Math.min(turns - current.from, current.count + HISTORY_BATCH), - }))); - await painted(2); - return true; - } - if (edge !== 'older') return false; + messages: transcriptTurns(from, turns - from, mixed), + hasEarlierHistory: from > -olderTurns, + onLoadEarlierHistory: async () => { historyLoads.push(firstResidentTurnId() ?? '(none)'); - viewportNavigation.commitRange(activeSession!.id, () => setRange((current) => ({ - from: Math.max(-olderTurns, current.from - HISTORY_BATCH), - count: current.count + Math.min(HISTORY_BATCH, current.from + olderTurns), - }))); + setFrom((current) => Math.max(-olderTurns, current - HISTORY_BATCH)); await painted(2); - return true; }, }} /> ); } -/** The band inside which the transcript keeps history loaded around the reader. */ -function loadBand(): number { - return Math.max(640, tailScroller().clientHeight * 2); -} - -/** History stops arriving once the band above the reader is full. */ -async function historySettled(): Promise { +/** + * The transcript opened at its tail. Rows mount only after the virtualizer + * measures its scroller, and an empty scroller is trivially at its tail. + */ +async function tailSettled(): Promise { await waitFor(() => { const settled = tailMetrics(); - expect(settled.scrollTop, JSON.stringify(settled)).toBeGreaterThan(loadBand()); + expect(settled.scrollHeight, JSON.stringify(settled)).toBeGreaterThan(settled.clientHeight); expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); }, { timeout: 10_000 }); - const loads = historyLoads.length; - await painted(12); - expect(historyLoads.length, 'history kept arriving after the band was full').toBe(loads); + await painted(4); } export const TailFollowsGrowthOutsideTurns: Story = { @@ -2571,7 +2506,7 @@ export const ReaderScrolledUpIsNotPulledBack: Story = { export const DockAffordanceReturnsToTail: Story = { render: () => , play: async () => { - await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); + await tailSettled(); scrollAsReader(tailScroller(), 0); await painted(6); @@ -2590,54 +2525,41 @@ export const DockAffordanceReturnsToTail: Story = { }; export const NestedScrollerNearHistoryBoundaryAsksForNothing: Story = { - render: () => , + render: () => , play: async () => { - await historySettled(); + await tailSettled(); const nested = injectNestedScroller(messageList()); + scrollAsReader(tailScroller(), 0); await painted(6); - historyLoads.length = 0; wheelUp(nested); + wheelUp(tailScroller()); await painted(6); - // The gesture crossed a scroller that could act on it, so it was never the - // reader asking for what is above the transcript. + // Scrolling never loads history; only the explicit control does. expect(historyLoads).toEqual([]); expect(nested.scrollTop).toBe(600); }, }; -export const TailPrefetchesHistoryUntilTheBandIsFull: Story = { - render: () => , - play: async () => { - const before = firstResidentTurnId(); - // Opening a Session fills the band above the reader without a gesture, and - // the reader stays at the tail while the pages land above them. - await historySettled(); - expect(historyLoads.length).toBeGreaterThan(0); - expect(firstResidentTurnId()).not.toBe(before); - }, -}; - -// Real path: a reader traverses a long session; useChatScroll requests older -// pages and trims distant Turns. Paging/storage is simulated at ChatView's -// callbacks; the production scroll policy, publication bridge and frame run. +// Real path: a reader scrolls through a long loaded Session; only the rows near +// the viewport are mounted. export const HistoryWindowTraversal: Story = { - render: () => , + render: () => , }; -// Real path: the bounded Desktop transcript mounts and evicts mixed prose and -// code turns. The fixed-membership geometry scene below does not virtualize. +// Real path: virtualized mixed prose and code turns. The fixed-membership +// geometry scene below uses the same list at a size it keeps mounted. export const VirtualHistoryMixedContent: Story = { - render: () => , + render: () => , }; // Real path: traverse a long Session, return through already read history, -// and jump to a loaded Turn whose body is currently outside the viewport. +// and keep a selected Turn mounted while it is outside the viewport. export const VirtualHistoryContinuity: Story = { - render: () => , + render: () => , play: async () => { - await historySettled(); + await tailSettled(); const root = tailScroller(); const bodies = () => root.querySelectorAll('.maka-turn[data-turn-id]'); await waitFor(() => { @@ -2654,14 +2576,15 @@ export const VirtualHistoryContinuity: Story = { } throw new Error('History traversal did not reach its edge'); }; - const tailId = bodies().item(bodies().length - 1).dataset.turnId; + const tailId = 'turn-scroll-23'; + const tail = () => root.querySelector(`.maka-turn[data-turn-id="${tailId}"]`); + expect(tail()).not.toBeNull(); await traverse(-1); await traverse(1); await painted(8); - expect(bodies().item(bodies().length - 1).dataset.turnId, 'returning through history must reach the original tail').toBe(tailId); - expect(root.scrollTop, 'the retained history stays inside the eviction band').toBeLessThanOrEqual(root.clientHeight * 6); + expect(tail(), 'returning through history must reach the original tail').not.toBeNull(); - const selected = bodies().item(bodies().length - 1); + const selected = tail()!; const selection = document.getSelection()!; const range = document.createRange(); range.selectNodeContents(selected); @@ -2753,7 +2676,7 @@ export const GeometryMixed24Turns: Story = { const turnId = `geometry-${i}`; return [user(`geometry-u-${i}`, turnId, 50 - i, `检查第 ${i + 1} 组。`), assistant(`geometry-a-${i}`, turnId, 50 - i, mixedTurnText(i))]; - }).flat(), hasOlderHistory: false, hasNewerHistory: false }} />, + }).flat() }} />, }; export const GeometryLongCode: Story = { @@ -2763,7 +2686,7 @@ export const GeometryLongCode: Story = { Array.from({ length: 1200 }, (_, line) => `${line + 1}: ${'wrapped-code-content-'.repeat(9)}`, ).join('\n') + '\n```'), - ], hasOlderHistory: false, hasNewerHistory: false }} />, + ] }} />, }; export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { @@ -2857,47 +2780,57 @@ export const OversizedLiveTurnHoldsAReadingAnchorOnColdScroll: Story = { }; /** - * First upward traversal of a deep fixed transcript: document height and - * the reader's content position must remain stable without a warm-up pass. + * First upward traversal of a deep fixed transcript, without a warm-up pass. + * A reader-sized step: a whole scrollport at a time would carry the Turn being + * tracked out of the mounted rows before it can be measured again. */ -const TRAVERSAL_STEP = 700; +const TRAVERSAL_STEP = 200; -/** The first Turn whose box is still on screen, and where it starts. */ +/** The Turn the reader is looking at — the one across the middle — and where it starts. */ function anchorInView(): { turnId: string; top: number } { const root = tailScroller(); - const rootTop = root.getBoundingClientRect().top; + const middle = root.getBoundingClientRect().top + root.clientHeight / 2; const turn = [...root.querySelectorAll('[data-turn-id]')].find( - (candidate) => candidate.getBoundingClientRect().bottom > rootTop, + (candidate) => candidate.getBoundingClientRect().bottom > middle, ); if (!turn?.dataset.turnId) throw new Error('no turn is on screen'); return { turnId: turn.dataset.turnId, top: Math.round(turn.getBoundingClientRect().top) }; } +// Turn heights have to vary: with uniform rows the virtualizer's estimate is +// right for every unmounted row, which is the one case where mounting a row +// above the reader cannot move them. export const UpwardTraversalHoldsTurnGeometry: Story = { - render: () => , + render: () => , play: async () => { const root = tailScroller(); await document.fonts.ready; + await tailSettled(); await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); - await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); const heightBefore = root.scrollHeight; expect( heightBefore / root.clientHeight, 'the transcript has to be deep enough to hold unrendered Turns', ).toBeGreaterThan(6); + const turnsBefore = document.querySelectorAll('.maka-prompt-rail-tick').length; const drifts: number[] = []; + const thumbs: number[] = []; + const mounted: number[] = []; let steps = 0; - while (root.scrollTop > 0 && steps < 40) { + while (root.scrollTop > 0 && steps < 60) { const anchor = anchorInView(); const scrollBefore = root.scrollTop; - scrollAsReader(root, Math.max(0, scrollBefore - TRAVERSAL_STEP)); + const travelled = scrollAsReader(root, Math.max(0, scrollBefore - TRAVERSAL_STEP)); await painted(4); - - // The reader moved by what the scroller actually moved, so the Turn under - // them comes down the viewport by that much plus whatever the estimates - // above them were off by. - const travelled = scrollBefore - root.scrollTop; + thumbs.push(root.scrollTop / (root.scrollHeight - root.clientHeight)); + mounted.push(document.querySelectorAll('.maka-transcript-turn').length); + + // The Turn under the reader comes down the viewport by exactly what the + // reader asked the scroller to travel. Rows mounting above them are + // measured and correct the virtualizer's estimates, and the virtualizer + // absorbs that correction into the scroll offset — so it must not reach + // the screen. drifts.push(Math.round(turnTop(anchor.turnId) - (anchor.top + travelled))); steps += 1; } @@ -2907,12 +2840,21 @@ export const UpwardTraversalHoldsTurnGeometry: Story = { expect(worstDrift, `per-step drift: ${drifts.join(' ')}`) .toBeLessThanOrEqual(1); - // The fixed document keeps its full height throughout the traversal. - const heightAfter = root.scrollHeight; - expect( - Math.abs(heightAfter - heightBefore), - JSON.stringify({ heightBefore, heightAfter, steps }), - ).toBeLessThanOrEqual(1); + // Nothing loaded: scrolling reads what is already here. + expect(document.querySelectorAll('.maka-prompt-rail-tick').length).toBe(turnsBefore); + + // And the window stays a window: the whole transcript is resident, only a + // slice of it is in the DOM at any point of the walk. + expect(Math.max(...mounted), `mounted Turns per step: ${mounted.join(' ')}`) + .toBeLessThanOrEqual(turnsBefore / 2); + + // The thumb only ever walks towards the top. Measuring unmounted Turns + // refines the document's extent as the reader goes, which moves the thumb + // a little under them; what the reader cannot be shown is the thumb + // running backwards, which is how a range change used to look. + const backwards = thumbs.slice(1).map((thumb, index) => thumb - thumbs[index]!); + expect(Math.max(...backwards), `thumb steps: ${backwards.map((step) => step.toFixed(4)).join(' ')}`) + .toBeLessThanOrEqual(0.01); // And the reader can still get back. dockButton().click(); @@ -2927,7 +2869,7 @@ export const UpwardTraversalHoldsTurnGeometry: Story = { }; export const HistoryAtTheTopStillLandsAboveTheReader: Story = { - render: () => , + render: () => , play: async () => { const root = tailScroller(); // Measure history publication against rendered content, not the cold @@ -2941,26 +2883,96 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { expect(settled.scrollTop, JSON.stringify(settled)).toBeGreaterThan(0); expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); }); - for (const offset of [0, 77]) { - // Native anchoring is absent at the origin and can be suppressed just - // above it during wheel input. Force that second condition deterministically. - const suppression = document.createElement('style'); - if (offset > 0) suppression.textContent = '[data-chat-scroll-container] { overflow-anchor: none !important; }'; - document.head.append(suppression); - try { - const before = firstResidentTurnId(); - scrollAsReader(root, offset); - const reading = anchorInView(); - wheelUp(root); - - await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); - suppression.remove(); - await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); - await painted(6); - - expect(Math.abs(turnTop(reading.turnId) - reading.top)).toBeLessThanOrEqual(1); - } finally { suppression.remove(); } - } + // The one position where the browser declines to anchor, and the one the + // load-earlier control sits at. + scrollAsReader(root, 0); + await painted(4); + const before = firstResidentTurnId(); + const reading = anchorInView(); + within(document.body).getByRole('button', { name: '载入更早的记录' }).click(); + + await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); + await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); + await painted(6); + + expect(historyLoads).toEqual([before]); + expect(Math.abs(turnTop(reading.turnId) - reading.top)).toBeLessThanOrEqual(1); + expect(within(document.body).queryByRole('button', { name: '载入更早的记录' })).toBeNull(); + }, +}; + +/** + * Walk the reader to the top of the transcript, returning the worst per-step + * drift. The step stays well inside the measure-ahead margin: the Turn being + * tracked has to survive from one measurement to the next. + */ +async function traverseToTop(step = TRAVERSAL_STEP): Promise { + const root = tailScroller(); + let worst = 0; + let steps = 0; + while (root.scrollTop > 0 && steps < 200) { + const anchor = anchorInView(); + const travelled = scrollAsReader(root, Math.max(0, root.scrollTop - step)); + await painted(4); + worst = Math.max(worst, Math.abs(Math.round(turnTop(anchor.turnId) - (anchor.top + travelled)))); + steps += 1; + } + return worst; +} + +/** + * Mount and measure every row, without tracking anyone: a pass that asserts + * nothing can take a scrollport-sized step, where the asserted pass cannot — + * the reader-sized step is what keeps the tracked Turn mounted from one + * measurement to the next. + */ +async function measureEveryRow(): Promise { + const root = tailScroller(); + let steps = 0; + while (root.scrollTop > 0 && steps < 200) { + scrollAsReader(root, Math.max(0, root.scrollTop - root.clientHeight)); + await painted(2); + steps += 1; + } +} + +/** + * Earlier history arriving must not detach the heights already measured from + * the Turns they were measured on. The virtualizer indexes its measurement + * cache by position and grows it at the end unless told the growth is at the + * front, so a prepend that does not say so slides every measured height one + * batch along: the document's extent goes wrong, and rows the reader has + * already been past push them when they come back. + */ +export const PrependedHistoryKeepsMeasuredHeights: Story = { + // Shallower than the other history stories: this one walks the whole + // transcript twice, and a slid measurement shows on the first row past the + // prepend as well as on the hundredth. + render: () => , + play: async () => { + const root = tailScroller(); + await document.fonts.ready; + await tailSettled(); + await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); + + // Measure every row once, so what follows is about heights the virtualizer + // already holds and not about rows whose height was never known. + await measureEveryRow(); + const before = firstResidentTurnId(); + + within(document.body).getByRole('button', { name: '载入更早的记录' }).click(); + await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); + await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); + await painted(6); + + // Walk back down to the tail and up again over the same rows. Their + // heights are known, so nothing here may move the reader; a measurement + // that has slid onto the wrong Turn does exactly that. + scrollAsReader(root, root.scrollHeight); + await painted(6); + const warmDrift = await traverseToTop(300); + + expect(warmDrift, 'a measured row moved the reader').toBeLessThanOrEqual(1); }, }; @@ -2968,41 +2980,16 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { * The prompt anchor rail (#563). All three of its shipped regressions had the * same shape — the code kept working and the pixels stopped — so the * assertions here are geometric. - * - * Tick count comes from `transcriptTurnIndex`, not from mounted Turns: the - * transcript holds a slice of the history and the index carries the rest of the - * landmarks, so the rail gets all 64 ticks against 10 Turns. That is what the - * Host does in production. */ const PROMPT_RAIL_TURN_COUNT = 120; -/** - * The Turns this story feeds the transcript — the tail a Session opens with, - * `DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS`, restated to keep stories off preload. - * What the reader ends up mounting is the Renderer's own retained band; this - * story never scrolls far enough to grow past its own slice. - */ -const PROMPT_RAIL_TAIL_TURNS = 10; - /** `MAX_PROMPT_RAIL_TICKS` in prompt-anchor-rail.tsx, which does not export it. */ const PROMPT_RAIL_MAX_TICKS = 64; -const PROMPT_RAIL_TAIL_RANGE_START = PROMPT_RAIL_TURN_COUNT - PROMPT_RAIL_TAIL_TURNS + 1; - -const promptRailIndex = Array.from({ length: PROMPT_RAIL_TURN_COUNT }, (_, offset) => ({ - turnId: `turn-scroll-${offset + 1}`, - sequence: offset + 1, - label: `第 ${offset + 1} 个问题`, -})); - -const promptRailMessages = transcriptTurns(PROMPT_RAIL_TAIL_RANGE_START, PROMPT_RAIL_TAIL_TURNS); +const promptRailMessages = transcriptTurns(1, PROMPT_RAIL_TURN_COUNT); function PromptRailHarness() { - return ( - - ); + return ; } function railTicks(): HTMLElement[] { @@ -3133,10 +3120,10 @@ export const PromptRailHasNoGapsBetweenTicks: Story = { }, }; -/** Away from the tail, but still inside the band that would ask for history. */ +/** Away from the tail, with the tail Turn still in view. */ async function scrollAwayFromTail(): Promise { const root = tailScroller(); - scrollAsReader(root, Math.min(root.scrollHeight - root.clientHeight - 100, loadBand() + 200)); + scrollAsReader(root, root.scrollHeight - root.clientHeight - 100); root.dispatchEvent(new Event('scroll')); await painted(4); } @@ -3152,26 +3139,17 @@ export const ActiveTurnsKeepStableDomIdentities: Story = { render: () => , play: async () => { await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); - const sourceCount = Number( - messageList().getAttribute('data-turn-source-count'), - ); - expect(sourceCount).toBe(PROMPT_RAIL_TAIL_TURNS); - expect(document.querySelectorAll('[data-turn-id]')).toHaveLength(sourceCount); - - // Marked on the elements themselves: a remount drops the attribute, which - // a count alone cannot tell apart from a remount that produced the same - // number of Turns. - for (const turn of document.querySelectorAll('[data-turn-id]')) { - turn.dataset.stableMountProbe = turn.dataset.turnId; - } - await scrollTranscriptTo('bottom'); + const tailTurnId = `turn-scroll-${PROMPT_RAIL_TURN_COUNT}`; + const tail = document.querySelector(`[data-turn-id="${tailTurnId}"]`); + if (!tail) throw new Error('the tail Turn is missing'); + + // Marked on the element itself: a remount drops the attribute. + tail.dataset.stableMountProbe = tailTurnId; + await scrollAwayFromTail(); - expect(document.querySelectorAll('[data-turn-id]')).toHaveLength(sourceCount); - expect(document.querySelectorAll('[data-turn-id][data-stable-mount-probe]')).toHaveLength( - sourceCount, - ); + expect(document.querySelector(`[data-turn-id="${tailTurnId}"][data-stable-mount-probe]`)).not.toBe(null); }, }; @@ -3209,36 +3187,6 @@ export const ScrollingAwayPreservesTurnOwnedFocus: Story = { }, }; -export const OffscreenActiveTurnsStayFindable: Story = { - render: () => , - play: async () => { - await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); - const firstTurnId = document - .querySelector('[data-turn-id]') - ?.getAttribute('data-turn-id'); - const turnNumber = Number(firstTurnId?.split('-').at(-1)); - expect(turnNumber).toBeGreaterThan(0); - const needle = `第 ${turnNumber} 个问题`; - - await scrollTranscriptTo('bottom'); - - // `window.find` walks the rendered text, so a Turn skipped by - // `content-visibility` would not be there to find. - // - // The E2E original also asserted the Turn's text was in the accessibility - // tree, which needs CDP and so did not come across. The smoke's AX audit - // is not a substitute: it checks for unnamed actionable nodes and - // duplicate landmarks, never that a given string is exposed. - document.getSelection()?.removeAllRanges(); - // `window.find` is non-standard, so it is not on the DOM lib's Window. - const found = (window as unknown as { find(text: string): boolean }).find(needle); - - expect(found, `searching for ${needle}`).toBe(true); - expect(document.getSelection()?.toString() ?? '').toContain(needle); - document.getSelection()?.removeAllRanges(); - }, -}; - /** Where a Turn sits relative to the top of the scrollport. */ function turnOffsetFromScroller(turnId: string): number { const root = tailScroller(); @@ -3247,32 +3195,8 @@ function turnOffsetFromScroller(turnId: string): number { return Math.round(turn.getBoundingClientRect().top - root.getBoundingClientRect().top); } -/** - * The Host's half of a rail jump, as `createSessionOpenCommand` does it: a tick - * for a Turn outside the active range comes back out as `onLoadTranscriptTurn`, - * the range moves to it and a scroll target names it. ChatView holds the claim - * until the Turn mounts, then aligns the target to the rail's edge. - */ -function PromptRailNavigationHarness() { - const [firstIndex, setFirstIndex] = useState(PROMPT_RAIL_TAIL_RANGE_START); - const [target, setTarget] = useState<{ turnId: string; nonce: number }>(); - return ( - { - setFirstIndex(loaded.sequence); - setTarget((previous) => ({ turnId: loaded.turnId, nonce: (previous?.nonce ?? 0) + 1 })); - }, - scrollTargetTurn: target, - }} - /> - ); -} - export const FirstRailClickLandsOnItsPromptAndHolds: Story = { - render: () => , + render: () => , play: async () => { await waitFor(() => expect(railTicks().length).toBeGreaterThan(0)); @@ -3287,12 +3211,8 @@ export const FirstRailClickLandsOnItsPromptAndHolds: Story = { 'a reduced-motion browser finishes the jump in one frame and this story stops testing anything', ).toBe(false); - // The case that used to fail: the head of the conversation is not mounted, - // so the jump has to bring it in, and the fill that follows changes - // scrollHeight underneath the tail-follow lock. A lock that ignores - // scroll-ups arriving with a changed height stays on and pulls the - // transcript back to the bottom — the click looks dead until the reader - // scrolls by hand. + // The head of the conversation is not mounted, so the jump has to bring it + // in while rows measure underneath the tail-follow lock. const targetTurnId = 'turn-scroll-1'; expect(document.querySelector(`[data-turn-id="${targetTurnId}"]`)).toBe(null); @@ -3380,7 +3300,7 @@ async function expectRailMatchesReadingPosition(where: string): Promise { } export const RailStaysOnTheVisiblePrompt: Story = { - render: () => , + render: () => , play: async () => { const root = tailScroller(); await waitFor(() => expect(railTicks().length).toBeGreaterThan(0)); @@ -3403,8 +3323,7 @@ export const RailStaysOnTheVisiblePrompt: Story = { record(); try { - // Reading positions across the active range, then a jump that replaces - // the range entirely — the two ways the rail's input changes. + // Reading positions across the transcript, then a jump to its head. for (const fraction of [0.75, 0.5, 0.25, 0]) { scrollAsReader(root, Math.round((root.scrollHeight - root.clientHeight) * fraction)); root.dispatchEvent(new Event('scroll')); @@ -3447,7 +3366,6 @@ const workbarLayoutWithOneFace: WorkbarLayoutState = reduceWorkbarLayout( function WorkbarInShell(props: { longTitle?: boolean; onShare?: () => void; workbarWidth?: number; withConversation?: boolean } = {}) { const [layout, dispatch] = useReducer(reduceWorkbarLayout, workbarLayoutWithOneFace); - const [scrollTarget, setScrollTarget] = useState<{ turnId: string; nonce: number }>(); const resizable = useResizable({ defaultSize: props.workbarWidth ?? layout.rightWidth, minSizePx: 320, maxSizePx: 760, @@ -3469,9 +3387,7 @@ function WorkbarInShell(props: { longTitle?: boolean; onShare?: () => void; work
{props.withConversation && }> - setScrollTarget((previous) => ({ turnId: turn.turnId, nonce: (previous?.nonce ?? 0) + 1 }))} /> + }
{!rightCollapsed && , play: async ({ canvasElement }) => { - const process = canvasElement.querySelector('.maka-processing-sequence'); - await expect(process).not.toBeNull(); + // Rows mount once the virtualizer has measured its scroller. + const process = await waitFor(() => { + const found = canvasElement.querySelector('.maka-processing-sequence'); + expect(found).not.toBeNull(); + return found; + }); await expect(process!.open).toBe(false); const answer = await within(canvasElement).findByText('已修复登录状态恢复。'); await expect(answer).toBeVisible(); @@ -3916,9 +3836,9 @@ export const CompletedProcessCollapsed: Story = { export const CompletedProcessExpanded: Story = { render: () => , play: async ({ canvasElement }) => { + await within(canvasElement).findByText('已修复登录状态恢复。'); const process = canvasElement.querySelector('.maka-processing-sequence')!; const summary = process.querySelector('summary')!; - await within(canvasElement).findByText('已修复登录状态恢复。'); // Check the browser-applied motion contract without assuming a frame will // run during the transition. A busy runner may paint only the endpoint; // ::details-content does not reliably expose Animation objects/events. diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index c9208ce587..c181b90946 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -74,7 +74,7 @@ function makeServices(failFirst: boolean, withHistory: boolean | 'usage', colore let questionPending = question; let pendingForm: import('@maka/core/events').FormRequestEvent | undefined; const publishExecution = () => updateExecution?.({ type: 'host_execution', available: true, rootTurn: pendingForm ? { sessionId, turnId: pendingForm.turnId, runId: 'selection-run', status: 'waiting_for_user' } : questionPending ? { sessionId, turnId: 'question-turn', runId: 'question-run', status: 'waiting_for_user' } : null }); - const publish = () => { publishExecution(); updateTranscript?.({ messages, hasOlder: false, hasNewer: false, ready: true }); }; + const publish = () => { publishExecution(); updateTranscript?.({ messages, hasOlder: false, ready: true }); }; return { inspector: { context: async () => ({ ok: true, data: { status: 'available', completedAt: 1, modelId: session.model, providerId: 'openai', inputTokens: 1000, contextWindow: 100_000 } }), @@ -147,7 +147,7 @@ function makeServices(failFirst: boolean, withHistory: boolean | 'usage', colore return { kind: 'committed', session: { ...session, workspace: { target: { kind: 'host_path', path: '/projects/maka' }, hostCwd: '/projects/maka' }, createdAt: 0, activityAt: 0, labelsTruncated: false, llmConnectionId: 'connection-test', collaborationMode: 'agent', orchestrationMode: 'default' } }; }, observe: (_id, _event, _error, _phase, execution) => { updateExecution = execution; publishExecution(); return () => { updateExecution = undefined; }; }, - openTranscript: async (_id, handler) => { updateTranscript = handler; publish(); return { observationChanged: () => {}, prefetchHistory: async () => false, retain: () => {}, loadLatest: async () => {}, close: async () => { updateTranscript = undefined; } }; }, + openTranscript: async (_id, handler) => { updateTranscript = handler; publish(); return { observationChanged: () => {}, loadEarlier: async () => {}, close: async () => { updateTranscript = undefined; } }; }, stop: async () => { questionPending = false; pendingForm = undefined; @@ -519,8 +519,10 @@ export const FilterWorkConversations: Story = { expect(canvas.getByText('请检查发布检查清单。')).toBeInTheDocument(); expect(writes.open).not.toHaveBeenCalled(); await userEvent.click(canvas.getByRole('button', { name: '显示全部对话' })); + await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(4)); const answerRail = canvasElement.querySelector('.maka-assistant-answer .workhub-message-rail') as HTMLElement; answerRail.focus(); + expect(answerRail).toHaveFocus(); await userEvent.keyboard('{Enter}'); await waitFor(() => expect(canvasElement.querySelectorAll('.maka-turn[data-turn-id]')).toHaveLength(2)); await userEvent.click(canvas.getByRole('button', { name: '显示全部对话' })); @@ -548,7 +550,7 @@ function PagedWorkConversation() { {}, navigateWork: () => {}, selectedWork, selectWork, toggleWork: (work) => selectWork((current) => current?.sessionId === work.sessionId ? undefined : work) }}>
{}} onNew={() => {}} scrollBehavior="auto" activeSession={{ id: sessionId, name: 'WorkHub', isFlagged: false, isArchived: false, labels: [], hasUnread: false, status: 'active', runningTurnIds: [], backend: 'ai-sdk', llmConnectionId: 'connection-test', llmConnectionSlug: 'test', connectionLocked: false, model: 'model-a', permissionMode: 'ask' }} - hasOlderHistory={!loaded} onPrefetchHistory={async () => { setLoaded(true); return true; }} + hasEarlierHistory={!loaded} onLoadEarlierHistory={() => setLoaded(true)} workLinks={['older-turn', 'latest-turn'].map((coordinationTurnId) => ({ id: coordinationTurnId, coordinationTurnId, targetSessionId: targetId, targetSessionName: '支付回调幂等性' }))} />
@@ -560,10 +562,10 @@ export const FilterWorkHistoryPages: Story = { const canvas = within(canvasElement); await waitFor(() => expect(canvas.getByText('继续补充异常场景。')).toBeInTheDocument()); expect(canvas.queryByText('请检查支付回调幂等性。')).toBeNull(); - await userEvent.click(canvas.getByRole('button', { name: '更早的历史' })); + await userEvent.click(canvas.getByRole('button', { name: '载入更早的记录' })); await waitFor(() => expect(canvas.getByText('请检查支付回调幂等性。')).toBeInTheDocument()); expect(canvas.queryByText('先讨论一下整体计划。')).toBeNull(); - expect(canvas.queryByRole('button', { name: '更早的历史' })).toBeNull(); + expect(canvas.queryByRole('button', { name: '载入更早的记录' })).toBeNull(); await userEvent.click(canvas.getByRole('button', { name: '显示全部对话' })); await waitFor(() => expect(canvas.getByText('先讨论一下整体计划。')).toBeInTheDocument()); }, diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 7c1c00ec4d..953e224a8c 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -255,7 +255,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem) | aligned | | `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned | | `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, Icon, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned | -| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, Spinner | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, Spinner) | aligned | +| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner) | aligned | | `packages/ui/src/choice-panel.tsx` | shell-chrome-or-panel | Kbd, RadioList, RadioListItem, Text | aligned — uses Astryx (Kbd, RadioList, RadioListItem, Text) | aligned | | `packages/ui/src/client-capability-prompt.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned | | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/package-lock.json b/package-lock.json index fc2bdd5b5e..8204437052 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16710,6 +16710,36 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/virtua": { + "version": "0.48.8", + "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz", + "integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0", + "solid-js": ">=1.0", + "svelte": ">=5.0", + "vue": ">=3.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", @@ -17373,7 +17403,8 @@ "lucide-react": "^1.38.0", "mermaid": "^11.17.2", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "virtua": "0.48.8" }, "devDependencies": { "@types/react": "^19.2.18", diff --git a/packages/core/src/e2e-fixture.ts b/packages/core/src/e2e-fixture.ts index 0099c188ee..1a76472b9f 100644 --- a/packages/core/src/e2e-fixture.ts +++ b/packages/core/src/e2e-fixture.ts @@ -28,6 +28,7 @@ export type E2eFixtureScenario = | 'turn-narrative-browser' | 'chat-prompt-rail' | 'chat-partial-history' + | 'chat-large-history' | 'settings-data' | 'settings-bots-onboarding' | 'settings-general' diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts index dca0f8ab4b..62baf17e59 100644 --- a/packages/runtime-host/src/__tests__/connection-session.test.ts +++ b/packages/runtime-host/src/__tests__/connection-session.test.ts @@ -179,9 +179,8 @@ test('transcript pages are serialized per connection before their responses are throughSequence: input.throughSequence, rawBytes: 0, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + endsAtTurnBoundary: true, }, }; }, @@ -1808,9 +1807,8 @@ function transcriptBootstrapFor(sessionId: string) { data: contents.toString('base64'), }, ], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + endsAtTurnBoundary: true, }, }; } diff --git a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts index d572e4ddbe..c3b3696e8f 100644 --- a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts +++ b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts @@ -18,32 +18,54 @@ */ import type { StoredMessage } from '@maka/core/session'; -import type { SessionTurnContribution, SessionTurnLandmark } from '@maka/storage/execution-stores'; +import type { SessionTurnContribution } from '@maka/storage/execution-stores'; import { foldTurnContribution } from '@maka/storage/session-message-projection'; +import type { SessionTurnLandmark } from '../../protocol/index.js'; import type { SessionTranscriptReader } from '../../server/session-transcript-reader.js'; export function transcriptReader( durable: readonly StoredMessage[], sequenceStride = 1, ): SessionTranscriptReader { - const durableRecords = () => - durable.map((message, index) => ({ sequence: index * sequenceStride, message })); + // Here Turns do not interleave, so a run of rows with one turnId is a whole + // Turn and a page is between Turns wherever that run changes. + const durableRecords = () => { + let cluster = 0; + let owner: string | undefined; + return durable.map((message, index) => { + const turnId = 'turnId' in message ? message.turnId : undefined; + if (index === 0 || turnId !== owner) cluster += 1; + owner = turnId; + return { sequence: index * sequenceStride, message, cluster }; + }); + }; const durableHighWater = () => durable.length === 0 ? null : (durable.length - 1) * sequenceStride + sequenceStride - 1; return { readDurableHighWater: async () => durableHighWater(), - readDurablePage: async (_sessionId, request) => { + // Cuts pages more simply than the ledger reader does — what it shares is + // the contract the pager depends on: rows the projection hides take up no + // room on the page, and the page resumes past them. + readDurablePage: async (_sessionId, request, project) => { const throughSequence = request.throughSequence === undefined ? durableHighWater() : request.throughSequence; if (throughSequence === null) { - return { throughSequence: null, fragments: [], rawBytes: 0, next: null }; + return { + throughSequence: null, + fragments: [], + rawBytes: 0, + next: null, + endsAtTurnBoundary: true, + }; } const position = request.position ?? (request.direction === 'older' ? throughSequence : 0); const candidates = durableRecords() - .map(({ sequence, message }) => ({ - sequence, - data: Buffer.from(JSON.stringify(message), 'utf8'), - })) + .flatMap(({ sequence, message, cluster }) => { + const projected = project ? project(message) : message; + return projected === null + ? [] + : [{ sequence, cluster, data: Buffer.from(JSON.stringify(projected), 'utf8') }]; + }) .filter( ({ sequence }) => sequence <= throughSequence && @@ -54,6 +76,7 @@ export function transcriptReader( ? right.sequence - left.sequence : left.sequence - right.sequence, ); + let endsAtTurnBoundary = true; const fragments = [] as Array<{ sequence: number; byteOffset: number; @@ -64,7 +87,10 @@ export function transcriptReader( let rawBytes = 0; let next: { position: number; byteOffset: number | null } | null = null; for (const candidate of candidates) { - if (fragments.length >= request.maxMessages || rawBytes >= request.maxBytes) break; + if (fragments.length >= request.maxMessages || rawBytes >= request.maxBytes) { + endsAtTurnBoundary = candidate.cluster !== candidates[fragments.length - 1]?.cluster; + break; + } const continued = candidate.sequence === position && request.byteOffset !== undefined; const edge = continued ? request.byteOffset! @@ -88,6 +114,7 @@ export function transcriptReader( const complete = request.direction === 'older' ? byteOffset === 0 : end === candidate.data.byteLength; if (!complete) { + endsAtTurnBoundary = false; next = { position: candidate.sequence, byteOffset: request.direction === 'older' ? byteOffset : end, @@ -101,7 +128,7 @@ export function transcriptReader( byteOffset: null, }; } - return { throughSequence, fragments, rawBytes, next }; + return { throughSequence, fragments, rawBytes, next, endsAtTurnBoundary }; }, readDurableRecords: async (_sessionId, request) => { const throughSequence = @@ -163,17 +190,28 @@ export function transcriptReader( nextPosition: null, }; }, - readDurableTurnLandmarks: async (_sessionId, maxLandmarks) => { + readDurableTurnLandmarks: async (_sessionId, request) => { const watermark = durableHighWater(); if (watermark === null) return { throughSequence: null, landmarks: [] }; const seen = new Set(); const landmarks: SessionTurnLandmark[] = []; for (const { sequence, message } of durableRecords()) { - if (landmarks.length >= maxLandmarks) break; + if (landmarks.length >= request.maxLandmarks) break; const turnId = message.turnId; if (message.type !== 'user' || turnId === undefined || seen.has(turnId)) continue; + if (request.turnId !== null && turnId !== request.turnId) continue; seen.add(turnId); - landmarks.push({ turnId, sequence, label: message.displayText ?? message.text }); + const lastSequence = Math.max( + ...durableRecords() + .filter((record) => record.message.turnId === turnId) + .map((record) => record.sequence), + ); + landmarks.push({ + turnId, + sequence, + lastSequence, + label: message.displayText ?? message.text, + }); } return { throughSequence: watermark, landmarks }; }, diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 8512a27443..b9ccb37de5 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -165,6 +165,7 @@ test('read marker clears unread only at the ledger transcript tail', async () => records: [ { sequence: 1, + cluster: 1, message: { type: 'assistant', id: 'message-2', @@ -176,6 +177,7 @@ test('read marker clears unread only at the ledger transcript tail', async () => }, { sequence: 0, + cluster: 1, message: { type: 'user', id: 'message-1', turnId: 'turn-1', ts: 10, text: 'ask' }, }, ], @@ -211,6 +213,7 @@ test('read marker pages past a hidden tail to reach the newest visible message', records: [ { sequence: 2, + cluster: 1, message: { type: 'turn_state' as const, id: 'turn-state-1', @@ -227,6 +230,7 @@ test('read marker pages past a hidden tail to reach the newest visible message', records: [ { sequence: 1, + cluster: 1, message: { type: 'assistant' as const, id: 'message-2', diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index 2637f4fd27..c584e4a3aa 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -1703,9 +1703,9 @@ test('an in-flight transcript page cannot outlive its Guest observation grant', let blockPage = false; const reader: SessionTranscriptReader = { ...baseReader, - readDurableRecords: async (sessionId, request) => { + readDurablePage: async (sessionId, request, project) => { if (blockPage) await continued.promise; - return baseReader.readDurableRecords(sessionId, request); + return baseReader.readDurablePage(sessionId, request, project); }, }; const grant = { diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts index ab8e7936a5..f8d6618c63 100644 --- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts +++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts @@ -680,7 +680,7 @@ test('decodes one bounded page without walking the remaining transcript', async assert.deepEqual(requests, []); }); -test('assembles the complete edge Turn while paging newer transcript', async () => { +test('returns a page of complete messages without reading past its cursor', async () => { const prompt = { type: 'user' as const, id: 'user-1', @@ -688,16 +688,7 @@ test('assembles the complete edge Turn while paging newer transcript', async () ts: 1, text: 'prompt', }; - const answer = { - type: 'assistant' as const, - id: 'assistant-1', - turnId: 'turn-1', - ts: 2, - text: 'answer', - modelId: 'model-1', - }; const promptBytes = Buffer.from(JSON.stringify(prompt), 'utf8'); - const answerBytes = Buffer.from(JSON.stringify(answer), 'utf8'); const requests: string[] = []; const initial: SessionTranscriptPage = { ...transcriptPage({ @@ -715,8 +706,6 @@ test('assembles the complete edge Turn while paging newer transcript', async () }), direction: 'newer', throughSequence: 1, - rangeBoundarySequence: 1, - protectedTurnSequence: 1, }; const subscription = clientSubscription( openResult('host-1', 'subscription-newer-turn', { @@ -725,24 +714,7 @@ test('assembles the complete edge Turn while paging newer transcript', async () async () => undefined, async (input) => { requests.push(input.cursor!); - return { - ...transcriptPage({ - rawBytes: answerBytes.byteLength, - fragments: [ - { - sequence: 1, - byteOffset: 0, - totalBytes: answerBytes.byteLength, - payloadDigest: null, - data: answerBytes.toString('base64'), - }, - ], - }), - direction: 'newer', - throughSequence: 1, - rangeBoundarySequence: 1, - protectedTurnSequence: 1, - }; + throw new Error('a complete page must not read its continuation'); }, ); @@ -750,12 +722,10 @@ test('assembles the complete edge Turn while paging newer transcript', async () assert.deepEqual( decoded.messages.map(({ identity, message }) => [identity, message.id]), - [ - [0, 'user-1'], - [1, 'assistant-1'], - ], + [[0, 'user-1']], ); - assert.deepEqual(requests, ['answer']); + assert.equal(decoded.nextCursor, 'answer'); + assert.deepEqual(requests, []); }); test('loads a durable transcript whose sequences are sparse', async () => { @@ -1283,6 +1253,7 @@ function transcriptPage( rawBytes?: number; fragments?: readonly SessionTranscriptFragment[]; nextCursor?: string | null; + endsAtTurnBoundary?: boolean; } = {}, ): SessionTranscriptPage { return { @@ -1292,9 +1263,8 @@ function transcriptPage( throughSequence: 0, rawBytes: options.rawBytes ?? 0, fragments: options.fragments ?? [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: options.nextCursor ?? null, + endsAtTurnBoundary: options.endsAtTurnBoundary ?? options.nextCursor == null, }; } diff --git a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts index 790034d94d..b46a0f03eb 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts @@ -66,8 +66,6 @@ test('reads newly durable messages forward from an announced watermark', async ( page.fragments.map((fragment) => fragment.sequence), [2, 3], ); - assert.equal(page.rangeBoundarySequence, 3); - assert.equal(page.protectedTurnSequence, 3); assert.equal(page.nextCursor, null); }); @@ -298,7 +296,7 @@ test('rejects cursor tampering and cross-subscription replay', async () => { ); }); -test('opens the complete latest Turn when bootstrap starts inside its assistant', async () => { +test('completes a byte-sliced bootstrap message from its continuation', async () => { const prompt = { ...userMessage(0, 'hello'), turnId: 'turn-1' }; const assistant = { ...assistantMessage(1), @@ -315,8 +313,6 @@ test('opens the complete latest Turn when bootstrap starts inside its assistant' projection: 'owner', }); - assert.equal(bootstrap.durable.rangeBoundarySequence, 0); - assert.equal(bootstrap.durable.protectedTurnSequence, 1); assert.ok(bootstrap.durable.nextCursor); const subscription = clientSubscription( { @@ -354,14 +350,11 @@ test('opens the complete latest Turn when bootstrap starts inside its assistant' const decoded = await subscription.decodeTranscriptPage(bootstrap.durable, decodeStoredMessage); - assert.deepEqual(decoded.messages, [ - { identity: 0, message: prompt }, - { identity: 1, message: assistant }, - ]); - assert.equal(decoded.nextCursor, null); + assert.deepEqual(decoded.messages, [{ identity: 1, message: assistant }]); + assert.ok(decoded.nextCursor); }); -test('keeps sparse adjacent Turns in separate client ranges while paging in either direction', async () => { +test('pages every sparse durable message in either direction', async () => { const durable: StoredMessage[] = [ ...Array.from({ length: 161 }, (_, index) => ({ ...assistantMessage(index), @@ -377,71 +370,17 @@ test('keeps sparse adjacent Turns in separate client ranges while paging in eith for (const direction of ['older', 'newer'] as const) { for (const projection of ['owner', 'shared'] as const) { - const ranges = await decodeSparseTranscriptRanges(durable, direction, projection); - const expected = direction === 'older' ? [134, 161] : [161, 134]; + const pages = await decodeSparseTranscriptPages(durable, direction, projection); + const identities = pages.flat().map(({ identity }) => identity); - assert.deepEqual( - ranges.map((range) => range.length), - expected, - `${projection} ${direction}`, - ); - assert.equal( - new Set(ranges.flat().map(({ identity }) => identity)).size, - durable.length, - `${projection} ${direction}`, - ); + assert.ok(pages.length > 1, `${projection} ${direction}`); + assert.equal(identities.length, durable.length, `${projection} ${direction}`); + assert.equal(new Set(identities).size, durable.length, `${projection} ${direction}`); } } }); -test('admits a sparse Turn exactly at the client range message bound', async () => { - const durable: StoredMessage[] = [ - ...Array.from({ length: 256 }, (_, index) => ({ - ...assistantMessage(index), - turnId: 'turn-at-bound', - text: 'x'.repeat(1_600), - })), - ...Array.from({ length: 20 }, (_, index) => ({ - ...assistantMessage(index + 256), - turnId: 'turn-after', - text: 'x'.repeat(1_600), - })), - ]; - - for (const projection of ['owner', 'shared'] as const) { - const ranges = await decodeSparseTranscriptRanges(durable, 'newer', projection); - assert.deepEqual( - ranges.map((range) => range.length), - [256, 20], - projection, - ); - } -}); - -test('restarts a partial first row deferred past a sparse range boundary', async () => { - const durable: StoredMessage[] = [ - ...Array.from({ length: 161 }, (_, index) => ({ - ...assistantMessage(index), - turnId: 'turn-older', - text: index === 160 ? 'y'.repeat(600 * 1024) : 'x'.repeat(1_600), - })), - ...Array.from({ length: 134 }, (_, index) => ({ - ...assistantMessage(index + 161), - turnId: 'turn-newer', - text: 'x'.repeat(1_600), - })), - ]; - - const ranges = await decodeSparseTranscriptRanges(durable, 'older', 'owner'); - assert.deepEqual( - ranges.map((range) => range.length), - [134, 161], - ); - assert.equal(new Set(ranges.flat().map(({ identity }) => identity)).size, durable.length); - assert.deepEqual(ranges[1]?.find(({ identity }) => identity === 160 * 8)?.message, durable[160]); -}); - -test('pages through a terminal Turn that exceeds the Host range message bound', async () => { +test('pages through a terminal Turn longer than one page', async () => { const durable: StoredMessage[] = Array.from({ length: 286 }, (_, index) => ({ ...assistantMessage(index), turnId: 'turn-1', @@ -457,8 +396,6 @@ test('pages through a terminal Turn that exceeds the Host range message bound', }); assert.equal(bootstrap.durable.fragments.length, 256); - assert.equal(bootstrap.durable.rangeBoundarySequence, null); - assert.equal(bootstrap.durable.protectedTurnSequence, null); assert.ok(bootstrap.durable.nextCursor); const subscription = clientSubscription( @@ -529,256 +466,6 @@ test('pages through a terminal Turn that exceeds the Host range message bound', ); }); -test('degrades the range boundary for an oversized running Turn', async () => { - const durable: StoredMessage[] = Array.from({ length: 286 }, (_, index) => ({ - ...assistantMessage(index), - turnId: 'turn-1', - })); - const { bootstrap } = await createSessionTranscriptBootstrap({ - reader: transcriptReader(durable), - sessionId: 'session-1', - subscriptionId: 'subscription-1', - throughSequence: durable.length - 1, - maxBytes: 512 * 1024, - projection: 'owner', - }); - - assert.equal(bootstrap.durable.fragments.length, 256); - assert.equal(bootstrap.durable.rangeBoundarySequence, null); - assert.equal(bootstrap.durable.protectedTurnSequence, null); - assert.ok(bootstrap.durable.nextCursor); -}); - -test('degrades the range boundary for a Turn that exceeds the byte bound', async () => { - const durable: StoredMessage[] = Array.from({ length: 32 }, (_, index) => ({ - ...assistantMessage(index), - turnId: 'turn-1', - text: 'x'.repeat(600 * 1024), - })); - const { bootstrap } = await createSessionTranscriptBootstrap({ - reader: transcriptReader(durable), - sessionId: 'session-1', - subscriptionId: 'subscription-1', - throughSequence: durable.length - 1, - maxBytes: 512 * 1024, - projection: 'owner', - }); - - assert.ok(bootstrap.durable.fragments.length <= 256); - assert.ok(bootstrap.durable.rawBytes <= 512 * 1024); - assert.equal(bootstrap.durable.rangeBoundarySequence, null); - assert.equal(bootstrap.durable.protectedTurnSequence, null); - assert.ok(bootstrap.durable.nextCursor); -}); - -test('admits a latest Turn exactly at the Host range message bound', async () => { - const durable: StoredMessage[] = [ - { ...assistantMessage(0), turnId: 'turn-before' }, - ...Array.from({ length: 256 }, (_, index) => ({ - ...assistantMessage(index + 1), - turnId: 'turn-latest', - })), - ]; - - for (const projection of ['owner', 'shared'] as const) { - const { bootstrap } = await createSessionTranscriptBootstrap({ - reader: transcriptReader(durable), - sessionId: 'session-1', - subscriptionId: `subscription-${projection}`, - throughSequence: durable.length - 1, - maxBytes: 16 * 1024, - projection, - }); - - assert.equal(bootstrap.durable.rangeBoundarySequence, 1); - assert.equal(bootstrap.durable.protectedTurnSequence, durable.length - 1); - } -}); - -test('excludes a partial far-edge Turn when the complete range would exceed its bound', async () => { - const durable: StoredMessage[] = [ - ...Array.from({ length: 3 }, (_, index) => ({ - ...assistantMessage(index), - turnId: 'turn-far-edge', - })), - ...Array.from({ length: 254 }, (_, index) => assistantMessage(index + 3)), - ]; - const reader = transcriptReader(durable); - const { bootstrap, state } = await createSessionTranscriptBootstrap({ - reader, - sessionId: 'session-1', - subscriptionId: 'subscription-1', - throughSequence: durable.length - 1, - maxBytes: 512 * 1024, - projection: 'owner', - }); - - assert.deepEqual( - bootstrap.durable.fragments.map((fragment) => fragment.sequence), - Array.from({ length: 254 }, (_, index) => 256 - index), - ); - assert.equal(bootstrap.durable.rangeBoundarySequence, 3); - assert.equal(bootstrap.durable.protectedTurnSequence, 256); - assert.ok(bootstrap.durable.nextCursor); - - const page = await readSessionTranscriptPage({ - reader, - state, - request: { - subscriptionId: 'subscription-1', - direction: 'older', - throughSequence: durable.length - 1, - cursor: bootstrap.durable.nextCursor, - anchorSequence: null, - maxBytes: 512 * 1024, - }, - }); - assert.deepEqual( - page.fragments.map((fragment) => fragment.sequence), - [2, 1, 0], - ); - assert.equal(page.rangeBoundarySequence, 0); - assert.equal(page.protectedTurnSequence, 2); -}); - -test('admits a forward Turn exactly at the Host range message bound', async () => { - for (const projection of ['owner', 'shared'] as const) { - const durable: StoredMessage[] = [{ ...assistantMessage(0), turnId: 'turn-before' }]; - const reader = transcriptReader(durable); - const subscriptionId = `subscription-${projection}`; - const { state } = await createSessionTranscriptBootstrap({ - reader, - sessionId: 'session-1', - subscriptionId, - throughSequence: 0, - maxBytes: 16 * 1024, - projection, - }); - durable.push( - ...Array.from({ length: 256 }, (_, index) => ({ - ...assistantMessage(index + 1), - turnId: 'turn-page', - })), - { ...assistantMessage(257), turnId: 'turn-after' }, - ); - assert.equal(updateSubscriberTranscriptHighWater(state, durable.length - 1), true); - - const page = await readSessionTranscriptPage({ - reader, - state, - request: { - subscriptionId, - direction: 'newer', - throughSequence: durable.length - 1, - cursor: null, - anchorSequence: 0, - maxBytes: 512 * 1024, - }, - }); - - assert.equal(page.rangeBoundarySequence, 256); - assert.equal(page.protectedTurnSequence, 256); - assert.ok(page.nextCursor); - } -}); - -test('defers a partial forward edge Turn to the next complete range', async () => { - for (const projection of ['owner', 'shared'] as const) { - const durable: StoredMessage[] = [{ ...assistantMessage(0), turnId: 'turn-before' }]; - const reader = transcriptReader(durable); - const subscriptionId = `subscription-${projection}`; - const { state } = await createSessionTranscriptBootstrap({ - reader, - sessionId: 'session-1', - subscriptionId, - throughSequence: 0, - maxBytes: 16 * 1024, - projection, - }); - durable.push( - ...Array.from({ length: 254 }, (_, index) => assistantMessage(index + 1)), - ...Array.from({ length: 3 }, (_, index) => ({ - ...assistantMessage(index + 255), - turnId: 'turn-far-edge', - })), - ); - assert.equal(updateSubscriberTranscriptHighWater(state, durable.length - 1), true); - - const first = await readSessionTranscriptPage({ - reader, - state, - request: { - subscriptionId, - direction: 'newer', - throughSequence: durable.length - 1, - cursor: null, - anchorSequence: 0, - maxBytes: 512 * 1024, - }, - }); - assert.deepEqual( - first.fragments.map((fragment) => fragment.sequence), - Array.from({ length: 254 }, (_, index) => index + 1), - ); - assert.equal(first.rangeBoundarySequence, 254); - assert.equal(first.protectedTurnSequence, 254); - assert.ok(first.nextCursor); - - const second = await readSessionTranscriptPage({ - reader, - state, - request: { - subscriptionId, - direction: 'newer', - throughSequence: durable.length - 1, - cursor: first.nextCursor, - anchorSequence: null, - maxBytes: 512 * 1024, - }, - }); - assert.deepEqual( - second.fragments.map((fragment) => fragment.sequence), - [255, 256, 257], - ); - assert.equal(second.rangeBoundarySequence, 257); - assert.equal(second.protectedTurnSequence, 257); - } -}); - -test('protects the latest Turn when a forward page ends in a session note', async () => { - const durable: StoredMessage[] = [{ ...assistantMessage(0), turnId: 'turn-before' }]; - const reader = transcriptReader(durable); - const { state } = await createSessionTranscriptBootstrap({ - reader, - sessionId: 'session-1', - subscriptionId: 'subscription-1', - throughSequence: 0, - maxBytes: 16 * 1024, - projection: 'owner', - }); - durable.push( - { ...assistantMessage(1), turnId: 'turn-latest' }, - { type: 'system_note', id: 'mode-change-2', ts: 3, kind: 'mode_change' }, - ); - assert.equal(updateSubscriberTranscriptHighWater(state, 2), true); - - const page = await readSessionTranscriptPage({ - reader, - state, - request: { - subscriptionId: 'subscription-1', - direction: 'newer', - throughSequence: 2, - cursor: null, - anchorSequence: 0, - maxBytes: 512 * 1024, - }, - }); - - assert.equal(page.rangeBoundarySequence, 2); - assert.equal(page.protectedTurnSequence, 1); -}); - test('shared paging skips a full hidden storage batch before a visible message', async () => { const hidden = Array.from( { length: 257 }, @@ -809,7 +496,7 @@ test('shared paging skips a full hidden storage batch before a visible message', assert.equal(bootstrap.durable.nextCursor, null); }); -test('shared range edges cross a hidden storage batch between visible messages', async () => { +test('shared paging crosses a hidden storage batch between visible messages', async () => { const durable: StoredMessage[] = [ userMessage(0, 'before'), ...Array.from( @@ -836,8 +523,10 @@ test('shared range edges cross a hidden storage batch between visible messages', projection: 'shared', }); - assert.equal(bootstrap.durable.rangeBoundarySequence, 0); - assert.equal(bootstrap.durable.protectedTurnSequence, 258); + assert.deepEqual( + decodeBootstrap(bootstrap.durable).map(({ id }) => id), + ['message-258', 'message-0'], + ); const forward = await readSessionTranscriptPage({ reader, @@ -851,8 +540,11 @@ test('shared range edges cross a hidden storage batch between visible messages', maxBytes: 512 * 1024, }, }); - assert.equal(forward.rangeBoundarySequence, 258); - assert.equal(forward.protectedTurnSequence, 258); + assert.deepEqual( + decodeBootstrap(forward).map(({ id }) => id), + ['message-0', 'message-258'], + ); + assert.equal(forward.nextCursor, null); }); test('shrinks the raw bootstrap until it fits its aggregate encoded budget', async () => { @@ -900,7 +592,7 @@ function decodeBootstrap( ); } -async function decodeSparseTranscriptRanges( +async function decodeSparseTranscriptPages( durable: readonly StoredMessage[], direction: 'older' | 'newer', projection: 'owner' | 'shared', @@ -944,7 +636,7 @@ async function decodeSparseTranscriptRanges( ); const decodeStoredMessage = (value: unknown): StoredMessage => decodePersistedStoredMessage(markPersisted(value)); - const ranges: Array = []; + const pages: Array = []; let cursor: string | null = null; do { const page = await readSessionTranscriptPage({ @@ -960,10 +652,10 @@ async function decodeSparseTranscriptRanges( }, }); const decoded = await subscription.decodeTranscriptPage(page, decodeStoredMessage); - ranges.push(decoded.messages); + pages.push(decoded.messages); cursor = decoded.nextCursor; } while (cursor !== null); - return ranges; + return pages; } test('shared transcript preserves admitted action identity alongside its physical Turn', () => { diff --git a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts index 47d16bf590..ff21d1b585 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts @@ -35,7 +35,7 @@ const input = { direction: 'older' as const, throughSequence: 3, cursor: null, - anchorSequence: 2, + anchorSequence: null, maxBytes: 1024, }; const payloadDigest = `sha256:${'a'.repeat(64)}` as const; @@ -55,9 +55,8 @@ const page = { data: Buffer.from('test').toString('base64'), }, ], - rangeBoundarySequence: 2, - protectedTurnSequence: 2, nextCursor: 'opaque-cursor', + endsAtTurnBoundary: false, }; test('Session transcript protocol accepts bounded correlated pages and bootstraps', () => { @@ -87,6 +86,7 @@ test('a maximum single-fragment continuation remains transport safe', () => { }, ], nextCursor: 'c'.repeat(1_024), + endsAtTurnBoundary: false, }; assert.deepEqual(decodeSessionTranscriptPage(result), result); const encoded = encodeProtocolMessage({ @@ -114,6 +114,7 @@ test('a maximum multi-message page remains transport safe', () => { data: Buffer.alloc(fragmentBytes, 0x61).toString('base64'), })), nextCursor: 'c'.repeat(1_024), + endsAtTurnBoundary: false, }; assert.deepEqual(decodeSessionTranscriptPage(result), result); assert.ok( @@ -126,19 +127,20 @@ test('a maximum multi-message page remains transport safe', () => { ); }); -test('Session transcript protocol rejects malformed and uncorrelated values', () => { - assert.throws( - () => decodeSessionTranscriptPageInput({ ...input, cursor: 'cursor', anchorSequence: 2 }), - isProtocolError, - ); +test('Session transcript anchors only start durable reads of newer rows', () => { + const newer = { ...input, direction: 'newer' as const, anchorSequence: 2 }; + assert.deepEqual(decodeSessionTranscriptPageInput(newer), newer); assert.throws( - () => decodeSessionTranscriptPage({ ...page, rangeBoundarySequence: 4 }), + () => decodeSessionTranscriptPageInput({ ...newer, cursor: 'cursor' }), isProtocolError, ); assert.throws( - () => decodeSessionTranscriptPage({ ...page, protectedTurnSequence: 4 }), + () => decodeSessionTranscriptPageInput({ ...newer, direction: 'older' }), isProtocolError, ); +}); + +test('Session transcript protocol rejects malformed and uncorrelated values', () => { assert.throws( () => decodeSessionTranscriptPage({ diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts index caecee9a64..f9d0886628 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -22,7 +22,10 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; +import { + seedInvocation, + type TestInvocationOpeningOverrides, +} from '@maka/runtime/test-only/invocation-fixture'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { WORKHUB_COORDINATION_SESSION_ID, @@ -44,6 +47,10 @@ import { createSessionTranscriptReader, TRANSCRIPT_TURN_MAX_BYTES, } from '../server/session-transcript-reader.js'; +import { + createSessionTranscriptBootstrap, + readSessionTranscriptPage, +} from '../server/session-transcript-pager.js'; for (const coordination of [false, true]) test(`pages ${coordination ? 'WorkHub' : 'ordinary'} running Turn rows as their events commit`, async () => { @@ -359,17 +366,26 @@ for (const coordination of [false, true]) }); assert.equal(durable.throughSequence, await read.readDurableHighWater(session.id)); assert.ok(durable.throughSequence !== null); - assert.deepEqual( - durable.fragments.map((fragment) => { + const ids = (page: typeof durable) => + page.fragments.map((fragment) => { const message = JSON.parse(fragment.data.toString('utf8')) as StoredMessage; return { type: message.type, id: message.id }; - }), - [ - { type: 'user', id: 'user-0' }, - { type: 'turn_state', id: 'terminal-0' }, - { type: 'user', id: 'user-1' }, - ], - ); + }); + // Three rows would stop inside the running Turn, so the page ends before it. + assert.deepEqual(ids(durable), [ + { type: 'user', id: 'user-0' }, + { type: 'turn_state', id: 'terminal-0' }, + ]); + assert.equal(durable.endsAtTurnBoundary, true); + assert.ok(durable.next); + const running = await read.readDurablePage(session.id, { + direction: 'newer', + throughSequence: durable.throughSequence, + position: durable.next.position, + maxBytes: 1024, + maxMessages: 1, + }); + assert.deepEqual(ids(running), [{ type: 'user', id: 'user-1' }]); if (largeBash) { await stores.runtimeEventStore.appendRuntimeEvent( session.id, @@ -583,7 +599,7 @@ test('pages the ledger without materializing Turns it takes no rows from', async ); assert.equal(JSON.parse(head.fragments[0]!.data.toString()).text, 'prompt 0'); const landmarks = await decoding('landmarks', SMALL_TURN_BUDGET, () => - read.readDurableTurnLandmarks(session.id, 3), + read.readDurableTurnLandmarks(session.id, { maxLandmarks: 3, turnId: null }), ); assert.deepEqual( landmarks.landmarks.map((item) => item.label), @@ -797,16 +813,120 @@ test('serves a running Turn that encloses two separated Turns', async () => { }); }); +test('ends a page between Turns only outside a nested Turn', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-nested-page-boundary-')); + await withNestedTranscript(base, async (read, sessionId) => { + await seed(read.stores, sessionId, 'outer'); + await read.text('outer', 'outer-before'); + await seed(read.stores, sessionId, 'inner'); + await read.text('inner', 'inner-a'); + await read.end('inner', 'inner-end'); + await read.text('outer', 'outer-after'); + await read.end('outer', 'outer-end'); + await seed(read.stores, sessionId, 'later'); + await read.text('later', 'later-a'); + await read.end('later', 'later-end'); + const throughSequence = (await read.readDurableHighWater(sessionId))!; + + for (const direction of ['older', 'newer'] as const) { + const stops: Array = []; + let position: number | undefined; + for (let page = 0; page < 16; page++) { + const result = await read.readDurablePage(sessionId, { + direction, + throughSequence, + ...(position === undefined ? {} : { position }), + maxBytes: 1 << 20, + maxMessages: 1, + }); + const { id } = JSON.parse(result.fragments[0]!.data.toString()) as StoredMessage; + stops.push([id, result.endsAtTurnBoundary]); + if (result.next === null) break; + position = result.next.position; + } + const between = direction === 'newer' ? 'outer-end' : 'later-a'; + assert.deepEqual( + stops, + stops.map(([id], index) => [id, id === between || index === stops.length - 1] as const), + direction, + ); + } + }); +}); + +test('does not end a page where a handoff resumes the same Turn', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-handoff-page-boundary-')); + await withNestedTranscript(base, async (read, sessionId) => { + await seed(read.stores, sessionId, 'first'); + await read.text('first', 'first-a'); + await read.pause('first', 'first-paused', 'resumed'); + await seed(read.stores, sessionId, 'resumed', { + turnId: 'turn-first', + opening: { + source: { + kind: 'handoff', + rootRunId: 'first', + sourceInvocationId: 'first', + sourceRunId: 'first', + sourceTurnId: 'turn-first', + sourceRuntimeEventHighWater: 2, + claimId: 'claim-1', + boundaryDigest: `sha256:${'0'.repeat(64)}`, + }, + }, + }); + await read.text('resumed', 'resumed-a', 'turn-first'); + await read.end('resumed', 'resumed-end', 'turn-first'); + await seed(read.stores, sessionId, 'later'); + await read.text('later', 'later-a'); + await read.end('later', 'later-end'); + const throughSequence = (await read.readDurableHighWater(sessionId))!; + + for (const direction of ['older', 'newer'] as const) { + const stops: Array = []; + let position: number | undefined; + for (let page = 0; page < 16; page++) { + const result = await read.readDurablePage(sessionId, { + direction, + throughSequence, + ...(position === undefined ? {} : { position }), + maxBytes: 1 << 20, + maxMessages: 1, + }); + const { id } = JSON.parse(result.fragments[0]!.data.toString()) as StoredMessage; + stops.push([id, result.endsAtTurnBoundary]); + if (result.next === null) break; + position = result.next.position; + } + const between = direction === 'newer' ? 'resumed-end' : 'later-a'; + assert.deepEqual( + stops, + stops.map(([id], index) => [id, id === between || index === stops.length - 1] as const), + direction, + ); + } + + const lookup = (turnId: string) => + read.readDurableTurnLandmarks(sessionId, { maxLandmarks: 1, turnId }); + assert.deepEqual((await lookup('turn-first')).landmarks, [ + { turnId: 'turn-first', sequence: 1 * 8, lastSequence: 6 * 8 + 7, label: '' }, + ]); + assert.deepEqual((await lookup('turn-missing')).landmarks, []); + }); +}); + const seed = ( stores: Awaited>, sessionId: string, runId: string, + overrides: { turnId?: string; opening?: TestInvocationOpeningOverrides } = {}, ) => seedInvocation(stores.runtimeEventStore, { sessionId, runId, - turnId: `turn-${runId}`, + turnId: overrides.turnId ?? `turn-${runId}`, openedAt: 0, + ...(overrides.opening ? { opening: overrides.opening } : {}), }); /** A reader over an empty Session, with the appenders these fixtures build from. */ @@ -815,8 +935,9 @@ async function withNestedTranscript( body: ( read: ReturnType & { stores: Awaited>; - text(runId: string, id: string): Promise; - end(runId: string, id: string): Promise; + text(runId: string, id: string, turnId?: string): Promise; + end(runId: string, id: string, turnId?: string): Promise; + pause(runId: string, id: string, successor: string): Promise; }, sessionId: string, ) => Promise, @@ -834,7 +955,12 @@ async function withNestedTranscript( permissionMode: 'ask', }); let ts = 0; - const append = (runId: string, id: string, overrides: Partial) => + const append = ( + runId: string, + id: string, + overrides: Partial, + turnId = `turn-${runId}`, + ) => stores.runtimeEventStore.appendRuntimeEvent( session.id, runId, @@ -842,7 +968,7 @@ async function withNestedTranscript( id, invocationId: runId, runId, - turnId: `turn-${runId}`, + turnId, ts: ++ts, ...overrides, }), @@ -854,15 +980,36 @@ async function withNestedTranscript( canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, }), stores, - text: (runId, id) => + text: (runId, id, turnId) => + append( + runId, + id, + { + role: 'model', + author: 'agent', + content: { kind: 'text', text: id }, + refs: { storedMessageId: id }, + }, + turnId, + ), + end: (runId, id, turnId) => + append(runId, id, { status: 'completed', actions: { endInvocation: true } }, turnId), + pause: (runId, id, successor) => append(runId, id, { - role: 'model', - author: 'agent', - content: { kind: 'text', text: id }, - refs: { storedMessageId: id }, + actions: { + endInvocation: true, + handoffPause: { + protocol: 'runtime_handoff_pause_v1', + handoffId: `${runId}-handoff`, + remainingSteps: null, + hostEpoch: 'old-host', + rootRunId: runId, + successorRunId: successor, + successorInvocationId: successor, + claimId: `${runId}-claim`, + }, + }, }), - end: (runId, id) => - append(runId, id, { status: 'completed', actions: { endInvocation: true } }), }, session.id, ); @@ -872,6 +1019,197 @@ async function withNestedTranscript( } } +test('cuts a byte-sized page back to the last whole Turn on it', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-page-boundary-')); + const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + try { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const session = await stores.sessionStore.create({ + cwd: capability.canonicalPath, + llmConnectionId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + await seedLargeTurns(stores, session.id); + + const read = createSessionTranscriptReader({ + stores, + canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, + }); + const throughSequence = await read.readDurableHighWater(session.id); + + for (const direction of ['older', 'newer'] as const) { + const paged: number[] = []; + let position: number | undefined; + let pages = 0; + for (; pages < 32; pages++) { + const result = await read.readDurablePage(session.id, { + direction, + throughSequence, + ...(position === undefined ? {} : { position }), + // Wide enough for more than one Turn and narrow enough to run out + // partway through the next one. + maxBytes: 100 * 1024, + maxMessages: 64, + }); + if (result.fragments.length === 0) break; + assert.equal(result.endsAtTurnBoundary, true, `${direction} page ${pages}`); + // Nothing arrives in slices: a page that would cut a row gives that + // row's Turn back instead. + for (const fragment of result.fragments) { + assert.equal(fragment.byteOffset, 0, `${direction} page ${pages}`); + assert.equal(fragment.data.byteLength, fragment.totalBytes, `${direction} page ${pages}`); + } + paged.push(...result.fragments.map((fragment) => fragment.sequence)); + if (result.next?.position === undefined || result.next.position === null) break; + position = result.next.position; + } + assert.ok(pages > 1, `${direction} needs more than one page to be worth cutting`); + + const sweep = await read.readDurablePage(session.id, { + direction, + throughSequence, + maxBytes: 1 << 20, + maxMessages: 64, + }); + assert.deepEqual( + paged, + sweep.fragments.map((fragment) => fragment.sequence), + direction, + ); + } + } finally { + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +test('cuts a guest page where it cuts an owner page', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-shared-page-boundary-')); + const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + try { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const session = await stores.sessionStore.create({ + cwd: capability.canonicalPath, + llmConnectionId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + await seedLargeTurns(stores, session.id); + + const reader = createSessionTranscriptReader({ + stores, + canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, + }); + const throughSequence = await reader.readDurableHighWater(session.id); + const hidden = await reader.readDurablePage( + session.id, + { direction: 'older', throughSequence, maxBytes: 1 << 20, maxMessages: 64 }, + () => null, + ); + assert.equal(hidden.fragments.length, 0); + + // A guest's rows are rewritten before they are weighed, so their pages are + // cut somewhere else than an owner's. Where they may be cut is the same + // question, and it has the same answer. + for (const projection of ['owner', 'shared'] as const) { + const { bootstrap, state } = await createSessionTranscriptBootstrap({ + reader, + sessionId: session.id, + subscriptionId: `subscription-${projection}`, + throughSequence, + maxBytes: 100 * 1024, + projection, + }); + let page = bootstrap.durable; + let pages = 0; + for (; pages < 32; pages++) { + assert.equal(page.endsAtTurnBoundary, true, `${projection} page ${pages}`); + for (const fragment of page.fragments) { + assert.equal(fragment.byteOffset, 0, `${projection} page ${pages}`); + assert.equal( + Buffer.byteLength(fragment.data, 'base64'), + fragment.totalBytes, + `${projection} page ${pages}`, + ); + } + if (page.nextCursor === null) break; + page = await readSessionTranscriptPage({ + reader, + state, + request: { + subscriptionId: `subscription-${projection}`, + direction: 'older', + throughSequence, + cursor: page.nextCursor, + anchorSequence: null, + maxBytes: 100 * 1024, + }, + }); + } + assert.ok(pages > 1, `${projection} needs more than one page to be worth cutting`); + } + } finally { + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +/** + * Six independent Turns, each carrying one row the size a real transcript + * holds. Rows this large are the point: a page cut by bytes lands inside one + * of them far more often than it lands between two Turns. + */ +async function seedLargeTurns( + stores: Awaited>, + sessionId: string, +) { + const body = 'x'.repeat(40 * 1024); + let counter = 0; + for (let index = 0; index < 6; index++) { + const runId = `run-${index}`; + await seedInvocation(stores.runtimeEventStore, { + sessionId, + runId, + turnId: `turn-${index}`, + openedAt: index, + }); + await stores.runtimeEventStore.appendRuntimeEvent( + sessionId, + runId, + runtimeEvent(sessionId, { + id: `${runId}-event-${counter++}`, + invocationId: runId, + runId, + turnId: `turn-${index}`, + ts: counter, + role: 'model', + author: 'agent', + content: { kind: 'text', text: body }, + }), + ); + await stores.runtimeEventStore.appendRuntimeEvent( + sessionId, + runId, + runtimeEvent(sessionId, { + id: `${runId}-event-${counter++}`, + invocationId: runId, + runId, + turnId: `turn-${index}`, + ts: counter, + status: 'completed', + actions: { endInvocation: true }, + }), + ); + } +} + function runtimeEvent(sessionId: string, overrides: Partial): RuntimeEvent { return { id: 'event-1', diff --git a/packages/runtime-host/src/__tests__/session-turns.test.ts b/packages/runtime-host/src/__tests__/session-turns.test.ts index d311a68ed9..31b761049a 100644 --- a/packages/runtime-host/src/__tests__/session-turns.test.ts +++ b/packages/runtime-host/src/__tests__/session-turns.test.ts @@ -38,6 +38,7 @@ test('keeps a full sampled landmark index inside its encoded result budget', () projectSessionTurnLandmarkForWire({ turnId: `${index}`.padEnd(128, 't'), sequence: Number.MAX_SAFE_INTEGER - index, + lastSequence: Number.MAX_SAFE_INTEGER - index, label: '\0'.repeat(256), }), ), diff --git a/packages/runtime-host/src/client/session-subscription.ts b/packages/runtime-host/src/client/session-subscription.ts index 6c3ac87dd5..12e3ea34b2 100644 --- a/packages/runtime-host/src/client/session-subscription.ts +++ b/packages/runtime-host/src/client/session-subscription.ts @@ -25,8 +25,6 @@ import { type SessionDomainChangedFrame, type SessionContinuitySnapshot, SESSION_TRANSCRIPT_PAGE_MAX_BYTES, - SESSION_TRANSCRIPT_RANGE_MAX_BYTES, - SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, type SubscriptionFrame, type SubscriptionOpenResult, type SessionTranscriptBootstrap, @@ -241,11 +239,7 @@ export class ClientSessionSubscription try { assembler.accept(page.fragments); let cursor = page.nextCursor; - let rangeBytes = page.fragments.reduce((total, fragment) => total + fragment.totalBytes, 0); - const rangeIdentities = new Set(page.fragments.map((fragment) => fragment.sequence)); - let reachedBoundary = - page.rangeBoundarySequence === null || rangeIdentities.has(page.rangeBoundarySequence); - while (assembler.continuationBytes !== null || !reachedBoundary) { + while (assembler.continuationBytes !== null) { if (cursor === null) { throw new RuntimeHostSubscriptionError( 'correlation_changed', @@ -258,10 +252,7 @@ export class ClientSessionSubscription throughSequence: page.throughSequence, cursor, anchorSequence: null, - maxBytes: - assembler.continuationBytes === null - ? SESSION_TRANSCRIPT_PAGE_MAX_BYTES - : Math.min(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, assembler.continuationBytes), + maxBytes: Math.min(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, assembler.continuationBytes), }); if (continuation.nextCursor === requestedCursor) { throw new RuntimeHostSubscriptionError( @@ -269,21 +260,7 @@ export class ClientSessionSubscription 'Session transcript cursor did not advance', ); } - for (const fragment of continuation.fragments) { - if (!rangeIdentities.has(fragment.sequence)) { - rangeIdentities.add(fragment.sequence); - rangeBytes += fragment.totalBytes; - } - } - if ( - rangeBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES || - rangeIdentities.size > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES - ) { - throw new RangeError('Session transcript range exceeds the local capacity limit'); - } assembler.accept(continuation.fragments); - reachedBoundary = - page.rangeBoundarySequence === null || rangeIdentities.has(page.rangeBoundarySequence); cursor = continuation.nextCursor; } return { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9c32a7e0f7..b6dd7337e7 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,11 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 160 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 161 as const; +// 161: Session transcript reads return the whole transcript under a byte budget, +// and every page says whether it stops between two Turns. The windowed read's +// range edges are gone, and the Turn landmark query takes a Turn to look up, so +// a peer older than this epoch asks for what this Host no longer answers. // 160: Usage queries add revision-consistent screens and revision-bound activity pages. // 159: External Session catalog queries distinguish adapter source limits from // persistence failures. Older Clients reject the new closed error code. diff --git a/packages/runtime-host/src/protocol/session-transcript.ts b/packages/runtime-host/src/protocol/session-transcript.ts index 1f93682b14..24065123a7 100644 --- a/packages/runtime-host/src/protocol/session-transcript.ts +++ b/packages/runtime-host/src/protocol/session-transcript.ts @@ -31,8 +31,6 @@ import { defineOperation } from './operation-spec.js'; export const SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES = 16 * 1024; export const SESSION_TRANSCRIPT_PAGE_MAX_BYTES = 512 * 1024; export const SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES = 256; -export const SESSION_TRANSCRIPT_RANGE_MAX_BYTES = 16 * 1024 * 1024; -export const SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES = SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES; export const SESSION_TRANSCRIPT_PAGE_RESULT_MAX_BYTES = 744 * 1024; export const SESSION_TRANSCRIPT_CURSOR_MAX_BYTES = 1024; @@ -53,11 +51,14 @@ export interface SessionTranscriptPage { readonly throughSequence: number | null; readonly rawBytes: number; readonly fragments: readonly SessionTranscriptFragment[]; - /** Host-selected far edge that the client must assemble before publishing this range. */ - readonly rangeBoundarySequence: number | null; - /** Host-selected Turn identity that bounded consumers must retain while trimming this range. */ - readonly protectedTurnSequence: number | null; readonly nextCursor: string | null; + /** + * Whether every Turn with rows on this page has all of them here. A reader + * that stops on a page which says so cannot be holding half a Turn — which a + * change of owner between rows does not tell it, because the Host writes a + * nested Turn's rows between the rows of the Turn around it. + */ + readonly endsAtTurnBoundary: boolean; } export interface SessionTranscriptBootstrap { @@ -119,9 +120,13 @@ export function decodeSessionTranscriptPageInput(value: unknown): SessionTranscr if (cursor !== null && anchorSequence !== null) { throw invalidProtocolFrame('Session transcript cursor and anchor are mutually exclusive'); } + const direction = decodeDirection(input.direction); + if (anchorSequence !== null && direction !== 'newer') { + throw invalidProtocolFrame('Session transcript anchor requires a newer read'); + } return { subscriptionId: requireId(input.subscriptionId, 'subscriptionId'), - direction: decodeDirection(input.direction), + direction, throughSequence: input.throughSequence === null ? null @@ -157,10 +162,12 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa 'throughSequence', 'rawBytes', 'fragments', - 'rangeBoundarySequence', - 'protectedTurnSequence', 'nextCursor', + 'endsAtTurnBoundary', ]); + if (typeof result.endsAtTurnBoundary !== 'boolean') { + throw invalidProtocolFrame('Invalid Session transcript page Turn boundary'); + } if (result.kind !== 'page') throw invalidProtocolFrame('Invalid Session transcript page kind'); const direction = decodeDirection(result.direction); const throughSequence = @@ -193,26 +200,6 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa 'Session transcript cursor', SESSION_TRANSCRIPT_CURSOR_MAX_BYTES, ); - const rangeBoundarySequence = - result.rangeBoundarySequence === null - ? null - : requireCount(result.rangeBoundarySequence, 'Session transcript range boundary sequence'); - const protectedTurnSequence = - result.protectedTurnSequence === null - ? null - : requireCount(result.protectedTurnSequence, 'Session transcript protected Turn sequence'); - if ( - rangeBoundarySequence !== null && - (throughSequence === null || rangeBoundarySequence > throughSequence) - ) { - throw invalidProtocolFrame('Invalid Session transcript range boundary'); - } - if ( - protectedTurnSequence !== null && - (throughSequence === null || protectedTurnSequence > throughSequence) - ) { - throw invalidProtocolFrame('Invalid Session transcript protected Turn sequence'); - } if (fragments.length === 0 && (rawBytes !== 0 || nextCursor !== null)) { throw invalidProtocolFrame('Invalid empty Session transcript page'); } @@ -223,9 +210,8 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa throughSequence, rawBytes, fragments, - rangeBoundarySequence, - protectedTurnSequence, nextCursor, + endsAtTurnBoundary: result.endsAtTurnBoundary, }; } diff --git a/packages/runtime-host/src/protocol/session-turns.ts b/packages/runtime-host/src/protocol/session-turns.ts index 136f7883f4..e72642ad39 100644 --- a/packages/runtime-host/src/protocol/session-turns.ts +++ b/packages/runtime-host/src/protocol/session-turns.ts @@ -40,13 +40,18 @@ export const SESSION_TURN_LANDMARK_RESULT_MAX_BYTES = 64 * 1024; export interface SessionTurnLandmark { readonly turnId: string; + /** No row of the Turn sits before this sequence. */ readonly sequence: number; + /** Nor after this one. */ + readonly lastSequence: number; readonly label: string; } export interface SessionTurnLandmarksQueryInput { readonly sessionId: string; readonly maxLandmarks: number; + /** Look up this one Turn instead of sampling the Session. */ + readonly turnId: string | null; } export interface SessionTurnLandmarksQueryResult { @@ -61,6 +66,7 @@ export function projectSessionTurnLandmarkForWire( return { turnId: requireEntityId(landmark.turnId, 'turnId'), sequence: requireCount(landmark.sequence, 'Session turn landmark sequence'), + lastSequence: requireCount(landmark.lastSequence, 'Session turn landmark last sequence'), label: truncateUtf8(landmark.label, SESSION_TURN_LANDMARK_LABEL_MAX_BYTES), }; } @@ -234,7 +240,12 @@ export const SESSION_TURNS_OPERATION_SPECS = { decodeInput: decodeSessionTurnLandmarksQueryInput, decodeOutput: decodeSessionTurnLandmarksQueryResult, assertOutputForInput: (input, output) => { - if (input.sessionId !== output.sessionId) { + if ( + input.sessionId !== output.sessionId || + output.landmarks.length > input.maxLandmarks || + (input.turnId !== null && + output.landmarks.some((landmark) => landmark.turnId !== input.turnId)) + ) { throw invalidProtocolFrame('Session turn landmark query identity changed'); } }, @@ -266,6 +277,7 @@ export function decodeSessionTurnLandmarksQueryInput( const input = requireExactRecord(value, 'Session turn landmark query input', [ 'sessionId', 'maxLandmarks', + 'turnId', ]); const maxLandmarks = requireCount(input.maxLandmarks, 'Session turn landmark limit'); if (maxLandmarks < 1 || maxLandmarks > SESSION_TURN_LANDMARK_MAX_ITEMS) { @@ -274,6 +286,7 @@ export function decodeSessionTurnLandmarksQueryInput( return { sessionId: requireEntityId(input.sessionId, 'sessionId'), maxLandmarks, + turnId: input.turnId === null ? null : requireEntityId(input.turnId, 'turnId'), }; } @@ -306,11 +319,13 @@ export function decodeSessionTurnLandmarksQueryResult( const landmark = requireExactRecord(value, 'Session turn landmark', [ 'turnId', 'sequence', + 'lastSequence', 'label', ]); return { turnId: requireEntityId(landmark.turnId, 'turnId'), sequence: requireCount(landmark.sequence, 'Session turn landmark sequence'), + lastSequence: requireCount(landmark.lastSequence, 'Session turn landmark last sequence'), label: requireUtf8String( landmark.label, 'Session turn landmark label', diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index adf3e743b8..a1f3a29004 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -553,10 +553,7 @@ export class HostSessionCatalogCoordinator { input: SessionTurnLandmarksQueryInput, ): Promise> { try { - const snapshot = await this.#turnIndex.readDurableTurnLandmarks( - input.sessionId, - input.maxLandmarks, - ); + const snapshot = await this.#turnIndex.readDurableTurnLandmarks(input.sessionId, input); return { ok: true, result: { diff --git a/packages/runtime-host/src/server/session-transcript-pager.ts b/packages/runtime-host/src/server/session-transcript-pager.ts index 4c13b6e10a..abd5caec12 100644 --- a/packages/runtime-host/src/server/session-transcript-pager.ts +++ b/packages/runtime-host/src/server/session-transcript-pager.ts @@ -18,11 +18,8 @@ */ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; -import type { StoredMessage } from '@maka/core/session'; import { SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, - SESSION_TRANSCRIPT_RANGE_MAX_BYTES, - SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, type SessionTranscriptBootstrap, type SessionTranscriptFragment, type SessionTranscriptPage, @@ -31,7 +28,7 @@ import { } from '../protocol/index.js'; import { type SessionTranscriptReader, - TRANSCRIPT_TURN_MAX_BYTES, + type TranscriptRowProjection, } from './session-transcript-reader.js'; import { projectSharedSessionTranscriptMessage } from './shared-session-transcript.js'; @@ -45,7 +42,6 @@ interface TranscriptCursorState { readonly throughSequence: number | null; readonly position: number; readonly byteOffset: number | null; - readonly rangeBoundarySequence: number | null; } export interface SubscriberTranscriptState { @@ -60,6 +56,7 @@ interface SelectedFragments { readonly fragments: readonly SessionTranscriptFragment[]; readonly rawBytes: number; readonly next: { position: number; byteOffset: number | null } | null; + readonly endsAtTurnBoundary: boolean; } export async function createSessionTranscriptBootstrap(input: { @@ -79,12 +76,13 @@ export async function createSessionTranscriptBootstrap(input: { direction: 'older', throughSequence: input.throughSequence, maxBytes: rawBudget, - maxMessages: SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, + maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, } as const; - const durableStorage = - projection === 'shared' - ? await readSharedDurablePage(input.reader, input.sessionId, durableRequest) - : await input.reader.readDurablePage(input.sessionId, durableRequest); + const durableStorage = await input.reader.readDurablePage( + input.sessionId, + durableRequest, + projection === 'shared' ? sharedRowProjection(input.sessionId) : undefined, + ); if (durableStorage.throughSequence !== input.throughSequence) { throw new Error('Session transcript durable watermark changed during bootstrap'); } @@ -95,22 +93,12 @@ export async function createSessionTranscriptBootstrap(input: { cursorSecret, projection, }; - const durableSelection = storageSelection(durableStorage); - const rangeEdges = await readRangeEdges({ - reader: input.reader, - state, - direction: 'older', - throughSequence: input.throughSequence, - selected: durableSelection, - }); const bootstrap: SessionTranscriptBootstrap = { durable: pageFromSelection( state, 'older', - rangeEdges.selected, + storageSelection(durableStorage), input.throughSequence, - rangeEdges.rangeBoundarySequence, - rangeEdges.protectedTurnSequence, ), }; const encodedBytes = Buffer.byteLength(JSON.stringify(bootstrap), 'utf8'); @@ -146,315 +134,28 @@ export async function readSessionTranscriptPage(input: { position: position.position, ...(position.byteOffset === null ? {} : { byteOffset: position.byteOffset }), maxBytes: request.maxBytes, - maxMessages: continuationMessageLimit(position), + maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, } as const; - const storage = - state.projection === 'shared' - ? await readSharedDurablePage( - input.reader, - state.sessionId, - durableRequest, - position.rangeBoundarySequence, - ) - : await input.reader.readDurablePage(state.sessionId, durableRequest); - const selected = selectionThroughRangeBoundary( - storageSelection(storage), - request.direction, - position.rangeBoundarySequence, + const storage = await input.reader.readDurablePage( + state.sessionId, + durableRequest, + state.projection === 'shared' ? sharedRowProjection(state.sessionId) : undefined, ); - const rangeEdges = await readRangeEdges({ - reader: input.reader, - state, - direction: request.direction, - throughSequence: request.throughSequence, - selected, - }); return pageFromSelection( state, request.direction, - rangeEdges.selected, + storageSelection(storage), request.throughSequence, - rangeEdges.rangeBoundarySequence, - rangeEdges.protectedTurnSequence, ); } -async function readRangeEdges(input: { - reader: SessionTranscriptReader; - state: SubscriberTranscriptState; - direction: SessionTranscriptPageDirection; - throughSequence: number | null; - selected: SelectedFragments; -}): Promise<{ - readonly selected: SelectedFragments; - readonly rangeBoundarySequence: number | null; - readonly protectedTurnSequence: number | null; -}> { - if (input.throughSequence === null) { - return { - selected: input.selected, - rangeBoundarySequence: null, - protectedTurnSequence: null, - }; - } - const selectedSequences = input.selected.fragments.map((fragment) => fragment.sequence); - if (selectedSequences.length === 0) { - return { - selected: input.selected, - rangeBoundarySequence: null, - protectedTurnSequence: null, - }; - } - const boundaryCandidate = - input.direction === 'older' ? Math.min(...selectedSequences) : Math.max(...selectedSequences); - const scanPosition = - input.direction === 'older' ? Math.max(...selectedSequences) : Math.min(...selectedSequences); - const rangeRecords: Array<{ - readonly sequence: number; - readonly turnId: string | undefined; - readonly bytes: number; - }> = []; - let targetTurnId: string | undefined; - let targetStart: number | null = null; - let candidateReached = false; - let hiddenBytes = 0; - let reachedFarEdge = false; - let position: number | null = scanPosition; - while (position !== null && !reachedFarEdge) { - const scanned = await input.reader.readDurableRecords(input.state.sessionId, { - direction: input.direction, - throughSequence: input.throughSequence, - position, - maxStoredBytes: SESSION_TRANSCRIPT_RANGE_MAX_BYTES, - maxMessages: SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, - }); - for (const record of scanned.records) { - const message = - input.state.projection === 'shared' - ? projectSharedSessionTranscriptMessage(record.message, input.state.sessionId) - : record.message; - if (!message) { - hiddenBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); - if (hiddenBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES) { - throw new RangeError('Session transcript projection scan exceeds its capacity limit'); - } - continue; - } - const turnId = messageTurnId(message); - if (targetStart !== null && turnId !== targetTurnId) { - reachedFarEdge = true; - break; - } - rangeRecords.push({ - sequence: record.sequence, - turnId, - bytes: Buffer.byteLength(JSON.stringify(message), 'utf8'), - }); - if (!candidateReached && record.sequence === boundaryCandidate) { - candidateReached = true; - if (turnId === undefined) { - reachedFarEdge = true; - break; - } - targetTurnId = turnId; - targetStart = rangeRecords.length - 1; - while (targetStart > 0 && rangeRecords[targetStart - 1]?.turnId === targetTurnId) { - targetStart -= 1; - } - } - if (targetStart !== null) { - const targetRecords = rangeRecords.slice(targetStart); - const targetBytes = targetRecords.reduce((sum, target) => sum + target.bytes, 0); - if ( - targetRecords.length > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES || - targetBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES - ) { - if (targetStart === 0) { - return { - selected: input.selected, - rangeBoundarySequence: null, - protectedTurnSequence: null, - }; - } - reachedFarEdge = true; - break; - } - } - } - if (reachedFarEdge || scanned.nextPosition === null) { - position = scanned.nextPosition; - break; - } - if (scanned.nextPosition === position) { - throw new Error('Session transcript projection scan did not advance'); - } - position = scanned.nextPosition; - } - if (!candidateReached || rangeRecords.length === 0) { - throw new Error('Session transcript range did not reach its authoritative Turn'); - } - let retainedEnd = 0; - let retainedMessages = 0; - let retainedBytes = 0; - while (retainedEnd < rangeRecords.length) { - const groupStart = retainedEnd; - const groupTurnId = rangeRecords[groupStart]!.turnId; - let groupEnd = groupStart + 1; - if (groupTurnId !== undefined) { - while (groupEnd < rangeRecords.length && rangeRecords[groupEnd]?.turnId === groupTurnId) { - groupEnd += 1; - } - } - const group = rangeRecords.slice(groupStart, groupEnd); - const groupBytes = group.reduce((sum, record) => sum + record.bytes, 0); - if ( - group.length > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES || - groupBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES - ) { - if (groupStart > 0) break; - return { - selected: input.selected, - rangeBoundarySequence: null, - protectedTurnSequence: null, - }; - } - if ( - retainedMessages + group.length > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES || - retainedBytes + groupBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES - ) { - break; - } - retainedMessages += group.length; - retainedBytes += groupBytes; - retainedEnd = groupEnd; - } - const retainedRecords = rangeRecords.slice(0, retainedEnd); - const boundary = retainedRecords.at(-1)?.sequence; - if (boundary === undefined) { - throw new RangeError('Session transcript Turn range exceeds its capacity limit'); - } - const selected = - retainedEnd === rangeRecords.length - ? input.selected - : (() => { - const retainedSequences = new Set(retainedRecords.map((record) => record.sequence)); - const fragments = input.selected.fragments.filter((fragment) => - retainedSequences.has(fragment.sequence), - ); - return { - fragments, - rawBytes: fragments.reduce( - (sum, fragment) => sum + Buffer.byteLength(fragment.data, 'base64'), - 0, - ), - next: { position: rangeRecords[retainedEnd]!.sequence, byteOffset: null }, - }; - })(); - const turnRecords = retainedRecords.filter((record) => record.turnId !== undefined); - const protectedTurnSequence = - input.direction === 'older' ? turnRecords[0]?.sequence : turnRecords.at(-1)?.sequence; - return { - selected, - rangeBoundarySequence: boundary, - protectedTurnSequence: protectedTurnSequence ?? boundary, - }; -} - -function messageTurnId(message: StoredMessage): string | undefined { - const turnId = 'turnId' in message ? message.turnId : undefined; - return typeof turnId === 'string' ? turnId : undefined; -} - -async function readSharedDurablePage( - reader: SessionTranscriptReader, - sessionId: string, - request: Parameters[1], - rangeBoundarySequence: number | null = null, -): ReturnType { - const position = - request.position ?? - (request.direction === 'older' ? (request.throughSequence ?? undefined) : 0); - const fragments: Awaited< - ReturnType - >['fragments'][number][] = []; - let rawBytes = 0; - let hiddenBytes = 0; - let next: { position: number; byteOffset: number | null } | null = null; - let scanPosition = position; - let throughSequence = request.throughSequence ?? null; - while (scanPosition !== undefined && next === null) { - const scanned = await reader.readDurableRecords(sessionId, { - direction: request.direction, - ...(request.throughSequence === undefined - ? {} - : { throughSequence: request.throughSequence }), - position: scanPosition, - maxStoredBytes: TRANSCRIPT_TURN_MAX_BYTES, - maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, - }); - throughSequence = scanned.throughSequence; - let recordIndex = 0; - for (; recordIndex < scanned.records.length; recordIndex += 1) { - const record = scanned.records[recordIndex]!; - const projected = projectSharedSessionTranscriptMessage(record.message, sessionId); - if (!projected) { - hiddenBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); - if (hiddenBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES) { - throw new RangeError('Session transcript projection scan exceeds its capacity limit'); - } - continue; - } - const bytes = Buffer.from(JSON.stringify(projected), 'utf8'); - const continuationOffset = - record.sequence === position && request.byteOffset !== undefined - ? request.byteOffset - : null; - const selected = selectBuffer( - bytes, - request.direction, - continuationOffset, - request.maxBytes - rawBytes, - ); - if (!selected) { - next = { position: record.sequence, byteOffset: null }; - break; - } - fragments.push({ - sequence: record.sequence, - byteOffset: selected.byteOffset, - totalBytes: bytes.byteLength, - payloadDigest: null, - data: selected.data, - }); - rawBytes += selected.data.byteLength; - if (!selected.complete) { - next = { position: record.sequence, byteOffset: selected.nextOffset }; - break; - } - if (record.sequence === rangeBoundarySequence) { - const following = scanned.records[recordIndex + 1]?.sequence ?? scanned.nextPosition; - next = following === null ? null : { position: following, byteOffset: null }; - break; - } - if (fragments.length === request.maxMessages || rawBytes === request.maxBytes) { - const following = scanned.records[recordIndex + 1]?.sequence ?? scanned.nextPosition; - next = following === null ? null : { position: following, byteOffset: null }; - break; - } - } - if (next !== null) break; - if (scanned.nextPosition === null) break; - if (scanned.nextPosition === scanPosition) { - throw new Error('Session transcript projection scan did not advance'); - } - scanPosition = scanned.nextPosition; - } - return { - throughSequence, - fragments, - rawBytes, - next, - }; +/** + * How a guest's rows are rewritten. Handed to the reader rather than applied + * after it: a page is cut where the rows it carries end, so a row that the + * guest never sees must not take up room on their page either. + */ +function sharedRowProjection(sessionId: string): TranscriptRowProjection { + return (message) => projectSharedSessionTranscriptMessage(message, sessionId); } export function updateSubscriberTranscriptHighWater( @@ -476,11 +177,7 @@ export class TranscriptPageRequestError extends Error { function resolvePosition( state: SubscriberTranscriptState, request: SessionTranscriptPageInput, -): { - position: number; - byteOffset: number | null; - rangeBoundarySequence: number | null; -} | null { +): { position: number; byteOffset: number | null } | null { if (request.cursor !== null) { const cursor = decodeCursor(request.cursor, state.cursorSecret); if ( @@ -491,32 +188,12 @@ function resolvePosition( ) { throw new TranscriptPageRequestError('Transcript cursor does not match request'); } - return { - position: cursor.position, - byteOffset: cursor.byteOffset, - rangeBoundarySequence: cursor.rangeBoundarySequence, - }; + return { position: cursor.position, byteOffset: cursor.byteOffset }; } if (request.throughSequence === null) return null; const position = - request.direction === 'older' - ? (request.anchorSequence ?? request.throughSequence + 1) - 1 - : (request.anchorSequence ?? -1) + 1; - return position < 0 || position > request.throughSequence - ? null - : { position, byteOffset: null, rangeBoundarySequence: null }; -} - -function continuationMessageLimit(position: { - position: number; - rangeBoundarySequence: number | null; -}): number { - return position.rangeBoundarySequence === null - ? SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES - : Math.min( - SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, - Math.abs(position.position - position.rangeBoundarySequence) + 1, - ); + request.direction === 'older' ? request.throughSequence : (request.anchorSequence ?? -1) + 1; + return position < 0 || position > request.throughSequence ? null : { position, byteOffset: null }; } function storageSelection( @@ -532,69 +209,7 @@ function storageSelection( })), rawBytes: storage.rawBytes, next: storage.next, - }; -} - -function selectionThroughRangeBoundary( - selected: SelectedFragments, - direction: SessionTranscriptPageDirection, - rangeBoundarySequence: number | null, -): SelectedFragments { - if (rangeBoundarySequence === null) return selected; - // RuntimeEvent-backed message sequences are sparse, so a continuation's - // message limit cannot infer how many records remain from sequence distance. - const firstOmittedIndex = selected.fragments.findIndex((fragment) => - direction === 'older' - ? fragment.sequence < rangeBoundarySequence - : fragment.sequence > rangeBoundarySequence, - ); - if (firstOmittedIndex === -1) return selected; - const firstOmitted = selected.fragments[firstOmittedIndex]!; - const fragments = selected.fragments.slice(0, firstOmittedIndex); - return { - fragments, - rawBytes: fragments.reduce( - (sum, fragment) => sum + Buffer.from(fragment.data, 'base64').byteLength, - 0, - ), - next: { position: firstOmitted.sequence, byteOffset: null }, - }; -} - -function selectBuffer( - bytes: Buffer, - direction: SessionTranscriptPageDirection, - byteOffset: number | null, - budget: number, -): { - byteOffset: number; - data: Buffer; - complete: boolean; - nextOffset: number; -} | null { - if (budget < 1) return null; - if (direction === 'older') { - const end = byteOffset ?? bytes.byteLength; - if (end < 1 || end > bytes.byteLength) - throw new TranscriptPageRequestError('Invalid cursor byte offset'); - const start = Math.max(0, end - budget); - return { - byteOffset: start, - data: bytes.subarray(start, end), - complete: start === 0, - nextOffset: start, - }; - } - const start = byteOffset ?? 0; - if (start < 0 || start >= bytes.byteLength) { - throw new TranscriptPageRequestError('Invalid cursor byte offset'); - } - const end = Math.min(bytes.byteLength, start + budget); - return { - byteOffset: start, - data: bytes.subarray(start, end), - complete: end === bytes.byteLength, - nextOffset: end, + endsAtTurnBoundary: storage.endsAtTurnBoundary, }; } @@ -603,17 +218,7 @@ function pageFromSelection( direction: SessionTranscriptPageDirection, selected: SelectedFragments, throughSequence: number | null, - rangeBoundarySequence: number | null, - protectedTurnSequence: number | null, ): SessionTranscriptPage { - const cursorRangeBoundarySequence = - selected.next !== null && - rangeBoundarySequence !== null && - (direction === 'older' - ? selected.next.position < rangeBoundarySequence - : selected.next.position > rangeBoundarySequence) - ? null - : rangeBoundarySequence; return { kind: 'page', sessionId: state.sessionId, @@ -621,8 +226,7 @@ function pageFromSelection( throughSequence, rawBytes: selected.rawBytes, fragments: selected.fragments, - rangeBoundarySequence, - protectedTurnSequence, + endsAtTurnBoundary: selected.endsAtTurnBoundary, nextCursor: selected.next ? encodeCursor( { @@ -631,7 +235,6 @@ function pageFromSelection( sessionId: state.sessionId, direction, throughSequence, - rangeBoundarySequence: cursorRangeBoundarySequence, ...selected.next, }, state.cursorSecret, @@ -651,9 +254,8 @@ function emptyPage( throughSequence: request.throughSequence, rawBytes: 0, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + endsAtTurnBoundary: true, }; } @@ -695,7 +297,6 @@ function decodeCursor(value: string, secret: Buffer): TranscriptCursorState { 'throughSequence', 'position', 'byteOffset', - 'rangeBoundarySequence', ]; if ( Object.keys(cursor).length !== keys.length || @@ -710,8 +311,7 @@ function decodeCursor(value: string, secret: Buffer): TranscriptCursorState { (cursor.direction !== 'older' && cursor.direction !== 'newer') || (cursor.throughSequence !== null && !isCount(cursor.throughSequence)) || !isCount(cursor.position) || - (cursor.byteOffset !== null && !isCount(cursor.byteOffset)) || - (cursor.rangeBoundarySequence !== null && !isCount(cursor.rangeBoundarySequence)) + (cursor.byteOffset !== null && !isCount(cursor.byteOffset)) ) { throw new TranscriptPageRequestError('Invalid transcript cursor values'); } diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts index 752af84fa3..a4d80a2311 100644 --- a/packages/runtime-host/src/server/session-transcript-reader.ts +++ b/packages/runtime-host/src/server/session-transcript-reader.ts @@ -40,11 +40,10 @@ import type { SessionTranscriptStoragePage, SessionTurnContribution, SessionTurnContributionPage, - SessionTurnLandmark, - SessionTurnLandmarkSnapshot, RuntimeTranscriptRun, } from '@maka/storage/execution-stores'; import { foldTurnContribution } from '@maka/storage/session-message-projection'; +import type { SessionTurnLandmark } from '../protocol/index.js'; const PERMISSION_OUTCOME_READ_CONCURRENCY = 8; /** One event can emit content, a permission, usage, and terminal/notice rows. */ @@ -59,6 +58,8 @@ const TRANSCRIPT_SOURCE_MAX_RECORD_BYTES = // separate from the bounded amount of immutable input it may visit. const TRANSCRIPT_SOURCE_MAX_BYTES = TRANSCRIPT_SOURCE_MAX_EVENTS * TRANSCRIPT_SOURCE_MAX_RECORD_BYTES; +/** How much a page may scan past to fill itself when a projection hides rows. */ +const PAGE_HIDDEN_SCAN_MAX_BYTES = 16 * 1024 * 1024; export function createSessionTranscriptReader(input: { stores: ExecutionStoresWriter<'interactive'>; @@ -77,8 +78,8 @@ export function createSessionTranscriptReader(input: { }; return { readDurableHighWater: async (sessionId) => (await prepared(sessionId)).readHighWater(sessionId), - readDurablePage: async (sessionId, request) => - (await prepared(sessionId)).readPage(sessionId, request), + readDurablePage: async (sessionId, request, project) => + (await prepared(sessionId)).readPage(sessionId, request, project), readDurableRecords: async (sessionId, request) => (await prepared(sessionId)).readRecords(sessionId, request), readDurableTurnContributions: async (sessionId, throughSequence, position, maxContributions) => @@ -88,16 +89,34 @@ export function createSessionTranscriptReader(input: { position, maxContributions, ), - readDurableTurnLandmarks: async (sessionId, maxLandmarks) => - (await prepared(sessionId)).readTurnLandmarks(sessionId, maxLandmarks), + readDurableTurnLandmarks: async (sessionId, request) => + (await prepared(sessionId)).readTurnLandmarks(sessionId, request), }; } +export interface SessionTurnLandmarkRequest { + readonly maxLandmarks: number; + readonly turnId: string | null; +} + +export interface SessionTurnLandmarkSnapshot { + readonly throughSequence: number | null; + readonly landmarks: readonly SessionTurnLandmark[]; +} + +/** + * Rewrites a row for the audience the page is being read for, or hides it. A + * page is cut where the rows it carries end, so what a row becomes has to be + * known while the page is being cut, not after. + */ +export type TranscriptRowProjection = (message: StoredMessage) => StoredMessage | null; + export interface SessionTranscriptReader { readDurableHighWater(sessionId: string): Promise; readDurablePage( sessionId: string, request: SessionTranscriptPageRequest, + project?: TranscriptRowProjection, ): Promise; readDurableRecords( sessionId: string, @@ -111,7 +130,7 @@ export interface SessionTranscriptReader { ): Promise; readDurableTurnLandmarks( sessionId: string, - maxLandmarks: number, + request: SessionTurnLandmarkRequest, ): Promise; } @@ -204,7 +223,7 @@ function createDurableLedgerTranscriptReader(input: { throughSequence?: number | null; position?: number; }, - ): AsyncGenerator<{ sequence: number; message: StoredMessage }> { + ): AsyncGenerator { const throughSequence = request.throughSequence === undefined ? await highWater(sessionId) : request.throughSequence; if (throughSequence === null) return; @@ -230,11 +249,20 @@ function createDurableLedgerTranscriptReader(input: { .filter( ({ sequence }) => sequence >= from && - sequence <= Math.min(to, throughSequence) && + sequence <= to && + sequence <= throughSequence && (older ? sequence <= position : sequence >= position), ) .sort((a, b) => (older ? b.sequence - a.sequence : a.sequence - b.sequence)); - yield* records; + for (const [index, record] of records.entries()) { + const between = + index === 0 && + !(await store.readTranscriptTurnCrossing( + sessionId, + older ? run.lastOrdinal + 1 : run.firstOrdinal, + )); + yield { ...record, between }; + } ordinal = older ? run.firstOrdinal - 1 : run.lastOrdinal + 1; } }; @@ -292,27 +320,30 @@ function createDurableLedgerTranscriptReader(input: { }; }, - /** Evenly spaced Turn starts, selected in SQL before loading their prompts. */ async readTurnLandmarks( sessionId: string, - maxLandmarks: number, + request: SessionTurnLandmarkRequest, ): Promise { const throughSequence = await highWater(sessionId); if (throughSequence === null) return { throughSequence: null, landmarks: [] }; - const turns = await store.readTranscriptLandmarks( + const turns = await store.readTranscriptTurns( sessionId, - ordinalOf(throughSequence), - maxLandmarks, + request.turnId === null + ? { throughOrdinal: ordinalOf(throughSequence), limit: request.maxLandmarks } + : { turnId: request.turnId }, ); const landmarks: SessionTurnLandmark[] = []; for (const turn of turns) { - if (!turn.prompt) continue; - const message = projectRuntimeEventUserMessage(turn.prompt.event, turn.prompt.event.id); + const message = turn.prompt + ? projectRuntimeEventUserMessage(turn.prompt.event, turn.prompt.event.id) + : undefined; const label = (message?.displayText ?? message?.text ?? '').trim(); - if (!label) continue; + // A sampled tick needs something to show; a looked-up Turn only needs a place. + if (!label && request.turnId === null) continue; landmarks.push({ - turnId: turn.invocation.turnId, - sequence: turn.prompt.ordinal * EVENT_SEQUENCE_STRIDE, + turnId: turn.turnId, + sequence: turn.firstOrdinal * EVENT_SEQUENCE_STRIDE, + lastSequence: turn.lastOrdinal * EVENT_SEQUENCE_STRIDE + EVENT_SEQUENCE_STRIDE - 1, label, }); } @@ -321,6 +352,13 @@ function createDurableLedgerTranscriptReader(input: { }; } +interface TranscriptRecord { + readonly sequence: number; + readonly message: StoredMessage; + /** Whether a page may end just before this row without splitting a Turn. */ + readonly between: boolean; +} + /** An ordered, bounded walk over one Session's transcript records. */ interface TranscriptRecordSource { readHighWater(sessionId: string): Promise; @@ -331,12 +369,12 @@ interface TranscriptRecordSource { throughSequence?: number | null; position?: number; }, - ): AsyncGenerator<{ sequence: number; message: StoredMessage }>; + ): AsyncGenerator; } /** * The reads that are the same whatever produces the records: a byte-bounded - * page, a record scan, and a lookup by message id. Each walks one source's + * page and a record scan. Each walks one source's * ordered records and never asks where they came from. */ function pagedTranscriptReads(source: TranscriptRecordSource) { @@ -344,25 +382,48 @@ function pagedTranscriptReads(source: TranscriptRecordSource) { async readPage( sessionId: string, request: SessionTranscriptPageRequest, + project?: TranscriptRowProjection, ): Promise { const throughSequence = request.throughSequence === undefined ? await source.readHighWater(sessionId) : request.throughSequence; if (throughSequence === null) { - return { throughSequence: null, fragments: [], rawBytes: 0, next: null }; + return { + throughSequence: null, + fragments: [], + rawBytes: 0, + next: null, + endsAtTurnBoundary: true, + }; } const fragments: SessionTranscriptStorageFragment[] = []; let rawBytes = 0; let next: SessionTranscriptStoragePage['next'] = null; let truncated = false; + let endsAtTurnBoundary = true; + /** The last point the page was between Turns, so it can be cut back there. */ + let between: { index: number; bytes: number; sequence: number } | undefined; + let hiddenBytes = 0; for await (const record of source.scan(sessionId, { ...request, throughSequence })) { if (fragments.length >= request.maxMessages || rawBytes >= request.maxBytes) { truncated = true; + endsAtTurnBoundary = record.between; next = { position: record.sequence, byteOffset: null }; break; } - const data = Buffer.from(JSON.stringify(record.message), 'utf8'); + if (record.between) { + between = { index: fragments.length, bytes: rawBytes, sequence: record.sequence }; + } + const projected = project ? project(record.message) : record.message; + if (projected === null) { + hiddenBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); + if (hiddenBytes > PAGE_HIDDEN_SCAN_MAX_BYTES) { + throw new RangeError('Session transcript projection scan exceeds its capacity limit'); + } + continue; + } + const data = Buffer.from(JSON.stringify(projected), 'utf8'); // A message larger than the remaining budget is served in byte slices, // from the edge the traversal is moving away from, so the next page // resumes inside the same record instead of skipping it. @@ -387,6 +448,8 @@ function pagedTranscriptReads(source: TranscriptRecordSource) { const complete = request.direction === 'older' ? byteOffset === 0 : end === data.byteLength; if (!complete) { truncated = true; + // The record itself is unfinished, so its Turn is too. + endsAtTurnBoundary = false; next = { position: record.sequence, byteOffset: request.direction === 'older' ? byteOffset : end, @@ -394,8 +457,15 @@ function pagedTranscriptReads(source: TranscriptRecordSource) { break; } } + if (!endsAtTurnBoundary && between !== undefined && between.index > 0) { + // Only a Turn that reaches the start of the page is served in slices. + rawBytes = between.bytes; + fragments.length = between.index; + next = { position: between.sequence, byteOffset: null }; + endsAtTurnBoundary = true; + } if (!truncated) next = null; - return { throughSequence, fragments, rawBytes, next }; + return { throughSequence, fragments, rawBytes, next, endsAtTurnBoundary }; }, async readRecords( @@ -412,11 +482,15 @@ function pagedTranscriptReads(source: TranscriptRecordSource) { const records: Array<{ sequence: number; message: StoredMessage }> = []; let storedBytes = 0; let nextPosition: number | null = null; - for await (const record of source.scan(sessionId, { ...request, throughSequence })) { + for await (const { sequence, message } of source.scan(sessionId, { + ...request, + throughSequence, + })) { if (records.length >= request.maxMessages || storedBytes >= request.maxStoredBytes) { - nextPosition = record.sequence; + nextPosition = sequence; break; } + const record = { sequence, message }; records.push(record); storedBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); } diff --git a/packages/storage/src/__tests__/execution-provider-conformance.test.ts b/packages/storage/src/__tests__/execution-provider-conformance.test.ts index 598c731d90..5ec27694e5 100644 --- a/packages/storage/src/__tests__/execution-provider-conformance.test.ts +++ b/packages/storage/src/__tests__/execution-provider-conformance.test.ts @@ -408,17 +408,6 @@ for (const backend of ['Local', 'Memory'] as const) { assert.deepEqual(await read('newer', 1, 2), [ { invocationId: settled.invocationId, first: 1, last: 2, ordinals: [1, 2] }, ]); - assert.deepEqual( - (await s.readTranscriptLandmarks(sessionId, 6, 8)).map((landmark) => ({ - invocationId: landmark.invocation.invocationId, - first: landmark.firstOrdinal, - prompt: landmark.prompt?.event.id, - })), - [ - { invocationId: settled.invocationId, first: 1, prompt: 'settled-prompt' }, - { invocationId: running.invocationId, first: 4, prompt: 'running-prompt' }, - ], - ); await s.importConversationCopyRuntimeEvents(sessionId, [ { @@ -432,6 +421,140 @@ for (const backend of ['Local', 'Memory'] as const) { assert.equal(commits.length, 7); }); }); + test( + backend + ': a transcript Turn spans every visible invocation carrying its turnId', + async () => { + await withProvider(make(), async ({ runtimeEventStore: s }) => { + const sessionId = 'turn-extent-session'; + const invocation = (name: string, turnId = `${name}-turn`) => ({ + sessionId, + runId: `${name}-run`, + turnId, + invocationId: `${name}-invocation`, + }); + const outer = invocation('outer'); + const inner = invocation('inner'); + const resumed = invocation('resumed', outer.turnId); + const hidden = invocation('hidden'); + const later = invocation('later'); + const opened = (run: typeof outer, opening: Parameters[0] = {}) => + buildInvocationOpenedEvent({ + id: `${run.invocationId}-opened`, + run, + openedAt: 1, + opening: invocationOpening(opening), + }); + const text = (run: typeof outer, id: string, role: 'user' | 'model'): RuntimeEvent => ({ + ...run, + id, + ts: 2, + partial: false, + role, + author: role === 'user' ? 'user' : 'agent', + content: { kind: 'text', text: id }, + }); + const ending = (run: typeof outer): RuntimeEvent => ({ + ...run, + id: `${run.invocationId}-ended`, + ts: 3, + partial: false, + role: 'system', + author: 'system', + actions: { endInvocation: true }, + status: 'completed', + }); + for (const event of [ + opened(outer), // 1 + text(outer, 'outer-prompt', 'user'), // 2 + opened(inner), // 3 + text(inner, 'inner-prompt', 'user'), // 4 + ending(inner), // 5 + opened(hidden, { lineage: { parentRunId: outer.runId } }), // 6 + text(hidden, 'hidden-answer', 'model'), // 7 + opened(resumed, { + source: { + kind: 'handoff', + rootRunId: outer.runId, + sourceInvocationId: outer.invocationId, + sourceRunId: outer.runId, + sourceTurnId: outer.turnId, + sourceRuntimeEventHighWater: 2, + claimId: 'handoff-claim', + boundaryDigest: `sha256:${'0'.repeat(64)}`, + }, + }), // 8 + text(resumed, 'resumed-answer', 'model'), // 9 + ending(resumed), // 10 + opened(later), // 11 + text(later, 'later-prompt', 'user'), // 12 + ]) { + await s.appendRuntimeEvent(sessionId, event.runId, event); + } + + const extents = (turns: Awaited>) => + turns.map(({ turnId, firstOrdinal, lastOrdinal, prompt }) => ({ + turnId, + firstOrdinal, + lastOrdinal, + prompt: prompt?.event.id, + })); + assert.deepEqual( + extents(await s.readTranscriptTurns(sessionId, { throughOrdinal: 12, limit: 8 })), + [ + { turnId: outer.turnId, firstOrdinal: 1, lastOrdinal: 10, prompt: 'outer-prompt' }, + { turnId: inner.turnId, firstOrdinal: 3, lastOrdinal: 5, prompt: 'inner-prompt' }, + { turnId: later.turnId, firstOrdinal: 11, lastOrdinal: 12, prompt: 'later-prompt' }, + ], + ); + assert.deepEqual( + extents(await s.readTranscriptTurns(sessionId, { throughOrdinal: 10, limit: 1 })), + [{ turnId: inner.turnId, firstOrdinal: 3, lastOrdinal: 5, prompt: 'inner-prompt' }], + ); + assert.deepEqual( + extents(await s.readTranscriptTurns(sessionId, { turnId: outer.turnId })).map( + ({ lastOrdinal }) => lastOrdinal, + ), + [10], + ); + assert.deepEqual(await s.readTranscriptTurns(sessionId, { turnId: hidden.turnId }), []); + const crossings: number[] = []; + for (let ordinal = 1; ordinal <= 13; ordinal += 1) { + if (await s.readTranscriptTurnCrossing(sessionId, ordinal)) crossings.push(ordinal); + } + assert.deepEqual(crossings, [2, 3, 4, 5, 6, 7, 8, 9, 10, 12]); + + // Repair renumbers every ordinal, so the extents move with them. + await s.resequenceSessionEventOrdinals(sessionId); + const entries = await s.readSessionRuntimeEventEntries(sessionId); + const ordinalsOf = (turnId: string) => + entries.filter((entry) => entry.event.turnId === turnId).map((entry) => entry.ordinal); + const [moved] = await s.readTranscriptTurns(sessionId, { turnId: outer.turnId }); + assert.deepEqual( + [moved?.firstOrdinal, moved?.lastOrdinal], + [Math.min(...ordinalsOf(outer.turnId)), Math.max(...ordinalsOf(outer.turnId))], + ); + + // The opening decides visibility, so events committed before it count. + const early = invocation('early'); + await s.appendRuntimeEvent(sessionId, early.runId, text(early, 'early-prompt', 'user')); + await s.appendRuntimeEvent(sessionId, early.runId, opened(early)); + const earlyOrdinals = (await s.readSessionRuntimeEventEntries(sessionId)) + .filter((entry) => entry.event.turnId === early.turnId) + .map((entry) => entry.ordinal); + assert.deepEqual( + extents(await s.readTranscriptTurns(sessionId, { turnId: early.turnId })), + [ + { + turnId: early.turnId, + firstOrdinal: Math.min(...earlyOrdinals), + lastOrdinal: Math.max(...earlyOrdinals), + prompt: 'early-prompt', + }, + ], + ); + }); + }, + ); test(backend + ': steering reorder preserves unselected and followup queue slots', async () => { await withProvider(make(), async ({ sessionStore: s }, root) => { const session = await s.create(sessionInput(root)); diff --git a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts index cebfb41c80..30b913bc20 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts @@ -119,6 +119,12 @@ describe('SQLite runtime schema migration', () => { payload_json TEXT NOT NULL, committed_at INTEGER NOT NULL ); + CREATE TABLE runtime_session_event_ordinals ( + session_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + event_id TEXT NOT NULL UNIQUE, + PRIMARY KEY (session_id, ordinal) + ); CREATE TABLE runtime_continuation_claims ( claim_id TEXT PRIMARY KEY, source_session_id TEXT NOT NULL, @@ -202,6 +208,117 @@ describe('SQLite runtime schema migration', () => { } }); + it('backfills the extent of every visible Turn already in the ledger', () => { + const db = new DatabaseSync(':memory:'); + try { + migrateSqliteRuntimeDatabase(db); + const insert = db.prepare( + 'INSERT INTO runtime_events(event_id, session_id, invocation_id, run_id, turn_id, event_seq, event_kind, payload_json, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + ); + const ordinal = db.prepare( + 'INSERT INTO runtime_session_event_ordinals(session_id, ordinal, event_id) VALUES (?, ?, ?)', + ); + const opening = (lineage?: object) => + JSON.stringify({ + content: { + kind: 'invocation_opened', + source: { kind: 'fresh' }, + ...(lineage ? { lineage } : {}), + }, + }); + const rows = [ + ['shown-opened', 'shown', 'invocation_opened', opening()], + ['hidden-opened', 'hidden', 'invocation_opened', opening({ parentRunId: 'shown' })], + ['hidden-text', 'hidden', 'text', '{}'], + ['shown-text', 'shown', 'text', '{}'], + ] as const; + rows.forEach(([eventId, invocation, kind, payload], index) => { + insert.run( + eventId, + 'session', + invocation, + invocation, + `${invocation}-turn`, + index + 1, + kind, + payload, + 1, + ); + ordinal.run('session', index + 1, eventId); + }); + db.exec('PRAGMA user_version = 18'); + migrateSqliteRuntimeDatabase(db); + + assert.deepEqual( + db + .prepare('SELECT turn_id, first_ordinal, last_ordinal FROM runtime_session_turn_extents') + .all() + .map((row) => ({ ...row })), + [{ turn_id: 'shown-turn', first_ordinal: 1, last_ordinal: 4 }], + ); + } finally { + db.close(); + } + }); + + it('backfills extents over a ledger holding an undecodable opening', () => { + const db = new DatabaseSync(':memory:'); + try { + migrateSqliteRuntimeDatabase(db); + const insert = db.prepare( + 'INSERT INTO runtime_events(event_id, session_id, invocation_id, run_id, turn_id, event_seq, event_kind, payload_json, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + ); + insert.run( + 'broken-opened', + 'session', + 'broken', + 'broken', + 'broken-turn', + 1, + 'invocation_opened', + '{', + 1, + ); + insert.run( + 'shown-opened', + 'session', + 'shown', + 'shown', + 'shown-turn', + 1, + 'invocation_opened', + JSON.stringify({ content: { kind: 'invocation_opened', source: { kind: 'fresh' } } }), + 1, + ); + insert.run('legacy-text', 'session', 'legacy', 'legacy', 'legacy-turn', 1, 'text', '{}', 1); + db.prepare( + 'INSERT INTO runtime_legacy_invocation_openings(invocation_id, session_id, run_id, turn_id, opened_at, opening_json, anchor_event_id) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run('legacy', 'session', 'legacy', 'legacy-turn', 1, '{', 'legacy-text'); + const ordinal = db.prepare( + 'INSERT INTO runtime_session_event_ordinals(session_id, ordinal, event_id) VALUES (?, ?, ?)', + ); + ordinal.run('session', 1, 'broken-opened'); + ordinal.run('session', 2, 'shown-opened'); + ordinal.run('session', 3, 'legacy-text'); + db.exec('PRAGMA user_version = 18'); + migrateSqliteRuntimeDatabase(db); + + assert.equal( + (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, + SQLITE_RUNTIME_SCHEMA_VERSION, + ); + assert.deepEqual( + db + .prepare('SELECT turn_id FROM runtime_session_turn_extents') + .all() + .map((row) => row.turn_id), + ['shown-turn'], + ); + } finally { + db.close(); + } + }); + it('builds the terminal index over a ledger holding an undecodable payload', () => { const db = new DatabaseSync(':memory:'); try { @@ -212,9 +329,7 @@ describe('SQLite runtime schema migration', () => { // A partial index is rebuilt by evaluating its predicate over every row, // so one such row would otherwise fail this migration — and the failure // rolls the version back, leaving the next open to fail the same way. - db.exec( - `DROP INDEX runtime_events_terminal; PRAGMA user_version = ${SQLITE_RUNTIME_SCHEMA_VERSION - 1}`, - ); + db.exec('DROP INDEX runtime_events_terminal; PRAGMA user_version = 17'); migrateSqliteRuntimeDatabase(db); assert.equal( diff --git a/packages/storage/src/conversation-operational-state.ts b/packages/storage/src/conversation-operational-state.ts index e390a0a657..3f0df9126f 100644 --- a/packages/storage/src/conversation-operational-state.ts +++ b/packages/storage/src/conversation-operational-state.ts @@ -78,6 +78,9 @@ class SqliteConversationOperationalStateStore implements ConversationOperational .run(sessionId, sessionId, sessionId); database.prepare('DELETE FROM runtime_partial_snapshots WHERE session_id = ?').run(sessionId); database.prepare('DELETE FROM runtime_events WHERE session_id = ?').run(sessionId); + database + .prepare('DELETE FROM runtime_session_turn_extents WHERE session_id = ?') + .run(sessionId); database .prepare('DELETE FROM core_agent_run_projections WHERE session_id = ?') .run(sessionId); diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index bcefb086ea..686d26e930 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -151,15 +151,10 @@ export type { SessionTranscriptStorageFragment, SessionTurnContribution, SessionTurnContributionPage, - SessionTurnLandmark, - SessionTurnLandmarkSnapshot, } from './session-store-contract.js'; export type ExecutionSessionWriter = SessionAuthorityStore; -export type { - RuntimeTranscriptLandmark, - RuntimeTranscriptRun, -} from './runtime-transcript-query.js'; +export type { RuntimeTranscriptRun, RuntimeTranscriptTurn } from './runtime-transcript-query.js'; export type ExecutionAgentRunWriter = DurableAgentRunStore; export type ExecutionRuntimeEventWriter = DurableRuntimeEventStore & RuntimeTranscriptQueries & @@ -754,8 +749,10 @@ async function createExecutionStoresForWrite( run(() => runtimeEventStore.readTranscriptHighWater(sessionId)), readTranscriptRun: (sessionId, request, project) => run(() => runtimeEventStore.readTranscriptRun(sessionId, request, project)), - readTranscriptLandmarks: (sessionId, throughOrdinal, limit) => - run(() => runtimeEventStore.readTranscriptLandmarks(sessionId, throughOrdinal, limit)), + readTranscriptTurns: (sessionId, request) => + run(() => runtimeEventStore.readTranscriptTurns(sessionId, request)), + readTranscriptTurnCrossing: (sessionId, ordinal) => + run(() => runtimeEventStore.readTranscriptTurnCrossing(sessionId, ordinal)), subscribeRuntimeEventCommits: (listener) => { if (closed) throw invalidExecutionStores(kind, 'write'); assertStorageRootLeaseActive(lease, kind, 'write'); diff --git a/packages/storage/src/runtime-transcript-query.ts b/packages/storage/src/runtime-transcript-query.ts index 09b7f4d662..d47dc239a6 100644 --- a/packages/storage/src/runtime-transcript-query.ts +++ b/packages/storage/src/runtime-transcript-query.ts @@ -55,13 +55,20 @@ export interface RuntimeTranscriptRun { readonly lastOrdinal: number; } -/** An invocation start, with the prompt event a landmark is labelled by. */ -export interface RuntimeTranscriptLandmark { - readonly invocation: RuntimeInvocationRecord; +/** One Turn of a Session transcript: every visible invocation carrying its turnId. */ +export interface RuntimeTranscriptTurn { + readonly turnId: string; readonly firstOrdinal: number; + readonly lastOrdinal: number; + /** The Turn's first user text event, which labels it. */ readonly prompt?: { readonly ordinal: number; readonly event: RuntimeEvent }; } +export type RuntimeTranscriptTurnsRequest = + | { readonly turnId: string } + /** Evenly spaced Turns that start at or below `throughOrdinal`. */ + | { readonly throughOrdinal: number; readonly limit: number }; + export interface RuntimeTranscriptRunRequest { readonly direction: 'older' | 'newer'; readonly throughOrdinal: number; @@ -93,11 +100,12 @@ export interface RuntimeTranscriptQueries { events: Iterable<{ readonly ordinal: number; readonly event: RuntimeEvent }>, ) => T, ): Promise; - readTranscriptLandmarks( + readTranscriptTurns( sessionId: string, - throughOrdinal: number, - limit: number, - ): Promise; + request: RuntimeTranscriptTurnsRequest, + ): Promise; + /** Whether some Turn has events both below `ordinal` and at or above it. */ + readTranscriptTurnCrossing(sessionId: string, ordinal: number): Promise; } export class RuntimeTranscriptOversizedTurnError extends Error { @@ -110,35 +118,22 @@ export class RuntimeTranscriptOversizedTurnError extends Error { * * This is a fact about the invocation, not about any row it produces — which * rows it produces is the read model's question, and is not asked here. + * + * An opening that is not JSON is not shown. `json_extract` on it would fail the + * whole statement, and the extent backfill runs this over every row inside a + * migration; see `TERMINAL_RUNTIME_EVENT_SQL`. */ const visibleOpening = (payload: string) => ` - (${payload} IS NOT NULL - AND (json_extract(${payload}, '$.lineage.parentRunId') IS NULL + (CASE WHEN json_valid(${payload}) THEN + json_extract(${payload}, '$.lineage.parentRunId') IS NULL OR (json_extract(${payload}, '$.source.kind') <> 'fresh' - AND json_extract(${payload}, '$.lineage.agentId') IS NULL)))`; -/** - * A Session migrated from run headers keeps some openings beside the ledger - * rather than in it, ordered by the anchor event each one names. - */ -const migratedOpening = ` - FROM runtime_legacy_invocation_openings legacy - JOIN runtime_session_event_ordinals o ON o.event_id = legacy.anchor_event_id - WHERE legacy.session_id = :sessionId - AND ${visibleOpening('legacy.opening_json')} - AND NOT EXISTS ( - SELECT 1 FROM runtime_events opened - WHERE opened.invocation_id = legacy.invocation_id - AND opened.event_kind = 'invocation_opened' - )`; -const ledgerOpening = ` - FROM runtime_session_event_ordinals o - JOIN runtime_events e ON e.event_id = o.event_id - WHERE o.session_id = :sessionId - AND e.event_kind = 'invocation_opened' - AND ${visibleOpening("json_extract(e.payload_json, '$.content')")}`; + AND json_extract(${payload}, '$.lineage.agentId') IS NULL) + ELSE 0 END)`; +const eventOpening = (payloadJson: string) => + `CASE WHEN json_valid(${payloadJson}) THEN json_extract(${payloadJson}, '$.content') END`; const openingContent = (invocation: string) => ` COALESCE( - (SELECT json_extract(op.payload_json, '$.content') FROM runtime_events op + (SELECT ${eventOpening('op.payload_json')} FROM runtime_events op WHERE op.invocation_id = ${invocation} AND op.event_kind = 'invocation_opened'), (SELECT lg.opening_json FROM runtime_legacy_invocation_openings lg WHERE lg.invocation_id = ${invocation}))`; @@ -182,6 +177,79 @@ const boundary = (direction: 'older' | 'newer') => ` ORDER BY o.ordinal ${direction === 'older' ? 'DESC' : 'ASC'} LIMIT 1`; +/** + * Invocations the transcript shows. The ledger opening wins over one parked + * beside it by the run-header migration. + */ +const VISIBLE_INVOCATIONS = ` + SELECT invocation_id FROM runtime_events + WHERE event_kind = 'invocation_opened' + AND ${visibleOpening(eventOpening('payload_json'))} + UNION + SELECT legacy.invocation_id FROM runtime_legacy_invocation_openings legacy + WHERE ${visibleOpening('legacy.opening_json')} + AND NOT EXISTS ( + SELECT 1 FROM runtime_events opened + WHERE opened.invocation_id = legacy.invocation_id + AND opened.event_kind = 'invocation_opened' + )`; + +/** + * Widen a Turn's extent to cover one committed event, in the transaction that + * gave the event its ordinal. Events of invocations the transcript does not + * show leave no extent. + * + * Visibility is decided by the opening, so the opening covers every event its + * invocation already committed: the extent does not depend on the opening + * having been committed first. + */ +export function recordTranscriptTurnExtent( + db: DatabaseSync, + event: { + readonly sessionId: string; + readonly invocationId: string; + readonly turnId: string; + readonly kind: string; + }, + ordinal: number, +): void { + db.prepare(` + INSERT INTO runtime_session_turn_extents(session_id, turn_id, first_ordinal, last_ordinal) + SELECT :sessionId, :turnId, MIN(o.ordinal), MAX(o.ordinal) + FROM runtime_events e + JOIN runtime_session_event_ordinals o ON o.event_id = e.event_id + WHERE e.invocation_id = :invocationId + AND ${event.kind === 'invocation_opened' ? '1' : 'o.ordinal = :ordinal'} + AND ${visibleOpening(openingContent(':invocationId'))} + HAVING COUNT(*) > 0 + ON CONFLICT(session_id, turn_id) DO UPDATE SET + first_ordinal = MIN(first_ordinal, excluded.first_ordinal), + last_ordinal = MAX(last_ordinal, excluded.last_ordinal) + `).run({ + sessionId: event.sessionId, + turnId: event.turnId, + invocationId: event.invocationId, + ...(event.kind === 'invocation_opened' ? {} : { ordinal }), + }); +} + +/** Recompute extents from the ledger, for one Session or all of them. */ +export function rebuildTranscriptTurnExtents(db: DatabaseSync, sessionId?: string): void { + const bind = { sessionId: sessionId ?? null }; + db.prepare( + 'DELETE FROM runtime_session_turn_extents WHERE :sessionId IS NULL OR session_id = :sessionId', + ).run(bind); + db.prepare(` + INSERT INTO runtime_session_turn_extents(session_id, turn_id, first_ordinal, last_ordinal) + SELECT o.session_id, e.turn_id, MIN(o.ordinal), MAX(o.ordinal) + FROM runtime_session_event_ordinals o + JOIN runtime_events e ON e.event_id = o.event_id + WHERE e.invocation_id IN (${VISIBLE_INVOCATIONS}) + AND (:sessionId IS NULL OR o.session_id = :sessionId) + GROUP BY o.session_id, e.turn_id + `).run(bind); +} + const RUN_QUERIES = { older: { seek: seek('older'), boundary: boundary('older') }, newer: { seek: seek('newer'), boundary: boundary('newer') }, @@ -249,53 +317,56 @@ export class RuntimeTranscriptQuery { ); } - landmarks(sessionId: string, throughOrdinal: number, limit: number): RuntimeTranscriptLandmark[] { - assertOrdinal(throughOrdinal); - if (limit < 1) return []; - // Evenly spaced Turn starts, chosen before any payload is read. - const rows = this.db - .prepare(` - WITH opened AS ( - SELECT e.invocation_id AS invocation_id, o.ordinal AS ordinal ${ledgerOpening} - AND o.ordinal <= :throughOrdinal - UNION ALL - SELECT legacy.invocation_id, o.ordinal ${migratedOpening} - AND o.ordinal <= :throughOrdinal - ), candidates AS ( - SELECT invocation_id, ordinal, - ROW_NUMBER() OVER (ORDER BY ordinal) - 1 AS rank, COUNT(*) OVER () AS total - FROM opened - ), samples(n) AS ( - SELECT 0 UNION ALL SELECT n + 1 FROM samples WHERE n + 1 < :limit - ) - SELECT DISTINCT invocation_id, ordinal FROM candidates - JOIN samples ON rank = CASE WHEN :limit = 1 THEN total - 1 - ELSE CAST(n * (total - 1) / (:limit - 1) AS INTEGER) END - ORDER BY ordinal - `) - .all({ sessionId, throughOrdinal, limit }) as Array<{ - invocation_id: string; - ordinal: number; - }>; + turns(sessionId: string, request: RuntimeTranscriptTurnsRequest): RuntimeTranscriptTurn[] { + let rows: Array<{ turn_id: string; first_ordinal: number; last_ordinal: number }>; + if ('turnId' in request) { + rows = this.db + .prepare(` + SELECT turn_id, first_ordinal, last_ordinal FROM runtime_session_turn_extents + WHERE session_id = ? AND turn_id = ? + `) + .all(sessionId, request.turnId) as typeof rows; + } else { + assertOrdinal(request.throughOrdinal); + if (!Number.isSafeInteger(request.limit) || request.limit < 1) return []; + rows = this.db + .prepare(` + WITH candidates AS ( + SELECT turn_id, first_ordinal, last_ordinal, + ROW_NUMBER() OVER (ORDER BY first_ordinal) - 1 AS rank, COUNT(*) OVER () AS total + FROM runtime_session_turn_extents + WHERE session_id = :sessionId AND first_ordinal <= :throughOrdinal + ), samples(n) AS ( + SELECT 0 UNION ALL SELECT n + 1 FROM samples WHERE n + 1 < :limit + ) + SELECT DISTINCT turn_id, first_ordinal, last_ordinal FROM candidates + JOIN samples ON rank = CASE WHEN :limit = 1 THEN total - 1 + ELSE CAST(n * (total - 1) / (:limit - 1) AS INTEGER) END + ORDER BY first_ordinal + `) + .all({ + sessionId, + throughOrdinal: request.throughOrdinal, + limit: request.limit, + }) as typeof rows; + } return rows.map((row) => { - // The prompt is the Turn's first user text event, which is what the read - // model projects a user message from. Only that one event is loaded: a - // landmark is a label, and projecting whole Turns to build a scrollbar - // would read most of the Session. const prompt = this.db .prepare(` - SELECT o.ordinal, e.event_id FROM runtime_events e - JOIN runtime_session_event_ordinals o ON o.event_id = e.event_id - WHERE e.invocation_id = ? AND o.ordinal <= ? - AND e.event_kind = 'text' AND json_extract(e.payload_json, '$.role') = 'user' - ORDER BY e.event_seq LIMIT 1 + SELECT po.ordinal, pe.event_id + FROM runtime_session_event_ordinals o + JOIN runtime_events e ON e.event_id = o.event_id + JOIN runtime_events pe ON pe.invocation_id = e.invocation_id + JOIN runtime_session_event_ordinals po ON po.event_id = pe.event_id + WHERE o.session_id = ? AND o.ordinal = ? + AND pe.event_kind = 'text' AND json_extract(pe.payload_json, '$.role') = 'user' + ORDER BY pe.event_seq LIMIT 1 `) - .get(row.invocation_id, throughOrdinal) as - | { ordinal: number; event_id: string } - | undefined; + .get(sessionId, row.first_ordinal) as { ordinal: number; event_id: string } | undefined; return { - invocation: this.invocation(sessionId, row.invocation_id), - firstOrdinal: row.ordinal, + turnId: row.turn_id, + firstOrdinal: row.first_ordinal, + lastOrdinal: row.last_ordinal, ...(prompt ? { prompt: { ordinal: prompt.ordinal, event: this.event(prompt.event_id) } } : {}), @@ -303,6 +374,30 @@ export class RuntimeTranscriptQuery { }); } + crossing(sessionId: string, ordinal: number): boolean { + assertOrdinal(ordinal); + return ( + this.db + .prepare(` + SELECT 1 FROM runtime_session_turn_extents + WHERE session_id = ? AND first_ordinal < ? AND last_ordinal >= ? + LIMIT 1 + `) + .get(sessionId, ordinal, ordinal) !== undefined + ); + } + + private event(eventId: string): RuntimeEvent { + const row = this.db + .prepare(` + SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json + FROM runtime_events WHERE event_id = ? + `) + .get(eventId) as StoredEventRow | undefined; + if (!row) throw new Error(`Transcript RuntimeEvent ${eventId} is missing`); + return decodeStoredEvent(row); + } + private *events( invocationId: string, limits: { @@ -353,16 +448,6 @@ export class RuntimeTranscriptQuery { yield { ordinal: row.ordinal, event: decodeStoredEvent({ ...row, payload_json: payload }) }; } } - - private event(id: string): RuntimeEvent { - const row = this.db - .prepare( - 'SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json FROM runtime_events WHERE event_id = ?', - ) - .get(id) as StoredEventRow | undefined; - if (!row) throw new Error(`Transcript RuntimeEvent ${id} is missing`); - return decodeStoredEvent(row); - } } type StoredEventRow = { diff --git a/packages/storage/src/session-store-contract.ts b/packages/storage/src/session-store-contract.ts index ead6f923ab..4a6c849444 100644 --- a/packages/storage/src/session-store-contract.ts +++ b/packages/storage/src/session-store-contract.ts @@ -291,6 +291,12 @@ export interface SessionTranscriptStoragePage { readonly position: number; readonly byteOffset: number | null; } | null; + /** + * Whether no Turn has rows on both sides of where this page stops. A change + * of owner between rows does not say that: a nested Turn's rows sit between + * the rows of the Turn around it. + */ + readonly endsAtTurnBoundary: boolean; } export interface SessionTranscriptRecordScanRequest { @@ -323,17 +329,6 @@ export interface SessionTurnContributionPage { readonly nextPosition: number | null; } -export interface SessionTurnLandmark { - readonly turnId: string; - readonly sequence: number; - readonly label: string; -} - -export interface SessionTurnLandmarkSnapshot { - readonly throughSequence: number | null; - readonly landmarks: readonly SessionTurnLandmark[]; -} - export interface SessionStore { create(input: CreateSessionInput, initialBoundary?: ExecutionBoundary): Promise; list(filter?: SessionListFilter): Promise; diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 186e32c6f0..81c7ac3467 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -83,8 +83,6 @@ export { type SessionTranscriptRecordScanPage, type SessionTurnContribution, type SessionTurnContributionPage, - type SessionTurnLandmark, - type SessionTurnLandmarkSnapshot, type SessionStore, type CoordinationTranscriptReference, type CoordinationTranscriptIndexRecord, diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index 2e74ce9a1a..b0649ee134 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -25,13 +25,16 @@ import { } from './legacy-run-header.js'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; -import { TERMINAL_RUNTIME_EVENT_SQL } from './runtime-transcript-query.js'; +import { + rebuildTranscriptTurnExtents, + TERMINAL_RUNTIME_EVENT_SQL, +} from './runtime-transcript-query.js'; import { buildInvocationOpenedEvent, buildSyntheticTerminalRuntimeEvent, } from '@maka/core/runtime-invocation'; -export const SQLITE_RUNTIME_SCHEMA_VERSION = 18; +export const SQLITE_RUNTIME_SCHEMA_VERSION = 19; export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY = 'runtime_recovery_authority'; export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY_VERSION = 1; export const RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY = 'runtime_continuation_authority'; @@ -622,6 +625,20 @@ const MIGRATIONS: ReadonlyMap = new Map([ CREATE INDEX IF NOT EXISTS runtime_events_terminal ON runtime_events(invocation_id, event_seq) WHERE ${TERMINAL_RUNTIME_EVENT_SQL}; `, ], + [ + 19, + ` + CREATE TABLE IF NOT EXISTS runtime_session_turn_extents ( + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + first_ordinal INTEGER NOT NULL CHECK (first_ordinal > 0), + last_ordinal INTEGER NOT NULL CHECK (last_ordinal >= first_ordinal), + PRIMARY KEY (session_id, turn_id) + ) WITHOUT ROWID; + CREATE INDEX IF NOT EXISTS runtime_session_turn_extents_by_first + ON runtime_session_turn_extents(session_id, first_ordinal, last_ordinal); + `, + ], ]); /** @@ -638,6 +655,7 @@ const DATA_MIGRATIONS: ReadonlyMap void> = new Map projectContinuationClaimOpenings(db); }, ], + [19, (db) => rebuildTranscriptTurnExtents(db)], ]); /** diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 3f858eda91..2b77324e0b 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -166,11 +166,14 @@ import { import { immutableSteeringMessageId, isRuntimeStorageSafeId } from './runtime-event-invariants.js'; import { assertNoReservedWorkspaceAuthorityAppend } from './runtime-event-authority.js'; import { + rebuildTranscriptTurnExtents, + recordTranscriptTurnExtent, RuntimeTranscriptQuery, TERMINAL_RUNTIME_EVENT_SQL, - type RuntimeTranscriptLandmark, type RuntimeTranscriptRun, type RuntimeTranscriptRunRequest, + type RuntimeTranscriptTurn, + type RuntimeTranscriptTurnsRequest, } from './runtime-transcript-query.js'; export { SQLITE_RUNTIME_SCHEMA_VERSION } from './sqlite-runtime-schema.js'; @@ -576,16 +579,17 @@ export class SqliteRuntimeStore return this.readTransaction(() => this.transcriptQuery().run(sessionId, request, project)); } - async readTranscriptLandmarks( + async readTranscriptTurns( sessionId: string, - throughOrdinal: number, - limit: number, - ): Promise { + request: RuntimeTranscriptTurnsRequest, + ): Promise { assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); - assertInvocationSearchLimit(limit); - return this.readTransaction(() => - this.transcriptQuery().landmarks(sessionId, throughOrdinal, limit), - ); + return this.readTransaction(() => this.transcriptQuery().turns(sessionId, request)); + } + + async readTranscriptTurnCrossing(sessionId: string, ordinal: number): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + return this.readTransaction(() => this.transcriptQuery().crossing(sessionId, ordinal)); } /** @@ -1682,6 +1686,7 @@ export class SqliteRuntimeStore WHERE session_id = :sessionId `) .run({ sessionId }); + rebuildTranscriptTurnExtents(this.db, sessionId); }); } @@ -4105,6 +4110,11 @@ export class SqliteRuntimeStore VALUES (?, ?, ?) `) .run(canonicalEvent.sessionId, ordinal, canonicalEvent.id); + recordTranscriptTurnExtent( + this.db, + { ...canonicalEvent, kind: runtimeEventKind(canonicalEvent) }, + ordinal, + ); this.noteEventCommit(canonicalEvent.sessionId); this.deleteCompletedPartialSnapshot(canonicalEvent); return next; diff --git a/packages/storage/src/test-only/memory-execution-runtime.ts b/packages/storage/src/test-only/memory-execution-runtime.ts index 21a39950f3..f967b019fe 100644 --- a/packages/storage/src/test-only/memory-execution-runtime.ts +++ b/packages/storage/src/test-only/memory-execution-runtime.ts @@ -68,6 +68,7 @@ import { immutableSteeringMessageId } from '../runtime-event-invariants.js'; import { RuntimeTranscriptOversizedTurnError, type RuntimeTranscriptRun, + type RuntimeTranscriptTurn, } from '../runtime-transcript-query.js'; import { partialRuntimeStream, @@ -962,32 +963,48 @@ export function createMemoryRuntimeStore(a: MemoryExecutionAuthority): Execution } return project(header, read()); }, - readTranscriptLandmarks: async (sessionId, throughOrdinal, n) => + readTranscriptTurns: async (sessionId, request) => a.read((s) => { - if (!Number.isSafeInteger(throughOrdinal) || throughOrdinal < 0 || !Number.isSafeInteger(n)) - throw new RangeError('Invalid landmark bounds'); - if (n < 1) return []; - const all = transcript(s, sessionId, throughOrdinal); - if (all.length === 0) return []; - const positions = new Set( - Array.from({ length: n }, (_, i) => - n === 1 ? all.length - 1 : Math.floor((i * (all.length - 1)) / (n - 1)), - ), - ); - return [...positions] - .map((i) => all[i]!) - .map((i) => ({ - invocation: i.invocation, - firstOrdinal: i.firstOrdinal, - ...(i.events.find((e) => e.event.role === 'user' && e.event.content?.kind === 'text') - ? { - prompt: i.events.find( - (e) => e.event.role === 'user' && e.event.content?.kind === 'text', - ), - } - : {}), - })); + const turns = transcriptTurns(s, sessionId); + return 'turnId' in request + ? turns.filter((turn) => turn.turnId === request.turnId) + : sampled( + turns.filter((turn) => turn.firstOrdinal <= request.throughOrdinal), + request.limit, + ); }), + readTranscriptTurnCrossing: async (sessionId, ordinal) => + a.read((s) => + transcriptTurns(s, sessionId).some( + (turn) => turn.firstOrdinal < ordinal && turn.lastOrdinal >= ordinal, + ), + ), }; return store; } + +function transcriptTurns(s: MemoryState, sessionId: string): RuntimeTranscriptTurn[] { + const turns = new Map(); + for (const { invocation, events } of transcript(s, sessionId)) { + const turnId = invocation.turnId; + const known = turns.get(turnId); + const prompt = events.find((e) => e.event.content?.kind === 'text' && e.event.role === 'user'); + turns.set(turnId, { + turnId, + firstOrdinal: Math.min(known?.firstOrdinal ?? Infinity, ...events.map((e) => e.ordinal)), + lastOrdinal: Math.max(known?.lastOrdinal ?? 0, ...events.map((e) => e.ordinal)), + ...((known?.prompt ?? prompt) ? { prompt: known?.prompt ?? prompt } : {}), + }); + } + return [...turns.values()].sort((x, y) => x.firstOrdinal - y.firstOrdinal); +} + +function sampled(items: readonly T[], limit: number): T[] { + if (limit < 1 || items.length === 0) return []; + if (limit === 1) return [items.at(-1)!]; + const picked = new Set(); + for (let n = 0; n < limit; n += 1) { + picked.add(items[Math.floor((n * (items.length - 1)) / (limit - 1))]!); + } + return [...picked]; +} diff --git a/packages/ui/package.json b/packages/ui/package.json index d31ad6d9ed..d8a163f5bb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -33,7 +33,8 @@ "lucide-react": "^1.38.0", "mermaid": "^11.17.2", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "virtua": "0.48.8" }, "devDependencies": { "@types/react": "^19.2.18", diff --git a/packages/ui/src/__tests__/chat-turn-steering-order.test.ts b/packages/ui/src/__tests__/chat-turn-steering-order.test.ts index e97578743f..f4a2f6e25c 100644 --- a/packages/ui/src/__tests__/chat-turn-steering-order.test.ts +++ b/packages/ui/src/__tests__/chat-turn-steering-order.test.ts @@ -28,6 +28,7 @@ import { materializeTurns, type TurnViewModel } from '../materialize.js'; import { createTranscriptProjection } from '../transcript-projection.js'; import { ChatView } from '../chat-view.js'; import { Composer } from '../composer.js'; +import { renderTranscriptMarkup } from './transcript-test-dom.js'; import { ChatSurfaceLayout } from '../chat-surface-layout.js'; import { armLiveTurn } from '../live-turn-projection.js'; import { applyLiveTurnEvent } from './live-turn-zh.js'; @@ -94,13 +95,13 @@ test('renders steering where it arrived in the assistant timeline', () => { } }); -test('holds steering above the composer while old output continues, then renders its real reply boundary', () => { +test('holds steering above the composer while old output continues, then renders its real reply boundary', async () => { const messages: StoredMessage[] = [{ type: 'user', id: 'original', turnId: 'turn-1', ts: 1, text: 'request' }]; const pending = { id: 'steer', hostTurnId: 'turn-1', ts: 2, text: 'inserted instruction', pendingSteering: true, transientPlacement: 'current_turn' as const }; let live: import('../live-turn-projection.js').LiveTurnProjection | undefined = applyLiveTurnEvent(armLiveTurn('turn-1'), { type: 'text_delta', id: 'first', turnId: 'turn-1', messageId: 'before', ts: 2, text: 'old answer continues', }); - const render = (transientMessages = [pending], durable = messages) => parseHTML(`${renderToStaticMarkup( + const render = async (transientMessages = [pending], durable = messages) => parseHTML(`${await renderTranscriptMarkup( createElement(LocaleProvider, { locale: 'en', children: createElement(ChatSurfaceLayout, { composer: createElement(Composer, { streaming: true, pendingMessages: transientMessages, onSend: () => undefined, onStop: () => undefined }), children: createElement(ChatView, { @@ -110,7 +111,7 @@ test('holds steering above the composer while old output continues, then renders }), }) }), )}`).document; - const waiting = render(); + const waiting = await render(); assert.equal(waiting.querySelector('.maka-composer-queue-text')?.textContent, pending.text); assert.equal(waiting.querySelectorAll('.maka-steering-message').length, 0); const timeline = () => createTranscriptProjection().project({ messages, liveTurns: live ? [live] : undefined, locale: 'en' })[0]!.timeline.map((item) => item.kind === 'user' ? item.message.text : item.kind === 'text' ? item.text : item.kind); @@ -118,7 +119,7 @@ test('holds steering above the composer while old output continues, then renders live = applyLiveTurnEvent(live, { type: 'text_complete', id: 'finished', turnId: 'turn-1', messageId: 'before', ts: 3, text: 'old answer continues' }); live = applyLiveTurnEvent(live, { type: 'steering_message', id: 'accepted', turnId: 'turn-1', messageId: pending.id, ts: 4, content: { text: pending.text } }); live = applyLiveTurnEvent(live, { type: 'text_delta', id: 'reply', turnId: 'turn-1', messageId: 'after', ts: 5, text: 'reply to new instruction' }); - const accepted = render([]); + const accepted = await render([]); assert.equal(accepted.querySelector('.maka-composer-queue'), null); const text = accepted.body.textContent ?? ''; assert.deepEqual(timeline(), ['old answer continues', pending.text, 'reply to new instruction']); diff --git a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx index ccb4c9a5a8..34d1464c70 100644 --- a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx +++ b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx @@ -28,6 +28,7 @@ import { ChatSurfaceLayout } from '../chat-surface-layout.js'; import { ChatView } from '../chat-view.js'; import type { LiveTurnProjection } from '../live-turn-projection.js'; import { LocaleProvider } from '../locale-context.js'; +import { renderTranscriptMarkup } from './transcript-test-dom.js'; const activeSession = { id: 'session-1', @@ -36,8 +37,8 @@ const activeSession = { labels: [] as string[], } as unknown as SessionSummary; -function renderChat(liveTurn?: LiveTurnProjection, overrides: Partial> = {}): string { - return renderToStaticMarkup( +function renderChat(liveTurn?: LiveTurnProjection, overrides: Partial> = {}): Promise { + return renderTranscriptMarkup( { - const markup = renderChat({ +test('renders the live compaction row in a session with no settled messages', async () => { + const markup = await renderChat({ turnId: 'turn-compact', rootExecutionKind: 'context_compact', startedAt: 0, @@ -66,34 +67,34 @@ test('renders the live compaction row in a session with no settled messages', () assert.match(markup, /Compacting context/); }); -test('retains tool and compaction evidence without activity after observation loss', () => { +test('retains tool and compaction evidence without activity after observation loss', async () => { const messages = [{ type: 'user' as const, id: 'user', turnId: 'prior', text: 'Earlier request', ts: 1 }]; const tool: LiveTurnProjection = { turnId: 'tool-turn', steps: [{ stepId: 'step', tools: [ { toolUseId: 'bash', toolName: 'Bash', args: { command: 'echo retained' }, status: 'running' }, ] }] }; const compact: LiveTurnProjection = { turnId: 'compact', rootExecutionKind: 'context_compact', steps: [] }; for (const observed of [true, false, true]) { - const toolDocument = parseHTML(renderChat(tool, { messages, activeTurn: observed ? { turnId: tool.turnId } : undefined })).document; + const toolDocument = parseHTML(await renderChat(tool, { messages, activeTurn: observed ? { turnId: tool.turnId } : undefined })).document; assert.equal(toolDocument.querySelector('.maka-tool-activity-card')?.getAttribute('data-activity-observed'), String(observed)); assert.match(toolDocument.querySelector('.maka-tool-activity-card')?.textContent ?? '', /echo retained/); - const compactDocument = parseHTML(renderChat(compact, { messages, activeTurn: observed ? { turnId: compact.turnId, compacting: true } : undefined })).document; + const compactDocument = parseHTML(await renderChat(compact, { messages, activeTurn: observed ? { turnId: compact.turnId, compacting: true } : undefined })).document; assert.equal(compactDocument.querySelector('[data-compaction-state]')?.getAttribute('data-compaction-state'), observed ? 'running' : 'unavailable'); assert.equal(compactDocument.querySelectorAll('.maka-compaction-status .astryx-spinner').length, observed ? 1 : 0); } assert.equal(tool.steps[0]?.tools[0]?.status, 'running', 'availability never rewrites retained execution evidence'); }); -test('shows one waiting indicator before a named live Turn reaches the transcript', () => { +test('shows one waiting indicator before a named live Turn reaches the transcript', async () => { const liveTurn: LiveTurnProjection = { turnId: 'pending-turn', steps: [], unconfirmed: true }; const pending = { id: 'pending-user', hostTurnId: liveTurn.turnId, text: 'Please help', ts: 1000, transientPlacement: 'current_turn' as const }; for (const messages of [[], [{ type: 'user' as const, id: 'old-user', turnId: 'old-turn', text: 'Earlier request', ts: 1 }]]) { - const markup = renderChat(liveTurn, { messages, transientMessages: [pending], activeTurn: { turnId: liveTurn.turnId! } }); + const markup = await renderChat(liveTurn, { messages, transientMessages: [pending], activeTurn: { turnId: liveTurn.turnId! } }); assert.equal((markup.match(/class="maka-turn-processing"/g) ?? []).length, 1); assert.match(markup, /Pondering/); assert.match(markup, /Please help/); assert.doesNotMatch(markup, /data-transcript-turn-id="pending-turn"/); } - const committed = renderChat(liveTurn, { + const committed = await renderChat(liveTurn, { messages: [{ type: 'user', id: 'durable-user', turnId: liveTurn.turnId, text: pending.text, ts: pending.ts }], activeTurn: { turnId: liveTurn.turnId! }, }); @@ -101,7 +102,7 @@ test('shows one waiting indicator before a named live Turn reaches the transcrip assert.match(committed, /data-transcript-turn-id="pending-turn"/); }); -test('a new Host Turn owns its waiting footer while the previous answer remains buffered', () => { +test('a new Host Turn owns its waiting footer while the previous answer remains buffered', async () => { const oldTurn: LiveTurnProjection = { turnId: 'old-turn', terminal: true, steps: [{ stepId: 'old-answer', text: { text: 'Previous answer', complete: true, truncated: false }, tools: [] }], @@ -109,19 +110,19 @@ test('a new Host Turn owns its waiting footer while the previous answer remains const messages = [{ type: 'user' as const, id: 'old-user', turnId: 'old-turn', text: 'Earlier request', ts: 1 }]; const transientMessages = [{ id: 'new-user', hostTurnId: 'new-turn', text: 'New request', ts: 2, transientPlacement: 'current_turn' as const }]; for (const content of [oldTurn, undefined]) { - const markup = renderChat(content, { messages, transientMessages, activeTurn: { turnId: 'new-turn' } }); + const markup = await renderChat(content, { messages, transientMessages, activeTurn: { turnId: 'new-turn' } }); const { document } = parseHTML(markup); assert.equal(document.querySelectorAll('.maka-turn-processing').length, 1); assert.equal(document.querySelector('[data-transcript-turn-id="old-turn"] .maka-turn-processing'), null); assert.ok(markup.indexOf('New request') < markup.indexOf('maka-turn-processing')); if (content) assert.match(markup, /Previous answer/); } - const idle = renderChat({ ...oldTurn, terminal: undefined }, { messages }); + const idle = await renderChat({ ...oldTurn, terminal: undefined }, { messages }); assert.doesNotMatch(idle, /maka-turn-processing/); }); -test('renders the empty hero when an empty session has no live compaction row', () => { - const markup = renderChat(undefined); +test('renders the empty hero when an empty session has no live compaction row', async () => { + const markup = await renderChat(undefined); assert.doesNotMatch(markup, /Compacting context/); }); @@ -191,9 +192,9 @@ test('ChatSurfaceLayout preserves the public emptyState for absent children', () }); -test('turn identity stays on its exact durable anchor and is absent from ordinary transcripts', () => { +test('turn identity stays on its exact durable anchor and is absent from ordinary transcripts', async () => { const messages = ['one', 'two'].map((turnId) => ({ type: 'user' as const, id: `user-${turnId}`, turnId, text: turnId, ts: 1 })); - const { document } = parseHTML(renderChat(undefined, { + const { document } = parseHTML(await renderChat(undefined, { messages, turnDecorations: new Map([['one', { header: Workspace / Work, accentColor: 'red', promptStatus: Running work }]]), })); @@ -204,12 +205,12 @@ test('turn identity stays on its exact durable anchor and is absent from ordinar assert.equal(document.querySelector('[data-transcript-turn-id="two"] [data-test-status]'), null); assert.match(one.querySelector('.maka-message-meta')!.textContent!, /Running work/); assert.equal(document.querySelector('[data-transcript-turn-id="two"]')!.getAttribute('data-turn-accent'), null); - assert.doesNotMatch(renderChat(undefined, { messages }), /data-turn-accent|Workspace \/ Work/); + assert.doesNotMatch(await renderChat(undefined, { messages }), /data-turn-accent|Workspace \/ Work/); }); -test('an initial optimistic prompt uses the same status projection as a durable prompt', () => { - const { document } = parseHTML(renderChat(undefined, { +test('an initial optimistic prompt uses the same status projection as a durable prompt', async () => { + const { document } = parseHTML(await renderChat(undefined, { messages: [], transientMessages: [{ id: 'pending', hostTurnId: 'choosing', text: 'Choose work', ts: 1, transientPlacement: 'current_turn' }], turnDecorations: new Map([['choosing', { header: <>, promptStatus: Waiting for user }]]), })); diff --git a/packages/ui/src/__tests__/chat-view-load-earlier.test.tsx b/packages/ui/src/__tests__/chat-view-load-earlier.test.tsx new file mode 100644 index 0000000000..f55b86ad87 --- /dev/null +++ b/packages/ui/src/__tests__/chat-view-load-earlier.test.tsx @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Earlier history arrives only when the reader asks for it, and whole Turns + * prepended above them neither move them off the Turn they are reading nor + * change whether the transcript follows its tail. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { Fragment, act, createElement, type ComponentProps, type ReactElement } from 'react'; +import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import { ChatSurfaceLayout } from '../chat-surface-layout.js'; +import { ChatView } from '../chat-view.js'; +import { LocaleProvider } from '../locale-context.js'; +import { + useTranscriptScrollAuthority, + type TranscriptScrollAuthority, +} from '../transcript-scroll-authority.js'; +import { installTranscriptDom, type TranscriptDom } from './transcript-test-dom.js'; + +const TURN_HEIGHT = 400; +const SCROLLPORT_HEIGHT = 600; + +let dom: TranscriptDom | undefined; + +afterEach(async () => { + await dom?.cleanup(); + dom = undefined; +}); + +const activeSession = { + id: 'session-earlier', + name: 'Earlier', + status: 'active', + labels: [] as string[], +} as unknown as SessionSummary; + +function turnMessages(from: number, to: number): StoredMessage[] { + return Array.from({ length: to - from }, (_, offset): StoredMessage => ({ + type: 'user', + id: `user-${from + offset}`, + turnId: `turn-${from + offset}`, + ts: from + offset, + text: `Prompt ${from + offset}`, + })); +} + +function harness() { + dom = installTranscriptDom({ viewportHeight: SCROLLPORT_HEIGHT, boxHeight: TURN_HEIGHT }); + let authority: TranscriptScrollAuthority | undefined; + let turnCount = 0; + const anchors: Array = []; + const Probe = (): ReactElement => { + authority = useTranscriptScrollAuthority(); + return createElement(Fragment); + }; + const current = dom; + return { + anchors, + get authority(): TranscriptScrollAuthority { + assert.ok(authority); + return authority; + }, + async render(props: Partial> & { messages: StoredMessage[] }) { + turnCount = new Set(props.messages.map((message) => message.turnId)).size; + await current.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(ChatSurfaceLayout, { + composer: null, + children: createElement(Fragment, null, createElement(ChatView, { + activeSession, + onNew: () => {}, + scrollBehavior: 'auto', + onReadingAnchorChange: (turnId?: string) => { anchors.push(turnId); }, + ...props, + }), createElement(Probe)), + }), + })); + const scroller = current.container.querySelector('[data-chat-scroll-container]'); + assert.ok(scroller); + if (!Object.hasOwn(scroller, 'scrollHeight')) { + Object.defineProperties(scroller, { + scrollHeight: { get: () => turnCount * TURN_HEIGHT }, + clientHeight: { get: () => SCROLLPORT_HEIGHT }, + }); + } + return scroller; + }, + loadButton(): HTMLButtonElement | null { + return [...current.container.querySelectorAll('button')] + .find((button) => button.textContent === 'Load earlier history') ?? null; + }, + readerScrollTo(scroller: HTMLElement, top: number): void { + const wheel = new current.window.Event('wheel', { bubbles: true }); + Object.defineProperty(wheel, 'deltaY', { value: top < scroller.scrollTop ? -120 : 120 }); + scroller.dispatchEvent(wheel); + scroller.scrollTop = top; + scroller.dispatchEvent(new current.window.Event('scroll')); + }, + click(element: Element): void { + element.dispatchEvent(new current.window.Event('click', { bubbles: true })); + }, + }; +} + +test('the load-earlier button exists only while earlier history does, and waits for its load', async () => { + const view = harness(); + await view.render({ messages: turnMessages(4, 8), onLoadEarlierHistory: () => {} }); + assert.equal(view.loadButton(), null, 'no button without earlier history'); + + let loads = 0; + let finish!: () => void; + const onLoadEarlierHistory = () => { + loads += 1; + return new Promise((resolve) => { finish = resolve; }); + }; + await view.render({ messages: turnMessages(4, 8), hasEarlierHistory: true, onLoadEarlierHistory }); + const button = view.loadButton(); + assert.ok(button); + await act(async () => { view.click(button); }); + assert.equal(loads, 1); + assert.equal(view.loadButton()?.disabled, true, 'a pending load cannot be requested again'); + + await act(async () => { finish(); }); + assert.equal(view.loadButton()?.disabled, false); +}); + +/** + * A visible transcript with nothing in it is exactly when the reader most needs + * the control: WorkHub filters the list to one Work, and a Work whose Turns are + * all in unloaded history filters it down to nothing. The control is the only + * way to load those Turns, so it cannot be inside the non-empty branch. + */ +test('offers to load earlier history even with nothing to show', async () => { + const view = harness(); + await view.render({ messages: [], hasEarlierHistory: true, onLoadEarlierHistory: () => {} }); + assert.ok(view.loadButton(), 'an empty message list hid the only way to load the rest'); +}); + +test('prepended Turns keep a released reader on their Turn without re-pinning', async () => { + const view = harness(); + const scroller = await view.render({ messages: turnMessages(4, 8), hasEarlierHistory: true, onLoadEarlierHistory: () => {} }); + scroller.scrollTop = 4 * TURN_HEIGHT - SCROLLPORT_HEIGHT; + await act(() => { view.readerScrollTo(scroller, TURN_HEIGHT + 100); }); + assert.equal(view.authority.getSnapshot().pinned, false); + assert.equal(view.anchors.at(-1), 'turn-5'); + + // The reader asks for history, which is the only way it arrives. + const button = view.loadButton(); + assert.ok(button); + await act(async () => { view.click(button); }); + await view.render({ messages: turnMessages(2, 8), hasEarlierHistory: false, onLoadEarlierHistory: () => {} }); + await act(() => { scroller.dispatchEvent(new dom!.window.Event('scroll')); }); + assert.equal(view.loadButton(), null); + assert.equal(view.authority.getSnapshot().pinned, false, 'arriving history is not reader input'); + // Where the prepend leaves the reader in pixels is a browser story + // (`PrependedHistoryKeepsMeasuredHeights`); this fake DOM has no measurement + // cache to get wrong. + assert.equal(view.anchors.at(-1), 'turn-5'); +}); + +test('a selection from outside every Turn keeps every Turn it spans mounted', async () => { + const view = harness(); + const scroller = await view.render({ messages: turnMessages(0, 20), hasEarlierHistory: true, onLoadEarlierHistory: () => {} }); + const mounted = () => [...dom!.container.querySelectorAll('[data-transcript-turn-id]')] + .map((row) => row.getAttribute('data-transcript-turn-id')); + const rows = mounted(); + assert.ok(rows.length < 20, 'the fixture mounts only some of the Turns'); + + const button = view.loadButton(); + const last = dom!.container.querySelector(`[data-transcript-turn-id="${rows.at(-1)}"]`); + assert.ok(button && last); + const selection = { isCollapsed: false, anchorNode: button, focusNode: last }; + dom!.document.getSelection = () => selection as unknown as Selection; + await act(async () => { dom!.document.dispatchEvent(new dom!.window.Event('selectionchange')); }); + await act(() => { view.readerScrollTo(scroller, 16 * TURN_HEIGHT); }); + assert.ok(mounted().includes('turn-19'), 'the reader reached the tail'); + assert.ok(mounted().includes('turn-0'), 'the selection starts above the first Turn, so it keeps that Turn mounted'); +}); + +test('prepended Turns do not release a pinned transcript', async () => { + const view = harness(); + await view.render({ messages: turnMessages(4, 8), hasEarlierHistory: true, onLoadEarlierHistory: () => {} }); + assert.equal(view.authority.getSnapshot().pinned, true); + const scroller = await view.render({ messages: turnMessages(0, 8) }); + await act(() => { scroller.dispatchEvent(new dom!.window.Event('scroll')); }); + assert.equal(view.authority.getSnapshot().pinned, true); + assert.equal(view.anchors.at(-1), undefined); +}); diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index a299f66891..ca8c9d3140 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -25,6 +25,23 @@ import { selectPromptRailTick, } from '../prompt-anchor-rail.js'; +test('indexed Turns outside the loaded range precede it, and loaded Turns keep their own entries', () => { + const loaded = [ + { turnId: 'turn-3', label: 'Prompt 3', reply: 'Answer 3' }, + { turnId: 'turn-4', label: 'Prompt 4', reply: 'Answer 4' }, + ]; + const index = [ + { turnId: 'turn-1', sequence: 8, label: 'Prompt 1' }, + { turnId: 'turn-3', sequence: 40, label: 'Prompt 3' }, + ]; + assert.deepEqual(mergePromptAnchorRailTurns(loaded, index, new Set(['turn-2', 'turn-3', 'turn-4'])), [ + { turnId: 'turn-1', sequence: 8, label: 'Prompt 1' }, + ...loaded, + ]); + assert.equal(mergePromptAnchorRailTurns(loaded, undefined, new Set()), loaded); + assert.equal(mergePromptAnchorRailTurns(loaded, index.slice(1), new Set(['turn-3'])), loaded); +}); + const orderedTurnIds = Array.from({ length: 120 }, (_, index) => `turn-${index + 1}`); const sampledRailTurnIds = Array.from({ length: 64 }, (_, railIndex) => orderedTurnIds[Math.round(railIndex * 119 / 63)]!, @@ -116,73 +133,6 @@ test('keeps the active tick visible when the rail viewport resizes', () => { assert.equal(disconnected, true); }); -test('merges complete-index landmarks with the resident transcript range', () => { - const turns = mergePromptAnchorRailTurns( - [ - { turnId: 'turn-1', label: 'Prompt 1', reply: 'Answer 1' }, - { turnId: 'turn-3', label: 'Prompt 3', reply: 'Answer 3' }, - ], - [ - { turnId: 'turn-1', sequence: 0, label: 'Prompt 1' }, - { turnId: 'turn-2', sequence: 2, label: 'Prompt 2' }, - { turnId: 'turn-3', sequence: 4, label: 'Prompt 3' }, - ], - ); - - assert.deepEqual(turns, [ - { - turnId: 'turn-1', - label: 'Prompt 1', - reply: 'Answer 1', - sequence: 0, - }, - { - turnId: 'turn-2', - label: 'Prompt 2', - reply: '', - sequence: 2, - }, - { - turnId: 'turn-3', - label: 'Prompt 3', - reply: 'Answer 3', - sequence: 4, - }, - ]); -}); - -test('preserves every projected turn without a durable landmark index', () => { - assert.deepEqual( - mergePromptAnchorRailTurns([ - { turnId: 'overlay-turn', label: 'Streaming prompt', reply: '' }, - ]), - [{ - turnId: 'overlay-turn', - label: 'Streaming prompt', - reply: '', - }], - ); -}); - -test('updates landmark content when its body enters a later resident range', () => { - const index = [ - { turnId: 'turn-1', sequence: 0, label: 'Prompt 1' }, - { turnId: 'turn-2', sequence: 2, label: 'Prompt 2' }, - ]; - const historical = mergePromptAnchorRailTurns( - [{ turnId: 'turn-1', label: 'Prompt 1', reply: 'Answer 1' }], - index, - ); - const intermediate = mergePromptAnchorRailTurns( - [{ turnId: 'turn-2', label: 'Prompt 2', reply: 'Answer 2' }], - index, - ); - - assert.deepEqual(historical.map((turn) => turn.reply), ['Answer 1', '']); - assert.deepEqual(intermediate.map((turn) => turn.reply), ['', 'Answer 2']); -}); - - function box(top: number, bottom: number): DOMRect { return { top, bottom } as DOMRect; } diff --git a/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx b/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx index d3478878f4..8bde080285 100644 --- a/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx +++ b/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx @@ -19,57 +19,34 @@ /** * The rail's current tick is the reading position the scroll authority - * publishes — the newest Turn while pinned to the tail, otherwise the Turn - * crossing the top of the scrollport — and nothing else. Mounted through the - * real layout, because that is what hands the authority the scroller the - * reader scrolls. + * publishes — the newest Turn while pinned to the tail, otherwise the Turn the + * virtualizer places under the top of the scrollport — and a tick navigates by + * that same index. Mounted through the real layout, because that is what hands + * the authority and the virtualizer the scroller the reader scrolls. */ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement, type ReactElement } from 'react'; -import { createRoot } from 'react-dom/client'; -import { parseHTML } from 'linkedom'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; -import { AstryxLocaleProvider } from '../astryx-i18n.js'; import { ChatSurfaceLayout } from '../chat-surface-layout.js'; import { ChatView } from '../chat-view.js'; import { LocaleProvider } from '../locale-context.js'; import { PromptAnchorRail, type PromptAnchorRailTurn } from '../prompt-anchor-rail.js'; import { TranscriptScrollAuthorityProvider } from '../transcript-scroll-authority.js'; +import { installTranscriptDom, type TranscriptDom } from './transcript-test-dom.js'; -const originalGlobals = { - CSS: globalThis.CSS, - document: globalThis.document, - Element: globalThis.Element, - HTMLElement: globalThis.HTMLElement, - MutationObserver: globalThis.MutationObserver, - Node: globalThis.Node, - ResizeObserver: globalThis.ResizeObserver, - matchMedia: globalThis.matchMedia, - requestAnimationFrame: globalThis.requestAnimationFrame, - cancelAnimationFrame: globalThis.cancelAnimationFrame, - window: globalThis.window, -}; -const originalActEnvironment = (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; -}).IS_REACT_ACT_ENVIRONMENT; +const TURN_COUNT = 6; +const TURN_HEIGHT = 400; +const SCROLLPORT_HEIGHT = 600; -let mountedRoot: ReturnType | undefined; +let dom: TranscriptDom | undefined; afterEach(async () => { - if (mountedRoot) await act(() => mountedRoot?.unmount()); - mountedRoot = undefined; - Object.assign(globalThis, { - ...originalGlobals, - IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, - }); + await dom?.cleanup(); + dom = undefined; }); -const TURN_COUNT = 6; -const TURN_HEIGHT = 400; -const SCROLLPORT_HEIGHT = 600; - const activeSession: SessionSummary = { id: 'session-rail', name: '提问导航', @@ -107,77 +84,23 @@ function turnMessages(): StoredMessage[] { ]).flat(); } -function view(messages: StoredMessage[]): ReactElement { - const chat = createElement(ChatView, { messages, activeSession, onNew: () => {} } as never); - const layout = createElement(ChatSurfaceLayout, { - composer: null, - children: chat, - }); - const astryx = createElement(AstryxLocaleProvider, { children: layout }); - return createElement(LocaleProvider, { locale: 'zh-CN', children: astryx }); +function view(messages: StoredMessage[], extra: Partial[0]> = {}): ReactElement { + const chat = createElement(ChatView, { messages, activeSession, onNew: () => {}, scrollBehavior: 'auto', ...extra }); + const layout = createElement(ChatSurfaceLayout, { composer: null, children: chat }); + return createElement(LocaleProvider, { locale: 'zh-CN', children: layout }); } -/** linkedom lays nothing out, so every box this reads is stated here. */ -function harness() { - const { document, window } = parseHTML('
'); - const viewport = { scrollTop: 0 }; - const scrollport = { - bottom: SCROLLPORT_HEIGHT, height: SCROLLPORT_HEIGHT, left: 0, right: 800, top: 0, - width: 800, x: 0, y: 0, toJSON: () => ({}), - } satisfies DOMRect; - window.Element.prototype.getBoundingClientRect = function (this: Element): DOMRect { - const turnId = this.getAttribute('data-turn-id'); - if (turnId === null) return scrollport; - const top = Number(turnId.split('-')[1]) * TURN_HEIGHT - viewport.scrollTop; - return { ...scrollport, top, bottom: top + TURN_HEIGHT, height: TURN_HEIGHT }; - }; - class InertResizeObserver { - observe(): void {} - unobserve(): void {} - disconnect(): void {} - } - Object.assign(globalThis, { - CSS: { supports: () => false, escape: (value: string) => value }, - document, - Element: window.Element, - HTMLElement: window.HTMLElement, - MutationObserver: window.MutationObserver, - Node: window.Node, - ResizeObserver: InertResizeObserver, - matchMedia: () => ({ - matches: false, - addEventListener() {}, - removeEventListener() {}, - }), - requestAnimationFrame: (callback: FrameRequestCallback) => { - callback(0); - return 0; - }, - cancelAnimationFrame: () => {}, - window, - IS_REACT_ACT_ENVIRONMENT: true, +async function mountTranscript(): Promise<{ dom: TranscriptDom; scroller: HTMLElement }> { + dom = installTranscriptDom({ viewportHeight: SCROLLPORT_HEIGHT, boxHeight: TURN_HEIGHT }); + await dom.render(view(turnMessages())); + const scroller = dom.container.querySelector('[data-chat-scroll-container]'); + assert.ok(scroller, 'the layout publishes the scroller the authority attaches to'); + Object.defineProperties(scroller, { + scrollHeight: { get: () => TURN_COUNT * TURN_HEIGHT }, + clientHeight: { get: () => SCROLLPORT_HEIGHT }, }); - const mount = document.querySelector('#mount'); - assert.ok(mount); - return { - mount, - window, - viewport, - /** Give the mounted scroller the geometry a scrolled transcript has. */ - scroller(): HTMLElement { - const element = document.querySelector('[data-chat-scroll-container]'); - assert.ok(element, 'the layout publishes the scroller the authority attaches to'); - Object.defineProperties(element, { - scrollTop: { - get: () => viewport.scrollTop, - set: (value: number) => { viewport.scrollTop = value; }, - }, - scrollHeight: { get: () => TURN_COUNT * TURN_HEIGHT }, - clientHeight: { get: () => SCROLLPORT_HEIGHT }, - }); - return element; - }, - }; + scroller.scrollTop = TURN_COUNT * TURN_HEIGHT - SCROLLPORT_HEIGHT; + return { dom, scroller }; } function activeTickTurnId(mount: HTMLElement): string | null { @@ -185,73 +108,85 @@ function activeTickTurnId(mount: HTMLElement): string | null { ?.getAttribute('data-prompt-turn-id') ?? null; } -test('the current tick follows the reading position the authority publishes', async () => { - const probe = harness(); - const root = createRoot(probe.mount); - mountedRoot = root; - await act(() => { - root.render(view(turnMessages())); - }); - const scroller = probe.scroller(); - - // Pinned to the tail, the reader is on the newest Turn. - probe.viewport.scrollTop = TURN_COUNT * TURN_HEIGHT - SCROLLPORT_HEIGHT; - assert.equal(activeTickTurnId(probe.mount), 'turn-5'); +test('the current tick follows the reading position the virtualizer maps', async () => { + const { dom, scroller } = await mountTranscript(); + assert.equal(activeTickTurnId(dom.container), 'turn-5', 'pinned to the tail, the reader is on the newest Turn'); - // The reader takes the transcript to the third Turn's box. A wheel first: - // a scroll the reader did not cause leaves the pin, and the newest Turn, alone. await act(() => { - const wheel = new probe.window.Event('wheel', { bubbles: true }); + const wheel = new dom.window.Event('wheel', { bubbles: true }); Object.defineProperty(wheel, 'deltaY', { value: -120 }); scroller.dispatchEvent(wheel); - probe.viewport.scrollTop = TURN_HEIGHT * 2 + 100; - scroller.dispatchEvent(new probe.window.Event('scroll')); + scroller.scrollTop = TURN_HEIGHT * 2 + 100; + scroller.dispatchEvent(new dom.window.Event('scroll')); }); - assert.equal(activeTickTurnId(probe.mount), 'turn-2'); + assert.equal(activeTickTurnId(dom.container), 'turn-2'); await act(() => { - probe.viewport.scrollTop = TURN_HEIGHT * 4; - scroller.dispatchEvent(new probe.window.Event('scroll')); + scroller.scrollTop = TURN_HEIGHT * 4; + scroller.dispatchEvent(new dom.window.Event('scroll')); }); - assert.equal(activeTickTurnId(probe.mount), 'turn-4'); + assert.equal(activeTickTurnId(dom.container), 'turn-4'); +}); + +test('a tick releases the pin and scrolls its Turn to the top by index', async () => { + const { dom, scroller } = await mountTranscript(); + const tick = dom.container.querySelector('[data-prompt-turn-id="turn-3"]'); + assert.ok(tick); + await act(async () => { tick.dispatchEvent(new dom.window.Event('click', { bubbles: true })); }); + assert.equal(scroller.scrollTop, TURN_HEIGHT * 3); + await act(() => { scroller.dispatchEvent(new dom.window.Event('scroll')); }); + assert.equal(activeTickTurnId(dom.container), 'turn-3'); }); -test('portals unloaded landmarks into the layout host and keeps them actionable', async () => { - const { mount } = harness(); - const root = createRoot(mount); - mountedRoot = root; +test('a tick for an indexed Turn outside the loaded range asks for history down to it', async () => { + const { dom } = await mountTranscript(); + const loaded = turnMessages().filter((message) => message.turnId !== 'turn-0' && message.turnId !== 'turn-1'); + const requests: { turnId: string; sequence: number }[] = []; + const extra = { + transcriptTurnIndex: [{ turnId: 'turn-0', sequence: 8, label: '第 0 个问题' }], + onLoadTranscriptTurn: (turn: { turnId: string; sequence: number }) => { requests.push(turn); }, + }; + await dom.render(view(loaded, extra)); + const tick = dom.container.querySelector('[data-prompt-turn-id="turn-0"]'); + assert.ok(tick, 'the indexed Turn has a tick before it is loaded'); + await act(async () => { tick.dispatchEvent(new dom.window.Event('click', { bubbles: true })); }); + assert.deepEqual(requests, [{ turnId: 'turn-0', sequence: 8 }]); + // Landing it at the top after the prepend is measured in the browser + // (`partial-history-notice` E2E); this fake DOM runs no frames. +}); + +test('portals landmarks into the layout host and keeps them actionable', async () => { + dom = installTranscriptDom(); const rail = createElement(PromptAnchorRail, { turns: [ - { turnId: 'turn-1', label: 'Prompt 1', sequence: 0 }, - { turnId: 'turn-2', label: 'Prompt 2', sequence: 2 }, - { turnId: 'turn-3', label: 'Prompt 3', sequence: 4 }, + { turnId: 'turn-1', label: 'Prompt 1' }, + { turnId: 'turn-2', label: 'Prompt 2' }, + { turnId: 'turn-3', label: 'Prompt 3' }, ], scrollRef: { current: null }, + onNavigateTurn: () => {}, }); // The rail reads its tick from the scroll authority, so the host-less render // still needs one — otherwise this would assert the absence of a rail that // threw rather than one that found no host. - await act(() => root.render(createElement(LocaleProvider, { + await dom.render(createElement(LocaleProvider, { locale: 'en', children: createElement(TranscriptScrollAuthorityProvider, { children: rail }), - }))); - assert.equal(mount.querySelector('.maka-prompt-rail'), null, 'no inline rail before a host exists'); - await act(() => root.render(createElement(LocaleProvider, { + })); + assert.equal(dom.container.querySelector('.maka-prompt-rail'), null, 'no inline rail before a host exists'); + await dom.render(createElement(LocaleProvider, { locale: 'en', children: createElement(ChatSurfaceLayout, { composer: null, children: rail }), - }))); - assert.equal(mount.querySelectorAll('.maka-prompt-rail-host .maka-prompt-rail').length, 1); - assert.match(mount.innerHTML, /data-prompt-turn-id="turn-2"/); - assert.doesNotMatch(mount.innerHTML, /data-resident|Not currently loaded|aria-disabled="true"/); - assert.match(mount.innerHTML, /aria-label="Jump to prompt: Prompt 2"/); + })); + assert.equal(dom.container.querySelectorAll('.maka-prompt-rail-host .maka-prompt-rail').length, 1); + assert.match(dom.container.innerHTML, /data-prompt-turn-id="turn-2"/); + assert.match(dom.container.innerHTML, /aria-label="Jump to prompt: Prompt 2"/); }); test('a retained tick uses updated content, decoration, and navigation callbacks', async () => { - const { mount, window } = harness(); - const root = createRoot(mount); - mountedRoot = root; + dom = installTranscriptDom(); const scrollRef = { current: null }; const turns: PromptAnchorRailTurn[] = Array.from({ length: 3 }, (_, index) => ({ - turnId: `turn-${index}`, label: `Prompt ${index}`, sequence: index, + turnId: `turn-${index}`, label: `Prompt ${index}`, })); const calls: string[] = []; const render = (items: PromptAnchorRailTurn[], navigate: (turn: PromptAnchorRailTurn) => void) => @@ -259,22 +194,21 @@ test('a retained tick uses updated content, decoration, and navigation callbacks locale: 'en', children: createElement(ChatSurfaceLayout, { composer: null, children: createElement(PromptAnchorRail, { turns: items, scrollRef, onNavigateTurn: navigate, - onNavigateStart: () => calls.push('release'), }), }), }); - await act(() => root.render(render(turns, () => calls.push('old')))); - const tick = mount.querySelector('[data-prompt-turn-id="turn-1"]')!; + await dom.render(render(turns, () => calls.push('old'))); + const tick = dom.container.querySelector('[data-prompt-turn-id="turn-1"]')!; const updated = turns.map((turn, index) => index === 1 - ? { ...turn, label: 'Updated prompt', reply: 'Updated answer', sequence: 42, highlighted: true } + ? { ...turn, label: 'Updated prompt', reply: 'Updated answer', highlighted: true } : turn); - await act(() => root.render(render(updated, (turn) => { + await dom.render(render(updated, (turn) => { assert.equal(turn, updated[1]); - calls.push(`new:${turn.sequence}`); - }))); - assert.equal(mount.querySelector('[data-prompt-turn-id="turn-1"]'), tick); + calls.push(`new:${turn.turnId}`); + })); + assert.equal(dom.container.querySelector('[data-prompt-turn-id="turn-1"]'), tick); assert.equal(tick.getAttribute('aria-label'), 'Jump to prompt: Updated prompt'); assert.equal(tick.getAttribute('data-highlighted'), 'true'); - await act(() => { tick.dispatchEvent(new window.Event('click', { bubbles: true })); }); - assert.deepEqual(calls, ['release', 'new:42']); + await act(() => { tick.dispatchEvent(new dom!.window.Event('click', { bubbles: true })); }); + assert.deepEqual(calls, ['new:turn-1']); }); diff --git a/packages/ui/src/__tests__/rail-alignment-claim.test.ts b/packages/ui/src/__tests__/rail-alignment-claim.test.ts deleted file mode 100644 index 98de36ac69..0000000000 --- a/packages/ui/src/__tests__/rail-alignment-claim.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { resolveRailAlignedTarget } from '../chat-view.js'; - -test('a rail claim aims its own navigation and nothing after it', () => { - // The click, before the shell has published anything. - let claim = resolveRailAlignedTarget({ turnId: 'a' }, undefined).claim; - assert.deepEqual(claim, { turnId: 'a' }); - - // The load the click asked for. The reveal has to agree with the rail. - let resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 1 }); - assert.equal(resolved.target?.align, 'start'); - claim = resolved.claim; - - // Still the same command, re-rendered while the loaded range settles. - resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 1 }); - assert.equal(resolved.target?.align, 'start'); - claim = resolved.claim; - - // A later search for the same Turn is a different command, and wants the - // search contract back. - resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 2 }); - assert.equal(resolved.target?.align, 'center'); - assert.equal(resolved.claim, undefined); -}); - -test('a search for another Turn spends an unconsumed rail claim', () => { - const resolved = resolveRailAlignedTarget({ turnId: 'a' }, { turnId: 'b', nonce: 1 }); - assert.equal(resolved.target?.align, 'center'); - assert.equal(resolved.claim, undefined); -}); - -test('a search with no rail claim behind it is centred', () => { - const resolved = resolveRailAlignedTarget(undefined, { turnId: 'a', nonce: 1 }); - assert.equal(resolved.target?.align, 'center'); - assert.deepEqual(resolved.target, { turnId: 'a', nonce: 1, align: 'center' }); -}); diff --git a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx deleted file mode 100644 index 2a18d4600a..0000000000 --- a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx +++ /dev/null @@ -1,296 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * The return-to-latest command must pin before it loads. An unpinned scroller - * reports the Turn it is leaving as the reading anchor, and the restore effect - * would pull the arriving range straight back — the bug the 60-run E2E guard - * covers, held here as a fast unit so reverting the two-line order goes red - * without a browser. - */ - -import assert from 'node:assert/strict'; -import { afterEach, test } from 'node:test'; -import { Fragment, act, createElement, type ReactElement } from 'react'; -import { createRoot } from 'react-dom/client'; -import { parseHTML } from 'linkedom'; -import type { SessionSummary, StoredMessage } from '@maka/core/session'; -import { AstryxLocaleProvider } from '../astryx-i18n.js'; -import { ChatSurfaceLayout } from '../chat-surface-layout.js'; -import { ChatView } from '../chat-view.js'; -import { LocaleProvider } from '../locale-context.js'; -import { - useTranscriptScrollAuthority, - type TranscriptScrollAuthority, -} from '../transcript-scroll-authority.js'; - -const originalGlobals = { - CSS: globalThis.CSS, - document: globalThis.document, - Element: globalThis.Element, - HTMLElement: globalThis.HTMLElement, - IntersectionObserver: globalThis.IntersectionObserver, - MutationObserver: globalThis.MutationObserver, - Node: globalThis.Node, - ResizeObserver: globalThis.ResizeObserver, - matchMedia: globalThis.matchMedia, - requestAnimationFrame: globalThis.requestAnimationFrame, - cancelAnimationFrame: globalThis.cancelAnimationFrame, - window: globalThis.window, -}; -const originalActEnvironment = (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; -}).IS_REACT_ACT_ENVIRONMENT; - -let mountedRoot: ReturnType | undefined; - -afterEach(async () => { - if (mountedRoot) await act(() => mountedRoot?.unmount()); - mountedRoot = undefined; - Object.assign(globalThis, { - ...originalGlobals, - IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, - }); -}); - -const activeSession: SessionSummary = { - id: 'session-return', - name: '回到最新', - isFlagged: false, - isArchived: false, - labels: [], - hasUnread: false, - status: 'active', - lastMessageAt: 0, - backend: 'ai-sdk', - llmConnectionId: 'connection-anthropic', - llmConnectionSlug: 'anthropic', - connectionLocked: false, - model: 'claude-sonnet-4-5', - permissionMode: 'ask', -}; - -const TURN_COUNT = 6; - -function turnMessages(): StoredMessage[] { - return Array.from({ length: TURN_COUNT }, (_, index): StoredMessage[] => [ - { - type: 'user', - id: `user-${index}`, - turnId: `turn-${index}`, - ts: index * 2, - text: `第 ${index} 个问题`, - }, - { - type: 'assistant', - id: `assistant-${index}`, - turnId: `turn-${index}`, - ts: index * 2 + 1, - text: '答案', - modelId: 'claude-sonnet-4-5', - }, - ]).flat(); -} - -function deferredClick(): { onClick: () => Promise; release: () => void } { - let release!: (value: void) => void; - const promise = new Promise((resolve) => { - release = resolve; - }); - return { - onClick: () => promise, - release: () => { - release(); - }, - }; -} - - -interface ReturnToLatestHarness { - readonly anchors: Array; - /** The authority `ChatSurfaceLayout` provided, read through a probe child. */ - readonly authority: TranscriptScrollAuthority; - readonly scrollRoot: HTMLElement; - readonly scrollButton: HTMLElement; - readonly clickEvent: Event; - readerScroll(): void; - geometryScroll(): void; -} - -/** - * Renders `ChatView` with a deferred return-to-latest load inside the same - * linkedom + act harness `prompt-rail-observer-identity.test.tsx` established. - * A probe child publishes the authority so the test can read the pin. - */ -function harness(options: { readonly onClick: () => Promise | void }): ReturnToLatestHarness { - const { document, window } = parseHTML('
'); - // linkedom lays nothing out, so every box is zero-sized. The geometry this - // test needs is a scroller away from its tail with a Turn visible, which a - // constant viewport rectangle plus scrollTop and scrollHeight fields give. - const rect = { - bottom: 600, height: 600, left: 0, right: 800, top: 0, width: 800, x: 0, y: 0, - toJSON: () => ({}), - } satisfies DOMRect; - window.Element.prototype.getBoundingClientRect = () => rect; - class InertResizeObserver { - observe(): void {} - unobserve(): void {} - disconnect(): void {} - } - class InertIntersectionObserver { - observe(): void {} - unobserve(): void {} - disconnect(): void {} - takeRecords(): IntersectionObserverEntry[] { - return []; - } - } - Object.assign(globalThis, { - CSS: { supports: () => false }, - document, - Element: window.Element, - HTMLElement: window.HTMLElement, - IntersectionObserver: InertIntersectionObserver, - MutationObserver: window.MutationObserver, - Node: window.Node, - ResizeObserver: InertResizeObserver, - matchMedia: () => ({ - matches: false, - addEventListener() {}, - removeEventListener() {}, - }), - requestAnimationFrame: (callback: FrameRequestCallback) => { - callback(0); - return 0; - }, - cancelAnimationFrame: () => {}, - window, - IS_REACT_ACT_ENVIRONMENT: true, - }); - const anchors: Array = []; - let authority: TranscriptScrollAuthority | undefined; - const AuthorityProbe = (): ReactElement => { - authority = useTranscriptScrollAuthority(); - return createElement(Fragment, null); - }; - const view = (): ReactElement => { - const chat = createElement(ChatView, { - messages: turnMessages(), - activeSession, - onNew: () => {}, - scrollBehavior: 'auto' as const, - hasOlderHistory: true, - hasNewerHistory: true, - onReadingAnchorChange: (turnId?: string) => { - anchors.push(turnId); - }, - } as never); - const layout = createElement(ChatSurfaceLayout, { - scrollToBottomLabel: '回到最新', - onReturnToTail: options.onClick, - composer: null, - children: createElement(Fragment, null, chat, createElement(AuthorityProbe)), - }); - const astryx = createElement(AstryxLocaleProvider, { children: layout }); - return createElement(LocaleProvider, { - locale: 'zh-CN', - children: astryx, - }); - }; - const mount = document.querySelector('#mount'); - assert.ok(mount); - const root = createRoot(mount); - mountedRoot = root; - act(() => { - root.render(view()); - }); - const scrollRoot = mount.querySelector('[data-chat-scroll-container]'); - assert.ok(scrollRoot, 'the layout mounts a scroll container'); - const scrollButton = mount.querySelector('button[aria-label="回到最新"]'); - assert.ok(scrollButton, 'the return-to-latest affordance is rendered'); - // LinkeDOM has no layout. Start at the tail; readerScroll supplies input and - // its resulting offset, whereas geometryScroll supplies no reading intent. - Object.assign(scrollRoot, { scrollHeight: 2_400, clientHeight: 600 }); - scrollRoot.scrollTop = 1_800; - // linkedom ships Event but not MouseEvent; a bubbling Event still reaches - // React's root listener, which reads only the type for onClick. - const clickEvent = new window.Event('click', { bubbles: true }); - return { - anchors, - get authority(): TranscriptScrollAuthority { - assert.ok(authority, 'the layout provided a scroll authority'); - return authority; - }, - scrollRoot, - scrollButton, - clickEvent, - readerScroll() { - const event = new window.Event('wheel', { bubbles: true }); - Object.defineProperty(event, 'deltaY', { value: -120 }); - scrollRoot.dispatchEvent(event); - scrollRoot.scrollTop = 600; - scrollRoot.dispatchEvent(new window.Event('scroll')); - }, - geometryScroll() { - scrollRoot.dispatchEvent(new window.Event('scroll')); - }, - }; -} - -test('returning to latest pins before it loads, so the anchor reports nothing', async () => { - const click = deferredClick(); - const view = harness({ onClick: click.onClick }); - - // Mount reports the pinned (cleared) anchor once; the reader scroll that - // parks away from the tail then reports the Turn being left. - view.readerScroll(); - assert.deepEqual(view.anchors, [undefined, 'turn-0'], 'an unpinned scroller reports the left Turn'); - - view.scrollButton.dispatchEvent(view.clickEvent); - await act(async () => {}); - assert.equal( - view.authority.getSnapshot().pinned, - true, - 'the click pinned synchronously, before the load settled', - ); - assert.deepEqual( - view.anchors, - [undefined, 'turn-0', undefined], - 'the pin cleared the reading anchor while the load was in flight', - ); - - click.release(); - await act(async () => {}); -}); - -test('the anchor stays cleared while the range is still loading', async () => { - const click = deferredClick(); - const view = harness({ onClick: click.onClick }); - view.readerScroll(); - view.scrollButton.dispatchEvent(view.clickEvent); - await act(async () => {}); - - // The arriving range is what used to re-report the anchor; whatever moves - // the scroller while the load is pending must not hand the shell a Turn. - view.geometryScroll(); - assert.deepEqual(view.anchors, [undefined, 'turn-0', undefined]); - - click.release(); - await act(async () => {}); -}); diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 8ab4f4f965..7b9af69120 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -22,14 +22,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createTranscriptScrollAuthority } from '../transcript-scroll-authority.js'; -import { createTranscriptViewportNavigation } from '../transcript-viewport-navigation.js'; - -interface FakeTurn { - turnId: string; - /** Offset within the scrolled content, which `scrollTop` then shifts. */ - top: number; - height: number; -} interface FakeRoot { ownerDocument: EventTarget; @@ -39,15 +31,10 @@ interface FakeRoot { clientHeight: number; /** The boxes `scrollHeight` is made of, which is what the authority watches. */ children: readonly unknown[]; - /** Mounted Turns, laid out relative to `scrollTop`. */ - turns: FakeTurn[]; - getBoundingClientRect(): DOMRect; - querySelectorAll(selector: string): readonly unknown[]; addEventListener(type: string, listener: (event: unknown) => void): void; removeEventListener(type: string, listener: (event: unknown) => void): void; input(deltaY: number, modifiers?: { ctrlKey?: boolean; metaKey?: boolean }): void; grabScrollbar(): void; - touch(type: 'touchstart' | 'touchend' | 'touchcancel', count: number): void; end(): void; /** Dispatch the scroll event the browser would, one frame later. */ emitScroll(): void; @@ -68,16 +55,6 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F scrollHeight: options?.scrollHeight ?? 3_000, clientHeight: options?.clientHeight ?? 600, children: [{}], - turns: [], - getBoundingClientRect: () => ({ top: 0 }) as DOMRect, - querySelectorAll: () => root.turns.map((turn) => ({ - dataset: { turnId: turn.turnId }, - getAttribute: () => turn.turnId, - getBoundingClientRect: () => ({ - top: turn.top - root.scrollTop, - bottom: turn.top + turn.height - root.scrollTop, - }) as DOMRect, - })), addEventListener(type, listener) { if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type)!.add(listener); @@ -93,7 +70,6 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F emit('pointerdown', { button: 0, pointerType: 'mouse', pointerId: 1, target: proxy }); }, end() { emit('scrollend'); }, - touch(type, count) { emit(type, { touches: Array.from({ length: count }, () => ({ clientY: 100 })) }); }, grow(by) { root.scrollHeight += by; }, @@ -116,24 +92,31 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F } /** - * The authority watches the scroller's box and its children's boxes, and keeps - * that set current with a `MutationObserver`, so the suite owns both. `resize` + * The authority watches the scroller's box and its children's boxes. `resize` * is every box changing at once, which is the only distinction the authority * draws between them: none. * * End-of-operation frame callbacks are advanced explicitly. */ -function withObservers(run: (resize: () => void, frame: () => void, mutate: () => void) => T): T { +function withObservers(run: (resize: () => void, frame: () => void) => T): T { const observers = new Set<() => void>(); - const mutations = new Set<() => void>(); - const frames: FrameRequestCallback[] = []; - const globals = globalThis as { CSS?: unknown; ResizeObserver?: unknown; MutationObserver?: unknown; requestAnimationFrame?: unknown }; - const originalCss = globals.CSS; - globals.CSS = { escape: (value: string) => value }; + const frames = new Map(); + let nextFrame = 0; + const globals = globalThis as { + ResizeObserver?: unknown; + MutationObserver?: unknown; + requestAnimationFrame?: unknown; + cancelAnimationFrame?: unknown; + }; const originalResize = globals.ResizeObserver; const originalMutation = globals.MutationObserver; const originalFrame = globals.requestAnimationFrame; - globals.requestAnimationFrame = (callback: FrameRequestCallback) => frames.push(callback); + const originalCancelFrame = globals.cancelAnimationFrame; + globals.requestAnimationFrame = (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }; + globals.cancelAnimationFrame = (id: number) => { frames.delete(id); }; globals.ResizeObserver = class { constructor(private readonly callback: () => void) {} // Registered on `observe` rather than on construction: the authority @@ -146,168 +129,91 @@ function withObservers(run: (resize: () => void, frame: () => void, mutate: ( observers.delete(this.callback); } }; - // The set of children only changes when the transcript mounts or unmounts - // one, and `resize` already stands for every box in that set changing. globals.MutationObserver = class { - constructor(private readonly callback: () => void) {} - observe(): void { - mutations.add(this.callback); - } - disconnect(): void { - mutations.delete(this.callback); - } + observe(): void {} + disconnect(): void {} }; try { return run(() => { for (const observer of [...observers]) observer(); - }, () => { for (const callback of frames.splice(0)) callback(0); }, () => { - for (const mutation of [...mutations]) mutation(); + }, () => { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(0); }); } finally { - globals.CSS = originalCss; globals.ResizeObserver = originalResize; globals.MutationObserver = originalMutation; globals.requestAnimationFrame = originalFrame; + globals.cancelAnimationFrame = originalCancelFrame; } } -test('Ctrl and Meta wheel zoom preserve following without requesting history', () => { +test('native scroll anchoring stays off while attached and is restored on detach', () => { + withObservers(() => { + const root = fakeRoot(); + root.style.overflowAnchor = 'auto'; + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + assert.equal(root.style.overflowAnchor, 'none'); + root.input(-100); + root.scrollTop = 1_000; + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, false); + assert.equal(root.style.overflowAnchor, 'none', 'releasing the pin does not hand anchoring back'); + detach(); + assert.equal(root.style.overflowAnchor, 'auto'); + }); +}); + +test('Ctrl and Meta wheel zoom preserve following', () => { withObservers((resize) => { for (const modifiers of [{ ctrlKey: true }, { metaKey: true }]) { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); const detach = authority.attach(root as unknown as HTMLElement); - let readerReports = 0; - authority.subscribeToReaderScroll(() => { readerReports += 1; }); root.input(-100, modifiers); root.grow(200); resize(); assert.equal(authority.getSnapshot().pinned, true); assert.equal(root.scrollTop, root.scrollHeight - root.clientHeight); - assert.equal(readerReports, 0); detach(); } }); }); -test('range publication leaves native input and reading geometry with the browser', () => { - withObservers(() => { - for (const input of ['wheel', 'touch', 'scrollbar'] as const) { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - const detach = authority.attach(root as unknown as HTMLElement); - let commits = 0; - if (input === 'wheel') root.input(-100); - else if (input === 'touch') root.touch('touchstart', 2); - else root.grabScrollbar(); - assert.equal(authority.isInputActive(), true); - authority.commitRange(() => commits++); - assert.equal(commits, 1); - assert.equal(authority.isInputActive(), true, 'publication does not retire native input'); - if (input === 'scrollbar') { - root.ownerDocument.dispatchEvent(new Event('pointerup')); - authority.commitRange(() => commits++); - assert.equal(commits, 2, 'publication remains available after release'); - } - detach(); - } - }); -}); - -test('range publication preserves a nonzero reading position with or without native anchoring', () => { - withObservers(() => { - for (const nativeCorrection of [false, true]) { - const root = fakeRoot(); - let rowTop = 0; - const anchor = { - dataset: { turnId: 'reading' }, - getBoundingClientRect: () => ({ top: rowTop - root.scrollTop, bottom: rowTop + 400 - root.scrollTop }), - }; - root.querySelectorAll = () => [anchor]; - const authority = createTranscriptScrollAuthority(); - const detach = authority.attach(root as unknown as HTMLElement); - authority.releasePin(); - root.scrollTop = 77; - authority.commitRange(() => { - rowTop += 4936; - root.scrollHeight += 4936; - if (nativeCorrection) root.scrollTop += 4936; - }); - assert.equal(root.scrollTop, 5013, 'the prepended page must not displace or doubly compensate the reader'); - assert.equal(anchor.getBoundingClientRect().top, -77); - detach(); - } - }); -}); - -test('explicit navigation during range publication outranks the old reading anchor', () => { - withObservers(() => { - for (const command of ['reveal', 'tail'] as const) { - const root = fakeRoot(); - root.turns = [{ turnId: 'old', top: 0, height: 400 }]; - const anchor = { - dataset: { turnId: 'old' }, - getBoundingClientRect: () => ({ top: -root.scrollTop, bottom: 400 - root.scrollTop }), - }; - root.querySelectorAll = () => [anchor]; - const authority = createTranscriptScrollAuthority(); - const detach = authority.attach(root as unknown as HTMLElement); - authority.releasePin(); - root.scrollTop = 0; - authority.commitRange(() => { - if (command === 'tail') authority.pinToTail(); - else authority.revealTurn({ scrollIntoView: () => { root.scrollTop = 1_200; } } as unknown as HTMLElement, - { block: 'start' }); - }); - assert.equal(root.scrollTop, command === 'tail' ? root.scrollHeight - root.clientHeight : 1_200); - detach(); - } - }); -}); - -test('range publication coalesces within a microtask and uses the viewport anchor owner', async () => { - const publication = createTranscriptViewportNavigation(); - const commits: number[] = []; - let scheduled = 0; - publication.attachCommitScheduler('session', { - subscribeToReaderScroll: () => () => {}, - commitRange(commit) { scheduled++; commit(); }, +test('an upward gesture at an unmoving edge keeps following', () => { + withObservers((resize, frame) => { + const root = fakeRoot({ scrollHeight: 600, clientHeight: 600 }); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + root.input(-100); + frame(); + frame(); + assert.equal(authority.getSnapshot().pinned, true); + root.grow(400); + resize(); + assert.equal(root.scrollTop, 400); }); - publication.commitRange('session', () => commits.push(1)); - publication.commitRange('session', () => commits.push(2)); - assert.deepEqual([...commits], []); - await Promise.resolve(); - assert.deepEqual(commits, [2]); - assert.equal(scheduled, 1); }); -test('held publication retains only the latest update and drains on settle or detach', async () => { - const publication = createTranscriptViewportNavigation(); - const commits: number[] = []; - let held = true; - let settled!: () => void; - const detach = publication.attachCommitScheduler('session', { - commitRange(commit) { if (!held) commit(); }, - subscribeToReaderScroll(listener) { - settled = () => { listener('settled'); }; - return () => {}; - }, +test('a passive wheel delivered after its threaded scroll reached the top releases the tail', () => { + withObservers((resize, frame) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(root.scrollTop, 2_400); + root.scrollTop = 0; + root.emitScroll(); + root.end(); + root.input(-4_000); + frame(); + frame(); + assert.equal(authority.getSnapshot().pinned, false); + root.grow(200); + resize(); + assert.equal(root.scrollTop, 0); }); - publication.commitRange('session', () => commits.push(1)); - await Promise.resolve(); - publication.commitRange('session', () => commits.push(2)); - await Promise.resolve(); - assert.deepEqual([...commits], []); - held = false; - settled(); - await Promise.resolve(); - assert.deepEqual(commits, [2]); - held = true; - publication.commitRange('session', () => commits.push(3)); - await Promise.resolve(); - detach(); - await Promise.resolve(); - assert.deepEqual(commits, [2, 3], 'closing the viewport cannot strand source publication'); }); test('content that grows under a pinned transcript keeps the tail on screen', () => { @@ -333,23 +239,53 @@ test('identical shrink/grow geometry follows only when no reader input intervene const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); - let readerMoves = 0; - authority.subscribeToReaderScroll((phase) => { - if (phase === 'scroll') readerMoves += 1; - }); if (readerInput) root.input(-100); root.grow(-190); root.scrollTop = 2_210; // Browser clamps at the intermediate bottom. root.grow(22); root.emitScroll(); assert.equal(authority.getSnapshot().pinned, !readerInput); - assert.equal(readerMoves, readerInput ? 1 : 0); resize(); assert.equal(root.scrollTop, readerInput ? 2_210 : 2_232); }); } }); +test('history prepended above a pinned reader moves the offset without releasing the pin', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + // The virtualizer shifts the offset by the prepended height itself. + root.grow(4_000); + root.scrollTop += 4_000; + root.emitScroll(); + resize(); + assert.equal(authority.getSnapshot().pinned, true); + assert.equal(root.scrollTop, root.scrollHeight - root.clientHeight); + }); +}); + +test('content landing above a released reader does not re-pin them', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + authority.releasePin(); + assert.equal(authority.getSnapshot().pinned, false); + + // Distance to the tail is unchanged — which is exactly the reading that + // used to put the pin back and scroll the new turns away. + root.grow(4_000); + root.scrollTop = 6_400; + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, false); + + resize(); + assert.equal(root.scrollTop, 6_400); + }); +}); + test('scrollend cannot retire a continuing operation or a newer input', () => { withObservers((resize, frame) => { const root = fakeRoot(); @@ -406,24 +342,6 @@ test('scrollbar defaults can land after pointerup, while an unmoved click retire }); }); -test('explicit navigation cancels input provenance before positioning its target', () => { - withObservers(() => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - root.input(-100); - root.scrollTop = 1_700; - root.emitScroll(); - authority.releasePin(); - let reports = 0; - authority.subscribeToReaderScroll(() => { reports += 1; }); - root.scrollTop = 200; - root.emitScroll(); - assert.equal(reports, 0); - assert.equal(authority.getSnapshot().pinned, false); - }); -}); - test('user input releases the tail before content can overwrite the scroll', () => { withObservers((resize) => { const root = fakeRoot(); @@ -436,9 +354,6 @@ test('user input releases the tail before content can overwrite the scroll', () assert.equal(authority.getSnapshot().pinned, false); assert.equal(authority.getSnapshot().awayFromTail, true); - // Nothing arriving afterwards may move the reader: with the pin released - // this authority writes nothing at all, and native anchoring holds the - // position the reader chose. root.grow(4_000); resize(); assert.equal(root.scrollTop, 1_000); @@ -465,7 +380,7 @@ test('returning to the tail re-pins, and following resumes', () => { }); }); -test('a detached authority writes nothing and reports the tail', () => { +test('a detached authority writes nothing', () => { withObservers((resize) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); @@ -483,9 +398,6 @@ test('a viewport that loses height takes the pinned reader back to the tail', () const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); - - // The transcript did not change at all — the box looking at it did, which - // is a window resize, a composer gaining a line, or a dock growing taller. root.shrinkViewport(300); resize(); assert.equal(root.scrollTop, 2_700); @@ -508,54 +420,23 @@ test('a reader who scrolls up while the answer grows is still the reader', () => assert.equal(authority.getSnapshot().pinned, false); assert.equal(authority.getSnapshot().awayFromTail, true); - // And the pin stays off: what arrives next is more of the same answer, and - // following it would take the transcript away from where they went. root.grow(300); resize(); assert.equal(root.scrollTop, 1_900); }); }); -test('reports both moves even when the reader returns to the last written offset', () => { - withObservers(() => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - let readerMoves = 0; - authority.subscribeToReaderScroll((phase) => { if (phase === 'scroll') readerMoves += 1; }); - root.emitScroll(); - root.input(-100); - root.scrollTop = 900; - root.emitScroll(); - root.input(100); - root.scrollTop = 2_400; - root.emitScroll(); - root.end(); - assert.equal(readerMoves, 2); - }); -}); - test('a slow reader is a reader, however small each step is', () => { withObservers((resize) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); - let readerMoves = 0; - authority.subscribeToReaderScroll(() => { - readerMoves += 1; - }); - - // A trackpad crossing the transcript unhurriedly. Judged one event at a - // time against the rounding this has to tolerate, every one of these is - // noise and the reader never moves at all; they only mean anything added - // up. Nothing grows here, so there is nothing else they could be. for (let step = 0; step < 90; step += 1) { root.input(-2); root.scrollTop -= 2; root.emitScroll(); } assert.equal(authority.getSnapshot().pinned, false); - assert.ok(readerMoves > 0, 'the reader moved 180px and was never heard'); root.grow(500); resize(); @@ -563,69 +444,20 @@ test('a slow reader is a reader, however small each step is', () => { }); }); -test('content leaving from above the reader is not the reader either', () => { - withObservers(() => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - authority.releasePin(); - root.scrollTop = 1_500; - root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, false); - let readerMoves = 0; - authority.subscribeToReaderScroll(() => { - readerMoves += 1; - }); - - // A tool block above them folds away. Anchoring answers a removal the same - // way it answers an arrival — by moving the offset exactly as far — so the - // reader is still looking at the same content and has asked for nothing. - root.grow(-60); - root.scrollTop = 1_440; - root.emitScroll(); - assert.equal(readerMoves, 0); - assert.equal(authority.getSnapshot().pinned, false); - }); -}); - -test('content landing above a released reader does not re-pin them', () => { +test('the reading position is the Turn the attached reader names under the offset', () => { withObservers((resize) => { const root = fakeRoot(); + let turnIds = ['turn-1', 'turn-2', 'turn-3']; + let offset = 0; const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - - // The reader is at the tail and asks for what is above them: a wheel the - // scroller cannot act on, so only the command says so. - authority.releasePin(); - assert.equal(authority.getSnapshot().pinned, false); - - // History lands above them and native anchoring moves the offset to keep - // them still. Distance to the tail is unchanged — which is exactly the - // reading that used to put the pin back and scroll the new turns away. - root.grow(4_000); - root.scrollTop = 6_400; - root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, false); - - resize(); - assert.equal(root.scrollTop, 6_400); - }); -}); - -test('the reading position names the Turn crossing the top of the scrollport', () => { - withObservers((resize, _frame, mutate) => { - const root = fakeRoot(); - root.turns = [ - { turnId: 'turn-1', top: 0, height: 1_000 }, - { turnId: 'turn-2', top: 1_000, height: 1_000 }, - { turnId: 'turn-3', top: 2_000, height: 1_000 }, - ]; - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); + authority.attach(root as unknown as HTMLElement, { + turnAt: (scrollTop) => turnIds[Math.min(Math.floor((scrollTop - offset) / 1_000), turnIds.length - 1)], + offsetOf: () => undefined, + reveal: () => {}, + }); let publications = 0; authority.subscribe(() => { publications += 1; }); - // Attached pinned, so the authority wrote the tail under the reader. assert.equal(root.scrollTop, 2_400); assert.equal(authority.getSnapshot().readingTurnId, 'turn-3'); @@ -635,63 +467,168 @@ test('the reading position names the Turn crossing the top of the scrollport', ( assert.equal(authority.getSnapshot().readingTurnId, 'turn-2'); assert.ok(publications > 0, 'a new reading position is published'); - // Same Turn still under the top edge: nothing new to say. const published = publications; root.scrollTop = 1_400; root.emitScroll(); - assert.equal(publications, published); + assert.equal(publications, published, 'the same Turn under the top edge says nothing new'); - // A Turn arriving above the reader moves the position without the reader. - for (const turn of root.turns) turn.top += 500; - root.turns.unshift({ turnId: 'turn-0', top: 0, height: 500 }); - root.grow(500); - root.scrollTop = 1_900; - mutate(); + // A Turn prepended above the reader, with the offset shifted to match. + turnIds = ['turn-0', ...turnIds]; + root.grow(1_000); + root.scrollTop = 2_400; + root.emitScroll(); assert.equal(authority.getSnapshot().readingTurnId, 'turn-2'); - root.scrollTop = 400; + offset = 500; resize(); - assert.equal(authority.getSnapshot().readingTurnId, 'turn-0'); + assert.equal(authority.getSnapshot().readingTurnId, 'turn-1'); }); }); -test('a transcript without Turns has no reading position', () => { - withObservers(() => { +/** Turns 1,000px tall from the top of the content, which `fakeRoot` sizes to match. */ +function turnList(root: FakeRoot, initial: string[]) { + const list = { + turnIds: initial, + reveals: [] as Array<{ turnId: string; align: string; smooth: boolean }>, + set(next: string[]) { + root.scrollHeight = next.length * 1_000; + list.turnIds = next; + }, + layout: { + turnAt: (scrollTop: number) => + list.turnIds[Math.min(Math.floor(scrollTop / 1_000), list.turnIds.length - 1)], + offsetOf: (turnId: string) => { + const index = list.turnIds.indexOf(turnId); + return index === -1 ? undefined : index * 1_000; + }, + reveal: (turnId: string, options: { align: string; smooth: boolean }) => { + list.reveals.push({ turnId, ...options }); + }, + }, + }; + root.scrollHeight = initial.length * 1_000; + return list; +} + +function frames(frame: () => void, count = 30): void { + for (let step = 0; step < count; step += 1) frame(); +} + +test('a change to the list other than growth at its tail keeps the reader on their Turn', () => { + withObservers((_resize, frame) => { const root = fakeRoot(); + const list = turnList(root, ['b', 'c', 'd']); const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - assert.equal(authority.getSnapshot().readingTurnId, undefined); + authority.attach(root as unknown as HTMLElement, list.layout); + root.input(-100); + root.scrollTop = 1_250; + root.emitScroll(); + assert.equal(authority.getSnapshot().readingTurnId, 'c'); + + list.set(['a', 'b', 'c', 'd']); + authority.turnsChanged('prepend'); + assert.equal(root.scrollTop, 2_250, 'the reader stays 250px into their Turn'); + assert.equal(authority.getSnapshot().positioning, true); + root.scrollTop = 2_000; + frame(); + assert.equal(root.scrollTop, 2_250, 'a settling row that moves the offset is written back'); + + list.set(['a', 'c', 'd']); + authority.turnsChanged('reset'); + assert.equal(root.scrollTop, 1_250); + + list.set(['a', 'c', 'd', 'e']); + authority.turnsChanged('append'); + frames(frame); + assert.equal(root.scrollTop, 1_250); + assert.equal(authority.getSnapshot().positioning, false); }); }); -test('only the reader\'s own movement reaches a reader-scroll listener', () => { - withObservers(() => { +test('reader input ends the positioning, and neither a pinned reader nor a removed Turn is positioned', () => { + withObservers((_resize, frame) => { const root = fakeRoot(); + const list = turnList(root, ['b', 'c', 'd']); const authority = createTranscriptScrollAuthority(); - let heard = 0; - const stop = authority.subscribeToReaderScroll((phase) => { - if (phase === 'scroll') heard += 1; - }); - authority.attach(root as unknown as HTMLElement); + authority.attach(root as unknown as HTMLElement, list.layout); + assert.equal(authority.getSnapshot().pinned, true); + list.set(['a', 'b', 'c', 'd']); + authority.turnsChanged('prepend'); + assert.equal(authority.getSnapshot().positioning, false, 'the pin already decides where a pinned reader is'); - // This authority's own write, echoed back late. + root.input(-100); + root.scrollTop = 1_100; root.emitScroll(); - assert.equal(heard, 0); + list.set(['z', 'a', 'b', 'c', 'd']); + authority.turnsChanged('prepend'); + assert.equal(root.scrollTop, 2_100); + root.input(-100); + assert.equal(authority.getSnapshot().positioning, false); + root.scrollTop = 500; + frames(frame); + assert.equal(root.scrollTop, 500, 'the reader took the offset back'); - // Content arriving, with anchoring moving the offset to hold the reader. - root.grow(500); - root.scrollTop = 2_900; root.emitScroll(); - assert.equal(heard, 0); + list.set(['a', 'b']); + authority.turnsChanged('reset'); + assert.equal(authority.getSnapshot().positioning, false, 'the reader\'s Turn is gone from the list'); + }); +}); - // The reader, at last. - root.input(-100); - root.scrollTop = 900; - root.emitScroll(); - assert.equal(heard, 1); +test('a navigation waits for its Turn, and a later command supersedes it before it settles', () => { + withObservers((_resize, frame) => { + const root = fakeRoot(); + const list = turnList(root, ['c', 'd']); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement, list.layout); + let settled: string[] = []; + authority.navigate({ turnId: 'a', align: 'start', onSettled: () => { settled = [...settled, 'a']; } }); + assert.equal(authority.getSnapshot().pinned, false); + assert.equal(authority.getSnapshot().positioning, true, 'a Turn not yet loaded is still a destination'); - stop(); - root.scrollTop = 400; - root.emitScroll(); - assert.equal(heard, 1); + list.set(['a', 'b', 'c', 'd']); + authority.turnsChanged('prepend'); + assert.equal(root.scrollTop, 0); + frames(frame); + assert.deepEqual(settled, ['a']); + assert.equal(authority.getSnapshot().positioning, false); + + authority.navigate({ turnId: 'c', align: 'center', smooth: true, onSettled: () => { settled = [...settled, 'c']; } }); + assert.deepEqual(list.reveals, [{ turnId: 'c', align: 'center', smooth: true }]); + frame(); + authority.pinToTail(); + frames(frame); + assert.deepEqual(settled, ['a'], 'returning to the tail cancels the reveal it interrupted'); + assert.equal(root.scrollTop, root.scrollHeight - root.clientHeight); + assert.equal(list.reveals.length, 1); + }); +}); + +test('a navigation whose arrival settles without its Turn ends', () => { + withObservers((_resize, frame) => { + const root = fakeRoot(); + const list = turnList(root, ['c', 'd']); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement, list.layout); + let arrive!: () => void; + const arrival: PromiseLike = { then: (resolve) => { arrive = () => resolve?.(); return arrival as never; } }; + authority.navigate({ turnId: 'a', align: 'start', arrival }); + arrive(); + assert.equal(authority.getSnapshot().positioning, true, 'the list renders what arrived before this is decided'); + frame(); + assert.equal(authority.getSnapshot().positioning, false); + + list.set(['a', 'b', 'c', 'd']); + authority.turnsChanged('prepend'); + frames(frame); + assert.notEqual(root.scrollTop, 0, 'a Turn that arrives later does not pull the reader to it'); + }); +}); + +test('a transcript without a Turn reader has no reading position', () => { + withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(authority.getSnapshot().readingTurnId, undefined); }); }); diff --git a/packages/ui/src/__tests__/transcript-test-dom.ts b/packages/ui/src/__tests__/transcript-test-dom.ts new file mode 100644 index 0000000000..c0ae6a96cd --- /dev/null +++ b/packages/ui/src/__tests__/transcript-test-dom.ts @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The virtualized transcript mounts Turns only after its scroller reports a + * size, so static server markup no longer contains them. This renders on a + * LinkeDOM client whose ResizeObserver reports every box as `boxHeight` tall + * inside a `viewportHeight` scroller. Scroll geometry is zero unless a test + * defines `scrollHeight` and `clientHeight` on an element. + */ + +import { act, type ReactElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; + +const GLOBAL_KEYS = [ + 'CSS', + 'Element', + 'HTMLElement', + 'IS_REACT_ACT_ENVIRONMENT', + 'IntersectionObserver', + 'MutationObserver', + 'Node', + 'ResizeObserver', + 'cancelAnimationFrame', + 'document', + 'getComputedStyle', + 'matchMedia', + 'requestAnimationFrame', + 'window', +] as const; + +export interface TranscriptDom { + document: Document; + window: ReturnType['window']; + container: HTMLElement; + render(element: ReactElement): Promise; + cleanup(): Promise; +} + +export function installTranscriptDom(options: { viewportHeight?: number; boxHeight?: number } = {}): TranscriptDom { + const viewportHeight = options.viewportHeight ?? 100_000; + const boxHeight = options.boxHeight ?? 100; + const originals = new Map( + GLOBAL_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ); + const { document, window } = parseHTML('
'); + const scrollTops = new WeakMap(); + Object.defineProperties(window.HTMLElement.prototype, { + offsetParent: { configurable: true, get(this: HTMLElement) { return this.parentElement; } }, + scrollTop: { + configurable: true, + get(this: HTMLElement) { return scrollTops.get(this) ?? 0; }, + set(this: HTMLElement, value: number) { + scrollTops.set(this, Math.max(0, Math.min(value, this.scrollHeight - this.clientHeight))); + }, + }, + // Content moves up by every ancestor's scroll offset, so a margin measured + // from rects and scrollTop does not grow with scrolling. + getBoundingClientRect: { + configurable: true, + value(this: HTMLElement) { + let top = 0; + for (let parent = this.parentElement; parent; parent = parent.parentElement) top -= parent.scrollTop; + return { top, bottom: top, left: 0, right: 0, width: 0, height: 0, x: 0, y: top }; + }, + }, + scrollHeight: { configurable: true, get: () => 0 }, + clientHeight: { configurable: true, get: () => 0 }, + // LinkeDOM has no scroll methods, and a scroller that silently ignores them + // would make every programmatic scroll look like it landed. + scroll: { + configurable: true, + value(this: HTMLElement, options?: ScrollToOptions) { + if (options?.top !== undefined) this.scrollTop = options.top; + }, + }, + }); + // LinkeDOM's compareDocumentPosition returns browser bitmasks without naming them. + Object.assign(window.Node, { + DOCUMENT_POSITION_DISCONNECTED: 1, + DOCUMENT_POSITION_PRECEDING: 2, + DOCUMENT_POSITION_FOLLOWING: 4, + DOCUMENT_POSITION_CONTAINS: 8, + DOCUMENT_POSITION_CONTAINED_BY: 16, + }); + class MeasuringResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + queueMicrotask(() => { + const height = target.hasAttribute('data-chat-scroll-container') ? viewportHeight : boxHeight; + this.callback( + [{ target, contentRect: { height, width: 800 } } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + }); + } + unobserve(): void {} + disconnect(): void {} + } + class InertObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + takeRecords(): [] { return []; } + } + Object.assign(window, { ResizeObserver: MeasuringResizeObserver }); + Object.assign(globalThis, { + CSS: { escape: String, supports: () => false }, + Element: window.Element, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + IntersectionObserver: InertObserver, + MutationObserver: InertObserver, + Node: window.Node, + ResizeObserver: MeasuringResizeObserver, + cancelAnimationFrame: () => undefined, + document, + getComputedStyle: () => ({ overflowY: 'visible' }), + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 0, + window, + }); + const container = document.querySelector('#root')!; + const root = createRoot(container); + return { + document, + window, + container, + async render(element) { + await act(async () => { root.render(element); }); + // Viewport measurement mounts the Turns; their own measurement settles them. + await act(async () => {}); + await act(async () => {}); + }, + async cleanup() { + await act(async () => { root.unmount(); }); + for (const [key, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + }, + }; +} + +/** Client-rendered markup of `element`, with every Turn of a transcript mounted. */ +export async function renderTranscriptMarkup(element: ReactElement): Promise { + const dom = installTranscriptDom(); + try { + await dom.render(element); + return dom.container.innerHTML; + } finally { + await dom.cleanup(); + } +} diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index 1b6595619d..18bd704bcd 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -19,10 +19,10 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; -import { act, useRef, useState } from 'react'; +import { act, useRef } from 'react'; import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; -import type { StoredMessage } from '@maka/core/session'; +import type { VirtualizerHandle } from 'virtua'; import { TranscriptScrollAuthorityProvider, useTranscriptScrollAuthority, @@ -31,18 +31,21 @@ import { import { useChatScroll } from '../use-chat-scroll.js'; import { createTranscriptViewportNavigation } from '../transcript-viewport-navigation.js'; +const TURN_HEIGHT = 500; +const CLIENT_HEIGHT = 600; + const originalGlobals = { CSS: globalThis.CSS, document: globalThis.document, Element: globalThis.Element, HTMLElement: globalThis.HTMLElement, - IntersectionObserver: globalThis.IntersectionObserver, getComputedStyle: globalThis.getComputedStyle, MutationObserver: globalThis.MutationObserver, Node: globalThis.Node, ResizeObserver: globalThis.ResizeObserver, window: globalThis.window, requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, }; const originalActEnvironment = (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; @@ -50,15 +53,6 @@ const originalActEnvironment = (globalThis as typeof globalThis & { let mountedRoot: ReturnType | undefined; -function wheel(target: HTMLElement, deltaY: number): void { - const event = new window.Event('wheel', { bubbles: true }); - Object.defineProperties(event, { - deltaY: { value: deltaY }, - composedPath: { value: () => [target] }, - }); - target.dispatchEvent(event); -} - afterEach(async () => { if (mountedRoot) await act(() => mountedRoot?.unmount()); mountedRoot = undefined; @@ -68,53 +62,35 @@ afterEach(async () => { }); }); +interface ScrollCall { index: number; align?: string; smooth?: boolean } + /** - * Installs the globals the scroll hook reads onto the LinkeDOM window, either - * queueing rAF frames for explicit flushes or running them inline. + * A scroller whose Turns are `TURN_HEIGHT` tall rows of `turnIds`, addressed + * through a virtualizer handle. Row elements exist only for `mounted` Turns, the + * way a virtualizer mounts rows around the viewport. */ -const installScrollTestEnvironment = ( - document: Document, - window: ReturnType['window'], - { queueFrames = true }: { queueFrames?: boolean } = {}, -): { - frames: Map; - resizeCallbacks: ResizeObserverCallback[]; - /** Delivers a resize to whoever is observing `target` at this moment. */ - deliverResizeOf: (target: unknown) => void; -} => { +function setup(turnIds: string[]) { + const { document, window } = parseHTML('
'); let frameId = 0; const frames = new Map(); - const resizeCallbacks: ResizeObserverCallback[] = []; - const observers: TestResizeObserver[] = []; + const resizeCallbacks = new Set<() => void>(); class TestResizeObserver { - readonly targets = new Set(); - constructor(readonly callback: ResizeObserverCallback) { - resizeCallbacks.push(callback); - observers.push(this); - } - disconnect() { this.targets.clear(); } - observe(target: unknown) { this.targets.add(target); } - unobserve(target: unknown) { this.targets.delete(target); } + constructor(private readonly callback: () => void) {} + observe() { resizeCallbacks.add(this.callback); } + unobserve() {} + disconnect() { resizeCallbacks.delete(this.callback); } } class TestMutationObserver { - disconnect() {} observe() {} - unobserve() {} - takeRecords(): MutationRecord[] { return []; } + disconnect() {} } - Object.assign(window, { - cancelAnimationFrame: (id: number) => frames.delete(id), - requestAnimationFrame: queueFrames - ? (callback: FrameRequestCallback) => { - const id = ++frameId; - frames.set(id, callback); - return id; - } - : (callback: FrameRequestCallback) => { - callback(0); - return 0; - }, - }); + const requestFrame = (callback: FrameRequestCallback) => { + const id = ++frameId; + frames.set(id, callback); + return id; + }; + const cancelFrame = (id: number) => { frames.delete(id); }; + Object.assign(window, { requestAnimationFrame: requestFrame, cancelAnimationFrame: cancelFrame }); Object.assign(globalThis, { CSS: { escape: (value: string) => value }, document, @@ -125,979 +101,279 @@ const installScrollTestEnvironment = ( Node: window.Node, ResizeObserver: TestResizeObserver, window, - requestAnimationFrame: window.requestAnimationFrame, + requestAnimationFrame: requestFrame, + cancelAnimationFrame: cancelFrame, IS_REACT_ACT_ENVIRONMENT: true, }); - return { - frames, - resizeCallbacks, - deliverResizeOf: (target: unknown): void => { - for (const observer of [...observers]) { - if (observer.targets.has(target)) observer.callback([], observer as unknown as ResizeObserver); - } - }, - }; -}; - -function boxOf(top: number, bottom: number): DOMRect { - return { - top, - bottom, - height: bottom - top, - left: 0, - right: 800, - width: 800, - x: 0, - y: top, - toJSON: () => undefined, - } as DOMRect; -} -/** - * A scroller of `turnCount` equal Turns whose geometry the test drives. Turn - * boxes are derived from the current offset, so a scroll moves every box the - * way a real one does. - */ -function createTranscript( - document: Document, - window: ReturnType['window'], - options: { clientHeight: number; turnHeight: number; turnCount: number }, -) { - const scroller = document.querySelector('#scroller'); - assert.ok(scroller); + const scroller = document.querySelector('#scroller')!; let scrollTop = 0; - let turnCount = options.turnCount; - let clientHeight = options.clientHeight; - const contentHeight = (): number => - Math.max(clientHeight, turnCount * options.turnHeight); + const transcript = { turnIds }; + const contentHeight = (): number => Math.max(CLIENT_HEIGHT, transcript.turnIds.length * TURN_HEIGHT); Object.defineProperties(scroller, { - clientHeight: { get: () => clientHeight }, - scrollHeight: { get: () => contentHeight() }, + clientHeight: { get: () => CLIENT_HEIGHT }, + scrollHeight: { get: contentHeight }, scrollTop: { get: () => scrollTop, - set: (value: number) => { - scrollTop = Math.max(0, Math.min(value, contentHeight() - clientHeight)); - }, + set: (value: number) => { scrollTop = Math.max(0, Math.min(value, contentHeight() - CLIENT_HEIGHT)); }, }, }); - scroller.getBoundingClientRect = () => boxOf(0, clientHeight); - const install = (): void => { + const calls: ScrollCall[] = []; + const handle = { + findItemIndex: (offset: number) => Math.max(0, Math.floor(offset / TURN_HEIGHT)), + getItemOffset: (index: number) => index * TURN_HEIGHT, + scrollToIndex: (index: number, options: { align?: string; smooth?: boolean } = {}) => { + calls.push({ index, ...options }); + scroller.scrollTop = options.align === 'center' + ? index * TURN_HEIGHT - (CLIENT_HEIGHT - TURN_HEIGHT) / 2 + : index * TURN_HEIGHT; + }, + } as unknown as VirtualizerHandle; + + const focused: string[] = []; + const mountRows = (mounted: readonly string[]): void => { scroller.replaceChildren(); - for (let index = 0; index < turnCount; index += 1) { - const turn = document.createElement('article'); - turn.dataset.turnId = `turn-${index}`; - const start = index * options.turnHeight; - turn.getBoundingClientRect = () => - boxOf(start - scrollTop, start + options.turnHeight - scrollTop); - turn.scrollIntoView = () => { scroller.scrollTop = start; }; - scroller.append(turn); + for (const turnId of mounted) { + const row = document.createElement('section'); + row.dataset.turnId = turnId; + row.focus = () => { focused.push(turnId); }; + scroller.append(row); } }; - install(); + return { + document, + window, scroller, - get scrollTop(): number { return scrollTop; }, - /** The viewport alone changes; a real one re-clamps its offset too. */ - setClientHeight(next: number): void { - clientHeight = next; - scroller.scrollTop = scrollTop; - }, - setTurnCount(next: number): void { - turnCount = next; - install(); - // A real scroller clamps its offset the moment its content shrinks. - scroller.scrollTop = scrollTop; + transcript, + calls, + focused, + handle, + mountRows, + resize(): void { for (const callback of [...resizeCallbacks]) callback(); }, + async flushFrames(count = 1): Promise { + for (let frame = 0; frame < count; frame += 1) { + await act(() => { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(0); + }); + } }, - /** A reader gesture and the scroll it produces, in that order. */ readerScrollTo(top: number): void { - const delta = top - scrollTop; - if (delta === 0) return; - wheel(scroller, delta); + const event = new window.Event('wheel', { bubbles: true }); + Object.defineProperties(event, { + deltaY: { value: top < scrollTop ? -100 : 100 }, + composedPath: { value: () => [scroller] }, + }); + scroller.dispatchEvent(event); scroller.scrollTop = top; scroller.dispatchEvent(new window.Event('scroll')); }, }; } -test('history loads follow the reader band, in both directions, once per direction', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 4, - }); +type Env = ReturnType; - const calls: string[] = []; - const resolvers: Array<() => void> = []; - const load = (direction: string) => (): Promise => { - calls.push(direction); - return new Promise((resolve) => resolvers.push(() => resolve(true))); - }; - let history = { older: true, newer: true }; - function Harness({ older, newer }: { older: boolean; newer: boolean }) { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-band', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - hasOlderHistory: older, - hasNewerHistory: newer, - onPrefetchHistory: (edge) => load(edge === 'older' ? 'up' : 'down')(), - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - const render = async (): Promise => act(() => mountedRoot?.render( - - - , - )); - - await render(); - // Opened at the tail: nothing lies below, so the newer edge is inside the - // band even though the reader never moved. - assert.equal(transcript.scrollTop, 1_800); - assert.deepEqual(calls, ['down']); - - calls.length = 0; - transcript.readerScrollTo(900); - // 900px above and 900px below, both inside two screens. The downward fetch - // is already in flight, so only the older edge is asked. - assert.deepEqual(calls, ['up']); - transcript.readerScrollTo(800); - assert.deepEqual(calls, ['up'], 'a direction with a request in flight is not asked again'); - - // Both pages land, and the edges they established close the transcript. - history = { older: false, newer: false }; - await render(); - await act(async () => { - for (const resolve of resolvers.splice(0)) resolve(); - }); - calls.length = 0; - transcript.readerScrollTo(200); - assert.deepEqual(calls, [], 'no request beyond an authoritative history edge'); - - // Only the tail is open now: the reader moving up still asks for it, - // because what decides is the band, not the direction of the gesture. - history = { older: false, newer: true }; - await render(); - transcript.readerScrollTo(1_000); - assert.deepEqual(calls, ['down']); -}); - -test('a failed fill is not reissued until the reader moves again', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 8, - }); - - let requests = 0; - function Harness() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-failing', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - hasOlderHistory: true, - onPrefetchHistory: () => { - requests += 1; - return Promise.reject(new Error('the range read failed')); - }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render( - , - )); - - await act(async () => { transcript.readerScrollTo(0); }); - // A failed read leaves the geometry and the history flags exactly as they - // were, so re-checking on its own would ask again forever. - assert.equal(requests, 1); - await act(async () => {}); - assert.equal(requests, 1); -}); - -test('a fill that issued no read is not chained into another one', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 8, - }); - - let requests = 0; - function Harness() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-idle', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - hasOlderHistory: true, - // The first read issued; the window it answered is then the window the - // next ask is made against, so the range refuses to read it again. - onPrefetchHistory: () => Promise.resolve(++requests === 1), - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render( - , - )); - - await act(async () => { transcript.readerScrollTo(0); }); - - assert.equal(requests, 1, 'the landed read waits for input settlement'); - await act(() => transcript.scroller.dispatchEvent(new window.Event('scrollend'))); - assert.equal(requests, 2, 'settlement rechecks the published range, whose refusal ends it'); -}); - -test('an older request at offset zero does not move the reader', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 8, - }); - - let requests = 0; - function Harness() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ +function mountHook(env: Env) { + const state: { + authority?: TranscriptScrollAuthority; + highlighted: string | null; + revealTurnAtStart?: (turnId: string, arrival: PromiseLike) => void; + measurement?: { shift: boolean; generation: number }; + anchors: Map; + } = { highlighted: null, anchors: new Map() }; + const viewportNavigation = createTranscriptViewportNavigation(); + function Harness(props: { + sessionId: string; + target?: { turnId: string; nonce: number; preserveFocus?: boolean }; + restoreTarget?: { turnId: string; unavailable?: boolean }; + }) { + const scrollRef = useRef(env.scroller); + const virtualizerRef = useRef(env.handle); + state.authority = useTranscriptScrollAuthority(); + const result = useChatScroll({ scrollRef, - sessionId: 'session-top', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - hasOlderHistory: true, - onPrefetchHistory: () => { - requests += 1; - return new Promise(() => undefined); - }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render( - , - )); - assert.equal(requests, 0, 'the tail of a deep transcript is nowhere near the older edge'); - - transcript.readerScrollTo(0); - assert.equal(requests, 1); - assert.equal(transcript.scrollTop, 0, 'publication owns anchoring; input must not nudge the reader'); -}); - -test('range publication commits React synchronously through native input', async () => { - const navigation = createTranscriptViewportNavigation(); - const { document, window } = parseHTML('
'); - const { frames } = installScrollTestEnvironment(document, window); - const transcript = createTranscript(document, window, { - clientHeight: 400, turnHeight: 400, turnCount: 12, - }); - let authority!: TranscriptScrollAuthority; - let publish!: (value: string) => void; - function Harness() { - const [value, setValue] = useState('old'); - publish = setValue; - authority = useTranscriptScrollAuthority(); - const scrollRef = useRef(transcript.scroller); - useChatScroll({ scrollRef, sessionId: 'admission', messages: [], behavior: 'auto', viewportNavigation: navigation }); - return {value}; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render()); - await act(async () => { - navigation.commitRange('admission', () => publish('new')); - await Promise.resolve(); - assert.equal(document.querySelector('#mount')!.textContent, 'new', - 'an admitted update must not remain in React scheduling after publication'); - }); - await act(async () => { - navigation.commitRange('admission', () => publish('held')); - const down = new window.Event('pointerdown'); - Object.defineProperties(down, { - button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 1 }, - }); - transcript.scroller.dispatchEvent(down); - await Promise.resolve(); - assert.equal(document.querySelector('#mount')!.textContent, 'held', - 'an arriving range publishes while the browser owns the reading anchor'); - }); - await act(() => document.dispatchEvent(new window.Event('pointerup'))); - await act(() => { - const pending = [...frames.values()]; frames.clear(); - for (const callback of pending) callback(0); - }); - assert.equal(document.querySelector('#mount')!.textContent, 'held'); - await act(async () => { - const down = new window.Event('pointerdown'); - Object.defineProperties(down, { - button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 3 }, - }); - transcript.scroller.dispatchEvent(down); - navigation.commitRange('admission', () => publish('navigation')); - await Promise.resolve(); - authority.releasePin(); - assert.equal(document.querySelector('#mount')!.textContent, 'navigation'); - document.dispatchEvent(new window.Event('pointerup')); - }); - assert.equal(document.querySelector('#mount')!.textContent, 'navigation', - 'ending physical input publishes navigation even when its gesture was superseded'); - await act(async () => { - const down = new window.Event('pointerdown'); - Object.defineProperties(down, { - button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 2 }, + virtualizerRef, + sessionId: props.sessionId, + turnIds: env.transcript.turnIds, + measureStartMargin: () => 0, + target: props.target, + restoreTarget: props.restoreTarget, + viewportNavigation, + onReadingAnchorChange: (turnId) => { state.anchors.set(props.sessionId, turnId); }, + behavior: 'smooth', }); - transcript.scroller.dispatchEvent(down); - navigation.commitRange('admission', () => publish('latest')); - await Promise.resolve(); - assert.equal(document.querySelector('#mount')!.textContent, 'latest'); - authority.pinToTail(); - }); - assert.equal(document.querySelector('#mount')!.textContent, 'latest', - 'explicit tail navigation must not strand a deferred range'); -}); - -test('a source publication survives viewport unmount without another source update', async () => { - const { document, window } = parseHTML('
'); - installScrollTestEnvironment(document, window); - const transcript = createTranscript(document, window, { clientHeight: 400, turnHeight: 400, turnCount: 12 }); - const navigation = createTranscriptViewportNavigation(); - let publish!: (value: string) => void; - let show!: (value: boolean) => void; - function Surface() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ scrollRef, sessionId: 'session', messages: [], behavior: 'auto', viewportNavigation: navigation }); + state.highlighted = result.highlightedTurnId; + state.revealTurnAtStart = result.revealTurnAtStart; + state.measurement = result.measurement; return null; } - function Harness() { - const [value, setValue] = useState('old'); - const [visible, setVisible] = useState(true); - publish = setValue; show = setVisible; - return <>{value}{visible && }; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render()); - await act(() => wheel(transcript.scroller, -100)); - await act(() => { - navigation.commitRange('session', () => publish('latest')); - show(false); - }); - assert.equal(document.querySelector('#mount')!.textContent, 'latest'); - await act(() => show(true)); - assert.equal(document.querySelector('#mount')!.textContent, 'latest'); -}); - -for (const hasOlder of [false, true]) { - test(`stationary upward input ${hasOlder ? 'reads available history' : 'keeps following without history'}`, async () => { - const navigation = createTranscriptViewportNavigation(); - const { document, window } = parseHTML('
'); - const { frames, deliverResizeOf } = installScrollTestEnvironment(document, window); - const transcript = createTranscript(document, window, { - clientHeight: 400, turnHeight: 200, turnCount: 1, - }); - let authority!: TranscriptScrollAuthority; - let requests = 0; - function Harness() { - authority = useTranscriptScrollAuthority(); - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, sessionId: 'short', messages: [], behavior: 'auto', viewportNavigation: navigation, - hasOlderHistory: hasOlder, - onPrefetchHistory: () => { requests++; return new Promise(() => {}); }, - }); - return null; - } - const frame = async () => act(() => { - const pending = [...frames.values()]; - frames.clear(); - for (const callback of pending) callback(0); - }); - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render()); - await frame(); // Initial fill may already be in flight when the reader asks. - await act(() => wheel(transcript.scroller, -100)); - let publications = 0; - await act(() => navigation.commitRange('short', () => { - publications++; - transcript.setTurnCount(3); - if (hasOlder) { - [...transcript.scroller.children].forEach((turn, index) => { - (turn as HTMLElement).dataset.turnId = `turn-${index - 2}`; - }); - } - })); - assert.equal(publications, 1, 'input must not starve an accepted history request'); - await frame(); await frame(); - assert.equal(requests, hasOlder ? 1 : 0); - assert.equal(publications, 1); - assert.equal(authority.getSnapshot().pinned, !hasOlder); - const beforeGrowth = transcript.scrollTop; - await act(() => { - transcript.setTurnCount(4); - deliverResizeOf(transcript.scroller); - }); - assert.equal(transcript.scrollTop, hasOlder ? beforeGrowth : 400); - }); + mountedRoot = createRoot(env.document.querySelector('#mount')!); + return { + state, + viewportNavigation, + async render(props: Parameters[0]): Promise { + await act(() => mountedRoot?.render( + , + )); + }, + }; } -for (const settlesBeforePublication of [false, true]) { -test(`a fill publishes before eviction when input settles ${settlesBeforePublication ? 'before' : 'after'} publication`, async () => { - const navigation = createTranscriptViewportNavigation(); - const { document, window } = parseHTML('
'); - const { frames } = installScrollTestEnvironment(document, window); - const transcript = createTranscript(document, window, { - clientHeight: 400, turnHeight: 400, turnCount: 12, - }); - let authority!: TranscriptScrollAuthority; - let finishRead!: () => void; - let requests = 0; - let publications = 0; - const retained: string[] = []; - function Harness() { - authority = useTranscriptScrollAuthority(); - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, sessionId: 'held-fill', messages: [], behavior: 'auto', viewportNavigation: navigation, - hasOlderHistory: true, - onPrefetchHistory: () => { - requests++; - return new Promise((resolve) => { - finishRead = () => { - navigation.commitRange('held-fill', () => { - publications++; - transcript.setTurnCount(14); - [...transcript.scroller.children].forEach((turn, index) => { - (turn as HTMLElement).dataset.turnId = `turn-${index - 2}`; - }); - // Linkedom has no layout or native scroll anchoring. Model the - // browser retaining turn-0; real geometry is checked in Chromium. - transcript.scroller.scrollTop += 800; - }); - resolve(true); - }; - }); - }, - onRetainWindow: (range) => { retained.push(range.firstTurnId); }, - }); - return null; - } - const frame = async () => act(() => { - const pending = [...frames.values()]; frames.clear(); - for (const callback of pending) callback(0); - }); - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render()); - await frame(); - retained.length = 0; - await act(() => { - const down = new window.Event('pointerdown'); - Object.defineProperties(down, { - button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 1 }, - }); - transcript.scroller.dispatchEvent(down); - transcript.scroller.scrollTop = 0; - transcript.scroller.dispatchEvent(new window.Event('scroll')); - }); - assert.equal(requests, 1); - if (settlesBeforePublication) { - await act(() => document.dispatchEvent(new window.Event('pointerup'))); - await frame(); await frame(); - assert.equal(authority.isInputActive(), false); - assert.deepEqual(retained, [], 'an unfinished read cannot be trimmed using the old window'); - } - await act(() => finishRead()); - assert.equal(publications, 1); - if (!settlesBeforePublication) { - assert.deepEqual(retained, [], 'active input still prevents eviction'); - assert.equal(requests, 1, 'the fill does not eagerly chain while input is active'); - await act(() => document.dispatchEvent(new window.Event('pointerup'))); - } - await frame(); await frame(); - assert.equal(publications, 1); - assert.equal(retained.at(-1), 'turn--2', 'the new published band includes the older page'); +const turns = (count: number, prefix = 'turn'): string[] => + Array.from({ length: count }, (_, index) => `${prefix}-${index}`); + +test('the reading anchor is the Turn the virtualizer maps under the offset', async () => { + const env = setup(turns(6)); + const hook = mountHook(env); + await hook.render({ sessionId: 's' }); + assert.equal(env.scroller.scrollTop, 6 * TURN_HEIGHT - CLIENT_HEIGHT); + assert.equal(hook.state.anchors.get('s'), undefined, 'a pinned reader has no anchor'); + + // No row is mounted at all: the mapping, not DOM measurement, names the Turn. + await act(() => { env.readerScrollTo(2 * TURN_HEIGHT + 10); }); + assert.equal(hook.state.anchors.get('s'), 'turn-2'); + + // Earlier Turns prepended above the reader, who stays on their Turn. + env.transcript.turnIds = [...turns(2, 'earlier'), ...env.transcript.turnIds]; + await hook.render({ sessionId: 's' }); + assert.equal(env.scroller.scrollTop, 4 * TURN_HEIGHT + 10); + await env.flushFrames(30); + env.resize(); + assert.equal(hook.state.anchors.get('s'), 'turn-2'); + assert.equal(hook.state.authority?.getSnapshot().pinned, false); }); -} -test('a transcript change re-reads the band while the reader stays at the tail', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 8, - }); +test('the measurement cache moves with a prepend and starts over when the list is rearranged', async () => { + const env = setup(turns(3)); + const hook = mountHook(env); + await hook.render({ sessionId: 's' }); + assert.deepEqual(hook.state.measurement, { shift: false, generation: 0 }); - let requests = 0; - function Harness({ messages }: { messages: readonly StoredMessage[] }) { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-tail', - messages, - behavior: 'auto', - hasOlderHistory: true, - onPrefetchHistory: () => { - requests += 1; - return new Promise(() => undefined); - }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - const render = async (messages: readonly StoredMessage[]): Promise => - act(() => mountedRoot?.render( - , - )); + env.transcript.turnIds = [...env.transcript.turnIds, 'turn-3']; + await hook.render({ sessionId: 's' }); + assert.deepEqual(hook.state.measurement, { shift: false, generation: 0 }, 'a Turn at the tail moves nothing'); - await render([{ id: 'message-1' }] as StoredMessage[]); - assert.equal(requests, 0); - assert.equal(transcript.scrollTop, 4_200); + env.transcript.turnIds = ['earlier-0', ...env.transcript.turnIds]; + await hook.render({ sessionId: 's' }); + assert.deepEqual(hook.state.measurement, { shift: true, generation: 0 }); - // A trim leaves the reader pinned at a tail with barely a screen above it. - // Nobody scrolled, so only the transcript itself can report the band. - transcript.setTurnCount(2); - transcript.scroller.scrollTop = transcript.scroller.scrollHeight; - await render([{ id: 'message-2' }] as StoredMessage[]); - assert.equal(requests, 1); + // A filter cleared: Turns come back between the ones already there. + env.transcript.turnIds = ['earlier-0', 'turn-0', 'filtered', 'turn-1', 'turn-2', 'turn-3']; + await hook.render({ sessionId: 's' }); + assert.deepEqual(hook.state.measurement, { shift: false, generation: 1 }); }); -test('the retained window is the band around the reader, and an unmounted bookmark cannot freeze it', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 20, - }); - // Start at the existing reading position. A pending bookmark need not visit - // the tail as a side effect of attaching the scroll authority. - transcript.scroller.scrollTop = 11_400; - - const retained: Array<{ firstTurnId: string; lastTurnId: string }> = []; - function Harness({ - messages, - unavailable, - }: { messages: readonly StoredMessage[]; unavailable: boolean }) { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-window', - messages, - restoreTarget: { turnId: 'turn-never-mounted', unavailable }, - behavior: 'auto', - onRetainWindow: (value) => { retained.push(value); }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - const render = async ( - messages: readonly StoredMessage[], - unavailable: boolean, - ): Promise => act(() => mountedRoot?.render( - - - , - )); - - // A bookmark whose Turn is not mounted cannot be trimmed away, so waiting - // for it would only let the window grow without a bound. The window is four - // screens of Turns around a scrollport 20 screens deep. - await render([{ id: 'message-1' }] as StoredMessage[], false); - assert.equal(transcript.scrollTop, 11_400); - assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-14', lastTurnId: 'turn-19' }); - - retained.length = 0; - await render([{ id: 'message-2' }] as StoredMessage[], true); - assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-14', lastTurnId: 'turn-19' }); - - retained.length = 0; - transcript.readerScrollTo(6_000); - assert.deepEqual(retained, [], 'trim waits for the input to finish'); - transcript.scroller.dispatchEvent(new window.Event('scrollend')); - assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-5', lastTurnId: 'turn-15' }); - - // Six screens is the threshold: with less than that beyond the scrollport in - // both directions there is nothing worth dropping. - transcript.setTurnCount(8); - transcript.readerScrollTo(2_000); - retained.length = 0; - transcript.readerScrollTo(2_100); - assert.deepEqual(retained, []); +test('a search target is revealed by index once per nonce, then focused and highlighted', async () => { + const env = setup(turns(3)); + const hook = mountHook(env); + await hook.render({ sessionId: 's', target: { turnId: 'turn-5', nonce: 1 } }); + await env.flushFrames(); + assert.deepEqual(env.calls, [], 'a Turn that is not loaded cannot be revealed yet'); + assert.equal(hook.state.highlighted, null); + + env.transcript.turnIds = turns(8); + await hook.render({ sessionId: 's', target: { turnId: 'turn-5', nonce: 1 } }); + assert.deepEqual(env.calls, [{ index: 5, align: 'center', smooth: true }]); + assert.equal(hook.state.authority?.getSnapshot().pinned, false); + + // The row mounts while the reveal settles; it is focused once it has. + await env.flushFrames(); + assert.equal(hook.state.highlighted, null); + env.mountRows(['turn-4', 'turn-5']); + await env.flushFrames(30); + assert.equal(hook.state.highlighted, 'turn-5'); + assert.deepEqual(env.focused, ['turn-5']); + assert.equal(hook.state.anchors.get('s'), 'turn-4'); + + env.scroller.scrollTop = 100; + await hook.render({ sessionId: 's', target: { turnId: 'turn-5', nonce: 1 } }); + await env.flushFrames(); + assert.equal(env.calls.length, 1, 'a landed command does not repeat on render'); + assert.equal(env.scroller.scrollTop, 100); + + await hook.render({ sessionId: 's', target: { turnId: 'turn-5', nonce: 2 } }); + assert.equal(env.calls.length, 2, 'a new nonce is a new command'); }); -test('a viewport that grows fills the band it just widened, without a reader gesture', async () => { - const { document, window } = parseHTML( - '
', - ); - const { deliverResizeOf } = installScrollTestEnvironment(document, window, { - queueFrames: false, - }); - const transcript = createTranscript(document, window, { - clientHeight: 400, turnHeight: 600, turnCount: 10, - }); - - const edges: string[] = []; - function Harness() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-grow', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - hasOlderHistory: true, - onPrefetchHistory: (edge) => { - edges.push(edge); - return new Promise(() => undefined); - }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render( - , - )); - - transcript.readerScrollTo(1_000); - assert.deepEqual(edges, [], '1000px above is outside two 400px screens'); - - transcript.setClientHeight(800); - await act(async () => { deliverResizeOf(transcript.scroller); }); - assert.deepEqual(edges, ['older'], 'the same 1000px is inside two 800px screens'); - - await act(async () => { deliverResizeOf(transcript.scroller); }); - assert.deepEqual(edges, ['older'], 'the in-flight guard still holds across resizes'); +test('a bookmark restores its Turn at the top edge, and an unavailable one falls back to the tail', async () => { + const env = setup(turns(6, 'a')); + const hook = mountHook(env); + await hook.render({ sessionId: 'a', restoreTarget: { turnId: 'a-2' } }); + assert.equal(env.scroller.scrollTop, 2 * TURN_HEIGHT); + assert.equal(hook.state.anchors.get('a'), undefined, 'a landing reader has not chosen a position yet'); + await env.flushFrames(30); + assert.equal(hook.state.anchors.get('a'), 'a-2'); + assert.equal(hook.state.highlighted, null, 'a restore is not a search result'); + assert.equal(hook.state.authority?.getSnapshot().pinned, false); + + env.transcript.turnIds = turns(2, 'b'); + env.mountRows([]); + await hook.render({ sessionId: 'b', restoreTarget: { turnId: 'b-gone' } }); + assert.equal(hook.state.authority?.getSnapshot().pinned, false, 'a bookmark waits for its Turn'); + await hook.render({ sessionId: 'b', restoreTarget: { turnId: 'b-gone', unavailable: true } }); + assert.equal(hook.state.authority?.getSnapshot().positioning, false); + assert.equal(hook.state.anchors.get('b'), 'b-1', 'the Turn under the offset replaces the lost bookmark'); }); -test('a viewport that shrinks trims what it just pushed beyond the band', async () => { - const { document, window } = parseHTML( - '
', - ); - const { deliverResizeOf } = installScrollTestEnvironment(document, window, { - queueFrames: false, - }); - const transcript = createTranscript(document, window, { - clientHeight: 1_000, turnHeight: 600, turnCount: 18, - }); - - const retained: Array<{ firstTurnId: string; lastTurnId: string }> = []; - function Harness() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-shrink', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - onRetainWindow: (value) => { retained.push(value); }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render( - , - )); - - transcript.readerScrollTo(4_000); - retained.length = 0; - transcript.readerScrollTo(4_100); - transcript.scroller.dispatchEvent(new window.Event('scrollend')); - assert.deepEqual(retained, [], 'nothing lies six 1000px screens away'); - - transcript.setClientHeight(400); - await act(async () => { deliverResizeOf(transcript.scroller); }); - assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-4', lastTurnId: 'turn-10' }); +test('following the latest cancels a bookmark that has not landed', async () => { + const env = setup(turns(1, 'a')); + const hook = mountHook(env); + await hook.render({ sessionId: 'a', restoreTarget: { turnId: 'a-9' } }); + assert.equal(hook.state.authority?.getSnapshot().pinned, false); + await act(() => hook.viewportNavigation.followLatest('a')); + env.transcript.turnIds = turns(10, 'a'); + await hook.render({ sessionId: 'a', restoreTarget: { turnId: 'a-9' } }); + env.resize(); + assert.deepEqual(env.calls, []); + assert.equal(hook.state.authority?.getSnapshot().pinned, true); + assert.equal(env.scroller.scrollTop, 10 * TURN_HEIGHT - CLIENT_HEIGHT); }); -test('a session switch restores a Turn anchor after async fill and preserves tail intent', async () => { - const { document, window } = parseHTML( - '
', - ); - const mount = document.querySelector('#mount'); - const scroller = document.querySelector('#scroller'); - assert.ok(mount); - assert.ok(scroller); - - let scrollHeight = 600; - let scrollTop = 0; - let dispatchCommandScroll = true; - Object.defineProperties(scroller, { - clientHeight: { value: 600 }, - scrollHeight: { get: () => scrollHeight }, - scrollTop: { - get: () => scrollTop, - set: (value: number) => { - scrollTop = Math.max(0, Math.min(value, scrollHeight - 600)); - }, - }, - }); - scroller.getBoundingClientRect = () => boxOf(0, 600); - - const { frames, resizeCallbacks } = installScrollTestEnvironment(document, window); - - const installTranscript = ( - height: number, - turns: ReadonlyArray<{ id: string; start: number; height: number }>, - ): void => { - scrollHeight = height; - scroller.replaceChildren(); - for (const turn of turns) { - const element = document.createElement('article'); - element.dataset.turnId = turn.id; - element.getBoundingClientRect = () => - boxOf(turn.start - scrollTop, turn.start + turn.height - scrollTop); - element.scrollIntoView = (options?: boolean | ScrollIntoViewOptions) => { - const block = typeof options === 'object' ? options.block : undefined; - scroller.scrollTop = block === 'center' - ? turn.start - 300 + turn.height / 2 - : turn.start; - if (dispatchCommandScroll) scroller.dispatchEvent(new window.Event('scroll')); - }; - scroller.append(element); - } - }; - const collapseTranscript = (): void => { - scrollHeight = 600; - scrollTop = 0; - scroller.replaceChildren(); - }; - const deliverResize = (): void => { - for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); - }; - const flushFrames = async (): Promise => { - await act(() => { - const pending = [...frames.values()]; - frames.clear(); - for (const callback of pending) callback(0); - }); - }; - - const anchors = new Map(); - const viewportNavigation = createTranscriptViewportNavigation(); - const unavailableRestores = new Map(); - let authority: TranscriptScrollAuthority | undefined; - let messageRevision = 0; - let target: { turnId: string; nonce: number } | undefined; - function Harness({ sessionId }: { sessionId: string }) { - const scrollRef = useRef(scroller); - authority = useTranscriptScrollAuthority(); - const unavailableTurnId = unavailableRestores.get(sessionId); - const restoreTurnId = unavailableTurnId ?? anchors.get(sessionId); - const restoreTarget = restoreTurnId - ? { turnId: restoreTurnId, unavailable: unavailableTurnId === restoreTurnId } - : undefined; - useChatScroll({ - scrollRef, - sessionId, - messages: [{ id: `message-${messageRevision}` }] as StoredMessage[], - target, - restoreTarget, - viewportNavigation, - onReadingAnchorChange: (turnId) => { - unavailableRestores.delete(sessionId); - if (turnId) anchors.set(sessionId, turnId); - else anchors.delete(sessionId); - }, - behavior: 'auto', - }); - return null; - } - - const renderSession = async (sessionId: string): Promise => { - messageRevision += 1; - await act(() => mountedRoot?.render( - - - , - )); - }; - - installTranscript(3_000, [ - { id: 'turn-a-1', start: 0, height: 800 }, - { id: 'turn-a-2', start: 800, height: 600 }, - { id: 'turn-a-3', start: 1_400, height: 1_600 }, - ]); - mountedRoot = createRoot(mount); - await renderSession('session-a'); - assert.equal(scroller.scrollTop, 2_400); - - wheel(scroller, -100); - scroller.scrollTop = 900; - scroller.dispatchEvent(new window.Event('scroll')); - assert.equal(anchors.get('session-a'), 'turn-a-2'); - - collapseTranscript(); - await renderSession('session-b'); - installTranscript(2_000, [{ id: 'turn-b-1', start: 0, height: 2_000 }]); - deliverResize(); - assert.equal(scroller.scrollTop, 1_400); - assert.equal(anchors.has('session-b'), false); - - collapseTranscript(); - await renderSession('session-a'); - assert.equal(authority?.getSnapshot().pinned, false); - installTranscript(2_000, [{ id: 'turn-a-latest', start: 0, height: 2_000 }]); - await renderSession('session-a'); - deliverResize(); - assert.equal(anchors.get('session-a'), 'turn-a-2'); - installTranscript(3_000, [ - { id: 'turn-a-1', start: 0, height: 800 }, - { id: 'turn-a-2', start: 800, height: 600 }, - { id: 'turn-a-3', start: 1_400, height: 1_600 }, - ]); - await renderSession('session-a'); - await flushFrames(); - assert.equal(scroller.scrollTop, 800); - assert.equal(scroller.querySelector('[data-turn-id="turn-a-2"]') - ?.getBoundingClientRect().top, 0); - assert.equal(authority?.getSnapshot().pinned, false); - - collapseTranscript(); - await renderSession('session-b'); - installTranscript(2_400, [{ id: 'turn-b-1', start: 0, height: 2_400 }]); - deliverResize(); - assert.equal(scroller.scrollTop, 1_800); - assert.equal(authority?.getSnapshot().pinned, true); - - // The same restore key can be handled successfully on one activation and - // become unavailable on the next. The earlier success must not swallow the - // later terminal result. - collapseTranscript(); - await renderSession('session-a'); - installTranscript(2_000, [{ id: 'turn-a-visible', start: 0, height: 2_000 }]); - await renderSession('session-a'); - await flushFrames(); - assert.equal(anchors.get('session-a'), 'turn-a-2'); - - unavailableRestores.set('session-a', 'turn-a-2'); - await renderSession('session-a'); - await flushFrames(); - assert.equal(anchors.get('session-a'), 'turn-a-visible'); - assert.equal(unavailableRestores.has('session-a'), false); - - collapseTranscript(); - await renderSession('session-b'); - installTranscript(2_400, [{ id: 'turn-b-1', start: 0, height: 2_400 }]); - deliverResize(); - assert.equal(scroller.scrollTop, 1_800); - assert.equal(authority?.getSnapshot().pinned, true); - - // A command can land without producing a scroll event when layout or native - // anchoring already put the Turn at the requested offset. Its semantic - // reading position must still be reported before the user switches away. - dispatchCommandScroll = false; - target = { turnId: 'turn-b-1', nonce: 1 }; - await renderSession('session-b'); - await flushFrames(); - assert.equal(anchors.get('session-b'), 'turn-b-1'); - scroller.scrollTop = 100; - await renderSession('session-b'); - await flushFrames(); - assert.equal(scroller.scrollTop, 100, 'a completed navigation must not repeat on render'); - - target = undefined; - // With no resident Turn to re-anchor to, abandoning the restore falls back - // to the default tail intent and clears the stale reading anchor. - anchors.set('session-b', 'turn-b-never-renders'); - collapseTranscript(); - await renderSession('session-c'); - collapseTranscript(); - await renderSession('session-b'); - assert.equal(authority?.getSnapshot().pinned, false); - unavailableRestores.set('session-b', 'turn-b-never-renders'); - await renderSession('session-b'); - await flushFrames(); - assert.equal(authority?.getSnapshot().pinned, true); - assert.equal(anchors.has('session-b'), false); - - // A send/return-to-latest clears a pending bookmark. Its old frame must not - // scroll to the historical Turn if that Turn arrives in a later batch. - anchors.set('session-a', 'turn-a-2'); - collapseTranscript(); - await renderSession('session-a'); - assert.equal(authority?.getSnapshot().pinned, false); - anchors.delete('session-a'); - await renderSession('session-a'); - assert.equal(authority?.getSnapshot().pinned, false, 'clearing a bookmark is not a viewport command'); - await act(() => viewportNavigation.followLatest('session-a')); - installTranscript(3_000, [ - { id: 'turn-a-2', start: 0, height: 800 }, - { id: 'turn-a-latest', start: 800, height: 2_200 }, - ]); - await renderSession('session-a'); - deliverResize(); - await flushFrames(); - assert.equal(authority?.getSnapshot().pinned, true); - assert.equal(scroller.scrollTop, 2_400); - assert.equal(anchors.has('session-a'), false); - - wheel(scroller, -100); - scroller.scrollTop = 1_000; - scroller.dispatchEvent(new window.Event('scroll')); - assert.equal(anchors.get('session-a'), 'turn-a-latest'); - scroller.dispatchEvent(new window.Event('scrollend')); - installTranscript(800, [{ id: 'geometry-resident', start: 0, height: 800 }]); - scroller.scrollTop = scroller.scrollTop; - await renderSession('session-a'); - deliverResize(); - assert.equal(authority?.getSnapshot().pinned, false); - assert.equal(authority?.getSnapshot().awayFromTail, false); - assert.equal(anchors.get('session-a'), 'turn-a-latest', 'range geometry does not report a new reading intent'); +test('a rail navigation releases the pin and scrolls its Turn to the top by index', async () => { + const env = setup(turns(6)); + const hook = mountHook(env); + await hook.render({ sessionId: 's' }); + assert.equal(hook.state.authority?.getSnapshot().pinned, true); + await act(() => { hook.state.revealTurnAtStart?.('turn-3', Promise.resolve()); }); + assert.equal(env.scroller.scrollTop, 3 * TURN_HEIGHT); + assert.equal(hook.state.authority?.getSnapshot().pinned, false); + + env.resize(); + assert.equal(env.scroller.scrollTop, 3 * TURN_HEIGHT, 'growth does not take a navigated reader back to the tail'); }); -test('a target lands on the render that mounts its Turn, whatever moved the range', async () => { - const { document, window } = parseHTML( - '
', - ); - const { frames } = installScrollTestEnvironment(document, window); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 3, - }); - - // The Renderer owns the window now: a jump to an unloaded Turn changes the - // resident range without touching the message list the shell passes down. - const messages = [{ id: 'message-1' }] as StoredMessage[]; - let highlighted: string | null = null; - function Harness() { - const scrollRef = useRef(transcript.scroller); - const result = useChatScroll({ - scrollRef, - sessionId: 'session-jump', - messages, - target: { turnId: 'turn-5', nonce: 7 }, - behavior: 'auto', - }); - highlighted = result.highlightedTurnId; - return null; - } - const render = async (): Promise => act(() => mountedRoot?.render( - , - )); - const flushFrames = async (): Promise => { - await act(() => { - const pending = [...frames.values()]; - frames.clear(); - for (const callback of pending) callback(0); - }); - }; - - mountedRoot = createRoot(document.querySelector('#mount')!); - await render(); - await flushFrames(); - assert.equal(highlighted, null, 'a Turn that is not mounted cannot be revealed yet'); +test('returning to the tail during a prepend landing is not undone by it', async () => { + const env = setup(turns(4)); + const hook = mountHook(env); + await hook.render({ sessionId: 's' }); + await act(() => { env.readerScrollTo(TURN_HEIGHT + 100); }); + assert.equal(hook.state.authority?.getSnapshot().pinned, false); + + env.transcript.turnIds = [...turns(2, 'earlier'), ...env.transcript.turnIds]; + await hook.render({ sessionId: 's' }); + assert.equal(env.scroller.scrollTop, 3 * TURN_HEIGHT + 100, 'the prepend left the reader on turn-1'); + + // The landing repeats for several frames. A reader who asks for the tail in + // that window is asking for a position of their own. + await act(() => { hook.state.authority?.pinToTail(); }); + const tail = 6 * TURN_HEIGHT - CLIENT_HEIGHT; + assert.equal(env.scroller.scrollTop, tail); + await env.flushFrames(); + assert.equal(env.scroller.scrollTop, tail, 'the landing took the reader back off the tail'); +}); - transcript.setTurnCount(8); - await render(); - await flushFrames(); - assert.equal(highlighted, 'turn-5'); - assert.equal(transcript.scrollTop, 3_000, 'the reveal puts the Turn at the top edge'); +test('a reader who moves while earlier history is on its way stays where they went', async () => { + const env = setup(turns(6)); + const hook = mountHook(env); + await hook.render({ sessionId: 's' }); + // Earlier history is asked for from the top, and the reader moves on before it arrives. + await act(() => { env.readerScrollTo(0); }); + await act(() => { env.readerScrollTo(3 * TURN_HEIGHT + 100); }); + + env.transcript.turnIds = [...turns(2, 'earlier'), ...env.transcript.turnIds]; + await hook.render({ sessionId: 's' }); + await env.flushFrames(4); + assert.equal(env.scroller.scrollTop, 5 * TURN_HEIGHT + 100, 'the arriving history carried the reader back to the top'); }); diff --git a/packages/ui/src/chat-surface-layout.tsx b/packages/ui/src/chat-surface-layout.tsx index 321845e652..0547161bd7 100644 --- a/packages/ui/src/chat-surface-layout.tsx +++ b/packages/ui/src/chat-surface-layout.tsx @@ -35,8 +35,6 @@ import { PromptAnchorRailHostContext } from './prompt-anchor-rail.js'; */ export type ChatSurfaceLayoutProps = Omit, 'autoScroll'> & { scrollToBottomLabel?: string; - /** Loads the durable tail after the scroll authority pins to it. */ - onReturnToTail?(): Promise | void; }; /** @@ -61,7 +59,6 @@ export function ChatSurfaceLayout({ children, density = 'balanced', scrollToBottomLabel, - onReturnToTail, ...props }: ChatSurfaceLayoutProps) { const [railHost, setRailHost] = useState(null); @@ -85,7 +82,7 @@ export function ChatSurfaceLayout({ // Astryx's default button reads `isScrolledUp`, which stops updating the // moment its scroll layer is off. Maka's reads Maka's pin instead. scrollButton={props.scrollButton === null ? null - : } + : } density={density} className={cn('maka-chat-layout', className)} data-chat-scroll-container="true" diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index e3a668a533..7c8f21f100 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -17,7 +17,19 @@ * under the License. */ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { + Fragment, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode, + type RefObject, +} from 'react'; +import { Virtualizer, type CustomContainerComponentProps, type VirtualizerHandle } from 'virtua'; import { ICON_SIZE, AlertTriangle, @@ -58,8 +70,7 @@ import { type TurnFooterActionMeta, type TurnPresentationDeriver, } from './chat-turn.js'; -import { useChatScroll } from './use-chat-scroll.js'; -import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; +import { useChatScroll, useTranscriptStartMargin } from './use-chat-scroll.js'; import type { TranscriptViewportNavigation } from './transcript-viewport-navigation.js'; import { placeChatConversationItems } from './chat-conversation-items.js'; import { useUiLocale } from './locale-context.js'; @@ -70,6 +81,19 @@ import { type ReadAttachmentBytes, } from './attachment-image.js'; +/** + * How far outside the viewport, in pixels, rows are mounted. + * + * virtua only corrects `scrollTop` for a row whose measurement lands while the + * row is entirely above the viewport; a row still straddling the top edge is + * left to push the reader. virtua's default is 200px and a wheel notch travels + * 600, so a row went from unmounted to straddling within one notch and was + * always measured too late: reading upwards through the 24-Turn geometry scene + * jumped 13 times, by 5 to 194px. Mounting 2000px ahead leaves the measurement + * room to land before the row reaches the reader. + */ +const MEASURE_AHEAD_MARGIN = 2000; + export interface LiveContentActivationSnapshot { turnId: string; entries: ReadonlyMap; @@ -87,35 +111,6 @@ export interface ChatViewGoalIndicatorProps { goalIndicator?: SessionContextGoal; } -/** A rail click's outstanding request that the reveal for its Turn agree with it. */ -export type RailAlignmentClaim = { turnId: string; nonce?: number }; - -/** - * Which edge the transcript's reveal should use for the target the shell is - * publishing, and what is left of the rail's claim afterwards. - * - * A claim belongs to the one navigation its click asked for, not to the Turn: - * it binds to the first target that arrives for that Turn and is spent on - * anything else. A later search for the same Turn is a different command with - * its own nonce, and gets the search contract back. - */ -export function resolveRailAlignedTarget( - claim: RailAlignmentClaim | undefined, - target: T | undefined, -): { - claim: RailAlignmentClaim | undefined; - target: (T & { align: 'start' | 'center' }) | undefined; -} { - if (!target) return { claim, target: undefined }; - const aimedByRail = claim !== undefined - && claim.turnId === target.turnId - && (claim.nonce === undefined || claim.nonce === target.nonce); - return { - claim: aimedByRail ? { turnId: target.turnId, nonce: target.nonce } : undefined, - target: { ...target, align: aimedByRail ? 'start' : 'center' }, - }; -} - /** * A user Message this client has shown but cannot yet prove is durable. * @@ -259,21 +254,25 @@ export function ChatView(props: { * chat view only scrolls/highlights the already-rendered turn. */ scrollTargetTurn?: { turnId: string; nonce: number; preserveFocus?: boolean }; - /** Runtime-only reading position restored without search focus or highlight. */ + /** + * Runtime-only reading position restored without search focus or highlight. + * `unavailable`: the Turn is not in `messages` and no earlier history remains. + */ restoreTargetTurn?: { turnId: string; unavailable?: boolean }; viewportNavigation?: TranscriptViewportNavigation; onReadingAnchorChange?(turnId?: string): void; scrollBehavior: ScrollBehavior; - hasOlderHistory?: boolean; - hasNewerHistory?: boolean; - /** Fills the window at an edge the reader is approaching. */ - onPrefetchHistory?(edge: 'older' | 'newer'): Promise; - onRetainWindow?(window: { firstTurnId: string; lastTurnId: string }): void; + /** Turns older than the first one in `messages` exist and can be loaded. */ + hasEarlierHistory?: boolean; + /** Prepends whole earlier Turns to `messages`. */ + onLoadEarlierHistory?(): void | Promise; + /** Turns outside `messages`, from the Session's Turn index, oldest first. */ transcriptTurnIndex?: ReadonlyArray<{ turnId: string; sequence: number; label: string }>; + /** Loads `messages` back to the start of an indexed Turn. */ + onLoadTranscriptTurn?(turn: { turnId: string; sequence: number }): void | Promise; /** Optional identity decorations shared with a host's work navigation. */ promptRailDecorations?: ReadonlyMap>; onPromptRailHighlight?(turnId: string | undefined): void; - onLoadTranscriptTurn?(target: { turnId: string; sequence: number }): void; /** * PR109f: when the active session is a branched session * (`parentSessionId` set on its summary), show a banner above the @@ -406,9 +405,6 @@ export function ChatView(props: { const hasRenderedLiveTurn = tailTurnId !== undefined && turns.some((turn) => turn.turnId === tailTurnId); const boundaryOverlayTurnId = activeContent?.turnId ?? (streamingActive ? tailTurnId : undefined); - // One rail tick per turn that carries a user prompt (Codex-style prompt - // navigation). Memoized so the rail's IntersectionObserver isn't rebuilt - // on every render. const transformedUserTurnIds = useMemo( () => new Set( props.messages.flatMap((message) => @@ -421,12 +417,12 @@ export function ChatView(props: { ), [props.messages], ); - // The rail's entries change only when a turn's persisted prompt/answer text - // does, but `turns` gets a new array on every delta. Handing the previous - // array back when nothing it reads moved keeps the memoized rail — and its - // transcript-wide IntersectionObserver — out of the streaming path. The - // per-entry comparison is O(1) per turn because an unaffected turn keeps its - // object identity, so its text is the same string reference. + // One rail tick per turn that carries a user prompt. The rail's entries + // change only when a turn's persisted prompt/answer text does, but `turns` + // gets a new array on every delta. Handing the previous array back when + // nothing it reads moved keeps the memoized rail out of the streaming path. + // The per-entry comparison is O(1) per turn because an unaffected turn keeps + // its object identity, so its text is the same string reference. const promptRailTurnsRef = useRef>([]); const loadedPromptRailTurns = useMemo(() => { const next = turns @@ -449,15 +445,25 @@ export function ChatView(props: { promptRailTurnsRef.current = next; return next; }, [turns]); + const turnIds = useMemo(() => new Set(turns.map((turn) => turn.turnId)), [turns]); const promptRailTurns = useMemo( () => { - const merged = mergePromptAnchorRailTurns(loadedPromptRailTurns, props.transcriptTurnIndex); + const merged = mergePromptAnchorRailTurns(loadedPromptRailTurns, props.transcriptTurnIndex, turnIds); return props.promptRailDecorations ? merged.map((turn) => ({ ...turn, ...props.promptRailDecorations?.get(turn.turnId) })) : merged; }, - [loadedPromptRailTurns, props.transcriptTurnIndex, props.promptRailDecorations], + [loadedPromptRailTurns, props.transcriptTurnIndex, turnIds, props.promptRailDecorations], ); + // Turn identity and order only, so a streaming delta keeps the same array. + const orderedTurnIdsRef = useRef([]); + if ( + orderedTurnIdsRef.current.length !== turns.length + || turns.some((turn, index) => orderedTurnIdsRef.current[index] !== turn.turnId) + ) { + orderedTurnIdsRef.current = turns.map((turn) => turn.turnId); + } + const orderedTurnIds = orderedTurnIdsRef.current; // Stable event wrappers (advanced-use-latest): parent handlers are // recreated per render upstream; routing through refs keeps the // memoized TurnView's function props identity-stable without @@ -492,7 +498,6 @@ export function ChatView(props: { (turnId: string) => onSwitchToBypassAndRetryRef.current?.(turnId), [], ); - const turnIds = useMemo(() => new Set(turns.map((turn) => turn.turnId)), [turns]); const conversationItemPlacement = useMemo(() => placeChatConversationItems( (props.conversationItems ?? []).map((item) => ({ afterTurnId: item.afterTurnId, @@ -501,28 +506,12 @@ export function ChatView(props: { })), turnIds, ), [props.conversationItems, turnIds]); - const turnIdsRef = useRef(turnIds); - turnIdsRef.current = turnIds; - const loadTranscriptTurnRef = useRef(props.onLoadTranscriptTurn); - loadTranscriptTurnRef.current = props.onLoadTranscriptTurn; const chatLayout = useChatLayoutContext(); if (!chatLayout) { throw new Error('ChatView must be rendered inside ChatSurfaceLayout'); } const scrollRef = chatLayout.scrollContainerRef; - const scrollAuthority = useTranscriptScrollAuthority(); - // The rail uses the same semantic navigation as search, but asks the shared - // reveal to place the prompt at the top without animation. - const railClaimRef = useRef(undefined); - const navigatePromptRail = useCallback((turn: PromptAnchorRailTurn) => { - if (turn.sequence !== undefined) { - railClaimRef.current = { turnId: turn.turnId }; - loadTranscriptTurnRef.current?.({ turnId: turn.turnId, sequence: turn.sequence }); - } - }, []); - const railAlignment = resolveRailAlignedTarget(railClaimRef.current, props.scrollTargetTurn); - railClaimRef.current = railAlignment.claim; - const scrollTargetTurn = railAlignment.target; + const virtualizerRef = useRef(null); // Ownership also groups retained prompts after their Turn stops running or // a successor starts. Execution recency must not move a prompt below its reply. const turnsById = new Map(turns.map((turn) => [turn.turnId, turn])); @@ -548,20 +537,55 @@ export function ChatView(props: { inlineTransientMessageIds, turns, ); - const { highlightedTurnId } = useChatScroll({ + const { startMargin, listRef, measureStartMargin } = useTranscriptStartMargin(scrollRef); + const { highlightedTurnId, commandTurnId, revealTurnAtStart, measurement } = useChatScroll({ scrollRef, + measureStartMargin, + virtualizerRef, sessionId: props.activeSession?.id, - messages: props.messages, - target: scrollTargetTurn, + turnIds: orderedTurnIds, + target: props.scrollTargetTurn, restoreTarget: props.restoreTargetTurn, viewportNavigation: props.viewportNavigation, onReadingAnchorChange: props.onReadingAnchorChange, behavior: props.scrollBehavior, - hasOlderHistory: props.hasOlderHistory, - hasNewerHistory: props.hasNewerHistory, - onPrefetchHistory: props.onPrefetchHistory, - onRetainWindow: props.onRetainWindow, }); + const onLoadTranscriptTurnRef = useRef(props.onLoadTranscriptTurn); + onLoadTranscriptTurnRef.current = props.onLoadTranscriptTurn; + const navigatePromptRail = useCallback( + (turn: PromptAnchorRailTurn) => { + const load = turn.sequence !== undefined && !orderedTurnIdsRef.current.includes(turn.turnId) + ? onLoadTranscriptTurnRef.current?.({ turnId: turn.turnId, sequence: turn.sequence }) + : undefined; + revealTurnAtStart(turn.turnId, Promise.resolve(load)); + }, + [revealTurnAtStart], + ); + const interaction = useTurnsHoldingInteraction(scrollRef); + const keepMountedIndexes = new Set(); + for (const turnId of [tailTurnId, commandTurnId, highlightedTurnId, interaction.focusTurnId]) { + const index = turnId ? orderedTurnIds.indexOf(turnId) : -1; + if (index !== -1) keepMountedIndexes.add(index); + } + // Unmounting a Turn inside a selection would drop that part of it. + const selectionIndexes = interaction.selectionEnds + .map((end) => end === 'before' ? 0 : end === 'after' ? orderedTurnIds.length - 1 : orderedTurnIds.indexOf(end.turnId)) + .filter((index) => index !== -1); + if (selectionIndexes.length > 0) { + for (let index = Math.min(...selectionIndexes); index <= Math.max(...selectionIndexes); index += 1) { + keepMountedIndexes.add(index); + } + } + const [loadingEarlierHistory, setLoadingEarlierHistory] = useState(false); + const loadEarlierHistory = (): void => { + const pending = props.onLoadEarlierHistory?.(); + if (!pending) return; + setLoadingEarlierHistory(true); + void pending.then( + () => setLoadingEarlierHistory(false), + () => setLoadingEarlierHistory(false), + ); + }; const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( scrollRef, Boolean(props.onQuoteSelection || props.onAskAboutSelection), @@ -698,6 +722,26 @@ export function ChatView(props: { ) ); + /** + * Nothing to show is exactly when this matters most: WorkHub filters the + * transcript to one Work, and a Work whose Turns are all still in unloaded + * history filters it down to nothing. So the control rides along with the + * empty state rather than sitting in the branch that replaces it — which + * also keeps the list's only child a single `null`, the shape that lets + * `ChatMessageList` render an empty state at all. + */ + const loadEarlierHistoryControl = props.hasEarlierHistory && props.onLoadEarlierHistory ? ( + +