diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 8bd366b228..255c207801 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -66,8 +66,8 @@ "electron": "observation seeding, reconnect and settle are Host subscriptions surviving a renderer remount" }, "transcript-scroll-cost.spec.ts": { - "tests": 3, - "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping" + "tests": 4, + "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping, and reader displacement only exists against a real layout" }, "workhub-layout.spec.ts": { "tests": 2, diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts index 5179ef42f7..b46d20963b 100644 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -21,20 +21,21 @@ import { expect, test } from './fixtures'; const GAP = '.maka-transcript-gap-row'; const TURN = '.maka-transcript-turn'; +/** Turns the partial-history fixture seeds. */ +const PARTIAL_HISTORY_TURN_COUNT = 18; -test('bounded transcript ranges expose only their truthful boundary gaps', async ({ +test('a bounded transcript range reaches its whole history without a control to ask', async ({ partialHistoryWindow: page, }) => { await page.setViewportSize({ width: 1_400, height: 800 }); - const olderGap = page.locator('[data-transcript-gap="older"]'); - const newerGap = page.locator('[data-transcript-gap="newer"]'); - await expect(olderGap).toBeVisible(); - await expect(olderGap.getByRole('button', { - name: /^(?:加载较早消息|Load earlier messages)$/, - })).toBeVisible(); - await expect(newerGap).toHaveCount(0); + // The window is bounded, and its boundaries are not something the reader is + // shown or has to act on: the transcript renders Turns and nothing else. + await expect(page.locator(TURN).first()).toBeVisible(); + await expect(page.locator(GAP)).toHaveCount(0); + await expect(page.locator('[data-transcript-gap]')).toHaveCount(0); await expect(page.locator('.maka-transcript-history-controls')).toHaveCount(0); + expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); const oldestPrompt = page.locator( '.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]', @@ -42,34 +43,24 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async await expect(oldestPrompt).toBeVisible(); await oldestPrompt.click(); - const firstTurn = page.locator('[data-turn-id="turn-partial-history-1"]'); - await expect(firstTurn).toBeVisible(); - await expect(firstTurn).toHaveAttribute('data-search-highlight', 'true'); - await expect(olderGap).toHaveCount(0); - await expect(newerGap).toBeVisible(); - await expect(newerGap.getByRole('button', { - name: /^(?:加载较新消息|Load newer messages)$/, - })).toBeVisible(); - await expect(page.locator(GAP)).toHaveCount(1); - expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10); + 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); + await expect(page.locator('[data-transcript-gap]')).toHaveCount(0); - const loadNewer = newerGap.getByRole('button', { - name: /^(?:加载较新消息|Load newer messages)$/, - }); - await loadNewer.click(); - await expect(page.locator('[data-turn-id="turn-partial-history-2"]')).toBeVisible(); - await expect(olderGap).toHaveCount(0); - await expect(newerGap).toBeVisible(); - await expect(loadNewer).toBeEnabled(); - - await loadNewer.click(); - await expect(page.locator('[data-turn-id="turn-partial-history-3"]')).toBeVisible(); - await expect(olderGap).toBeVisible(); - await expect(newerGap).toBeVisible(); - await expect(loadNewer).toBeEnabled(); - await expect(oldestPrompt).toBeVisible(); - await expect(page.locator(GAP)).toHaveCount(2); - expect(await page.locator(TURN).count()).toBeLessThanOrEqual(10); + // 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 }); + await expect(page.locator('[data-transcript-gap]')).toHaveCount(0); const returnToLatest = page.getByRole('button', { name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, @@ -77,8 +68,10 @@ test('bounded transcript ranges expose only their truthful boundary gaps', async await expect(returnToLatest).toBeVisible(); await returnToLatest.click(); - await expect(page.locator('[data-turn-id="turn-partial-history-18"]')).toBeVisible(); - await expect(newerGap).toHaveCount(0); + // 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()).toBeLessThanOrEqual(10); + expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); }); diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 4a9dcb1530..41a7b1ce72 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -37,12 +37,35 @@ import type { CDPSession, Page } from '@playwright/test'; import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; -import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS } from '../src/preload/transcript-contract'; import { expect, test } from './fixtures'; const SCROLLER = '[data-chat-scroll-container="true"]'; const TURN = '.maka-transcript-turn'; +/** + * The mounted range is now a band of pixels, not a Host constant: useChatScroll + * keeps the Turns within four screens of the reader and drops what sits beyond + * six, so what bounds this count is the viewport these tests set (700px) and + * how tall a fixture Turn is — no number the Main tail cache owns. + * + * Generous on purpose. The property worth guarding is that paging through 120 + * Turns stops adding Turns; a range that kept everything it paged in would + * mount all 120, and a band that quietly doubled would pass no threshold that + * left this much room. + */ +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 — 18px here, unchanged by this work — where a frame that + * lost the reader lands a Turn away or more. + */ +const BOUNDARY_DISPLACEMENT_MAX_PX = 40; + declare global { interface Window { __makaTranscriptCost?: { @@ -51,9 +74,33 @@ declare global { skipped: WeakSet; skippedCount: number; }; + __makaTranscriptDisplacement?: { + boundaries: TranscriptBoundary[]; + peakMounted: number; + 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; + readonly olderGapPx: string; + readonly newerGapPx: string; + /** 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 @@ -125,6 +172,129 @@ async function observe(page: Page): Promise { }); } +/** + * 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); + } + const gap = (direction: string): number => { + const row = document.querySelector(`[data-transcript-gap="${direction}"]`); + return row ? row.getBoundingClientRect().height : 0; + }; + return { + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + olderGap: gap('older'), + newerGap: gap('newer'), + tops, + key: [...tops.keys()].join(','), + }; + }; + const state: { boundaries: unknown[]; peakMounted: number; stop(): void } = { + boundaries: [], + peakMounted: 0, + stop: () => { running = false; }, + }; + let running = true; + // Only frames the reader is not currently scrolling through can be + // compared: a wheel tick moves every Turn on screen by its own delta, which + // is indistinguishable from a page that moved them. The reader's hand stops + // between gestures, and a page that lands then is exactly the one they see + // jump. + let lastWheelAt = -Infinity; + const QUIET_MS = 250; + document.addEventListener('wheel', () => { lastWheelAt = performance.now(); }, 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; + let peakMounted = 0; + const tick = (): void => { + if (!running) return; + const current = read(); + peakMounted = Math.max(peakMounted, current.tops.size); + state.peakMounted = peakMounted; + if (performance.now() - lastWheelAt < QUIET_MS) { + 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, + olderGapPx: `${before.olderGap}->${current.olderGap}`, + newerGapPx: `${before.newerGap}->${current.newerGap}`, + carried, + worstTurnId, + worstPx, + }); + } + previous = current; + requestAnimationFrame(tick); + }; + window.__makaTranscriptDisplacement = state as never; + requestAnimationFrame(tick); + }, SCROLLER); +} + +async function displacement(page: Page): Promise<{ + boundaries: readonly TranscriptBoundary[]; + peakMounted: number; +}> { + return page.evaluate(() => { + const state = window.__makaTranscriptDisplacement; + if (!state) throw new Error('the transcript displacement probe is missing'); + state.stop(); + return { boundaries: state.boundaries, peakMounted: state.peakMounted }; + }); +} + interface CostSample { transitionRuns: number; animationStarts: number; @@ -149,7 +319,37 @@ async function sample(page: Page): Promise { }); } +/** + * 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: how many pages the ramp reads is how tall the + * viewport happens to be against the fixture, and a loaded CI runner measured + * past it where this machine finishes in under two seconds. + */ +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); await page.locator(TURN).last().scrollIntoViewIfNeeded(); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())), @@ -232,9 +432,10 @@ test('the browser skips the Turns the reader has scrolled past', async ({ /** * The bound the Desktop transcript is built on: paging back through a history - * far longer than the active range mounts a bounded number of Turns, not a - * growing one. Sampled at every page rather than only at the end, because a - * range that overshoots and is trimmed afterwards is the regression. + * far longer than the retained band mounts a bounded number of Turns, not a + * growing one. Sampled at every page rather than only at the end, because the + * regression is a range that grows while the reader travels and is only trimmed + * once they stop. */ test('paging back through the whole history keeps the mounted range bounded', async ({ promptRailWindow: page, @@ -267,14 +468,74 @@ test('paging back through the whole history keeps the mounted range bounded', as expect(pages).toBeGreaterThan(0); await expect(turns.first()).toHaveAttribute('data-turn-id', 'turn-prompt-rail-1'); - expect(mountedMax).toBeLessThanOrEqual(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); + expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); - // Coming back from the far end is a range reload, not a scroll: the Host - // resolves a new window around the tail and the renderer mounts it. The - // suite's 10s expect timeout is sized for UI that is already on screen, and - // this step measured past it on a loaded CI runner. + // Coming back from the far end reads the tail page and rebuilds the window + // around it, so it is slower than the scrolling above. The suite's 10s expect + // timeout is sized for UI that is already on screen, and this step measured + // past it on a loaded CI runner. 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(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); + expect(await turns.count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); +}); + +/** + * 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. + * + * The tests above establish that paging works and stays bounded. Neither says + * 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 document + * position. + * + * 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('paging back never moves the reader at a range boundary', 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); + + 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 }); + // Let the hand come off the wheel. A page requested by this gesture + // lands here, in the quiet the probe measures across — which is also + // when a reader would see it move. + await page.waitForTimeout(150); + return turns.first().getAttribute('data-turn-id'); + }) + .not.toBe(firstBefore); + } + + const { boundaries, peakMounted } = 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([]); + // What the window holds is bounded in pixels, and the tests above already + // hold it to that. Reported here only so a boundary that moved the reader can + // be read against how much the range was carrying when it did. + expect(peakMounted).toBeGreaterThan(0); }); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index cc39f3b958..d9bbc8330a 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -500,7 +500,7 @@ "react": 1 }, "importSpecifiers": 19, - "nonTriviaTokens": 3816 + "nonTriviaTokens": 3801 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 7, @@ -710,7 +710,7 @@ "nonTriviaTokens": 1273 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 70, + "importDeclarations": 69, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -775,7 +775,7 @@ "useShellRunUpdates": 1, "useShellSearch": 1, "useStableActions": 6, - "useState": 14, + "useState": 13, "useSystemUiLocale": 1, "useTaskSubmissionReadiness": 1, "useToast": 1, @@ -786,7 +786,6 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../preload/transcript-contract.js": 1, "./agent-graph-panel": 1, "./app-shell-chat-actions": 1, "./app-shell-chrome-actions": 1, @@ -871,8 +870,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 108, - "nonTriviaTokens": 13697 + "importSpecifiers": 107, + "nonTriviaTokens": 13670 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, 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 4758d5ad67..b9e069ef8b 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 @@ -33,11 +33,8 @@ import { } from '../../renderer/app-shell-session-ui-state.js'; import { createTranscriptRestoreLifecycle, - loadTranscriptHistory, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, - type TranscriptHistoryGates, - type TranscriptHistoryPending, } from '../../renderer/features/conversation/testing.js'; function boundaryRequest(requestId: string): SandboxBoundaryRequestEvent { @@ -67,93 +64,6 @@ function answeredArm(turnId: string) { return confirmLiveTurn(armLiveTurn(turnId), turnId)!; } -function deferred() { - let resolve!: () => void; - let reject!: (error: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function deferredHistoryController() { - const before = deferred(); - const after = deferred(); - const latest = deferred(); - const calls: string[] = []; - return { - controller: { - loadBefore: () => { - calls.push('before'); - return before.promise; - }, - loadAfter: (maxBytes: number, anchorTurnId?: string) => { - calls.push(`after:${maxBytes}:${anchorTurnId}`); - return after.promise; - }, - loadLatest: () => { - calls.push('latest'); - return latest.promise; - }, - }, - calls, - settleBefore: before.resolve, - settleAfter: after.resolve, - settleLatest: latest.resolve, - failBefore: before.reject, - }; -} - -function crossSessionGateScenario() { - type HistoryRequest = Parameters[0]['request']; - const gates: TranscriptHistoryGates = new WeakMap(); - const sessionIds = { a: 'session', b: 'session:a' } as const; - const sides = { - a: deferredHistoryController(), - b: deferredHistoryController(), - }; - let active: 'a' | 'b' = 'a'; - let range: object = sides.a.controller; - let currentPending: TranscriptHistoryPending | undefined; - const pending = { - a: [] as Array | undefined>, - b: [] as Array | undefined>, - }; - const errors = { a: [] as unknown[], b: [] as unknown[] }; - return { - sides, - pending, - errors, - currentPending: () => currentPending, - switchTo(id: 'a' | 'b') { - active = id; - range = sides[id].controller; - }, - load( - id: 'a' | 'b', - request: { target: 'earlier' | 'later' | 'latest'; anchorTurnId?: string }, - ) { - const side = sides[id]; - return loadTranscriptHistory({ - gates, - sessionId: sessionIds[id], - request, - controller: side.controller, - maxBytes: 4096, - isCurrent: () => active === id && range === side.controller, - setPending: (update) => { - currentPending = update(currentPending); - pending[id].push(currentPending?.sessionId === sessionIds[id] - ? { target: currentPending.target } - : undefined); - }, - onError: (error) => errors[id].push(error), - }); - }, - }; -} - function seededState(): AppShellSessionUiState { return { ...createInitialAppShellSessionUiState(), @@ -405,7 +315,6 @@ describe('app shell session UI state controller', () => { it('enriches a Turn-only reading anchor when its range sequence arrives later', async () => { let anchor: { turnId: string; sequence?: number } | undefined; - const admitted: Array = []; restoreSessionTranscriptRange({ lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'session', @@ -418,7 +327,6 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => 17, snapshot: () => ({ messages: [] }), }, - setReadingAnchor: async (sequence) => { admitted.push(sequence); }, loadAround: async () => assert.fail('the resident Turn must not load another range'), }, isCurrent: () => true, @@ -430,7 +338,7 @@ describe('app shell session UI state controller', () => { assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 }); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(admitted, [17]); + assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 }); }); it('does not enrich a reading anchor from another Session range', () => { @@ -451,7 +359,6 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => 17, snapshot: () => ({ messages: [] }), }, - setReadingAnchor: async () => {}, loadAround: async () => assert.fail('a stale range must not load'), }, isCurrent: () => true, @@ -480,7 +387,6 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => null, snapshot: () => ({ messages: [] }), }, - setReadingAnchor: async () => {}, loadAround: async () => assert.fail('a Turn-only anchor has no load target'), }, isCurrent: () => true, @@ -516,7 +422,6 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => 29, snapshot: () => ({ messages: [{ id: 'latest' }] }), }, - setReadingAnchor: async () => assert.fail('a missing durable Turn must load its range'), loadAround: async (sequence: number) => { loadedSequence = sequence; }, @@ -539,155 +444,6 @@ describe('app shell session UI state controller', () => { assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'removed' }); }); - it('starts another Session history load without waiting behind an in-flight load elsewhere', async () => { - const scenario = crossSessionGateScenario(); - const stale = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(scenario.sides.a.calls, ['before']); - assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }]); - - scenario.switchTo('b'); - const navigation = scenario.load('b', { target: 'latest' }); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(scenario.sides.b.calls, ['latest']); - assert.deepEqual(scenario.pending.b, [{ target: 'latest' }]); - - scenario.sides.a.settleBefore(); - await stale; - assert.deepEqual(scenario.currentPending(), { sessionId: 'session:a', target: 'latest' }); - scenario.sides.b.settleLatest(); - await navigation; - }); - - it('leaves the switched-to Session untouched when a stale Session load settles late', async () => { - const scenario = crossSessionGateScenario(); - const stale = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - scenario.switchTo('b'); - const navigation = scenario.load('b', { target: 'latest' }); - scenario.sides.b.settleLatest(); - await navigation; - assert.deepEqual(scenario.pending.b, [{ target: 'latest' }, undefined]); - assert.deepEqual(scenario.sides.b.calls, ['latest']); - - scenario.sides.a.settleBefore(); - await stale; - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(scenario.pending.b, [{ target: 'latest' }, undefined]); - assert.deepEqual(scenario.sides.b.calls, ['latest']); - assert.deepEqual(scenario.errors.b, []); - }); - - it('reports a late history load failure to its own Session alone', async () => { - const scenario = crossSessionGateScenario(); - const stale = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - scenario.switchTo('b'); - scenario.sides.a.failBefore(new Error('earlier read failed')); - await stale; - assert.deepEqual(scenario.errors.a, []); - assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }]); - assert.deepEqual(scenario.pending.b, []); - }); - - it('replays the queued latest load once after an in-flight load settles on the same Session', async () => { - const scenario = crossSessionGateScenario(); - const inFlight = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - const queuedEarlier = scenario.load('a', { target: 'earlier', anchorTurnId: 'turn-anchor' }); - const queuedLatest = scenario.load('a', { target: 'latest' }); - assert.deepEqual(scenario.sides.a.calls, ['before']); - - scenario.sides.a.settleBefore(); - await inFlight; - assert.deepEqual(scenario.sides.a.calls, ['before', 'latest']); - scenario.sides.a.settleLatest(); - await Promise.allSettled([queuedEarlier, queuedLatest]); - assert.deepEqual(scenario.pending.a, [ - { target: 'earlier' }, - undefined, - { target: 'latest' }, - undefined, - ]); - }); - - it('replays a queued forward load with its reading anchor after a backward load settles', async () => { - const scenario = crossSessionGateScenario(); - const inFlight = scenario.load('a', { target: 'earlier' }); - const queued = scenario.load('a', { target: 'later', anchorTurnId: 'turn-anchor' }); - assert.deepEqual(scenario.sides.a.calls, ['before']); - - scenario.sides.a.settleBefore(); - await inFlight; - assert.deepEqual(scenario.sides.a.calls, ['before', 'after:4096:turn-anchor']); - scenario.sides.a.settleAfter(); - await queued; - assert.deepEqual(scenario.pending.a, [ - { target: 'earlier' }, - undefined, - { target: 'later' }, - undefined, - ]); - }); - - it('keeps the queued latest load when adjacent requests arrive after it', async () => { - const scenario = crossSessionGateScenario(); - const inFlight = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - const queuedLatest = scenario.load('a', { target: 'latest' }); - const queuedEarlier = scenario.load('a', { target: 'earlier', anchorTurnId: 'turn-anchor' }); - const queuedLater = scenario.load('a', { target: 'later', anchorTurnId: 'turn-anchor' }); - assert.deepEqual(scenario.sides.a.calls, ['before']); - - scenario.sides.a.settleBefore(); - await inFlight; - assert.deepEqual(scenario.sides.a.calls, ['before', 'latest']); - scenario.sides.a.settleLatest(); - await Promise.allSettled([queuedLatest, queuedEarlier, queuedLater]); - assert.deepEqual(scenario.pending.a, [ - { target: 'earlier' }, - undefined, - { target: 'latest' }, - undefined, - ]); - }); - - it('does not replay a settled load after its Session range was replaced', async () => { - const scenario = crossSessionGateScenario(); - const inFlight = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - const queued = scenario.load('a', { target: 'latest' }); - scenario.switchTo('b'); - scenario.sides.a.settleBefore(); - await inFlight; - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(scenario.sides.a.calls, ['before']); - assert.deepEqual(scenario.sides.b.calls, []); - assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }]); - scenario.sides.a.settleLatest(); - await queued; - }); - - it('replays a queued load through its own Session callbacks', async () => { - const scenario = crossSessionGateScenario(); - const inFlight = scenario.load('a', { target: 'earlier' }); - await new Promise((resolve) => setImmediate(resolve)); - const queued = scenario.load('a', { target: 'latest' }); - scenario.sides.a.settleBefore(); - await inFlight; - assert.deepEqual(scenario.sides.a.calls, ['before', 'latest']); - scenario.sides.a.settleLatest(); - await queued; - assert.deepEqual(scenario.pending.a, [ - { target: 'earlier' }, - undefined, - { target: 'latest' }, - undefined, - ]); - assert.deepEqual(scenario.pending.b, []); - assert.deepEqual(scenario.sides.b.calls, []); - }); - 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 6c84432260..0d3472f688 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 @@ -26,9 +26,9 @@ import { encodeDesktopTranscriptSnapshot, } from '../desktop-transcript-ipc.js'; import { - DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, } from '../../preload/transcript-contract.js'; import { createDesktopTranscriptReconnectRecovery, @@ -123,10 +123,6 @@ test('moves a fragmented overlay record to durable storage without duplicating i const change = [...encodeDesktopTranscriptChange(identity, { durableThrough: 4, durableUpserts: [{ sequence: 4, message }], - evictedDurableSequences: [], - completedOverlayMessageIds: [message.id], - hasOlder: true, - hasNewer: false, })]; for (const batch of change) store.accept(batch); assert.deepEqual(store.snapshot().messages, [message]); @@ -136,7 +132,7 @@ test('moves a fragmented overlay record to durable storage without duplicating i assert.deepEqual(store.snapshot().messages, [message]); }); -test('retains the newest observed durable prompt across eviction', () => { +test('tracks the newest resident durable prompt as the window changes', () => { const identity = { sessionId: 'session-1', generation: 'generation-1', @@ -159,13 +155,13 @@ test('retains the newest observed durable prompt across eviction', () => { for (const batch of encodeDesktopTranscriptChange(identity, { durableThrough: 4, - durableUpserts: [{ sequence: 4, message: assistantMessage('latest') }], - evictedDurableSequences: [3], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - })) store.accept(batch); + 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', () => { @@ -198,12 +194,7 @@ test('drops stale transcript batches after a generation reset', () => { { sessionId: 'session-1', generation: 'old', hostEpoch: 'host-1' }, { durableThrough: 3, - durableUpserts: [{ sequence: 3, message: assistantMessage('stale') }], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - }, + durableUpserts: [{ sequence: 3, message: assistantMessage('stale') }], }, )]; for (const batch of staleChange) assert.equal(store.accept(batch), false); assert.deepEqual(store.snapshot().messages, [nextMessage]); @@ -218,9 +209,9 @@ test('cached reload snapshots allow the same live transcript generation to resum }; let opens = 0; const deliveries: Array<{ generation: string; accepted: boolean }> = []; - const publish = (generation: string, text: string, navigationVersion = 0) => { + const publish = (generation: string, text: string, windowEpoch = 0) => { for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, generation, navigationVersion, durableThrough: 1, + ...identity, generation, windowEpoch, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage(text) }], overlay: [], hasOlder: false, hasNewer: false, })) deliveries.push({ generation, accepted: store.accept(batch) }); @@ -238,7 +229,10 @@ test('cached reload snapshots allow the same live transcript generation to resum async loadBefore() {}, async loadAfter() {}, async loadAround(_sequence, _maxBytes, navigation) { - publish(identity.generation, `live-${opens}`, navigation?.navigationVersion); + publish(identity.generation, `live-${opens}`, navigation?.windowEpoch); + }, + async loadLatest(navigation) { + publish(identity.generation, `live-${opens}`, navigation?.windowEpoch); }, async close() {}, }; @@ -257,10 +251,7 @@ test('cached reload snapshots allow the same live transcript generation to resum const updated = assistantMessage('live update', 'assistant-2'); for (const batch of encodeDesktopTranscriptChange(identity, { durableThrough: 2, - durableUpserts: [{ sequence: 2, message: updated }], - evictedDurableSequences: [], completedOverlayMessageIds: [], - hasOlder: false, hasNewer: false, - })) 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]); } finally { await controller.close(); @@ -286,10 +277,7 @@ test('a replacement live generation retires the previous replica through cached sessionId: 'session-1', generation, hostEpoch: 'host-1', }, { durableThrough: 2, - durableUpserts: [{ sequence: 2, message: assistantMessage('stale', 'stale') }], - evictedDurableSequences: [], completedOverlayMessageIds: [], - hasOlder: false, hasNewer: false, - })) 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')]); @@ -322,12 +310,7 @@ test('keeps unchanged message references stable across immutable range snapshots for (const batch of encodeDesktopTranscriptChange(identity, { durableThrough: 2, - durableUpserts: [{ sequence: 2, message: secondMessage }], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - })) store.accept(batch); + durableUpserts: [{ sequence: 2, message: secondMessage }], })) store.accept(batch); const second = store.snapshot(); assert.notStrictEqual(second, first); @@ -364,11 +347,11 @@ test('bounds the default active transcript range by Turn identities', async () = const snapshot = replica.snapshot(); assert.equal( new Set(snapshot.durable.map(({ message }) => message.turnId)).size, - DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, + DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, ); assert.equal( snapshot.durable[0]?.sequence, - messages.length - DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, + messages.length - DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, ); assert.equal(snapshot.durable.at(-1)?.sequence, 199); assert.equal(snapshot.hasOlder, true); @@ -481,349 +464,6 @@ test('keeps an oversized latest Turn visible before a trailing session note', as assert.ok(replica.snapshot().durable.some(({ sequence }) => sequence === latest.identity)); }); -test('keeps an oversized latest Turn when returning from history to a trailing session note', async () => { - const older = { - identity: 0, - message: { ...assistantMessage('older', 'assistant-older'), turnId: 'turn-older' }, - }; - const latest = { - identity: 1, - message: { - ...assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-latest'), - turnId: 'turn-latest', - }, - }; - const trailingNote = { - identity: 2, - message: { - type: 'system_note' as const, - id: 'mode-change-latest', - ts: 3, - kind: 'mode_change' as const, - }, - }; - const bootstrapPage = { - ...transcriptPage('older', 'older', trailingNote.identity), - rangeBoundarySequence: latest.identity, - protectedTurnSequence: latest.identity, - }; - const olderPage = { - ...transcriptPage('older', null, trailingNote.identity), - rangeBoundarySequence: older.identity, - protectedTurnSequence: older.identity, - }; - const latestPage = { - ...transcriptPage('older', null, trailingNote.identity), - rangeBoundarySequence: latest.identity, - protectedTurnSequence: latest.identity, - }; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: trailingNote.identity, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { - ...bootstrapPage, - source: 'overlay', - rangeBoundarySequence: null, - protectedTurnSequence: null, - }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (page) => page === bootstrapPage - ? { messages: [latest, trailingNote], nextCursor: 'older' } - : page === olderPage - ? { messages: [older], nextCursor: null } - : { messages: [latest, trailingNote], nextCursor: null }, - loadTranscriptPage: async (input) => input.anchorSequence === latest.identity - ? olderPage - : latestPage, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle); - - await replica.loadBefore(latest.identity, 128 * 1024); - assert.equal(replica.snapshot().hasNewer, true); - - await replica.loadAround(trailingNote.identity, 128 * 1024); - - assert.ok(replica.snapshot().durable.some(({ sequence }) => sequence === latest.identity)); -}); - -test('keeps a bounded contiguous window while moving between history and the tail', async () => { - const messages = [0, 1, 2, 3, 4].map((sequence) => ({ - identity: sequence, - message: { - ...assistantMessage(String(sequence), `assistant-${sequence}`), - turnId: `turn-${sequence}`, - }, - })); - const page = (nextCursor: string | null) => ({ - kind: 'page' as const, - sessionId: 'session-1', - source: 'durable' as const, - direction: 'older' as const, - throughSequence: 4, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page('older'); - const olderPage = page('older'); - const latestPage = page(null); - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 4, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...page(null), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => candidate === bootstrapPage - ? { messages: messages.slice(3), nextCursor: 'older' } - : candidate === olderPage - ? { messages: messages.slice(1, 3), nextCursor: 'older' } - : { messages: messages.slice(4), nextCursor: null }, - loadTranscriptPage: async (input) => input.anchorSequence === 3 ? olderPage : latestPage, - async close() {}, - }); - const maxResidentBytes = ( - Buffer.byteLength(JSON.stringify(messages[0]!.message), 'utf8') - + Buffer.byteLength(JSON.stringify(messages[1]!.message), 'utf8') - + 1 - ); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes, - }); - - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [3, 4]); - await replica.loadBefore(3, 128 * 1024); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2, 3]); - assert.equal(replica.snapshot().hasNewer, true); - - await replica.loadAround(4, 128 * 1024); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [4]); - assert.equal(replica.snapshot().hasNewer, false); - assert.ok(replica.residentBytes <= maxResidentBytes); -}); - -test('retains the reading anchor while an older page replaces the far edges', async () => { - const messages = Array.from({ length: 8 }, (_, sequence) => ({ - identity: sequence, - message: { - ...assistantMessage(String(sequence), `assistant-${sequence}`), - turnId: `turn-${sequence}`, - }, - })); - const bootstrapPage = transcriptPage('older', 'older', 7); - const olderPage = transcriptPage('older', null, 7); - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 7, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, 7), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (page) => ({ - messages: page === bootstrapPage ? messages.slice(4) : messages.slice(0, 4), - nextCursor: page === bootstrapPage ? 'older' : null, - }), - loadTranscriptPage: async () => olderPage, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes: 1024 * 1024, - maxResidentTurns: 4, - }); - - await replica.loadBefore(4, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - - const snapshot = replica.snapshot(); - assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [2, 3, 4, 5]); - assert.equal(snapshot.hasOlder, true); - assert.equal(snapshot.hasNewer, true); -}); - -for (const { stride, textBytes } of [1, 3].flatMap((stride) => - [0, 300 * 1024, 600 * 1024].map((textBytes) => ({ stride, textBytes })), -)) { - test(`scrolls both ways through bounded stride-${stride} history with ${textBytes}-byte Turns`, async () => { - const messages = Array.from({ length: 40 }, (_, index) => ({ - identity: index * stride, - message: { ...assistantMessage('x'.repeat(textBytes), `assistant-${index}`), turnId: `turn-${index}` }, - })); - const largestTurnBytes = Math.max(...messages.map(({ message }) => - Buffer.byteLength(JSON.stringify(message), 'utf8'), - )); - const maxNavigationBytes = Math.max(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, 2 * largestTurnBytes); - const pageTurns = textBytes === 0 ? 10 : 1; - const through = 39 * stride; - const pages = new Map(); - const makePage = (direction: 'older' | 'newer', anchor: number | null) => { - const candidates = messages.filter(({ identity }) => anchor === null - || (direction === 'older' ? identity < anchor : identity > anchor)); - const nextCursor = candidates.length > pageTurns ? 'more' : null; - const page = transcriptPage(direction, nextCursor, through); - pages.set(page, { - messages: direction === 'older' ? candidates.slice(-pageTurns) : candidates.slice(0, pageTurns), - nextCursor, - }); - return page; - }; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - async close() {}, - transcriptBootstrap: { - throughSequence: through, - overlayMessageCount: 0, - durable: makePage('older', null), - overlay: { ...transcriptPage('older', null, through), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (page) => pages.get(page)!, - loadTranscriptPage: async (input) => makePage(input.direction, input.anchorSequence), - }); - const store = transcriptStore(); - let navigationVersion = 0; - const replica = await DesktopTranscriptReplica.prepare(handle, { - generation: 'generation-1', - onChange: (current, change) => { - for (const batch of encodeDesktopTranscriptChange({ ...current.snapshot(), navigationVersion }, change)) store.accept(batch); - }, - }); - for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) store.accept(batch); - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch, - readThroughMessageId: null, - loadBefore: (anchor, maxBytes, navigation) => { - navigationVersion = navigation?.navigationVersion ?? navigationVersion; - return replica.loadBefore(anchor, maxBytes!); - }, - loadAfter: (anchor, maxBytes, navigation) => { - navigationVersion = navigation?.navigationVersion ?? navigationVersion; - return replica.loadAfter(anchor, maxBytes!); - }, - loadAround: async () => { throw new Error('ordinary scrolling must not replace the range'); }, - close: async () => replica.close(), - })); - for (const direction of ['older', 'newer', 'older', 'newer'] as const) { - let steps = 0; - while (direction === 'older' ? store.range().hasOlder : store.range().hasNewer) { - assert.ok(++steps <= 40, 'paging must make progress'); - const before = replica.snapshot(); - const anchor = (direction === 'older' ? before.durable[0] : before.durable.at(-1))!; - await (direction === 'older' - ? controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchor.message.turnId) - : controller.loadAfter(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchor.message.turnId)); - const after = replica.snapshot(); - assert.ok(after.durable.some(({ sequence }) => sequence === anchor.sequence)); - assert.ok(after.durable.length <= DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); - assert.ok(replica.residentBytes <= maxNavigationBytes, - 'only the reading Turn and one adjacent Turn may exceed the soft range budget'); - assert.ok(direction === 'older' - ? after.durable[0]!.sequence < before.durable[0]!.sequence - : after.durable.at(-1)!.sequence > before.durable.at(-1)!.sequence, - 'every adjacent edge load must make progress'); - for (let i = 1; i < after.durable.length; i++) { - assert.equal(after.durable[i]!.sequence - after.durable[i - 1]!.sequence, stride); - } - } - assert.equal(direction === 'older' ? store.range().oldestSequence : store.range().newestSequence, - direction === 'older' ? 0 : through); - } - // Global pressure may reclaim the range even after navigation used the - // atomic-Turn exception; the protection is local to that operation. - replica.trimDurable(128 * 1024); - assert.ok(replica.residentBytes <= 128 * 1024); - await controller.close(); - }); -} - -test('delivers a mid-session tail append after following the tail from a history window', async () => { - // A follow-tail intent must recover the tail even if the resident cache - // still contains history when the Host advances. - const messages = [0, 1, 2, 3, 4].map((sequence) => ({ - identity: sequence, - message: { - ...assistantMessage(String(sequence), `assistant-${sequence}`), - turnId: `turn-${sequence}`, - }, - })); - const appended = { identity: 5, message: assistantMessage('5', 'assistant-5') }; - const page = (nextCursor: string | null) => ({ - kind: 'page' as const, - sessionId: 'session-1', - source: 'durable' as const, - direction: 'older' as const, - throughSequence: 4, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page('older'); - const olderPage = page('older'); - // The tail reload after the append: a fresh newest window ending at seq 5, - // with older history still available below it. - const tailPage = { ...page('older'), throughSequence: 5 }; - const changes: { durableUpserts: readonly { sequence: number }[] }[] = []; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 4, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...page(null), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => candidate === bootstrapPage - ? { messages: messages.slice(3), nextCursor: 'older' } - : candidate === tailPage - ? { messages: [appended], nextCursor: 'older' } - : { messages: messages.slice(1, 3), nextCursor: 'older' }, - loadTranscriptPage: async (input) => input.throughSequence === 5 ? tailPage : olderPage, - async close() {}, - }); - const maxResidentBytes = ( - Buffer.byteLength(JSON.stringify(messages[0]!.message), 'utf8') - + Buffer.byteLength(JSON.stringify(messages[1]!.message), 'utf8') - + 1 - ); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes, - onChange: (_replica, change) => changes.push(change), - }); - - await replica.loadBefore(3, 128 * 1024); - assert.equal(replica.snapshot().hasNewer, true); - replica.setNavigation('followTail'); - changes.splice(0); - - // The Host persists a new assistant message (sequence 5) and advances. - await replica.advance(5); - - const upserts = changes.flatMap((change) => change.durableUpserts.map(({ sequence }) => sequence)); - assert.ok(upserts.includes(5), 'the tail append must be delivered to open consumers'); - assert.equal(replica.durableThrough, 5); -}); - test('advances a projected transcript across hidden durable records', async () => { const visible = (sequence: number) => ({ identity: sequence, @@ -966,102 +606,6 @@ test('keeps an oversized settled Turn visible before a trailing session note', a assert.deepEqual(snapshot.overlay, []); }); -test('does not resurrect a discarded replica when a tail re-anchor is in flight', async () => { - // Guards the concurrency edge introduced by re-anchoring on `hasNewer`: the - // re-anchor now awaits a page load, and `discard()` (memory reclaim for a - // non-visible session) can run during that await. When the page resolves the - // replica must stay non-resident and empty — repopulating durable state here - // would undo the eviction and blow the memory bound. - const messages = [0, 1, 2, 3, 4].map((sequence) => ({ - identity: sequence, - message: { - ...assistantMessage(String(sequence), `assistant-${sequence}`), - turnId: `turn-${sequence}`, - }, - })); - const appended = { identity: 5, message: assistantMessage('5', 'assistant-5') }; - const page = (nextCursor: string | null) => ({ - kind: 'page' as const, - sessionId: 'session-1', - source: 'durable' as const, - direction: 'older' as const, - throughSequence: 4, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page('older'); - const olderPage = page('older'); - const tailPage = { ...page('older'), throughSequence: 5 }; - let releaseTail: () => void = () => {}; - const tailGate = new Promise((resolve) => { - releaseTail = resolve; - }); - let signalTailEntered: () => void = () => {}; - const tailEntered = new Promise((resolve) => { - signalTailEntered = resolve; - }); - const changes: { durableUpserts: readonly { sequence: number }[] }[] = []; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 4, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...page(null), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => candidate === bootstrapPage - ? { messages: messages.slice(3), nextCursor: 'older' } - : candidate === tailPage - ? { messages: [appended], nextCursor: 'older' } - : { messages: messages.slice(1, 3), nextCursor: 'older' }, - loadTranscriptPage: async (input) => { - if (input.throughSequence === 5) { - // Signal that catch-up is now parked inside the re-anchor's page await, - // so the test can `discard()` at exactly that point. - signalTailEntered(); - await tailGate; - return tailPage; - } - return olderPage; - }, - async close() {}, - }); - const maxResidentBytes = ( - Buffer.byteLength(JSON.stringify(messages[0]!.message), 'utf8') - + Buffer.byteLength(JSON.stringify(messages[1]!.message), 'utf8') - + 1 - ); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes, - onChange: (_replica, change) => changes.push(change), - }); - - await replica.loadBefore(3, 128 * 1024); - assert.equal(replica.snapshot().hasNewer, true); - replica.setNavigation('followTail'); - changes.splice(0); - - // Start the tail re-anchor; wait until catch-up is parked inside its page - // await, then reclaim memory before the page resolves. - const advancing = replica.advance(5); - await tailEntered; - replica.discard(); - assert.equal(replica.resident, false); - releaseTail(); - await advancing; - - const upserts = changes.flatMap((change) => change.durableUpserts.map(({ sequence }) => sequence)); - assert.ok(!upserts.includes(5), 'a discarded replica must not be repopulated by an in-flight re-anchor'); - assert.equal(replica.resident, false); - assert.equal(replica.residentBytes, 0); -}); - 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. @@ -1215,154 +759,6 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is assert.equal(replica.residentBytes, 0); }); -test('loads a history target with newer messages available below it', async () => { - const messages = [0, 1, 2, 3, 4].map((sequence) => ({ - identity: sequence, - message: assistantMessage(String(sequence), `assistant-${sequence}`), - })); - const bootstrapPage = transcriptPage('older', null, 4); - const aroundPage = transcriptPage('newer', 'newer', 4); - const inputs: Array<{ direction: string; anchorSequence: number | null }> = []; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 4, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, 4), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (page) => page === bootstrapPage - ? { messages: messages.slice(4), nextCursor: null } - : { messages: messages.slice(0, 3), nextCursor: 'newer' }, - loadTranscriptPage: async (input) => { - inputs.push(input); - return input.direction === 'older' ? olderProbePage(0, false) : aroundPage; - }, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes: 128 * 1024, - }); - - await replica.loadAround(0, 128 * 1024); - - assert.deepEqual( - inputs.map(({ direction, anchorSequence }) => ({ direction, anchorSequence })), - [ - { direction: 'newer', anchorSequence: null }, - // Nothing older than the anchor exists, and only this read can say so. - { direction: 'older', anchorSequence: 0 }, - ], - ); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [0, 1, 2]); - assert.equal(replica.snapshot().hasOlder, false); - assert.equal(replica.snapshot().hasNewer, true); -}); - -test('keeps an oversized transcript sparse while moving between indexed prompts', async () => { - const messages = syntheticLargeTranscript(); - const totalBytes = messages.reduce( - (total, entry) => total + Buffer.byteLength(JSON.stringify(entry.message), 'utf8'), - 0, - ); - assert.ok(totalBytes > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES * 2); - - const bootstrapPage = transcriptPage('older', 'older', 15); - const historicalPage = transcriptPage('newer', 'newer', 15); - const intermediatePage = transcriptPage('newer', 'newer', 15); - const latestPage = transcriptPage('older', 'older', 15); - const requests: Array<{ - direction: 'older' | 'newer'; - anchorSequence: number | null; - maxBytes: number; - }> = []; - const rendererStore = transcriptStore(); - const generation = 'oversized-range'; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 15, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, 15), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (page) => page === bootstrapPage || page === latestPage - ? { messages: messages.slice(12, 16), nextCursor: 'older' } - : page === historicalPage - ? { messages: messages.slice(0, 5), nextCursor: 'newer' } - : { messages: messages.slice(6, 11), nextCursor: 'newer' }, - loadTranscriptPage: async (input) => { - requests.push({ - direction: input.direction, - anchorSequence: input.anchorSequence, - maxBytes: input.maxBytes, - }); - if (input.direction === 'older') { - if (input.maxBytes > 1) return latestPage; - return olderProbePage(input.anchorSequence!, input.anchorSequence !== 0); - } - return input.anchorSequence === null ? historicalPage : intermediatePage; - }, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle, { - generation, - onChange: (_current, change) => { - for (const batch of encodeDesktopTranscriptChange({ - sessionId: 'session-1', - generation, - hostEpoch: 'host-1', - }, change)) rendererStore.accept(batch); - }, - }); - for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) { - rendererStore.accept(batch); - } - - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [12, 13, 14, 15]); - assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 7', 'Prompt 8']); - assert.equal(rendererStore.range().hasNewer, false); - assertRangeFitsBudget(rendererStore); - - await replica.loadAround(0, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [0, 1, 2, 3, 4]); - assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 1', 'Prompt 2', 'Prompt 3']); - assert.equal(rendererStore.range().hasOlder, false); - assert.equal(rendererStore.range().hasNewer, true); - assertRangeFitsBudget(rendererStore); - - await replica.loadAround(6, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [6, 7, 8, 9, 10]); - assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 4', 'Prompt 5', 'Prompt 6']); - assert.equal(rendererStore.range().hasOlder, true); - assert.equal(rendererStore.range().hasNewer, true); - assertRangeFitsBudget(rendererStore); - - await replica.loadAround(15, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [12, 13, 14, 15]); - assert.deepEqual(renderedUserPrompts(rendererStore), ['Prompt 7', 'Prompt 8']); - assert.equal(rendererStore.range().hasOlder, true); - assert.equal(rendererStore.range().hasNewer, false); - assertRangeFitsBudget(rendererStore); - - // Every jump that is not to the tail pays one extra single-byte read, the - // only thing that can say whether the anchor has anything older than it. - assert.deepEqual(requests, [ - { direction: 'newer', anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, - { direction: 'older', anchorSequence: 0, maxBytes: 1 }, - { direction: 'newer', anchorSequence: 5, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, - { direction: 'older', anchorSequence: 6, maxBytes: 1 }, - { direction: 'older', anchorSequence: 16, maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES }, - ]); - replica.close(); -}); - test('rejects an overlay that exceeds its cache budget', async () => { const messages = [ assistantMessage('x'.repeat(700), 'overlay-1'), @@ -1386,40 +782,6 @@ test('rejects an overlay that exceeds its cache budget', async () => { ); }); -test('keeps history resident when an active overlay uses its own cache budget', async () => { - const overlay = assistantMessage('o'.repeat(700), 'overlay-1'); - const historical = assistantMessage('h'.repeat(700), 'history-1'); - const bootstrapPage = transcriptPage('older', 'older', 1); - const olderPage = transcriptPage('older', null, 1); - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 1, - overlayMessageCount: 1, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, 1), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [overlay], - decodeTranscriptPage: async (page) => page === olderPage - ? { messages: [{ identity: 0, message: historical }], nextCursor: null } - : { messages: [], nextCursor: 'older' }, - loadTranscriptPage: async () => olderPage, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes: 1_024, - maxOverlayBytes: 1_024, - maxMessageBytes: 1_024, - }); - - await replica.loadBefore(null, 128 * 1024); - - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [0]); - assert.equal(replica.snapshot().hasOlder, false); -}); - test('transfers prepared transcript bytes into active replica accounting', async () => { const message = assistantMessage('prepared', 'overlay-1'); const messageBytes = Buffer.byteLength(JSON.stringify(message), 'utf8'); @@ -1496,6 +858,7 @@ test('reopens a failed transcript range with a fresh generation', async () => { async loadBefore() {}, async loadAfter() {}, async loadAround() {}, + async loadLatest() {}, async close() {}, }; }); @@ -1550,6 +913,7 @@ test('forwards a larger logical history range without changing batch size', asyn sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', + windowEpoch: 0, durableThrough: 4, durable: [ { sequence: 1, message: assistantMessage('earlier') }, @@ -1579,17 +943,17 @@ test('forwards a larger logical history range without changing batch size', asyn request = { anchorSequence, maxBytes }; }, async loadAround() {}, + async loadLatest() {}, async close() {}, })); - await controller.loadBefore(512 * 1024, 'turn-2'); + await controller.loadBefore(512 * 1024); - assert.deepEqual(request, { anchorSequence: 2, maxBytes: 512 * 1024 }); - await controller.loadAfter(512 * 1024, 'turn-2'); + 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 after the last resident record of the visible turn'); - await controller.loadAfter(512 * 1024, 'evicted-turn'); - assert.deepEqual(request, { anchorSequence: 3, maxBytes: 512 * 1024 }); + 'forward reads start at the newest record the window holds'); await controller.close(); }); @@ -1611,12 +975,7 @@ test('waits for the required durable message on the current transcript generatio const waiting = store.waitForDurableMessage('assistant-1', 100); for (const batch of encodeDesktopTranscriptChange(identity, { durableThrough: 0, - durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - })) store.accept(batch); + durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], })) store.accept(batch); assert.equal(await waiting, true); }); @@ -1689,24 +1048,6 @@ function transcriptPage( /** The one-byte read `loadAround` uses to ask whether `sequence` has anything * older than it: only the presence of a fragment answers, not its content. */ -function olderProbePage(sequence: number, exists: boolean) { - return { - ...transcriptPage('older', null, sequence), - fragments: exists - ? [ - { - kind: 'durable' as const, - sequence, - byteOffset: 0, - totalBytes: 1, - payloadDigest: null, - data: '', - }, - ] - : [], - }; -} - function syntheticLargeTranscript(): Array<{ identity: number; message: StoredMessage }> { return Array.from({ length: 8 }, (_, index) => { const number = index + 1; @@ -1730,20 +1071,6 @@ function syntheticLargeTranscript(): Array<{ identity: number; message: StoredMe }).flat(); } -function renderedUserPrompts(store: DesktopTranscriptRangeStore): string[] { - return store.snapshot().messages.flatMap((message) => - message.type === 'user' ? [message.text] : [], - ); -} - -function assertRangeFitsBudget(store: DesktopTranscriptRangeStore): void { - const bytes = store.snapshot().messages.reduce( - (total, message) => total + Buffer.byteLength(JSON.stringify(message), 'utf8'), - 0, - ); - assert.ok(bytes <= DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); -} - function continuitySnapshot() { return { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, @@ -1787,7 +1114,7 @@ test('cached fallback remains readable and retries once per observation generati return { ...identity, readThroughMessageId: null, loadBefore: async () => {}, loadAfter: async () => {}, loadAround: async () => {}, - close: async () => {}, + loadLatest: async () => {}, close: async () => {}, }; }, { onError: (error) => errors.push(error) }); const settle = () => new Promise((resolve) => setImmediate(resolve)); 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 8da53cb213..9dd2c4b0b3 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 @@ -95,25 +95,33 @@ for (const phase of ['connecting', 'seeding'] as const) { } } -test('forward transcript paging is an observation operation scoped to the renderer', async () => { +test('window transcript reads are observation operations scoped to the renderer', async () => { const ipc = ipcHarness(); const observations = new RuntimeHostSessionObservationRegistry(); const calls: unknown[] = []; - observations.loadTranscriptAfter = async (request, targetId) => { calls.push({ request, targetId }); }; + 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, - navigationVersion: 7, intent: 'history' as const, preserveRange: false, - readingTurnId: 'reading-turn', + anchorSequence: 42, maxBytes: 512 * 1024, windowEpoch: 7, }; await ipc.invoke('sessions:transcript:load-after', request); - assert.deepEqual(calls, [{ request, targetId: 9 }]); + await ipc.invoke('sessions:transcript:load-latest', { ...request, anchorSequence: null }); + assert.deepEqual(calls, [ + { command: 'after', request, targetId: 9 }, + { command: 'latest', request: { ...request, anchorSequence: null }, targetId: 9 }, + ]); await assert.rejects( ipc.invoke('sessions:transcript:load-after', { ...request, anchorSequence: -1 }), /Invalid Desktop transcript range anchor/, ); - assert.equal(calls.length, 1); + // 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, windowEpoch: undefined }), + /Invalid Desktop transcript navigation/, + ); + assert.equal(calls.length, 2); }); test("keeps synthetic E2E interactions visible through Host hydration and retires their answer", async () => { 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 a2efb98c7f..3294f7fb48 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 @@ -435,10 +435,7 @@ test('restores transcript consumers across Host replacement', async () => { generation, hostEpoch: `host-${generation}`, durableThrough: null, - fragments: [], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, + fragments: [], hasOlder: false, hasNewer: false, reset: true, ready: true, @@ -453,6 +450,7 @@ test('restores transcript consumers across Host replacement', async () => { async loadTranscriptBefore() {}, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, async closeTranscript() {}, }); const first = source('first'); @@ -511,6 +509,7 @@ test('does not hold Host observation recovery on transcript replay', async () => async loadTranscriptBefore() {}, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, async closeTranscript() {}, }); const first = source('first'); @@ -541,6 +540,7 @@ test('does not hold Host observation recovery on transcript replay', async () => }, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, acknowledgeTranscript() { transcriptAcknowledged = true; }, @@ -565,6 +565,7 @@ test('does not hold Host observation recovery on transcript replay', async () => hostEpoch: 'host-second', anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + windowEpoch: 1, }, transcriptTarget.id, ); @@ -605,6 +606,7 @@ test('fences transcript range failures to the current registration and Host sour loadTranscriptBefore, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, async closeTranscript() {}, }); const request = (consumerId: string, generation: string) => ({ @@ -613,6 +615,7 @@ test('fences transcript range failures to the current registration and Host sour hostEpoch: `host-${generation}`, anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + windowEpoch: 1, }); const closedFailure = deferred(); @@ -729,6 +732,7 @@ test('fences transcript range failures across same-source replica recovery', asy hostEpoch, anchorSequence: 1, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + windowEpoch: 1, }); const target: RuntimeHostTranscriptTarget = { id: 20, @@ -758,12 +762,10 @@ test('fences transcript range failures across same-source replica recovery', asy sequence: 1, reason: 'slow_consumer', }); - await waitFor(() => currentRangeStarted); - assert.equal( - batches.at(-1)?.generation, - opened.generation, - 'a failed recovery range does not replace the visible snapshot with an unrelated bootstrap', - ); + // 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 assert.doesNotReject(staleLoad); @@ -771,6 +773,7 @@ test('fences transcript range failures across same-source replica recovery', asy observations.loadTranscriptBefore(request(opened.hostEpoch), target.id), (error) => error === currentFailure, ); + assert.equal(currentRangeStarted, true); await observations.close(); await observer.close(); }); @@ -1125,6 +1128,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'host-1', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + windowEpoch: 1, }, 22, ), @@ -1143,6 +1147,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'other-host', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + windowEpoch: 1, }, 22, ), @@ -1157,6 +1162,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'host-1', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + windowEpoch: 1, }, 22, ), @@ -1258,6 +1264,9 @@ 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]!.windowEpoch, 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, @@ -1267,6 +1276,121 @@ 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 () => { + 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', + }, + }); + const durablePage = (nextCursor: string | null): SessionTranscriptPage => ({ + kind: 'page', + sessionId: 'session-1', + source: 'durable', + 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 observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + transcriptBootstrap: { + throughSequence: 2, + overlayMessageCount: 0, + durable: bootstrap, + overlay: { ...bootstrap, source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + 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)!, + async close() { + events.end(); + }, + }), + }, + emitSessionsChanged() {}, + }); + const batches: DesktopTranscriptBatch[] = []; + const consumerId = 'consumer-window'; + const request = (windowEpoch: number, anchorSequence: number | null) => ({ + consumerId, + sessionId: 'session-1', + hostEpoch: 'host-1', + anchorSequence, + maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + windowEpoch, + }); + await observer.openTranscript('session-1', consumerId, { + id: 26, + send(_channel, batch) { + batches.push(batch); + queueMicrotask(() => + observer.acknowledgeTranscript(consumerId, batch.generation, batch.deliverySequence, 26), + ); + }, + once() {}, + off() {}, + }); + batches.splice(0); + + await observer.loadTranscriptBefore(request(1, 2), 26); + assert.equal(batches.length, 1); + assert.equal(batches[0]!.windowEpoch, 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]!.windowEpoch, 1); + assert.equal(batches[1]!.reset, false); + assert.equal(batches[1]!.hasNewer, false); + + await observer.loadTranscriptAround(request(2, 1), 26); + assert.equal(batches.length, 3); + assert.equal(batches[2]!.windowEpoch, 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); + + await observer.loadTranscriptBefore(request(1, 2), 26); + assert.equal(batches.length, 3, 'a version the Renderer already abandoned is dropped silently'); + await observer.close(); +}); + test('does not let one backpressured transcript consumer block another', async () => { const events = new AsyncFrameQueue(); let decoded = 0; @@ -1462,6 +1586,7 @@ test('keeps a transcript consumer available after a delivery fails', async () => hostEpoch: opened.hostEpoch, anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + windowEpoch: 1, }, 25, ), diff --git a/apps/desktop/src/main/__tests__/transcript-identity.test.ts b/apps/desktop/src/main/__tests__/transcript-identity.test.ts index bd853f7083..4c43ae0398 100644 --- a/apps/desktop/src/main/__tests__/transcript-identity.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-identity.test.ts @@ -31,10 +31,7 @@ function batch(overrides: Partial = {}): DesktopTranscri generation: 'generation-1', hostEpoch: 'host-1', durableThrough: null, - fragments: [], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, + fragments: [], hasOlder: false, hasNewer: false, reset: false, ready: true, diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts index 1a3ec68814..93301f41db 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts @@ -74,26 +74,24 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a await replica.advance(completeThrough); assertRecords(replica, second); - await replica.loadBefore(second[0]!.sequence, PAGE_BYTES); - assertRecords(replica, complete); - assert.equal(replica.snapshot().hasOlder, false, - 'older paging keeps the complete oversized Turn and its adjacent anchor'); - await replica.readAt(first[0]!.sequence); - assertRecords(replica, first); - assert.equal(replica.snapshot().hasNewer, true); + // 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'); + assertRecords(replica, second); for (let attempt = 0; attempt < 2; attempt += 1) { - await replica.followLatest(PAGE_BYTES); + const around = await replica.loadAround(first[0]!.sequence, PAGE_BYTES); + assert.ok(around); + assert.deepEqual(around.durable, complete, + 'a reset anchored on the oldest row reaches the current tail in one page'); + assert.equal(around.hasOlder, false); + assert.equal(around.hasNewer, false); assertRecords(replica, second); - assert.equal(replica.snapshot().hasOlder, true); - assert.equal(replica.snapshot().hasNewer, false); - await replica.loadAround(first[0]!.sequence, PAGE_BYTES); - assertRecords(replica, first); - assert.equal(replica.snapshot().hasOlder, false); - assert.equal(replica.snapshot().hasNewer, true); } - await replica.followLatest(PAGE_BYTES); - assertRecords(replica, second); } finally { opened?.replica.close(); await opened?.subscription.close(); @@ -121,10 +119,6 @@ for (const checkpoint of ['running-b', 'result-b'] as const) { const expected = source.second.slice(0, source.second.findIndex(({ id }) => id === checkpoint) + 1) .filter((message) => message.type !== 'turn_state').map(({ id }) => id); assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), expected); - await replica.readAt(first[0]!.sequence); - assertRecords(replica, first); - await replica.followLatest(PAGE_BYTES); - assertRecords(replica, first); assert.ok(replica.messages().some(({ id }) => id === expected.at(-1)), 'the running Turn remains reachable'); const throughSequence = await ledger.appendThrough('completed-b'); diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts index 2dff394ff4..5c435206d8 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts @@ -21,24 +21,24 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol'; -import type { DesktopTranscriptBatch, DesktopTranscriptHandle, DesktopTranscriptNavigation, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; +import type { DesktopTranscriptBatch, DesktopTranscriptHandle, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.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 { RuntimeHostSessionObservationRegistry } from '../runtime-host-session-observation-registry.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; -for (const kind of ['before', 'around', 'catch-up'] as const) { - test(`a newer reading intent invalidates an in-flight ${kind} page before it mutates or publishes`, async () => { +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(kind === 'catch-up' ? 2 : 1); + const pending = page(1); const older = record(0); const latest = record(1); - const appended = record(2); const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, @@ -48,7 +48,7 @@ for (const kind of ['before', 'around', 'catch-up'] as const) { }, loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (candidate) => ({ - messages: candidate === bootstrap ? [latest] : kind === 'catch-up' ? [appended] : [older], + messages: candidate === bootstrap ? [latest] : [older], nextCursor: candidate === bootstrap ? 'older' : null, }), loadTranscriptPage: async () => { @@ -58,64 +58,27 @@ for (const kind of ['before', 'around', 'catch-up'] as const) { }, async close() {}, }), { onChange: (_replica, change) => installed.push(change.durableUpserts.map(({ sequence }) => sequence)) }); - const loading = kind === 'before' ? replica.loadBefore(1, 128 * 1024) - : kind === 'around' ? replica.loadAround(0, 128 * 1024) : replica.advance(2); + 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; - const reading = replica.readAt(1); + // The Renderer replaced its window while this page was in flight. + current = false; release.resolve(); - await Promise.all([loading, reading]); + assert.equal(await loading, undefined); assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [1]); - assert.ok(installed.every((sequences) => sequences.length === 0), 'superseded pages cannot upsert or evict the new reading range'); + assert.deepEqual(installed, []); replica.close(); }); } -test('repeated older paging retains at most the adjacent anchor pair and releases it as the reader moves', async () => { - const records = Array.from({ length: 8 }, (_, sequence) => record(sequence)); - const bootstrap = page(7); - const pages = new Map([[bootstrap, 7]]); - const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 7, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => { - const sequence = pages.get(candidate)!; - return { messages: [records[sequence]!], nextCursor: sequence > 0 ? 'older' : null }; - }, - loadTranscriptPage: async (request) => { - const candidate = page(7); - pages.set(candidate, request.anchorSequence! - 1); - return candidate; - }, - async close() {}, - }), { maxResidentBytes: 64, maxResidentTurns: 2 }); - const maxTurnBytes = Math.max(...records.map(({ message }) => Buffer.byteLength(JSON.stringify(message)))); - assert.ok(maxTurnBytes > 64, 'each complete turn exceeds the soft byte budget'); - for (let anchor = 7; anchor > 0; anchor -= 1) { - await replica.loadBefore(anchor, 128 * 1024); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [anchor - 1, anchor]); - assert.ok(replica.residentBytes <= maxTurnBytes * 2, 'successive pages cannot accumulate protected turns'); - // First prove consecutive paging itself releases the old pair, then prove - // reading-anchor movement trims each remaining pair down to one turn. - if (anchor <= 4) { - await replica.readAt(anchor - 1); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [anchor - 1]); - assert.ok(replica.residentBytes <= maxTurnBytes); - } - } - assert.equal(replica.snapshot().hasOlder, false); - assert.equal(replica.snapshot().hasNewer, true); - replica.close(); -}); - -test('memory trimming cannot turn an already durable reading anchor into an unresolved live Turn', async () => { +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]() {} }, @@ -133,19 +96,19 @@ test('memory trimming cannot turn an already durable reading anchor into an unre return candidate; }, async close() {}, - }), { maxResidentBytes: 64 }); + }), { maxResidentBytes: 64, onChange: (_replica, change) => changes.push(change.durableUpserts.length) }); try { - await replica.readAt(1, undefined, record(1).message.turnId); await replica.advance(2); - assert.equal(replica.snapshot().hasNewer, true); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [1]); + 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, []); - requests.length = 0; - await replica.advance(3); - assert.deepEqual(replica.snapshot().durable, [], 'budget reclaim must not authorize following a later Turn'); - assert.equal(replica.durableThrough, 3); - assert.deepEqual(requests, [], 'a known durable anchor remains history after reclaim'); + 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(); } @@ -154,14 +117,14 @@ test('memory trimming cannot turn an already durable reading anchor into an unre test('a superseded fragmented reset cannot clear or complete the next navigation', () => { const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); acceptSnapshot(store, 0, 'generation-1', [record(1)]); - store.expectNavigation(1); + store.replaceWindow(); const stale = [...encodeDesktopTranscriptSnapshot({ - ...identity, navigationVersion: 1, durableThrough: 1, + ...identity, windowEpoch: 1, durableThrough: 1, durable: [{ sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }], overlay: [], hasOlder: false, hasNewer: true, })]; assert.equal(store.accept(stale[0]!), false); - store.expectNavigation(2); + store.replaceWindow(); acceptSnapshot(store, 2, 'generation-2', [record(1)]); const committed = store.snapshot(); for (const batch of stale) assert.equal(store.accept(batch), false); @@ -172,44 +135,95 @@ test('a superseded fragmented reset cannot clear or complete the next navigation assert.strictEqual(store.snapshot(), committed); }); -test('follow latest invalidates before open resolves and a reload replays only that latest intent', async () => { +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, 0, 'generation-1', [record(1)]); + store.replaceWindow(); + 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 }, + ], + overlay: [], 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 replacement outlives the band trimming the window it was issued under', () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + acceptSnapshot(store, 0, 'generation-1', [record(1), record(2)]); + const navigating = store.replaceWindow(); + assert.equal(store.retain(2, 2), true); + const replacement = [...encodeDesktopTranscriptSnapshot({ + ...identity, windowEpoch: navigating, durableThrough: 1, + durable: [ + { sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }, + { sequence: 1, message: record(1).message }, + ], + overlay: [], 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']); + const settled = store.snapshot(); + // The trim's number is not handed back out: the page still in flight under it + // is anchored on an edge this replacement threw away. + for (const batch of encodeDesktopTranscriptPage({ ...identity, windowEpoch: navigating + 1 }, { + durableThrough: 3, durable: [{ sequence: 3, message: record(3).message }], hasNewer: false, + })) assert.equal(store.accept(batch), false); + assert.strictEqual(store.snapshot(), settled); +}); + +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, 0, 'generation-1', [record(1), record(2), record(3)]); + const anchored = store.windowEpoch(); + assert.equal(store.retain(3, 3), true); + const answer = [...encodeDesktopTranscriptPage({ ...identity, windowEpoch: anchored }, { + durableThrough: 3, durable: [{ sequence: 0, message: record(0).message }], + hasOlder: false, + })]; + for (const batch of answer) assert.equal(store.accept(batch), false); + // 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<{ generation: string; anchor: number | null; navigation?: DesktopTranscriptNavigation }> = []; + const requests: Array<{ command: 'around' | 'latest'; anchor: number | null; windowEpoch: number }> = []; const handle = (generation: string): DesktopTranscriptHandle => ({ ...identity, generation, readThroughMessageId: null, 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({ generation, anchor, navigation }); - acceptSnapshot(store, navigation?.navigationVersion ?? 0, generation, [record(1)]); + requests.push({ command: 'around', anchor, windowEpoch: navigation.windowEpoch }); + acceptSnapshot(store, navigation.windowEpoch, generation, [record(0)]); + }, + async loadLatest(navigation) { + requests.push({ command: 'latest', anchor: null, windowEpoch: navigation.windowEpoch }); + acceptSnapshot(store, navigation.windowEpoch, generation, [record(1)]); }, async close() {}, }); - let opens = 0; - const controller = createDesktopTranscriptRangeController(store, async () => { - opens += 1; - if (opens === 1) return opening.promise; - // A new preload can initialize and ACK version-zero bootstrap while the - // range store continues showing the last committed view until replay. - acceptSnapshot(store, 0, 'generation-2', [record(0)]); - return handle('generation-2'); - }); + 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.map(({ anchor, navigation }) => [anchor, navigation?.intent, navigation?.navigationVersion]), [[null, 'followTail', 2]]); - await controller.reload(); - assert.deepEqual(requests.map(({ generation, navigation }) => [generation, navigation?.intent, navigation?.navigationVersion]), [ - ['generation-1', 'followTail', 2], ['generation-2', 'followTail', 2], - ]); - assert.equal(store.range().generation, 'generation-2'); + assert.deepEqual(requests, [{ command: 'latest', anchor: null, windowEpoch: 2 }]); assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); await controller.close(); }); -test('a rejected older navigation cannot fail the newer follow-tail intent', async () => { +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; @@ -217,11 +231,13 @@ test('a rejected older navigation cannot fail the newer follow-tail intent', asy const controller = createDesktopTranscriptRangeController(store, async () => ({ ...identity, readThroughMessageId: null, async loadBefore() {}, async loadAfter() {}, - async loadAround(anchor, _bytes, navigation) { - if (anchor === 0) { - historyEntered.resolve(); - await historyResult; - } else acceptSnapshot(store, navigation!.navigationVersion, identity.generation, [record(1)]); + async loadAround(anchor) { + assert.equal(anchor, 0); + historyEntered.resolve(); + await historyResult; + }, + async loadLatest(navigation) { + acceptSnapshot(store, navigation.windowEpoch, identity.generation, [record(1)]); }, async close() {}, })); @@ -234,7 +250,7 @@ test('a rejected older navigation cannot fail the newer follow-tail intent', asy await controller.close(); }); -test('superseded batches remain ACKable and cannot reset the latest range while delivery drains', { timeout: 10_000 }, async () => { +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(); @@ -269,7 +285,7 @@ test('superseded batches remain ACKable and cannot reset the latest range while id: 1, once() {}, off() {}, send(_channel, batch) { store.accept(batch); - if (batch.navigationVersion === 1 && !releaseAcks) { + if (batch.windowEpoch === 1 && !releaseAcks) { blocked.push(batch); firstOldBatch.resolve(); } else queueMicrotask(() => ack(batch)); @@ -277,13 +293,13 @@ test('superseded batches remain ACKable and cannot reset the latest range while }); const request: DesktopTranscriptRangeRequest = { consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', - anchorSequence: 0, maxBytes: 128 * 1024, navigationVersion: 1, intent: 'history', + anchorSequence: 0, maxBytes: PAGE_BYTES, windowEpoch: 1, }; - store.expectNavigation(1); + store.replaceWindow(); const history = observer.loadTranscriptAround(request, 1); await firstOldBatch.promise; - store.expectNavigation(2); - const following = observer.loadTranscriptAround({ ...request, navigationVersion: 2, intent: 'followTail', anchorSequence: null }, 1); + store.replaceWindow(); + const following = observer.loadTranscriptLatest({ ...request, windowEpoch: 2, anchorSequence: null }, 1); releaseAcks = true; for (const batch of blocked) ack(batch); await Promise.all([history, following]); @@ -294,79 +310,65 @@ test('superseded batches remain ACKable and cannot reset the latest range while await observer.close(); }); -for (const changedEpoch of [false, true]) { -for (const latest of [false, true]) { -test(`${changedEpoch ? 'cross-epoch' : 'same-Host'} registry recovery admits newer ${latest ? 'latest' : 'Turn reading'} while old replay is pending`, { timeout: 10_000 }, async () => { - const registry = new RuntimeHostSessionObservationRegistry(); +test('a page naming a newer window does not discard the replacement in flight', async () => { const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const replayEntered = deferred(); - const replayRelease = deferred(); - const latestEntered = deferred(); - const calls: Array<{ generation: string; request: DesktopTranscriptRangeRequest }> = []; - const replacementEpoch = changedEpoch ? 'host-2' : 'host-1'; - const makeSource = (generation: string, hostEpoch: string) => ({ - async observe() {}, async unobserve() {}, async closeTranscript() {}, - async openTranscript(sessionId: string) { - return { sessionId, generation, hostEpoch, readThroughMessageId: null }; - }, - async loadTranscriptBefore() {}, async loadTranscriptAfter() {}, - async loadTranscriptAround(request: DesktopTranscriptRangeRequest) { - assert.equal(request.hostEpoch, hostEpoch, 'only the successfully opened source epoch is accepted'); - calls.push({ generation, request }); - if (generation === 'generation-2' && request.navigationVersion === 1) { - replayEntered.resolve(); - await replayRelease.promise; - } - const row = record(request.navigationVersion === 2 ? 2 : 0); - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, generation, hostEpoch, navigationVersion: request.navigationVersion, - durableThrough: 2, durable: [{ sequence: row.identity, message: row.message }], - overlay: [], hasOlder: false, hasNewer: false, - })) store.accept(batch); - if (generation === 'generation-2' && request.navigationVersion === 2) latestEntered.resolve(); + 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: { + throughSequence: 1, overlayMessageCount: 0, + durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + 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 target = { id: 1, send() {}, once() {}, off() {} }; - const first = makeSource('generation-1', 'host-1'); - await registry.attach(first); - await registry.openTranscript('session-1', 'consumer-1', target); const request: DesktopTranscriptRangeRequest = { consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', - anchorSequence: 0, maxBytes: 128 * 1024, navigationVersion: 1, intent: 'history', + anchorSequence: 0, maxBytes: PAGE_BYTES, windowEpoch: 1, }; - store.expectNavigation(1); - await registry.loadTranscriptAround(request, target.id); - registry.detach(first); - await registry.attach(makeSource('generation-2', replacementEpoch)); - await replayEntered.promise; - store.expectNavigation(2); - const next = registry.loadTranscriptAround({ ...request, navigationVersion: 2, - intent: latest ? 'followTail' : 'history', anchorSequence: latest ? null : 2, - readingTurnId: latest ? undefined : 'turn-2', preserveRange: true, - }, target.id); - await latestEntered.promise; - replayRelease.resolve(); - await next; - await flush(); - await registry.loadTranscriptAround(request, target.id); - assert.deepEqual(calls.map(({ generation, request: entry }) => [generation, entry.navigationVersion, entry.intent]), [ - ['generation-1', 1, 'history'], ['generation-2', 1, changedEpoch ? 'followTail' : 'history'], - ['generation-2', 2, latest ? 'followTail' : 'history'], - ]); - assert.equal(calls[2]!.request.hostEpoch, replacementEpoch); - assert.equal(calls[2]!.request.anchorSequence, latest || changedEpoch ? null : 2); - assert.equal(calls[2]!.request.readingTurnId, latest ? undefined : 'turn-2'); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-2'], 'late replay cannot replace the new navigation'); - await assert.rejects(registry.loadTranscriptAround({ ...request, navigationVersion: 3, hostEpoch: 'unrelated-epoch' }, target.id)); - await registry.close(); + store.replaceWindow(); + const navigation = observer.loadTranscriptAround(request, 1); + await entered.promise; + // The band trimmed an edge under the navigation, so the fill that follows + // names a newer window. It extends the window; it does not replace it. + const filling = observer.loadTranscriptBefore({ ...request, anchorSequence: 1, windowEpoch: 2 }, 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, navigationVersion: number, generation: string, records: Array>) { +function acceptSnapshot(store: DesktopTranscriptRangeStore, windowEpoch: number, generation: string, records: Array>) { for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, navigationVersion, generation, durableThrough: 1, + ...identity, windowEpoch, generation, durableThrough: 1, durable: records.map(({ identity: sequence, message }) => ({ sequence, message })), overlay: [], hasOlder: true, hasNewer: false, })) store.accept(batch); @@ -390,4 +392,3 @@ function deferred() { const promise = new Promise((complete) => { resolve = complete; }); return { promise, resolve }; } -async function flush() { await new Promise((resolve) => setImmediate(resolve)); } diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts index b56014c5d2..279e9610be 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts @@ -34,53 +34,44 @@ import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.j const PAGE_BYTES = 128 * 1024; -test('loading history before a small latest Turn makes an oversized earlier Turn reachable', async () => { +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]); - await fixture.replica.loadBefore(2, PAGE_BYTES); + const page = await fixture.replica.loadBefore(2, PAGE_BYTES); - assert.equal( - sequences(fixture.replica)[0], - 0, - 'the successfully fetched earlier Turn must survive eviction so history paging makes progress', + 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.deepEqual( + sequences(fixture.replica), + [2, 3], + 'a window read answers the Renderer and leaves the Main tail alone', ); - assert.equal(fixture.replica.snapshot().hasOlder, false); - assert.equal(fixture.replica.messages()[1]?.id, 'assistant-a'); - - await fixture.replica.readAt(0); - - assert.deepEqual(sequences(fixture.replica), [0, 1]); - assert.equal(fixture.replica.snapshot().hasNewer, true); } finally { fixture.replica.close(); } }); -test('durable tail advancement preserves an explicitly selected oversized history Turn', async () => { +test('tail catch-up evicts only the oldest Turns and always keeps the newest complete', async () => { const fixture = await oversizedHistoryFixture(); try { - await fixture.replica.loadAround(0, PAGE_BYTES); - assert.deepEqual(sequences(fixture.replica), [0, 1]); - fixture.requests.length = 0; - await fixture.replica.advance(4); + assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); + + await fixture.replica.advance(6); assert.deepEqual( sequences(fixture.replica), - [0, 1], - 'persisting a new answer must not replace the history range selected by the reader', + [5, 6], + 'the oversized newest Turn stays whole and the older Turn leaves the tail', ); - assert.equal(fixture.replica.durableThrough, 4); - assert.equal(fixture.replica.snapshot().hasNewer, true); - assert.deepEqual(fixture.requests, [], 'history ownership only advances the durable watermark'); - - await fixture.replica.followLatest(PAGE_BYTES); - - assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); - assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-b-later'); - assert.equal(fixture.replica.snapshot().hasNewer, false); + assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-c'); + assert.equal(fixture.replica.durableThrough, 6); + assert.equal(fixture.replica.snapshot().hasOlder, true); } finally { fixture.replica.close(); } @@ -91,8 +82,6 @@ test('a completed resident bookmark does not reload after streaming settlement e const lifecycle = createTranscriptRestoreLifecycle(); let loaded = 0; const controller = { - // This test isolates restore command lifetime from reader navigation. - setReadingAnchor: async () => {}, loadAround: async (sequence: number) => { loaded += 1; await fixture.replica.loadAround(sequence, PAGE_BYTES); @@ -138,7 +127,6 @@ test('a completed resident bookmark does not reload after streaming settlement e test('reopening a bookmark at the current Turn retains content persisted later in that same Turn', async () => { const fixture = await oversizedHistoryFixture(); try { - await fixture.replica.readAt(2); 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'); @@ -150,8 +138,7 @@ test('reopening a bookmark at the current Turn retains content persisted later i sessionId: 'session-1', readingAnchor: { turnId: 'turn-b', sequence: 2 }, controller: { - setReadingAnchor: (sequence) => fixture.replica.readAt(sequence), - loadAround: (sequence) => fixture.replica.loadAround(sequence, PAGE_BYTES), + loadAround: async (sequence) => { await fixture.replica.loadAround(sequence, PAGE_BYTES); }, store: { sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), @@ -177,53 +164,6 @@ test('reopening a bookmark at the current Turn retains content persisted later i } }); -test('an oversized new Turn cannot evict the current Turn being read while its own answer finishes', async () => { - const fixture = await oversizedHistoryFixture(); - try { - await fixture.replica.readAt(2); - await fixture.replica.advance(4); - await fixture.replica.advance(6); - - assert.deepEqual( - sequences(fixture.replica), - [2, 3, 4], - 'the reader keeps the complete selected Turn B when a new oversized Turn C is persisted', - ); - assert.equal(fixture.replica.durableThrough, 6); - assert.equal(fixture.replica.snapshot().hasNewer, true); - assert.equal(fixture.replica.messages().some(({ id }) => id === 'assistant-c'), false); - - await fixture.replica.followLatest(PAGE_BYTES); - - assert.deepEqual(sequences(fixture.replica), [5, 6]); - assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-c'); - assert.equal(fixture.replica.snapshot().hasNewer, false); - } finally { - fixture.replica.close(); - } -}); - -test('streaming persistence retains a newly loaded oversized neighbor until the reader chooses an anchor', async () => { - const fixture = await oversizedHistoryFixture(); - try { - await fixture.replica.loadBefore(2, PAGE_BYTES); - assert.deepEqual(sequences(fixture.replica), [0, 1, 2, 3]); - - await fixture.replica.advance(4); - - assert.deepEqual( - sequences(fixture.replica), - [0, 1, 2, 3, 4], - 'new durable text in B must not erase the older A that the reader just requested', - ); - await fixture.replica.readAt(2); - assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); - assert.equal(fixture.replica.snapshot().hasOlder, true); - } finally { - fixture.replica.close(); - } -}); - test('repeated message notifications share one pending restore and cancellation preserves the newer bookmark', async () => { const lifecycle = createTranscriptRestoreLifecycle(); let finishLoad!: () => void; diff --git a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts index c2d53b6873..c1d56a1a2b 100644 --- a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts @@ -19,11 +19,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { deferred } from '@maka/core/test-only/async-primitives'; import { markPersisted } from '@maka/core/persisted-value'; import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; import { SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionTranscriptPage, type SessionTranscriptPageInput, } from '@maka/runtime-host/protocol'; import { ClientSessionSubscription } from '../../../../../packages/runtime-host/dist/client/session-subscription.js'; @@ -32,8 +32,7 @@ import { readSessionTranscriptPage, updateSubscriberTranscriptHighWater, } from '../../../../../packages/runtime-host/dist/server/session-transcript-pager.js'; -import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import type { DesktopTranscriptNavigation } from '../../preload/transcript-contract.js'; +import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { encodeDesktopTranscriptChange, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { DesktopTranscriptReplica, type DesktopTranscriptReplicaChange } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; @@ -48,38 +47,65 @@ const B_COMPLETED_THROUGH = 'completed-b'; const C_COMPLETED_THROUGH = 'completed-c'; for (const coalesced of [false, true]) { - test(`settles a bootstrap overlay outside history through ${coalesced ? 'a coalesced B+C watermark' : 'separate B and C watermarks'}`, async () => { + test(`settles a bootstrap overlay through ${coalesced ? 'a coalesced B+C watermark' : 'separate B and C watermarks'}`, async () => { const fixture = await openFixture(); try { - const { replica, renderer, changes } = fixture; + const { replica, renderer } = fixture; assert.equal(replica.snapshot().overlay.find(({ id }) => id === 'answer-b')?.id, 'answer-b'); - await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); - assertHistoryRange(fixture); - const before = changes.length; if (!coalesced) { await fixture.advance(B_COMPLETED_THROUGH); - assertHistoryRange(fixture); assert.deepEqual(replica.snapshot().overlay, []); } await fixture.advance(C_COMPLETED_THROUGH); - assertHistoryRange(fixture); assert.equal(replica.durableThrough, fixture.watermark(C_COMPLETED_THROUGH)); assert.deepEqual(replica.snapshot().overlay, []); - assert.deepEqual(changes.slice(before).flatMap((change) => change.completedOverlayMessageIds), ['user-b', 'answer-b']); - assert.ok(changes.slice(before).every((change) => change.durableUpserts.length === 0)); - assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), ['user-a', 'answer-a', 'completed-a']); - - await replica.followLatest(PAGE_BYTES); - assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), ['user-c', 'answer-c', 'completed-c']); - assert.equal(replica.snapshot().hasNewer, false); - assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), ['user-c', 'answer-c', 'completed-c']); + assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), + ['user-c', 'answer-c', 'completed-c']); + assert.equal( + renderer.snapshot().messages.some((message) => message.type === 'assistant' && message.text === 'B partial'), + false, + 'the durable row replaced the partial overlay answer', + ); + assert.ok(renderer.snapshot().messages.some(({ id }) => id === 'answer-c')); } finally { await fixture.close(); } }); } +test('a window parked off the tail keeps the Turn it is reading when that Turn completes', async () => { + const fixture = await openFixture(); + try { + const { replica, renderer } = fixture; + // Reading history: the window dropped the newest rows to meet its budget, + // so its newer edge is a gap and tail growth is no longer its business. + const oldest = renderer.range().oldestSequence; + renderer.retain(oldest, oldest); + assert.equal(renderer.range().hasNewer, true); + const overlaid = renderer.snapshot().messages.find(({ id }) => id === 'answer-b'); + assert.equal(overlaid?.type === 'assistant' ? overlaid.text : undefined, 'B partial'); + + await fixture.advance(B_COMPLETED_THROUGH); + await fixture.advance(C_COMPLETED_THROUGH); + + assert.deepEqual( + renderer.snapshot().messages.flatMap((message) => + message.type === 'assistant' && message.turnId === 'b' ? [message.text] : []), + ['B partial and completed answer'], + 'the completed body replaces the overlay instead of both disappearing', + ); + assert.deepEqual( + renderer.snapshot().messages.flatMap(({ id, turnId }) => turnId === 'c' ? [id] : []), + [], + 'settling the read Turn does not pull the window forward to a later Turn', + ); + assert.equal(replica.snapshot().overlay.length, 0); + } finally { + await fixture.close(); + } +}); + test('a completed live answer remains unique after a fresh transcript subscription', async () => { const fixture = await openFixture(); let reopened: Awaited> | undefined; @@ -107,221 +133,109 @@ test('a completed live answer remains unique after a fresh transcript subscripti } }); -test('retains an unfinished overlay through runtime checkpoints and skips scans after settlement', async () => { +test('retains an unfinished overlay through runtime checkpoints', async () => { const fixture = await openFixture(); try { - const { replica, changes, requests } = fixture; - await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); + const { replica, renderer } = fixture; await fixture.advance(B_STEERING_THROUGH); assert.equal(replica.durableThrough, fixture.bootstrapThrough, 'running B has no durable ending yet'); - assertHistoryRange(fixture); const unfinished = replica.snapshot().overlay.find(({ id }) => id === 'answer-b'); assert.equal(unfinished?.type === 'assistant' ? unfinished.text : undefined, 'B partial'); - assert.deepEqual(changes.flatMap((change) => change.completedOverlayMessageIds), []); await fixture.advance(B_COMPLETED_THROUGH); - assert.deepEqual(replica.snapshot().overlay, []); - const before = requests.length; - await fixture.advance(C_COMPLETED_THROUGH); - assert.equal(requests.length, before, 'history with no pending overlay needs no durable page read'); - assertHistoryRange(fixture); + assert.deepEqual( + replica.snapshot().overlay, + [], + 'the Turn ending retires exactly the overlay rows it made durable', + ); + assert.deepEqual( + renderer.snapshot().messages.flatMap((message) => + message.type === 'assistant' && message.turnId === 'b' ? [message.text] : []), + ['B partial and completed answer'], + ); } finally { await fixture.close(); } }); -test('a latest range jump settles skipped overlays without waiting for another advance', async () => { +test('catch-up retires the completed overlay in one notification', async () => { const fixture = await openFixture(); try { - const { replica } = fixture; - await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); - await fixture.announce(C_COMPLETED_THROUGH); - // Both commands are queued synchronously. The range jump owns the newer - // navigation before the queued catch-up starts handling the watermark. - const latest = replica.followLatest(PAGE_BYTES); - const advance = replica.advance(fixture.watermark(C_COMPLETED_THROUGH)); - await latest; + const { replica, changes, requests } = fixture; + const before = requests.length; + await fixture.advance(B_COMPLETED_THROUGH); + const settled = changes.filter((change) => + change.durableUpserts.some(({ message }) => message.id === 'answer-b')); + assert.equal(settled.length, 1); + const answer = replica.messages().find(({ id }) => id === 'answer-b'); + assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'B partial and completed answer'); + assert.equal(requests.length - before, 1, 'catch-up settles through the same page it installs'); assert.deepEqual(replica.snapshot().overlay, []); - assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), ['user-c', 'answer-c', 'completed-c']); - await advance; } finally { await fixture.close(); } }); -for (const intent of ['followTail', 'history'] as const) { - test(`the ${intent} range retires the completed overlay in one notification`, async () => { - const fixture = await openFixture(); - try { - const { replica, changes, requests } = fixture; - if (intent === 'history') await replica.readAt(fixture.history[0]!.sequence); - const before = requests.length; - await fixture.advance(B_COMPLETED_THROUGH); - const settled = changes.filter((change) => change.completedOverlayMessageIds.includes('answer-b')); - assert.equal(settled.length, 1); - if (intent === 'followTail') { - assert.ok(settled[0]!.durableUpserts.some(({ message }) => message.id === 'answer-b')); - const answer = replica.messages().find(({ id }) => id === 'answer-b'); - assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'B partial and completed answer'); - } else { - assertHistoryRange(fixture); - assert.equal(settled[0]!.durableUpserts.length, 0, 'settlement preserves the selected oversized history Turn'); - } - assert.equal(requests.length - before, 1, 'normal catch-up settles through the same page it installs'); - assert.deepEqual(replica.snapshot().overlay, []); - } finally { - await fixture.close(); - } - }); -} - -for (const coalesced of [false, true]) { - test(`reading an overlay-only B survives ${coalesced ? 'coalesced B+C completion' : 'B completion followed by oversized C'}`, async () => { - const fixture = await openFixture(); - const { replica, renderer } = fixture; - const navigations: Array<{ anchor: number | null; navigation: DesktopTranscriptNavigation }> = []; - // Only the process boundary is in-process here: controller invalidation, - // replica ownership, Host cursors, SQLite ledger, and renderer batches all - // use their production implementations. - const controller = createDesktopTranscriptRangeController(renderer, async () => ({ - sessionId: replica.sessionId, generation: replica.generation, - hostEpoch: replica.hostEpoch, readThroughMessageId: null, - async loadBefore(anchor, maxBytes = PAGE_BYTES, navigation) { - assert.ok(navigation); - fixture.acceptNavigation(navigation); - await replica.loadBefore(anchor, maxBytes, replica.setNavigation(navigation.intent)); - }, - async loadAfter(anchor, maxBytes = PAGE_BYTES, navigation) { - assert.ok(navigation); - fixture.acceptNavigation(navigation); - await replica.loadAfter(anchor, maxBytes, replica.setNavigation(navigation.intent)); - }, - async loadAround(anchor, maxBytes = PAGE_BYTES, navigation) { - assert.ok(navigation); - navigations.push({ anchor, navigation }); - fixture.acceptNavigation(navigation); - const token = replica.setNavigation(navigation.intent); - if (navigation.preserveRange) await replica.readAt(anchor, token, navigation.readingTurnId); - else if (navigation.intent === 'followTail') await replica.followLatest(maxBytes, token); - else { - assert.notEqual(anchor, null); - await replica.loadAround(anchor!, maxBytes, token); - } - }, - async close() {}, - })); - try { - await controller.ready(); - assert.equal(renderer.sequenceForTurn('b'), null, - 'a running ledger invocation has no durable user sequence to use as a bookmark'); - assert.ok(renderer.snapshot().messages.some(({ id }) => id === 'answer-b')); - await controller.setReadingAnchor(renderer.sequenceForTurn('b'), 'b'); - assert.equal(navigations[0]?.anchor, null, 'the old A sequence cannot impersonate B'); - assert.equal(navigations[0]?.navigation.readingTurnId, 'b'); - assert.equal(navigations[0]?.navigation.intent, 'history'); - - const expectedB = ['user-b', 'steering-b', 'answer-b', 'completed-b']; - if (!coalesced) { - await fixture.advance(B_COMPLETED_THROUGH); - assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), expectedB); - assert.ok(renderer.sequenceForTurn('b') !== null, 'the selected Turn now resolves to its own durable sequence'); - } - await fixture.advance(C_COMPLETED_THROUGH); - assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), expectedB); - assert.deepEqual(replica.snapshot().overlay, []); - assert.equal(replica.snapshot().hasNewer, true); - assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), expectedB); - const answer = renderer.snapshot().messages.find(({ id }) => id === 'answer-b'); - assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'B partial and completed answer'); - assert.equal(renderer.snapshot().messages.some(({ turnId }) => turnId === 'c'), false, - 'finishing C cannot replace the reader-selected B range'); - - await controller.loadLatest(); - assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), ['user-c', 'answer-c', 'completed-c']); - assert.equal(replica.snapshot().hasNewer, false); - } finally { - await controller.close(); - await fixture.close(); - } +test('a window page read retires the tail overlay copy without notifying other windows', async () => { + const older: StoredMessage = { + type: 'assistant', id: 'answer-older', turnId: 'older', ts: 1, + text: 'Older answer', modelId: 'fixture-model', + }; + const newest: StoredMessage = { + type: 'assistant', id: 'answer-newest', turnId: 'newest', ts: 2, + text: 'Newest answer', modelId: 'fixture-model', + }; + const durablePage = (): SessionTranscriptPage => ({ + kind: 'page', sessionId: 'session-1', source: 'durable', direction: 'older', + throughSequence: 2, rawBytes: 1, fragments: [], rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }); -} - -test('a fresh replica restores B from an overlay-only bookmark after oversized C owns the tail', async () => { - const fixture = await openFixture(); - let reopened: Awaited> | undefined; + const bootstrap = durablePage(); + const decoded = new Map; + nextCursor: string | null; + }>([[bootstrap, { messages: [{ identity: 2, message: newest }], nextCursor: null }]]); + const changes: DesktopTranscriptReplicaChange[] = []; + const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ + snapshot: { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { sessionId: 'session-1', metadataRevision: 1, status: 'active', createdAt: 1, isArchived: false }, + projectionRevision: 1, rootTurn: null, goal: null, + queue: { hostEpoch: HOST_EPOCH, queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + }, + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 2, overlayMessageCount: 1, + durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + }, + // The Host was still streaming the older answer when the subscription + // opened, so bootstrap holds an overlay copy of an already durable row. + loadTranscriptOverlay: async () => [older], + loadTranscriptPage: async () => { + const page = durablePage(); + decoded.set(page, { messages: [{ identity: 1, message: older }], nextCursor: null }); + return page; + }, + decodeTranscriptPage: async (page) => decoded.get(page)!, + async close() {}, + }), { onChange: (_replica, change) => changes.push(change) }); try { - const bookmark = { turnId: 'b', sequence: fixture.renderer.sequenceForTurn('b') }; - assert.equal(bookmark.sequence, null); - await fixture.replica.readAt(bookmark.sequence, undefined, bookmark.turnId); - await fixture.advance(B_COMPLETED_THROUGH); - await fixture.advance(C_COMPLETED_THROUGH); + assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), ['answer-older']); - reopened = await openSettledReplica(fixture.ledger); - assert.deepEqual(reopened.replica.snapshot().durable.map(({ message }) => message.id), - ['user-c', 'answer-c', 'completed-c']); - assert.deepEqual(reopened.replica.snapshot().overlay, []); - const before = reopened.requests.length; - await reopened.replica.readAt(bookmark.sequence, undefined, bookmark.turnId); - assert.deepEqual(reopened.replica.snapshot().durable.map(({ message }) => message.id), - ['user-b', 'steering-b', 'answer-b', 'completed-b']); - assert.ok(reopened.requests.slice(before).some((request) => request.direction === 'older'), - 'a no-sequence bookmark finds its durable Turn through the real bounded pager'); - assert.ok(reopened.requests.slice(before).every((request) => request.maxBytes <= 512 * 1024)); - assert.equal(reopened.replica.snapshot().hasNewer, true); - await reopened.replica.advance(fixture.watermark(C_COMPLETED_THROUGH)); - assert.deepEqual(new Set(reopened.replica.snapshot().durable.map(({ message }) => message.turnId)), new Set(['b'])); - } finally { - await reopened?.close(); - await fixture.close(); - } -}); + const page = await replica.loadBefore(2, PAGE_BYTES); -test('superseded settlement pages cannot retire overlays or skip the current navigation retry', async () => { - const firstStarted = deferred(); - const releaseFirst = deferred(); - const secondStarted = deferred(); - const releaseSecond = deferred(); - let settlementReads = 0; - const fixture = await openFixture(async (request) => { - if (request.direction !== 'newer' || request.anchorSequence !== fixture.bootstrapThrough) return; - settlementReads += 1; - if (settlementReads === 1) { - firstStarted.resolve(); - await releaseFirst.promise; - } else if (settlementReads === 2) { - secondStarted.resolve(); - await releaseSecond.promise; - } - }); - try { - const { replica, changes } = fixture; - await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); - const advance = fixture.advance(C_COMPLETED_THROUGH); - await firstStarted.promise; - const latest = replica.followLatest(PAGE_BYTES); - releaseFirst.resolve(); - await secondStarted.promise; - assert.equal(replica.snapshot().overlay.find(({ id }) => id === 'answer-b')?.id, 'answer-b'); - assert.deepEqual(changes.flatMap((change) => change.completedOverlayMessageIds), []); - releaseSecond.resolve(); - await latest; - await advance; - assert.equal(settlementReads, 2, 'the new command retries from the last actually checked overlay watermark'); - assert.deepEqual(replica.snapshot().overlay, []); - assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), ['user-c', 'answer-c', 'completed-c']); - assert.deepEqual(changes.flatMap((change) => change.completedOverlayMessageIds), ['user-b', 'answer-b']); + assert.ok(page); + assert.deepEqual(page.durable.map(({ message }) => message.id), ['answer-older']); + assert.deepEqual(replica.snapshot().overlay, [], 'the durable row retires the tail overlay copy'); + assert.deepEqual(changes, [], 'a window page changes nothing another window holds'); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2]); } finally { - releaseFirst.resolve(); - releaseSecond.resolve(); - await fixture.close(); + replica.close(); } }); -function assertHistoryRange(fixture: Awaited>): void { - assert.deepEqual(fixture.replica.snapshot().durable, fixture.history); - assert.equal(fixture.replica.snapshot().hasNewer, fixture.replica.durableThrough! > fixture.bootstrapThrough); -} - async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => Promise) { const messages: StoredMessage[] = [ user('a'), assistant('a', 'A'.repeat(600 * 1024)), turnState('a', 'completed'), @@ -361,7 +275,6 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => }); const decodeMessage = (value: unknown) => decodeStoredMessage(markPersisted(value)); const changes: DesktopTranscriptReplicaChange[] = []; - let navigationVersion = 0; const renderer = new DesktopTranscriptRangeStore(JSON.stringify(['local', sessionId])); const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ snapshot: subscription.snapshot, activeAssistantStreams, events: subscription, @@ -375,7 +288,13 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => }), { onChange: (current, change) => { changes.push(change); - for (const batch of encodeDesktopTranscriptChange({ ...current.snapshot(), navigationVersion }, change)) renderer.accept(batch); + // Tail growth is broadcast to every consumer and carries no navigation. + const identity = { + sessionId: current.sessionId, + generation: current.generation, + hostEpoch: current.hostEpoch, + }; + for (const batch of encodeDesktopTranscriptChange(identity, change)) renderer.accept(batch); }, }); for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) renderer.accept(batch); @@ -401,7 +320,6 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => }; return { replica, renderer, changes, requests, announce, history, bootstrapThrough, ledger, - acceptNavigation: (navigation: DesktopTranscriptNavigation) => { navigationVersion = navigation.navigationVersion; }, watermark: (checkpoint: string) => { const value = watermarks.get(checkpoint); assert.notEqual(value, undefined); @@ -409,7 +327,7 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => }, async advance(messageId: string) { await announce(messageId); - await replica.advance(watermarks.get(messageId)!); + await replica.advance(watermarks.get(messageId) ?? bootstrapThrough); }, async close() { replica.close(); 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 8b16db2505..297b706854 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 @@ -22,14 +22,13 @@ 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, DesktopTranscriptNavigation } from '../../preload/transcript-contract.js'; +import type { DesktopTranscriptHandle, DesktopTranscriptWindowRead } from '../../preload/transcript-contract.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { createAppShellSessionUiStateController, TranscriptReadingPositionController, type TranscriptReadingPositionCommands, - type TranscriptHistoryPending, } from '../../renderer/features/conversation/index.js'; import { createTranscriptRestoreLifecycle, @@ -46,21 +45,23 @@ test('sending before transcript open completes supersedes the queued bookmark wi const opening = deferred(); const controller = createDesktopTranscriptRangeController(store, () => opening.promise); const lifecycle = createTranscriptRestoreLifecycle(); - const requests: Array<{ sequence: number | null; navigation?: DesktopTranscriptNavigation }> = []; + const requests: Array<{ sequence: number | null; navigation?: DesktopTranscriptWindowRead }> = []; + const publish = (sequence: number | null, navigation: DesktopTranscriptWindowRead) => { + requests.push({ sequence, navigation }); + const turnId = sequence === null ? 'b' : 'a'; + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', + windowEpoch: navigation.windowEpoch, durableThrough: 20, + durable: [{ sequence: sequence ?? 20, message: { + type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', + } }], overlay: [], hasOlder: true, hasNewer: sequence !== null, + })) store.accept(batch); + }; const handle: DesktopTranscriptHandle = { sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, loadBefore: async () => {}, loadAfter: async () => {}, close: async () => {}, - async loadAround(sequence, _maxBytes, navigation) { - requests.push({ sequence, navigation }); - const turnId = sequence === null ? 'b' : 'a'; - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - navigationVersion: navigation!.navigationVersion, durableThrough: 20, - durable: [{ sequence: sequence ?? 20, message: { - type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - } }], overlay: [], hasOlder: true, hasNewer: sequence !== null, - })) store.accept(batch); - }, + async loadAround(sequence, _maxBytes, navigation) { publish(sequence, navigation); }, + async loadLatest(navigation) { publish(null, navigation); }, }; const restore = () => restoreSessionTranscriptRange({ lifecycle, sessionId, controller, readingAnchor: { turnId: 'a', sequence: 10 }, @@ -83,12 +84,12 @@ test('sending before transcript open completes supersedes the queued bookmark wi restore(); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(requests.map(({ sequence, navigation }) => - [sequence, navigation?.intent, navigation?.navigationVersion]), [[null, 'followTail', 2]]); + [sequence, navigation?.windowEpoch]), [[null, 2]]); assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['answer-b']); const latest = store.snapshot(); for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - navigationVersion: 1, durableThrough: 20, + windowEpoch: 1, durableThrough: 20, durable: [{ sequence: 10, message: { type: 'assistant', id: 'answer-a', turnId: 'a', text: 'a', ts: 1, modelId: 'fixture', } }], overlay: [], hasOlder: false, hasNewer: true, @@ -100,100 +101,21 @@ test('sending before transcript open completes supersedes the queued bookmark wi } }); -test('a resident search supersedes send catch-up before its older latest response can evict the target', async () => { - const sessionId = JSON.stringify(['host-1', 'session-1']); - const store = new DesktopTranscriptRangeStore(sessionId); - const latestStarted = deferred(); - const latestFinished = deferred(); - const releaseLatest = deferred(); - const readingAdmitted = deferred(); - const admissions: Array<{ sequence: number | null; version: number; preserveRange?: boolean }> = []; - const publish = (turnId: string, sequence: number, navigationVersion: number) => { - const message: StoredMessage = { - type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - }; - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion, - durableThrough: 20, durable: [{ sequence, message }], overlay: [], hasOlder: false, hasNewer: turnId === 'a', - })) store.accept(batch); - }; - publish('a', 10, 0); - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, - loadBefore: async () => {}, loadAfter: async () => {}, - async loadAround(sequence, _maxBytes, navigation) { - const version = navigation!.navigationVersion; - admissions.push({ sequence, version, preserveRange: navigation!.preserveRange }); - if (sequence === null) { - latestStarted.resolve(); - await releaseLatest.promise; - publish('b', 20, version); - latestFinished.resolve(); - } else { - publish('a', 10, version); - readingAdmitted.resolve(); - } - }, - close: async () => {}, - })); - try { - const lifecycle = createTranscriptRestoreLifecycle(); - const sessionUi = createAppShellSessionUiStateController(); - assert.equal(await prepareTranscriptForSend({ - sessionId, currentSessionId: { current: sessionId }, controller: { current: controller }, - cancel: (sessionId) => lifecycle.cancel(sessionId), - followLatest: sessionUi.transcriptViewportNavigation.followLatest, - }), true, 'local admission must not wait for the latest range'); - await latestStarted.promise; - const restore = () => restoreSessionTranscriptRange({ - lifecycle, sessionId, controller, - searchTarget: { sessionId, turnId: 'a', sequence: 10, nonce: 1 }, - isCurrent: () => true, setReadingAnchor: () => {}, - onError: (error) => assert.fail(String(error)), - }); - restore(); - await readingAdmitted.promise; - releaseLatest.resolve(); - await latestFinished.promise; - await new Promise((resolve) => setImmediate(resolve)); - restore(); - assert.deepEqual(admissions, [ - { sequence: null, version: 1, preserveRange: undefined }, - { sequence: 10, version: 2, preserveRange: true }, - ]); - assert.equal(store.sequenceForTurn('a'), 10); - assert.equal(store.sequenceForTurn('b'), null); - assert.deepEqual(store.snapshot().messages.map((message) => message.turnId), ['a']); - } finally { - releaseLatest.resolve(); - await controller.close(); - } -}); - -for (const source of ['bootstrap overlay', 'live projection'] as const) { -test(`a ${source} bookmark admits its Turn once and retains it across range reload`, async () => { +test('an overlay-only bookmark stays available without loading another range', async () => { const sessionId = JSON.stringify(['host-1', 'session-1']); const store = new DesktopTranscriptRangeStore(sessionId); const overlay: StoredMessage = { type: 'assistant', id: 'answer-b', turnId: 'b', text: 'partial B', ts: 1, modelId: 'fixture', }; - const publish = (navigationVersion: number) => { - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion, - durableThrough: null, durable: [], overlay: source === 'bootstrap overlay' ? [overlay] : [], - hasOlder: false, hasNewer: false, - })) store.accept(batch); - }; - publish(0); - const admissions: Array<{ sequence: number | null; turnId?: string; version: number; preserveRange?: boolean }> = []; + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', windowEpoch: 0, + durableThrough: null, durable: [], overlay: [overlay], hasOlder: false, hasNewer: false, + })) store.accept(batch); const controller = createDesktopTranscriptRangeController(store, async () => ({ sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, loadBefore: async () => {}, loadAfter: async () => {}, close: async () => {}, - async loadAround(sequence, _maxBytes, navigation) { - admissions.push({ sequence, turnId: navigation!.readingTurnId, - version: navigation!.navigationVersion, preserveRange: navigation!.preserveRange }); - publish(navigation!.navigationVersion); - }, + loadAround: async () => assert.fail('an overlay-only bookmark has no page to load'), + loadLatest: async () => assert.fail('an overlay-only bookmark has no page to load'), })); const lifecycle = createTranscriptRestoreLifecycle(); let unavailable = 0; @@ -201,8 +123,6 @@ test(`a ${source} bookmark admits its Turn once and retains it across range relo const restore = () => restoreSessionTranscriptRange({ lifecycle, sessionId, controller, readingAnchor: { turnId: 'b' }, isCurrent: () => true, - isLiveTurn: (candidateSessionId, turnId) => source === 'live projection' && - candidateSessionId === sessionId && turnId === 'b', setReadingAnchor: (_sessionId, anchor) => { if (!anchor) cleared += 1; }, onRestoreUnavailable: () => { unavailable += 1; }, onError: (error) => assert.fail(String(error)), }); @@ -213,153 +133,72 @@ test(`a ${source} bookmark admits its Turn once and retains it across range relo assert.equal(store.sequenceForTurn('b'), null); assert.equal(unavailable, 0); assert.equal(cleared, 0); - assert.deepEqual(admissions, [{ sequence: null, turnId: 'b', version: 1, preserveRange: true }]); - await controller.reload(); - assert.deepEqual(admissions[1], { sequence: null, turnId: 'b', version: 1, preserveRange: false }); } finally { await controller.close(); } }); -} -test('both paging directions retain an overlay-only Turn identity and admit a fresh intent', async () => { - const sessionId = JSON.stringify(['host-1', 'session-1']); - const store = new DesktopTranscriptRangeStore(sessionId); - const message = (turnId: string): StoredMessage => ({ - type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - }); - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion: 0, - durableThrough: 40, durable: [{ sequence: 10, message: message('a') }], - overlay: [message('b')], hasOlder: true, hasNewer: true, - })) store.accept(batch); - const calls: Array<{ operation: string; sequence: number | null; turnId?: string; version?: number }> = []; - const record = (operation: string) => async (sequence: number | null, _maxBytes?: number, - navigation?: import('../../preload/transcript-contract.js').DesktopTranscriptNavigation) => { - calls.push({ operation, sequence, turnId: navigation?.readingTurnId, version: navigation?.navigationVersion }); - }; - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, - loadBefore: record('before'), loadAfter: record('after'), loadAround: record('around'), close: async () => {}, - })); - try { - await controller.setReadingAnchor(null, 'b'); - await controller.loadBefore(undefined, 'b'); - await controller.loadAfter(undefined, 'b'); - await controller.loadBefore(undefined, 'a'); - await controller.loadAfter(undefined, 'a'); - assert.deepEqual(calls, [ - { operation: 'around', sequence: null, turnId: 'b', version: 1 }, - { operation: 'around', sequence: null, turnId: 'b', version: 2 }, - { operation: 'around', sequence: null, turnId: 'b', version: 3 }, - { operation: 'before', sequence: 10, turnId: 'a', version: 4 }, - { operation: 'after', sequence: 10, turnId: 'a', version: 5 }, - ]); - } finally { - await controller.close(); - } -}); - -test('returning to latest supersedes pending history without letting its completion clear the new pending state', async () => { +test('a failed return to the tail reports to its own Session', async () => { const fixture = controllerFixture(); - const older = deferred(); - const latest = deferred(); - const calls: string[] = []; - fixture.controller.loadBefore = async () => { calls.push('older'); await older.promise; }; - fixture.controller.loadLatest = async () => { calls.push('latest'); await latest.promise; }; + const errors: string[] = []; + fixture.props.onNavigationError = (error) => { errors.push(String(error)); }; + fixture.controller.loadLatest = async () => { throw new Error('tail read failed'); }; await fixture.render(); - const loadingOlder = fixture.commands.current!.loadHistory('earlier'); - await fixture.commands.current!.loadHistory('earlier'); - const loadingLatest = fixture.commands.current!.loadHistory('latest'); - assert.deepEqual(calls, ['older', 'latest']); - - older.reject(new Error('superseded history request failed')); - await loadingOlder; - assert.equal(fixture.pending(), 'session-1'); - latest.resolve(); - await loadingLatest; - assert.equal(fixture.pending(), undefined); + await fixture.commands.current!.returnToLatest(); + assert.deepEqual(errors, ['Error: tail read failed']); }); -test('a new Session can load history while the previous Session request is still pending', async () => { +test('an old Session return to the tail cannot report against the new Session', async () => { const fixture = controllerFixture(); const first = deferred(); - const second = deferred(); - fixture.controller.loadBefore = () => first.promise; + fixture.props.onNavigationError = () => assert.fail('a superseded Session must not report'); + fixture.controller.loadLatest = () => first.promise; await fixture.render(); - const loadingFirst = fixture.commands.current!.loadHistory('earlier'); + const returningFirst = fixture.commands.current!.returnToLatest(); - const secondController = { ...fixture.controller, - store: { ...fixture.controller.store, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, - loadBefore: () => second.promise, - }; fixture.props.currentSessionId.current = 'session-2'; - fixture.props.rangeController.current = secondController; fixture.props.sessionId = 'session-2'; + fixture.props.rangeController.current = { + ...fixture.controller, + store: { ...fixture.controller.store, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, + loadLatest: async () => {}, + }; await fixture.render(); - const loadingSecond = fixture.commands.current!.loadHistory('earlier'); - assert.equal(fixture.pending(), 'session-2'); + await fixture.commands.current!.returnToLatest(); - first.resolve(); - await loadingFirst; - assert.equal(fixture.pending(), 'session-2'); - second.resolve(); - await loadingSecond; - assert.equal(fixture.pending(), undefined); + first.reject(new Error('superseded tail request failed')); + await returningFirst; }); -test('a new earlier request supersedes pending return-to-latest navigation', async () => { +test('filling an edge leaves an outstanding jump alone', async () => { const fixture = controllerFixture(); - const latest = deferred(); - const earlier = deferred(); - const calls: string[] = []; - fixture.controller.loadLatest = async () => { calls.push('latest'); await latest.promise; }; - fixture.controller.loadBefore = async () => { calls.push('earlier'); await earlier.promise; }; + 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; }; await fixture.render(); - const loadingLatest = fixture.commands.current!.loadHistory('latest'); - const loadingEarlier = fixture.commands.current!.loadHistory('earlier'); - assert.deepEqual(calls, ['latest', 'earlier']); - latest.resolve(); - await loadingLatest; - assert.equal(fixture.pending(), 'session-1'); - earlier.resolve(); - await loadingEarlier; - assert.equal(fixture.pending(), undefined); + // 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); }); -test('an old Session controller cannot clear pending history after returning to the same Session', async () => { +test('retaining the reader window trims the store to the visible Turns', async () => { const fixture = controllerFixture(); - const first = deferred(); - const replacement = deferred(); - fixture.controller.loadBefore = () => first.promise; - await fixture.render(); - const loadingFirst = fixture.commands.current!.loadHistory('earlier'); - - fixture.props.currentSessionId.current = 'session-2'; - fixture.props.sessionId = 'session-2'; - fixture.props.rangeController.current = { - ...fixture.controller, - store: { ...fixture.controller.store, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, - }; - await fixture.render(); - fixture.props.currentSessionId.current = 'session-1'; - fixture.props.sessionId = 'session-1'; - fixture.props.rangeController.current = { - ...fixture.controller, - loadBefore: () => replacement.promise, + 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; }; await fixture.render(); - const loadingReplacement = fixture.commands.current!.loadHistory('earlier'); - assert.equal(fixture.pending(), 'session-1'); - first.resolve(); - await loadingFirst; - assert.equal(fixture.pending(), 'session-1'); - replacement.resolve(); - await loadingReplacement; - assert.equal(fixture.pending(), undefined); + fixture.commands.current!.retainWindow({ firstTurnId: 'first', lastTurnId: 'last' }); + assert.deepEqual(retained, [[10, 21]]); }); function controllerFixture() { @@ -370,16 +209,15 @@ function controllerFixture() { loadBefore: async () => {}, loadAfter: async () => {}, loadLatest: async () => {}, - setReadingAnchor: async () => {}, store: { sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), - sequenceForTurn: () => null, + retain: (_oldest: number | null, _newest: number | null) => false, + sequenceForTurn: (_turnId: string, _edge?: 'first' | 'last'): number | null => null, newestDurableUserSequence: () => null, snapshot: () => ({ messages: [] }), }, }; - let pending: TranscriptHistoryPending | undefined; const props: ComponentProps = { commands, sessionId: 'session-1', @@ -392,13 +230,11 @@ function controllerFixture() { turnIndex: undefined, setTurnIndex: () => {}, listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), - setHistoryPending: (next) => { pending = typeof next === 'function' ? next(pending) : next; }, - historyPageBytes: 512 * 1024, onRestoreError: (error) => assert.fail(String(error)), onNavigationError: (error) => assert.fail(String(error)), }; return { - commands, controller, props, pending: () => pending?.sessionId, + commands, controller, props, render: () => act(() => root.render(createElement(TranscriptReadingPositionController, 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 78353d503f..f8729f7117 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -35,7 +35,6 @@ import { createAppShellSessionUiStateController, TranscriptReadingPositionController, type TranscriptReadingPositionCommands, - type TranscriptHistoryPending, } from '../../renderer/features/conversation/index.js'; const cleanups: Array<() => Promise> = []; @@ -69,18 +68,15 @@ 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 preserves its Turn identity', async () => { +test('reading a live Turn without a durable sequence bookmarks its Turn identity', async () => { const fixture = viewportFixture(); const sequenceForTurn = fixture.controller.store.sequenceForTurn; fixture.controller.store.sequenceForTurn = (turnId) => turnId === 'latest' ? null : sequenceForTurn(turnId); - const readingCalls: Array<{ sequence: number | null; turnId?: string }> = []; - fixture.controller.setReadingAnchor = async (sequence, turnId) => { readingCalls.push({ sequence, turnId }); }; await fixture.render(); await fixture.readAt(1900); assert.equal(fixture.pinned(), false); assert.deepEqual(fixture.sessionUi.transcriptReadingAnchorBySessionRef.current['session-a'], { turnId: 'latest' }); - assert.deepEqual(readingCalls, [{ sequence: null, turnId: 'latest' }]); }); test('a send is accepted before latest history loads and its old completion cannot move a new Session viewport', async () => { @@ -102,15 +98,15 @@ test('a send is accepted before latest history loads and its old completion cann assert.equal(fixture.scroller.scrollTop, 900); }); -for (const direction of ['earlier', 'later'] as const) { - test(`${direction} history navigation supersedes the background range load of an accepted send`, async () => { +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.activateHistoryGap(direction); + await fixture.fillEdge(direction); const readerTop = fixture.scroller.scrollTop; await act(async () => { latest.resolve(); }); @@ -160,15 +156,12 @@ test('geometry changes from the latest range do not cancel the background load o fixture.controller.loadLatest = () => latest.promise; await fixture.render(); await fixture.readAt(1000); - const readingCalls: Array = []; - fixture.controller.setReadingAnchor = async (sequence: number | null) => { readingCalls.push(sequence); }; 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.deepEqual(readingCalls, [], 'content geometry must not install a new history reading intent'); assert.equal(fixture.pinned(), true); await fixture.append('new-question', 200); assert.equal(fixture.scroller.scrollTop, 400); @@ -250,10 +243,11 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { let reads = 0; const controller = { loadAround: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, - loadLatest: async () => { reads += 1; }, setReadingAnchor: async (_sequence: number | null, _turnId?: string) => {}, + loadLatest: async () => { reads += 1; }, store: { sessionId: 'session-a', range: () => ({ sessionId: 'session-a' }), + retain: () => false, sequenceForTurn: (turnId: string) => { const sequence = messages.findIndex((message) => message.turnId === turnId); return sequence < 0 ? null : sequence; @@ -270,14 +264,11 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { searchTarget: undefined, clearSearchTarget: () => {}, turnIndex: undefined, setTurnIndex: () => {}, listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), - setHistoryPending: () => {}, historyPageBytes: 512 * 1024, onRestoreError: (error) => assert.fail(String(error)), onNavigationError: (error) => assert.fail(String(error)), }; let authority: TranscriptScrollAuthority | undefined; function Harness() { const scrollRef = useRef(scroller); - const [, setHistoryPending] = useState(); - props.setHistoryPending = setHistoryPending; authority = useTranscriptScrollAuthority(); const anchor = sessionUi.transcriptReadingAnchorBySessionRef.current[props.sessionId!]; useChatScroll({ @@ -288,7 +279,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { return createElement(Fragment, null, createElement(TranscriptReadingPositionController, props), options.returnButton ? createElement(TranscriptScrollButton, { - onActivate: () => commands.current?.loadHistory('latest'), + onActivate: () => commands.current?.returnToLatest(), }) : null, ); } @@ -304,9 +295,10 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { assert.ok(button); await act(() => { button.dispatchEvent(new window.Event('click', { bubbles: true })); }); }, - async activateHistoryGap(direction: 'earlier' | 'later') { - // ChatView releases the pin before invoking either history gap action. - await act(async () => { authority!.releasePin(); await commands.current!.loadHistory(direction); }); + 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(() => { 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 959e8e1c5d..cfb0e7975b 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 @@ -142,7 +142,7 @@ test('WorkHub projects the exact delegated Turn status and bounded assistant res durableThrough: 1, overlay: [], hasOlder: false, hasNewer: false, }; for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, navigationVersion: 0, durable: [{ sequence: 1, message: result }], + ...snapshot, windowEpoch: 0, durable: [{ sequence: 1, message: result }], })) onBatch({ ...batch, deliverySequence: 1 }); return { ...snapshot, readThroughMessageId: result.id, @@ -204,14 +204,14 @@ test('WorkHub proves a long historical Turn tail before caching its final result async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { opens += 1; const emit = ( - navigationVersion: number, + windowEpoch: number, 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, overlay: [], hasOlder, hasNewer, navigationVersion, durable, + durableThrough: 4, overlay: [], hasOlder, hasNewer, windowEpoch, durable, })) onBatch({ ...batch, deliverySequence: ++deliverySequence }); }; emit(0, [{ sequence: 4, message: { ...next, id: 'tail', ts: 4 } }], true, false); @@ -219,13 +219,13 @@ test('WorkHub proves a long historical Turn tail before caching its final result 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: { navigationVersion: number }) { - emit(navigation.navigationVersion, [{ sequence: 1, message: intermediate }], false, true); + async loadAround(_sequence: number | null, _maxBytes: number | undefined, navigation: { windowEpoch: number }) { + emit(navigation.windowEpoch, [{ sequence: 1, message: intermediate }], false, true); }, - async loadAfter(anchor: number | null, _maxBytes: number | undefined, navigation: { navigationVersion: number }) { + async loadAfter(anchor: number | null, _maxBytes: number | undefined, navigation: { windowEpoch: number }) { loadAfters += 1; assert.equal(anchor, 1); - emit(navigation.navigationVersion, [ + emit(navigation.windowEpoch, [ { sequence: 2, message: final }, { sequence: 3, message: next }, ], false, true); @@ -309,8 +309,8 @@ test('WorkHub does not infer live running when the Session catalog is unavailabl }]))[0]?.state, 'recovering'); }); -// Keep the real preload's navigation defaults and filtering in this consumer -// regression; the IPC stub models the observer's authoritative reset reply. +// 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) => { const owner = { hostId: 'owner-host', targetEpoch: 'owner-epoch', profileId: 'local', @@ -350,12 +350,12 @@ 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, navigationVersion: 0, durable: [] })) { + for (const batch of encodeDesktopTranscriptSnapshot({ ...snapshot, windowEpoch: 0, durable: [] })) { deliver(batch); } return { ...snapshot, readThroughMessageId: null }; } - if (channel === 'sessions:transcript:load-around') { + if (channel === 'sessions:transcript:load-latest') { const request = args[1] as DesktopTranscriptRangeRequest; requests.push(request); // Bound a regressed request loop so the test reports its cause. @@ -363,18 +363,14 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp await new Promise((resolve) => setImmediate(resolve)); try { for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, navigationVersion: request.navigationVersion, + ...snapshot, windowEpoch: request.windowEpoch, durable: [{ sequence: 7, message }], })) { deliver(batch); if (!batch.ready) { partialProjectionCounts.push(projections.length); - // A rejected ready/reset must not publish a partial valid snapshot - // or clear the load guard, even if a caller bypasses preload filtering. - deliverDirect?.({ - ...batch, navigationVersion: 0, fragments: [], ready: true, - deliverySequence: ++deliverySequence, - }); + // A batch from another replica generation must not publish a + // partial valid snapshot or clear the load guard. deliverDirect?.({ ...batch, generation: 'unrelated-generation', reset: false, fragments: [], ready: true, deliverySequence: ++deliverySequence, @@ -435,8 +431,7 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp await responseDelivered; await new Promise((resolve) => setImmediate(resolve)); assert.equal(requests.length, 1); - assert.equal(requests[0]!.navigationVersion, 1); - assert.equal(requests[0]!.intent, 'followTail'); + assert.equal(requests[0]!.windowEpoch, 1); assert.equal(requests[0]!.anchorSequence, null); assert.deepEqual(partialProjectionCounts, [1, 1]); assert.deepEqual(projections, [[], ['latest-message']]); @@ -485,9 +480,9 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] sessionId: 'coordination', generation: cached ? 'cached:epoch-1' : `live-${attempt}`, hostEpoch: 'epoch-1', durableThrough: 1, overlay: [], hasOlder: false, hasNewer: false, }; - const deliver = (navigationVersion = 0) => { + const deliver = (windowEpoch = 0) => { for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, navigationVersion, + ...snapshot, windowEpoch, 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 }); }; @@ -495,8 +490,8 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] const unavailable = async () => { throw new Error('Reconnect the Host to load uncached history'); }; return { ...snapshot, readThroughMessageId: null, - loadBefore: unavailable, loadAfter: unavailable, - loadAround: cached ? unavailable : async (_sequence, _maxBytes, navigation) => deliver(navigation?.navigationVersion), + loadBefore: unavailable, loadAfter: unavailable, loadLatest: unavailable, + loadAround: cached ? unavailable : async (_sequence, _maxBytes, navigation) => deliver(navigation?.windowEpoch), close: async () => { closedCount++; }, }; }, diff --git a/apps/desktop/src/main/desktop-transcript-ipc.ts b/apps/desktop/src/main/desktop-transcript-ipc.ts index 26c7efb2bc..62369b4c41 100644 --- a/apps/desktop/src/main/desktop-transcript-ipc.ts +++ b/apps/desktop/src/main/desktop-transcript-ipc.ts @@ -26,11 +26,12 @@ import { import type { DesktopSequencedTranscriptMessage, DesktopTranscriptReplicaChange, + DesktopTranscriptReplicaPage, DesktopTranscriptReplicaSnapshot, } from './desktop-transcript-replica.js'; interface TranscriptBatchIdentity { - readonly navigationVersion?: number; + readonly windowEpoch?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; @@ -40,10 +41,8 @@ interface TranscriptBatchContent { readonly durableThrough: number | null; readonly durable: readonly DesktopSequencedTranscriptMessage[]; readonly overlay: readonly StoredMessage[]; - readonly evictedDurableSequences: readonly number[]; - readonly completedOverlayMessageIds: readonly string[]; - readonly hasOlder: boolean; - readonly hasNewer: boolean; + readonly hasOlder?: boolean; + readonly hasNewer?: boolean; readonly reset: boolean; } @@ -54,14 +53,26 @@ export function encodeDesktopTranscriptSnapshot( durableThrough: snapshot.durableThrough, durable: snapshot.durable, overlay: snapshot.overlay, - evictedDurableSequences: [], - completedOverlayMessageIds: [], hasOlder: snapshot.hasOlder, hasNewer: snapshot.hasNewer, reset: true, }); } +export function encodeDesktopTranscriptPage( + identity: TranscriptBatchIdentity, + page: DesktopTranscriptReplicaPage, +): Iterable { + return encodeDesktopTranscriptBatches(identity, { + durableThrough: page.durableThrough, + durable: page.durable, + overlay: [], + hasOlder: page.hasOlder, + hasNewer: page.hasNewer, + reset: false, + }); +} + export function encodeDesktopTranscriptChange( identity: TranscriptBatchIdentity, change: DesktopTranscriptReplicaChange, @@ -70,10 +81,6 @@ export function encodeDesktopTranscriptChange( durableThrough: change.durableThrough, durable: change.durableUpserts, overlay: [], - evictedDurableSequences: change.evictedDurableSequences, - completedOverlayMessageIds: change.completedOverlayMessageIds, - hasOlder: change.hasOlder, - hasNewer: change.hasNewer, reset: false, }); } @@ -84,15 +91,8 @@ function* encodeDesktopTranscriptBatches( ): Iterable { const fragments = encodeMessages(content); let fragment = fragments.next(); - let evictedIndex = 0; - let completedIndex = 0; let first = true; - while ( - !fragment.done || - evictedIndex < content.evictedDurableSequences.length || - completedIndex < content.completedOverlayMessageIds.length || - first - ) { + while (!fragment.done || first) { const batchFragments: DesktopTranscriptFragment[] = []; let rawBytes = 0; while (!fragment.done) { @@ -104,41 +104,19 @@ function* encodeDesktopTranscriptBatches( rawBytes += bytes; fragment = fragments.next(); } - const evictedDurableSequences = content.evictedDurableSequences.slice( - evictedIndex, - evictedIndex + 256, - ); - evictedIndex += evictedDurableSequences.length; - const completedOverlayMessageIds: string[] = []; - let identityBytes = 0; - while (completedIndex < content.completedOverlayMessageIds.length) { - const messageId = content.completedOverlayMessageIds[completedIndex]!; - const bytes = Buffer.byteLength(messageId, 'utf8'); - if ( - completedOverlayMessageIds.length >= 256 || - (completedOverlayMessageIds.length > 0 && - identityBytes + bytes > DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) - ) { - break; - } - completedOverlayMessageIds.push(messageId); - identityBytes += bytes; - completedIndex += 1; - } - const ready = - fragment.done === true && - evictedIndex === content.evictedDurableSequences.length && - completedIndex === content.completedOverlayMessageIds.length; yield { - ...identity, + ...(identity.windowEpoch === undefined + ? {} + : { windowEpoch: identity.windowEpoch }), + sessionId: identity.sessionId, + generation: identity.generation, + hostEpoch: identity.hostEpoch, durableThrough: content.durableThrough, fragments: batchFragments, - evictedDurableSequences, - completedOverlayMessageIds, - hasOlder: content.hasOlder, - hasNewer: content.hasNewer, + ...(content.hasOlder === undefined ? {} : { hasOlder: content.hasOlder }), + ...(content.hasNewer === undefined ? {} : { hasNewer: content.hasNewer }), reset: content.reset && first, - ready, + ready: fragment.done === true, }; first = false; } diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index d7c8568f3c..996ef62c51 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -29,10 +29,9 @@ import { type SessionTranscriptPage, } from '@maka/runtime-host/protocol'; import { - DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, - type DesktopTranscriptNavigation, + DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, } from '../preload/transcript-contract.js'; import type { DesktopRuntimeHostSession } from './runtime-host-client.js'; @@ -55,7 +54,7 @@ export interface DesktopSequencedTranscriptMessage { } export interface DesktopTranscriptReplicaSnapshot { - readonly navigationVersion?: number; + readonly windowEpoch?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; @@ -66,19 +65,30 @@ export interface DesktopTranscriptReplicaSnapshot { readonly hasNewer: boolean; } +/** A durable page read on behalf of one Renderer window; never installed here. */ +export interface DesktopTranscriptReplicaPage { + readonly durableThrough: number; + readonly durable: readonly DesktopSequencedTranscriptMessage[]; + readonly hasOlder?: boolean; + readonly hasNewer?: boolean; +} + +/** Tail-cache growth broadcast to every consumer; carries no window edges. */ export interface DesktopTranscriptReplicaChange { readonly durableThrough: number | null; readonly durableUpserts: readonly DesktopSequencedTranscriptMessage[]; - readonly evictedDurableSequences: readonly number[]; - readonly completedOverlayMessageIds: readonly string[]; - readonly hasOlder: boolean; - readonly hasNewer: boolean; } interface ResidentMessage extends DesktopSequencedTranscriptMessage { readonly encodedBytes: number; } +/** + * Main's view of one Session transcript: the durable tail the projector needs, + * the overlay of not-yet-durable messages, 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. + */ export class DesktopTranscriptReplica { readonly sessionId: string; readonly generation: string; @@ -98,20 +108,13 @@ export class DesktopTranscriptReplica { #residentBytes = 0; #overlayBytes = 0; #durableThrough: number | null; - #overlaySettledThrough: number | null; #targetThrough: number | null; #hasOlder: boolean; - #hasNewer = false; #resident = true; #residentExternallyAccounted = true; #closed = false; #catchUpTask: Promise | undefined; #operationTail = Promise.resolve(); - #navigationToken = 0; - #intent: DesktopTranscriptNavigation['intent'] = 'followTail'; - #readingAnchorSequence: number | undefined; - #readingAnchorTurnId: string | undefined; - #adjacentReadingSequence: number | undefined; private constructor( handle: DesktopRuntimeHostSession, @@ -124,14 +127,13 @@ export class DesktopTranscriptReplica { this.#maxResidentBytes = options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES; this.#maxResidentTurns = - options.maxResidentTurns ?? DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS; + options.maxResidentTurns ?? DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS; this.#maxOverlayBytes = options.maxOverlayBytes ?? DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES; this.#maxMessageBytes = options.maxMessageBytes ?? SESSION_TRANSCRIPT_RANGE_MAX_BYTES; this.#accountPreparationBytes = options.accountPreparationBytes ?? (() => undefined); this.#onChange = options.onChange ?? (() => undefined); this.#durableThrough = handle.transcriptBootstrap.throughSequence; - this.#overlaySettledThrough = this.#durableThrough; this.#targetThrough = this.#durableThrough; this.#hasOlder = handle.transcriptBootstrap.durable.nextCursor !== null; } @@ -153,7 +155,6 @@ export class DesktopTranscriptReplica { }); replica.#evictToBudget( undefined, - 'oldest', handle.transcriptBootstrap.durable.protectedTurnSequence ?? replica.#durableThrough ?? undefined, @@ -202,7 +203,7 @@ export class DesktopTranscriptReplica { durable: this.#orderedDurable(false), overlay: [...this.#overlay.values()], hasOlder: this.#hasOlder, - hasNewer: this.#hasNewer, + hasNewer: false, }; } @@ -233,271 +234,116 @@ export class DesktopTranscriptReplica { return latest?.message.id ?? null; } - setNavigation(intent: DesktopTranscriptNavigation['intent']): number { - this.#assertOpen(); - this.#intent = intent; - if (intent === 'followTail') { - this.#readingAnchorSequence = undefined; - this.#readingAnchorTurnId = undefined; - this.#adjacentReadingSequence = undefined; - } - return ++this.#navigationToken; - } - - readAt( - sequence: number | null, - token = this.setNavigation('history'), - readingTurnId?: string, - ): Promise { - return this.#enqueue(async () => { - if (!this.#isNavigationCurrent(token)) return; - let anchor = readingTurnId === undefined - ? sequence - : this.#sequenceForTurn(readingTurnId) ?? sequence; - const through = this.#targetThrough ?? this.#durableThrough; - if (anchor === null && readingTurnId !== undefined && through !== null && - !(through <= (this.#overlaySettledThrough ?? -1) && - [...this.#overlay.values()].some((message) => message.turnId === readingTurnId))) { - // After reconnect, an overlay-only bookmark may already be durable and - // outside the bootstrap tail. Locate it through the existing bounded - // pager; retaining only its sequence keeps the scan's memory bounded. - anchor = await this.#findTurnSequence(readingTurnId, through, token); - } - if (!this.#isNavigationCurrent(token)) return; - if (anchor === null) { - // Active RuntimeEvent invocations have no durable sequence. Put the - // durable range at its current tail, then protect the requested Turn - // when catch-up first projects it. Never borrow another Turn's anchor. - if (through !== null && this.#hasNewer) { - await this.#replaceWithRange(through, through, this.#maxResidentBytes, token); - } - if (!this.#isNavigationCurrent(token)) return; - this.#readingAnchorTurnId = readingTurnId; - this.#readingAnchorSequence = undefined; - this.#adjacentReadingSequence = undefined; - this.#publish([], [], []); - return; - } - if (!this.#durable.has(anchor)) { - if (through !== null && anchor <= through) { - await this.#replaceWithRange(through, anchor, this.#maxResidentBytes, token); - } - return; - } - this.#readingAnchorSequence = anchor; - this.#readingAnchorTurnId = readingTurnId ?? this.#durable.get(anchor)?.message.turnId; - this.#adjacentReadingSequence = undefined; - const evicted = this.#evictToBudget(undefined, 'newest', anchor); - this.#publish([], [], evicted); - }); - } - - #sequenceForTurn(turnId: string): number | undefined { - for (const entry of this.#orderedDurable(false)) { - if (entry.message.turnId === turnId) return entry.sequence; - } - return undefined; - } - - #resolveReadingAnchor(): number | undefined { - if (this.#readingAnchorTurnId !== undefined) { - this.#readingAnchorSequence = this.#sequenceForTurn(this.#readingAnchorTurnId) - ?? this.#readingAnchorSequence; - } - return this.#readingAnchorSequence; - } - - #awaitingReadingTurn(): boolean { - return this.#readingAnchorTurnId !== undefined && this.#resolveReadingAnchor() === undefined; - } - - async #findTurnSequence(turnId: string, throughSequence: number, token: number): Promise { - let cursor: string | null = null; - do { - if (!this.#isNavigationCurrent(token)) return null; - const page = await this.#handle.loadTranscriptPage({ - source: 'durable', direction: 'older', throughSequence, - cursor, anchorSequence: null, maxBytes: this.#maxResidentBytes, - }); - let sequence: number | undefined; - await this.#withDecodedPage(page, (decoded) => { - if (!this.#isNavigationCurrent(token)) return; - sequence = decoded.messages.find((entry) => entry.message.turnId === turnId)?.identity; - cursor = decoded.nextCursor; - }); - if (!this.#isNavigationCurrent(token)) return null; - if (sequence !== undefined) return sequence; - } while (cursor !== null); - return null; - } - - async followLatest(maxBytes: number, token = this.setNavigation('followTail')): Promise { - return this.#enqueue(async () => { - if (!this.#isNavigationCurrent(token)) return; - const through = this.#targetThrough ?? this.#durableThrough; - if (through !== null) await this.#replaceWithRange(through, through, maxBytes, token); - }); - } - - async loadBefore( + loadBefore( anchorSequence: number | null, maxBytes: number, - token = this.setNavigation('history'), - ): Promise { - return this.#enqueue(() => this.#loadAdjacent('older', anchorSequence, maxBytes, token)); + isCurrent: () => boolean = () => true, + ): Promise { + return this.#enqueue(() => this.#loadPage('older', anchorSequence, maxBytes, isCurrent)); } - async loadAfter( + loadAfter( anchorSequence: number | null, maxBytes: number, - token = this.setNavigation('history'), - ): Promise { - return this.#enqueue(() => this.#loadAdjacent('newer', anchorSequence, maxBytes, token)); + isCurrent: () => boolean = () => true, + ): Promise { + return this.#enqueue(() => this.#loadPage('newer', anchorSequence, maxBytes, isCurrent)); } - async #loadAdjacent( + async #loadPage( direction: 'older' | 'newer', anchorSequence: number | null, maxBytes: number, - token: number, - ): Promise { - if (!this.#isNavigationCurrent(token)) return; + isCurrent: () => boolean, + ): Promise { + if (!this.#isLive() || !isCurrent()) return undefined; const throughSequence = this.#durableThrough; - if (throughSequence === null) return; - const anchor = anchorSequence ?? (direction === 'older' - ? this.#oldestSequence() - : this.#orderedDurable(false).at(-1)?.sequence ?? null); + if (throughSequence === null) return undefined; const page = await this.#handle.loadTranscriptPage({ source: 'durable', direction, throughSequence, cursor: null, - anchorSequence: anchor, + anchorSequence, maxBytes, }); - await this.#withDecodedPage(page, (decoded) => { - if (!this.#isNavigationCurrent(token)) return; - // Same post-await `#resident` invariant as `#replaceWithRange` and the - // paged catch-up: a concurrent `discard()` may have reclaimed this - // replica while the adjacent page was in flight. Installing the page here - // would repopulate durable state and undo the eviction. - if (!this.#resident) return; + return this.#withDecodedPage(page, (decoded) => { + if (!this.#isLive() || !isCurrent()) return undefined; this.#acceptRange(decoded.messages); if ( - anchor !== null && + anchorSequence !== null && decoded.messages.length > 0 && !(direction === 'older' - ? this.#matchesCoverageStep(anchor, decoded.messages.at(-1)!.identity + 1) - : this.#matchesCoverageStep(decoded.messages[0]!.identity, anchor + 1)) + ? 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`); } - const completedOverlayMessageIds = this.#installDurable(decoded.messages); - if (direction === 'older') this.#hasOlder = decoded.nextCursor !== null; - else this.#hasNewer = decoded.nextCursor !== null; - const anchorTurnId = anchor === null ? undefined : this.#durable.get(anchor)?.message.turnId; - const towardEdge = direction === 'older' ? [...decoded.messages].reverse() : decoded.messages; - const adjacent = towardEdge.find(({ message }) => - messageTurnId(message) !== undefined && messageTurnId(message) !== anchorTurnId, - ) ?? towardEdge.at(-1); - this.#readingAnchorSequence = anchor ?? undefined; - this.#readingAnchorTurnId = anchorTurnId; - this.#adjacentReadingSequence = adjacent?.identity; - const evictedDurableSequences = this.#evictToBudget( - undefined, - direction === 'older' ? 'newest' : 'oldest', - anchor ?? undefined, - adjacent?.identity, - ); - this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); + this.#completeOverlay(decoded.messages); + return { + durableThrough: throughSequence, + durable: decoded.messages.map((entry) => ({ + sequence: entry.identity, + message: entry.message, + })), + ...(direction === 'older' + ? { hasOlder: decoded.nextCursor !== null } + : { hasNewer: decoded.nextCursor !== null }), + }; }); } - async loadAround( - sequence: number, - maxBytes: number, - token = this.setNavigation('history'), - ): Promise { - return this.#enqueue(() => this.#loadAround(sequence, maxBytes, token)); - } - - async #loadAround(sequence: number, maxBytes: number, token: number): Promise { - if (!this.#isNavigationCurrent(token)) return; - const throughSequence = this.#durableThrough; - if (throughSequence === null || sequence > throughSequence) return; - await this.#replaceWithRange(throughSequence, sequence, maxBytes, token); - } - - async #replaceWithRange( - throughSequence: number, + loadAround( sequence: number, maxBytes: number, - token: number, - ): Promise { - const loadTail = sequence === throughSequence; - const page = await this.#handle.loadTranscriptPage({ - source: 'durable', - direction: loadTail ? 'older' : 'newer', - throughSequence, - cursor: null, - anchorSequence: loadTail ? sequence + 1 : sequence === 0 ? null : sequence - 1, - maxBytes, - }); - if (!this.#isNavigationCurrent(token)) return; - // 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 this. - // Ask for one row older than the anchor instead; a jump is user-initiated, - // so the extra bounded read is paid once per jump. - const older = loadTail - ? null - : await this.#handle.loadTranscriptPage({ - source: 'durable', - direction: 'older', - throughSequence, - cursor: null, - anchorSequence: sequence, - maxBytes: 1, - }); - await this.#withDecodedPage(page, (decoded) => { - if (!this.#isNavigationCurrent(token)) return; - // `#resident` can flip to false across the `await` above (a concurrent - // `discard()` reclaims memory for a non-visible session while the page is - // in flight). Re-anchoring here would repopulate durable state and undo - // the eviction, resurrecting a deliberately discarded replica past its - // memory budget. The paged catch-up guards its own post-await callback - // the same way; mirror it before mutating or publishing. - if (!this.#resident) return; - this.#acceptRange(decoded.messages); - if ( - decoded.messages.length > 0 && - (loadTail - ? !this.#matchesCoverageStep(sequence, decoded.messages.at(-1)!.identity) - : decoded.messages[0]!.identity !== sequence) - ) { - throw correlationError('Desktop transcript range did not meet its anchor'); - } - const evictedDurableSequences = [...this.#durable.keys()]; - this.#clearDurable(); - const completedOverlayMessageIds = this.#installDurable(decoded.messages); - this.#durableThrough = throughSequence; - this.#readingAnchorSequence = this.#intent === 'history' ? sequence : undefined; - this.#readingAnchorTurnId = this.#intent === 'history' - ? this.#durable.get(sequence)?.message.turnId : undefined; - this.#adjacentReadingSequence = undefined; - this.#hasOlder = loadTail ? decoded.nextCursor !== null : older!.fragments.length > 0; - this.#hasNewer = loadTail ? false : decoded.nextCursor !== null; - evictedDurableSequences.push( - ...this.#evictToBudget( - undefined, - loadTail ? 'oldest' : 'newest', - loadTail ? (page.protectedTurnSequence ?? sequence) : sequence, - ), - ); - this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); + 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({ + source: 'durable', + 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({ + source: 'durable', + 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'); + } + this.#completeOverlay(decoded.messages); + return { + sessionId: this.sessionId, + generation: this.generation, + hostEpoch: this.hostEpoch, + durableThrough: throughSequence, + durable: decoded.messages.map((entry) => ({ + sequence: entry.identity, + message: entry.message, + })), + overlay: [...this.#overlay.values()], + hasOlder: older.fragments.length > 0, + hasNewer: decoded.nextCursor !== null, + }; + }); }); - if (this.#isNavigationCurrent(token) && this.#needsOverlaySettlement(throughSequence)) { - await this.#settleOverlayThrough(throughSequence, token); - } } advance(throughSequence: number): Promise { @@ -514,8 +360,7 @@ export class DesktopTranscriptReplica { if ( !this.#closed && this.#targetThrough !== null && - (this.#durableThrough === null || this.#targetThrough > this.#durableThrough || - this.#needsOverlaySettlement(this.#targetThrough)) + (this.#durableThrough === null || this.#targetThrough > this.#durableThrough) ) { void this.advance(this.#targetThrough).catch(() => undefined); } @@ -523,13 +368,10 @@ export class DesktopTranscriptReplica { return this.#catchUpTask; } - trimDurable(targetResidentBytes: number): DesktopTranscriptReplicaChange | undefined { + trimDurable(targetResidentBytes: number): void { this.#assertOpen(); - if (!this.#resident) return undefined; - const evictedDurableSequences = this.#evictToBudget(targetResidentBytes); - return evictedDurableSequences.length === 0 - ? undefined - : this.#change([], [], evictedDurableSequences); + if (!this.#resident) return; + this.#evictToBudget(targetResidentBytes); } discard(): void { @@ -557,34 +399,15 @@ export class DesktopTranscriptReplica { } async #catchUp(): Promise { - while (!this.#closed && this.#resident) { + while (this.#isLive()) { const target = this.#targetThrough; if (target === null) return; - if ( - this.#durableThrough !== null && target <= this.#durableThrough && - !this.#needsOverlaySettlement(target) - ) return; - const token = this.#navigationToken; - if ( - (this.#durableThrough !== null && target <= this.#durableThrough) || - (this.#intent === 'history' && this.#hasNewer && !this.#awaitingReadingTurn()) - ) { - await this.#settleOverlayThrough(target, token); - if (!this.#isNavigationCurrent(token)) return; - if (this.#durableThrough !== null && target <= this.#durableThrough) return; - this.#durableThrough = target; - this.#publish([], [], []); - return; - } - if (this.#hasNewer && !this.#awaitingReadingTurn()) { - await this.#replaceWithRange(target, target, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, token); - return; - } - let cursor: string | null = null; const anchorSequence = this.#durableThrough; + if (anchorSequence !== null && target <= anchorSequence) return; + let cursor: string | null = null; let nextSequence = (anchorSequence ?? -1) + 1; do { - if (!this.#isNavigationCurrent(token)) return; + if (!this.#isLive()) return; const page: SessionTranscriptPage = await this.#handle.loadTranscriptPage({ source: 'durable', direction: 'newer', @@ -594,7 +417,10 @@ export class DesktopTranscriptReplica { maxBytes: 512 * 1024, }); await this.#withDecodedPage(page, (decoded) => { - if (!this.#isNavigationCurrent(token)) return; + // A concurrent `discard()` (LRU reclaim for another observed session) + // can flip `#resident` across the `await` above; installing the page + // would resurrect the reclaimed replica. + if (!this.#isLive()) return; if (decoded.messages.length === 0 && decoded.nextCursor !== null) { throw correlationError('Desktop transcript catch-up returned an empty continuation'); } @@ -608,46 +434,18 @@ export class DesktopTranscriptReplica { if (decoded.messages.length > 0) { nextSequence = decoded.messages.at(-1)!.identity + 1; } - const completedOverlayMessageIds = this.#installDurable(decoded.messages); - this.#acknowledgeOverlayCoverage(anchorSequence, decoded.messages.at(-1)?.identity); - // Reading the start of the still-growing latest Turn must continue - // receiving its durable text. Preserve the reader's anchor (and an - // older page awaiting the reader), rather than protecting a new Turn - // that could evict the range they are reading. - const readingHistory = this.#intent === 'history'; - const readingSequence = readingHistory ? this.#resolveReadingAnchor() : undefined; - const awaitingReadingTurn = readingHistory && this.#awaitingReadingTurn(); - const evictedDurableSequences = this.#evictToBudget( + this.#installDurable(decoded.messages); + this.#evictToBudget( undefined, - readingHistory && !awaitingReadingTurn ? 'newest' : 'oldest', - readingHistory && !awaitingReadingTurn - ? readingSequence ?? this.#oldestSequence() ?? undefined - : page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, - readingHistory ? this.#adjacentReadingSequence : undefined, + page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, ); - this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); + this.#publish(decoded.messages); cursor = decoded.nextCursor; }); - if (!this.#isNavigationCurrent(token)) return; - if (this.#intent === 'history' && this.#hasNewer && !this.#awaitingReadingTurn()) { - await this.#settleOverlayThrough(target, token); - if (!this.#isNavigationCurrent(token)) return; - this.#durableThrough = target; - this.#publish([], [], []); - return; - } } while (cursor !== null); - // A concurrent `discard()` (LRU reclaim for another observed session) can - // flip `#resident` to false across any page `await` above. The per-page - // callback already returns early in that case, so `nextSequence` is - // left short of the watermark. Without this guard the check below would - // turn a benign memory reclaim into a fatal `correlation_changed` that - // drives the session terminal. A discarded replica has no watermark to - // meet, so return cleanly and let a later resume re-catch-up. - if (!this.#isNavigationCurrent(token)) return; - this.#acknowledgeOverlayCoverage(anchorSequence, target); + if (!this.#isLive()) return; this.#durableThrough = target; - this.#publish([], [], []); + this.#publish([]); } } @@ -660,73 +458,12 @@ export class DesktopTranscriptReplica { } } - #needsOverlaySettlement(throughSequence: number): boolean { - return this.#overlay.size > 0 && - (this.#overlaySettledThrough === null || throughSequence > this.#overlaySettledThrough); - } - - #acknowledgeOverlayCoverage(anchorSequence: number | null, throughSequence: number | undefined): void { - if ( - throughSequence !== undefined && - (anchorSequence ?? -1) <= (this.#overlaySettledThrough ?? -1) - ) { - this.#overlaySettledThrough = Math.max(this.#overlaySettledThrough ?? -1, throughSequence); - } - } - - async #settleOverlayThrough(throughSequence: number, token: number): Promise { - // A navigation can skip durable pages while the bootstrap overlay still - // contains an unfinished message from one of those pages. Its settlement - // watermark must therefore be independent of the visible range watermark. - // Only matching durable identities retire overlay records; unrelated new - // messages are decoded one page at a time without entering the range. - if (!this.#needsOverlaySettlement(throughSequence)) return; - const anchorSequence = this.#overlaySettledThrough; - let nextSequence = (anchorSequence ?? -1) + 1; - let cursor: string | null = null; - do { - if (!this.#isNavigationCurrent(token)) return; - const page = await this.#handle.loadTranscriptPage({ - source: 'durable', - direction: 'newer', - throughSequence, - cursor, - anchorSequence: cursor === null ? anchorSequence : null, - maxBytes: 512 * 1024, - }); - await this.#withDecodedPage(page, (decoded) => { - if (!this.#isNavigationCurrent(token)) return; - if (decoded.messages.length === 0 && decoded.nextCursor !== null) { - throw correlationError('Desktop transcript overlay settlement returned an empty continuation'); - } - this.#acceptRange(decoded.messages); - if (decoded.messages.length > 0) { - if (!this.#matchesCoverageStep(decoded.messages[0]!.identity, nextSequence)) { - throw correlationError('Desktop transcript overlay settlement has a sequence gap'); - } - const lastSequence = decoded.messages.at(-1)!.identity; - nextSequence = lastSequence + 1; - this.#overlaySettledThrough = lastSequence; - } - const completedOverlayMessageIds = this.#completeOverlay(decoded.messages); - if (completedOverlayMessageIds.length > 0) { - this.#publish([], completedOverlayMessageIds, []); - } - cursor = decoded.nextCursor; - }); - if (!this.#isNavigationCurrent(token) || this.#overlay.size === 0) return; - } while (cursor !== null); - // RuntimeEvent projection can leave gaps and a watermark beyond its last - // visible row. Exhausting the correlated cursor establishes coverage. - this.#overlaySettledThrough = throughSequence; - } - #installDurable( messages: readonly { readonly identity: number; readonly message: StoredMessage; }[], - ): string[] { + ): void { for (const item of messages) { const previous = this.#durable.get(item.identity); if (previous && previous.message.id !== item.message.id) { @@ -742,20 +479,21 @@ export class DesktopTranscriptReplica { }); this.#adjustResidentBytes(encodedBytes); } - return this.#completeOverlay(messages); + this.#completeOverlay(messages); } - #completeOverlay(messages: readonly { readonly message: StoredMessage }[]): string[] { - const completedOverlayMessageIds: string[] = []; + /** + * The durable row settles the overlay it replaces, so the tail cache stops + * carrying both. Each window retires its own overlay when it installs the + * row; a window that never installs it keeps showing what it has. + */ + #completeOverlay(messages: readonly { readonly message: StoredMessage }[]): void { for (const { message } of messages) { const overlay = this.#overlay.get(message.id); - if (overlay) { - this.#overlay.delete(message.id); - this.#adjustOverlayBytes(-encodedMessageBytes(overlay)); - completedOverlayMessageIds.push(message.id); - } + if (!overlay) continue; + this.#overlay.delete(message.id); + this.#adjustOverlayBytes(-encodedMessageBytes(overlay)); } - return completedOverlayMessageIds; } #acceptRange( @@ -783,110 +521,53 @@ export class DesktopTranscriptReplica { readonly identity: number; readonly message: StoredMessage; }[], - completedOverlayMessageIds: readonly string[], - evictedDurableSequences: readonly number[], ): void { - this.#onChange(this, this.#change(messages, completedOverlayMessageIds, evictedDurableSequences)); - } - - #change( - messages: readonly { - readonly identity: number; - readonly message: StoredMessage; - }[], - completedOverlayMessageIds: readonly string[], - evictedDurableSequences: readonly number[], - ): DesktopTranscriptReplicaChange { - return { + this.#onChange(this, { durableThrough: this.#durableThrough, - durableUpserts: messages.flatMap((entry) => { - const resident = this.#durable.get(entry.identity); - return resident?.message.id === entry.message.id - ? [{ sequence: entry.identity, message: resident.message }] - : []; - }), - evictedDurableSequences: [...new Set(evictedDurableSequences)].filter( - (sequence) => !this.#durable.has(sequence), - ), - completedOverlayMessageIds, - hasOlder: this.#hasOlder, - hasNewer: this.#hasNewer, - }; + // Every row this catch-up read, whether or not the tail cache kept it: + // the budget that evicts it here is Main's, not any window's. + durableUpserts: messages.map((entry) => ({ + sequence: entry.identity, + message: entry.message, + })), + }); } + /** + * 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. + */ #evictToBudget( budget: number | undefined = undefined, - edge: 'oldest' | 'newest' = 'oldest', protectedSequence?: number, - protectedThroughSequence = protectedSequence, - ): number[] { + ): void { const residentBudget = budget ?? this.#maxResidentBytes + this.#overlayBytes; - const evicted: number[] = []; - const sequences = [...this.#durable.keys()].sort((left, right) => left - right); - const turnGroups = new Map(); - for (const sequence of sequences) { - const entry = this.#durable.get(sequence); - if (!entry) continue; - const turnKey = residentTurnKey(entry); - const group = turnGroups.get(turnKey); + const turns = new Map(); + for (const sequence of [...this.#durable.keys()].sort((left, right) => left - right)) { + const key = residentTurnKey(this.#durable.get(sequence)!); + const group = turns.get(key); if (group) group.push(sequence); - else turnGroups.set(turnKey, [sequence]); + else turns.set(key, [sequence]); } - const orderedTurns = [...turnGroups.entries()]; - let oldestIndex = 0; - let newestIndex = orderedTurns.length - 1; - let residentTurns = orderedTurns.length; - const protectedIndices = [protectedSequence, protectedThroughSequence].flatMap((sequence) => { - const entry = sequence === undefined ? undefined : this.#durable.get(sequence); - return entry === undefined ? [] : [orderedTurns.findIndex(([key]) => key === residentTurnKey(entry))]; - }); - const protectedStart = Math.min(...protectedIndices); - const protectedEnd = Math.max(...protectedIndices); - // A single oversized Turn already outranks the per-range soft budget. - // Adjacent navigation needs the same exception for the minimal span from - // the reader to the next Turn; otherwise that Turn is evicted on arrival - // and every subsequent scroll reloads it without making progress. Global - // pressure calls trimDurable without protection and still reclaims it. - const take = ( - candidateEdge: 'oldest' | 'newest', - ): readonly [string, number[]] | undefined => { - const index = candidateEdge === 'oldest' ? oldestIndex : newestIndex; - if (oldestIndex > newestIndex) return undefined; - const turn = orderedTurns[index]; - if (!turn || (index >= protectedStart && index <= protectedEnd)) return undefined; - if (candidateEdge === 'oldest') oldestIndex += 1; - else newestIndex -= 1; - return turn; - }; - while ( - this.#residentBytes > residentBudget - || residentTurns > this.#maxResidentTurns - ) { - let evictionEdge = protectedIndices.length === 0 - ? edge - : protectedStart - oldestIndex > newestIndex - protectedEnd - ? 'oldest' - : protectedStart - oldestIndex < newestIndex - protectedEnd - ? 'newest' - : edge; - let turn = take(evictionEdge); - if (turn === undefined) { - evictionEdge = evictionEdge === 'oldest' ? 'newest' : 'oldest'; - turn = take(evictionEdge); - } - if (turn === undefined) break; - for (const sequence of turn[1]) { + const protectedEntry = protectedSequence === undefined + ? undefined + : this.#durable.get(protectedSequence); + const protectedKey = protectedEntry === undefined ? undefined : residentTurnKey(protectedEntry); + let residentTurns = turns.size; + for (const [key, sequences] of turns) { + if (this.#residentBytes <= residentBudget && residentTurns <= this.#maxResidentTurns) return; + if (key === protectedKey) return; + for (const sequence of sequences) { const entry = this.#durable.get(sequence); if (!entry) continue; this.#durable.delete(sequence); this.#adjustResidentBytes(-entry.encodedBytes); - evicted.push(sequence); } residentTurns -= 1; - if (evictionEdge === 'oldest') this.#hasOlder = true; - else this.#hasNewer = true; + this.#hasOlder = true; } - return evicted; } #orderedDurable(cloneMessages = true): DesktopSequencedTranscriptMessage[] { @@ -898,14 +579,6 @@ export class DesktopTranscriptReplica { })); } - #oldestSequence(): number | null { - let oldest: number | null = null; - for (const sequence of this.#durable.keys()) { - if (oldest === null || sequence < oldest) oldest = sequence; - } - return oldest; - } - #clearDurable(): void { for (const entry of this.#durable.values()) this.#adjustResidentBytes(-entry.encodedBytes); this.#durable.clear(); @@ -960,8 +633,8 @@ export class DesktopTranscriptReplica { } } - #isNavigationCurrent(token: number): boolean { - return !this.#closed && this.#resident && token === this.#navigationToken; + #isLive(): boolean { + return !this.#closed && this.#resident; } #assertOpen(): void { @@ -974,9 +647,9 @@ export class DesktopTranscriptReplica { } } - #enqueue(operation: () => Promise): Promise { + #enqueue(operation: () => Promise): Promise { const task = this.#operationTail.then(operation); - this.#operationTail = task.catch(() => undefined); + this.#operationTail = task.then(() => undefined, () => undefined); return task; } } 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 d6b6234f8b..262ee0f02f 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 @@ -177,6 +177,7 @@ export interface RuntimeHostSessionObservationIpcDeps { | 'loadTranscriptAround' | 'loadTranscriptBefore' | 'loadTranscriptAfter' + | 'loadTranscriptLatest' | 'observe' | 'openTranscript' >; @@ -228,6 +229,12 @@ export function registerRuntimeHostSessionObservationIpc( event.sender.id, ); }); + ipcMain.handle('sessions:transcript:load-latest', async (event, input: unknown) => { + await deps.observations.loadTranscriptLatest( + normalizeTranscriptRangeRequest(input), + event.sender.id, + ); + }); } /** @@ -826,12 +833,7 @@ function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRange throw new Error('Invalid Desktop transcript range byte limit'); } if ( - (value.navigationVersion !== undefined && - (!Number.isSafeInteger(value.navigationVersion) || (value.navigationVersion as number) < 0)) || - (value.intent !== undefined && value.intent !== 'history' && value.intent !== 'followTail') || - (value.preserveRange !== undefined && typeof value.preserveRange !== 'boolean') || - (value.readingTurnId !== undefined && - (typeof value.readingTurnId !== 'string' || value.readingTurnId.length === 0)) + !Number.isSafeInteger(value.windowEpoch) || (value.windowEpoch as number) < 0 ) { throw new Error('Invalid Desktop transcript navigation'); } @@ -841,10 +843,7 @@ function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRange hostEpoch: requiredId(value.hostEpoch, 'Host epoch'), anchorSequence: anchorSequence as number | null, maxBytes: maxBytes as number, - navigationVersion: value.navigationVersion as number | undefined, - intent: value.intent as DesktopTranscriptRangeRequest['intent'], - preserveRange: value.preserveRange as boolean | undefined, - readingTurnId: value.readingTurnId as string | undefined, + windowEpoch: value.windowEpoch as number, }; } 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 8b0379b5b8..3384a671e1 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -41,6 +41,7 @@ type SessionObservationSource = Pick & | 'loadTranscriptAround' | 'loadTranscriptBefore' | 'loadTranscriptAfter' + | 'loadTranscriptLatest' | 'openTranscript' > >; @@ -52,6 +53,7 @@ type TranscriptSource = Required< | 'loadTranscriptAround' | 'loadTranscriptBefore' | 'loadTranscriptAfter' + | 'loadTranscriptLatest' | 'openTranscript' > >; @@ -74,6 +76,7 @@ function requireTranscriptSource( !source.loadTranscriptBefore || !source.loadTranscriptAfter || !source.loadTranscriptAround || + !source.loadTranscriptLatest || !source.closeTranscript ) { throw new Error('Runtime Host transcript source is unavailable'); @@ -95,19 +98,6 @@ function isMissingRuntimeHostSessionError(error: unknown): boolean { return error.code === "not_found"; } -function recoveredTranscriptRequest( - request: DesktopTranscriptRangeRequest, - hostEpoch: string, -): DesktopTranscriptRangeRequest { - return { - ...request, preserveRange: false, - ...(request.hostEpoch === hostEpoch ? {} : { - hostEpoch, anchorSequence: null, - ...(request.readingTurnId === undefined ? { intent: 'followTail' as const } : {}), - }), - }; -} - interface SessionObservationRegistration { readonly sessionId: string; readonly messageAdmissions: boolean; @@ -124,14 +114,7 @@ interface TranscriptRegistration { readonly ready: TranscriptReadiness; restore: ObservationReadiness | undefined; restoreOpened: boolean; - hostEpoch?: string; - recoveredIdentity?: { - readonly source: SessionObservationSource; - readonly previousHostEpoch: string; - readonly hostEpoch: string; - }; lifecycle: 'pending' | 'active'; - navigation?: { readonly request: DesktopTranscriptRangeRequest }; } interface TranscriptReadiness { @@ -389,7 +372,6 @@ export class RuntimeHostSessionObservationRegistry { ); if (this.#source === source && this.#transcripts.get(consumerId) === registration) { registration.lifecycle = 'active'; - registration.hostEpoch = result.hostEpoch; registration.ready.resolve(result); } else { await transcriptSource.closeTranscript(consumerId); @@ -408,10 +390,8 @@ export class RuntimeHostSessionObservationRegistry { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptOperation(request, (source, accepted) => - accepted === request - ? source.loadTranscriptBefore(accepted, targetId) - : source.loadTranscriptAround(accepted, targetId), + await this.#runTranscriptOperation(request, (source) => + source.loadTranscriptBefore(request, targetId), ); } @@ -419,8 +399,8 @@ export class RuntimeHostSessionObservationRegistry { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptOperation(request, (source, accepted) => - source.loadTranscriptAround(accepted, targetId), + await this.#runTranscriptOperation(request, (source) => + source.loadTranscriptAround(request, targetId), ); } @@ -428,10 +408,17 @@ export class RuntimeHostSessionObservationRegistry { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptOperation(request, (source, accepted) => - accepted === request - ? source.loadTranscriptAfter(accepted, targetId) - : source.loadTranscriptAround(accepted, targetId), + await this.#runTranscriptOperation(request, (source) => + source.loadTranscriptAfter(request, targetId), + ); + } + + async loadTranscriptLatest( + request: DesktopTranscriptRangeRequest, + targetId?: number, + ): Promise { + await this.#runTranscriptOperation(request, (source) => + source.loadTranscriptLatest(request, targetId), ); } @@ -520,46 +507,25 @@ export class RuntimeHostSessionObservationRegistry { async #runTranscriptOperation( request: DesktopTranscriptRangeRequest, - operation: ( - source: SessionObservationSource & TranscriptSource, - accepted: DesktopTranscriptRangeRequest, - ) => Promise, + operation: (source: SessionObservationSource & TranscriptSource) => Promise, ): Promise { const consumerId = request.consumerId; const registration = this.#transcripts.get(consumerId); if (!registration) { throw new Error('Desktop transcript consumer does not exist'); } - if ((request.navigationVersion ?? 0) < (registration.navigation?.request.navigationVersion ?? 0)) return; - const navigation = { request }; - registration.navigation = navigation; const source = requireTranscriptSource(this.#source); try { const restore = registration.restore; if (restore && !registration.restoreOpened) await restore.promise; - if ( - this.#source !== source || - this.#transcripts.get(consumerId) !== registration || - registration.navigation !== navigation - ) { + if (this.#source !== source || this.#transcripts.get(consumerId) !== registration) { return; } - const recovered = registration.recoveredIdentity; - if (recovered?.source === source && request.hostEpoch === recovered.previousHostEpoch) { - // A successful open proves this consumer moved to this source. Its - // preload may still await the replay snapshot before learning the new - // epoch; admit a newer intent without accepting arbitrary stale epochs. - navigation.request = recoveredTranscriptRequest(request, recovered.hostEpoch); - } - await operation(source, navigation.request); + await operation(source); } catch (error) { // Once either owner changes, this rejection belongs to stale work and // must not escape as a failure of the current renderer intent. - if ( - this.#source !== source || - this.#transcripts.get(consumerId) !== registration || - registration.navigation !== navigation - ) { + if (this.#source !== source || this.#transcripts.get(consumerId) !== registration) { return; } throw error; @@ -595,23 +561,7 @@ export class RuntimeHostSessionObservationRegistry { this.#transcripts.get(consumerId) === registration && registration.restore === restore ) { - const previousHostEpoch = registration.hostEpoch; - if (previousHostEpoch && previousHostEpoch !== result.hostEpoch) { - registration.recoveredIdentity = { source, previousHostEpoch, hostEpoch: result.hostEpoch }; - } - registration.hostEpoch = result.hostEpoch; registration.restoreOpened = true; - // Reconnection owns no new navigation intent. Reapply only the latest - // command admitted while the old source was alive or recovery waited. - const navigation = registration.navigation; - if (navigation) { - const request = recoveredTranscriptRequest(navigation.request, result.hostEpoch); - await transcriptSource.loadTranscriptAround(request, registration.target.id); - if (this.#source !== source || registration.restore !== restore) { - restore.resolve(); - return; - } - } registration.lifecycle = 'active'; registration.ready.resolve(result); registration.restore = undefined; diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 2993097826..112c8c2778 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -58,6 +58,7 @@ import { } from './desktop-transcript-replica.js'; import { encodeDesktopTranscriptChange, + encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot, } from './desktop-transcript-ipc.js'; @@ -129,13 +130,23 @@ interface TranscriptConsumer { readonly consumerId: string; readonly target: RuntimeHostTranscriptTarget; generation: string; - navigationVersion: number; - navigationPending: boolean; - navigationRequest?: DesktopTranscriptRangeRequest; + /** The window the standing replacement installs; what a read must still reach. */ + windowEpoch: number; + /** The newest window the Renderer has named, replacing or extending. */ + newestWindowEpoch: 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. + */ + resetWindowEpoch?: number; + /** Page answers queued behind the delivery loop so they never interleave with a change. */ + readonly pendingPages: PendingTranscriptPage[]; pendingChange?: PendingTranscriptChange; readonly pendingDeliveries: Map; - readonly evictedDurableSequences: Set; - readonly completedOverlayMessageIds: Set; - hasOlder: boolean; - hasNewer: boolean; encodedBytes: number; } +interface PendingTranscriptPage { + readonly windowEpoch: number; + /** Whether this answer installs its window or splices onto one already there. */ + readonly replaces: boolean; + readonly generation: string; + readonly batches: Iterable; + readonly encodedBytes: number; +} + interface PendingTranscriptUpsert { readonly entry: DesktopSequencedTranscriptMessage; readonly encodedBytes: number; @@ -296,11 +312,12 @@ export class RuntimeHostSessionObserver { consumerId, target, generation: replica.generation, - navigationVersion: 0, - navigationPending: false, + windowEpoch: 0, + newestWindowEpoch: 0, deliverySequence: 0, deliveryBytes: 0, resetRequested: false, + pendingPages: [], pendingDeliveries: new Map(), }; state.transcriptConsumers.set(consumerId, consumer); @@ -337,90 +354,157 @@ export class RuntimeHostSessionObserver { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, (replica, token) => - replica.loadBefore( + await this.#runTranscriptRangeOperation(request, targetId, false, async (replica, isCurrent) => { + const page = await replica.loadBefore( request.anchorSequence, requireTranscriptRangeBytes(request.maxBytes), - token, - ), - ); + isCurrent, + ); + return page && { batches: encodeDesktopTranscriptPage(this.#pageIdentity(replica, request), page), bytes: page.durable }; + }); } - async loadTranscriptAround( + async loadTranscriptAfter( request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, (replica, token) => { - if (request.intent === 'followTail') { - return replica.followLatest(requireTranscriptRangeBytes(request.maxBytes), token); - } - if (request.readingTurnId !== undefined) { - return replica.readAt(request.anchorSequence, token, request.readingTurnId); - } - if (request.anchorSequence === null) { - throw new Error('Desktop transcript around request requires an anchor'); - } - if (request.preserveRange) return replica.readAt(request.anchorSequence, token); - return replica.loadAround( + await this.#runTranscriptRangeOperation(request, targetId, false, async (replica, isCurrent) => { + const page = await replica.loadAfter( request.anchorSequence, requireTranscriptRangeBytes(request.maxBytes), - token, + isCurrent, ); + return page && { batches: encodeDesktopTranscriptPage(this.#pageIdentity(replica, request), page), bytes: page.durable }; }); } - async loadTranscriptAfter( + async loadTranscriptAround( request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, (replica, token) => - replica.loadAfter( - request.anchorSequence, + 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), - token, - ), - ); + isCurrent, + ); + return snapshot && { + batches: encodeDesktopTranscriptSnapshot({ ...snapshot, windowEpoch: request.windowEpoch }), + bytes: [...snapshot.durable, ...snapshot.overlay.map((message) => ({ message }))], + }; + }); } - async #runTranscriptRangeOperation( + async loadTranscriptLatest( request: DesktopTranscriptRangeRequest, - targetId: number | undefined, - operation: (replica: DesktopTranscriptReplica, token: number) => Promise, + targetId?: number, ): Promise { + const { state, consumer } = this.#admitTranscriptNavigation(request, targetId, true); + if (!consumer) return; + consumer.resetRequested = true; + consumer.resetWindowEpoch = request.windowEpoch; + await this.#scheduleTranscriptDelivery(state, consumer); + this.#touchReplica(state); + } + + #pageIdentity(replica: DesktopTranscriptReplica, request: DesktopTranscriptRangeRequest) { + return { + sessionId: replica.sessionId, + generation: replica.generation, + hostEpoch: replica.hostEpoch, + windowEpoch: request.windowEpoch, + }; + } + + /** + * The Renderer mints a version for every window it can no longer splice onto + * — navigating away, and the band trimming an edge out. Only the first of + * those abandons a read already in flight: a replacement discards the edges, + * so a trim under it leaves it perfectly answerable. So the consumer's + * version is what the last replacement named, an extension that names a newer + * one is admitted without moving it, and only a version older than the + * standing replacement belongs to a window the Renderer has left. + */ + #admitTranscriptNavigation( + request: DesktopTranscriptRangeRequest, + targetId: number | undefined, + replaces: boolean, + ): { state: ObservedSessionState; replica: DesktopTranscriptReplica; consumer?: TranscriptConsumer } { const { state, replica, consumer } = this.#requireTranscriptConsumer(request, targetId); - const version = request.navigationVersion ?? consumer.navigationVersion; + const version = request.windowEpoch; if (!Number.isSafeInteger(version) || version < 0) throw new Error('Invalid transcript navigation version'); - if (version < consumer.navigationVersion) return; - if (request.intent !== undefined && request.intent !== 'history' && request.intent !== 'followTail') { - throw new Error('Invalid transcript navigation intent'); + if (version < consumer.windowEpoch) return { state, replica }; + if (replaces) consumer.windowEpoch = version; + if (version > consumer.newestWindowEpoch) consumer.newestWindowEpoch = version; + this.#dropSupersededTranscriptPages(consumer); + return { state, replica, consumer }; + } + + /** + * Releases the delivery budget held by queued answers the window can no longer + * take. An extension is spliceable only onto the window it named, so any newer + * one strands it; a replacement installs its own window, so only a newer + * replacement does. + */ + #dropSupersededTranscriptPages(consumer: TranscriptConsumer): void { + for (let index = consumer.pendingPages.length - 1; index >= 0; index -= 1) { + const page = consumer.pendingPages[index]!; + if (this.#deliversTranscriptPage(consumer, page)) continue; + consumer.pendingPages.splice(index, 1); + this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes); } - consumer.navigationVersion = version; - consumer.navigationRequest = request; - consumer.navigationPending = true; - consumer.resetRequested = true; - this.#clearPendingTranscriptChange(consumer); - // Admission invalidates in-flight pages immediately, before the replica's - // operation queue can run the newer command. - const token = replica.setNavigation(request.intent ?? 'history'); + } + + #deliversTranscriptPage(consumer: TranscriptConsumer, page: PendingTranscriptPage): boolean { + return page.windowEpoch >= (page.replaces ? consumer.windowEpoch : consumer.newestWindowEpoch); + } + + 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 = () => state.replica === replica && state.transcriptConsumers.get(request.consumerId) === consumer && - consumer.navigationRequest === request; + consumer.windowEpoch <= request.windowEpoch; + let answer: Awaited>; try { - await operation(replica, token); - if (!isCurrent()) return; - consumer.navigationPending = false; - // Already dispatched batches remain ACKable; finish draining them before - // issuing the authoritative snapshot for this navigation. - await consumer.deliveryTask; - if (!isCurrent()) return; - consumer.resetRequested = true; - await this.#scheduleTranscriptDelivery(state, consumer); + answer = await operation(replica, isCurrent); } catch (error) { + // A replaced replica's failure is not a failure of the current window. if (!isCurrent()) return; - consumer.navigationPending = false; 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'); + } + consumer.pendingPages.push({ + windowEpoch: request.windowEpoch, + replaces, + generation: replica.generation, + batches: answer.batches, + encodedBytes, + }); + await this.#scheduleTranscriptDelivery(state, consumer); if (isCurrent()) this.#touchReplica(state); } @@ -1159,7 +1243,7 @@ export class RuntimeHostSessionObserver { this.#broadcast(state.sessionId, event); } this.#sendTranscriptChange(state, replica, change); - if (!change.hasNewer && change.durableUpserts.length > 0) { + if (change.durableUpserts.length > 0) { this.#markTranscriptRead(state, replica); } this.#touchReplica(state); @@ -1184,14 +1268,7 @@ export class RuntimeHostSessionObserver { #resetTranscriptConsumers(state: ObservedSessionState): void { for (const consumer of [...state.transcriptConsumers.values()]) { - const request = consumer.navigationRequest; - if (request && request.hostEpoch === state.replica?.hostEpoch) { - const recoveryRequest = { ...request, preserveRange: false }; - void this.loadTranscriptAround(recoveryRequest, consumer.target.id).catch(() => undefined); - } else { - consumer.navigationPending = false; - this.#requestTranscriptReset(state, consumer); - } + this.#requestTranscriptReset(state, consumer); } } @@ -1204,9 +1281,10 @@ export class RuntimeHostSessionObserver { task = (async () => { try { while (state.transcriptConsumers.get(consumer.consumerId) === consumer) { - if (consumer.navigationPending) return; if (consumer.resetRequested) { consumer.resetRequested = false; + const resetWindowEpoch = consumer.resetWindowEpoch; + consumer.resetWindowEpoch = undefined; this.#clearPendingTranscriptChange(consumer); const replica = state.replica; if (!replica?.resident || state.closing) return; @@ -1220,7 +1298,7 @@ export class RuntimeHostSessionObserver { consumer, encodeDesktopTranscriptSnapshot({ ...replica.snapshot(), - navigationVersion: consumer.navigationVersion, + windowEpoch: resetWindowEpoch, }), ); } finally { @@ -1228,6 +1306,21 @@ export class RuntimeHostSessionObserver { } continue; } + const page = consumer.pendingPages.shift(); + if (page) { + try { + if ( + this.#deliversTranscriptPage(consumer, page) && + page.generation === consumer.generation && + state.replica?.generation === consumer.generation + ) { + await this.#sendTranscriptBatches(consumer, page.batches); + } + } finally { + this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes); + } + continue; + } const pending = consumer.pendingChange; if (!pending) return; consumer.pendingChange = undefined; @@ -1244,15 +1337,10 @@ export class RuntimeHostSessionObserver { sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch, - navigationVersion: consumer.navigationVersion, }, { durableThrough: pending.durableThrough, durableUpserts: [...pending.durableUpserts.values()].map(({ entry }) => entry), - evictedDurableSequences: [...pending.evictedDurableSequences], - completedOverlayMessageIds: [...pending.completedOverlayMessageIds], - hasOlder: pending.hasOlder, - hasNewer: pending.hasNewer, }, ), ); @@ -1288,42 +1376,16 @@ export class RuntimeHostSessionObserver { const pending = consumer.pendingChange ?? { durableThrough: change.durableThrough, durableUpserts: new Map(), - evictedDurableSequences: new Set(), - completedOverlayMessageIds: new Set(), - hasOlder: change.hasOlder, - hasNewer: change.hasNewer, encodedBytes: 0, }; let byteDelta = 0; pending.durableThrough = change.durableThrough; - pending.hasOlder = change.hasOlder; - pending.hasNewer = change.hasNewer; for (const entry of change.durableUpserts) { const previous = pending.durableUpserts.get(entry.sequence); if (previous) byteDelta -= previous.encodedBytes; const encodedBytes = encodedTranscriptMessageBytes(entry.message); pending.durableUpserts.set(entry.sequence, { entry, encodedBytes }); byteDelta += encodedBytes; - if (pending.evictedDurableSequences.delete(entry.sequence)) { - byteDelta -= encodedTranscriptIdentityBytes(entry.sequence); - } - } - for (const sequence of change.evictedDurableSequences) { - const previous = pending.durableUpserts.get(sequence); - if (previous) { - pending.durableUpserts.delete(sequence); - byteDelta -= previous.encodedBytes; - } - if (!pending.evictedDurableSequences.has(sequence)) { - pending.evictedDurableSequences.add(sequence); - byteDelta += encodedTranscriptIdentityBytes(sequence); - } - } - for (const messageId of change.completedOverlayMessageIds) { - if (!pending.completedOverlayMessageIds.has(messageId)) { - pending.completedOverlayMessageIds.add(messageId); - byteDelta += encodedTranscriptIdentityBytes(messageId); - } } pending.encodedBytes += byteDelta; consumer.pendingChange = pending; @@ -1419,7 +1481,7 @@ export class RuntimeHostSessionObserver { ): Promise { const deliveries = new Set>(); for (const batch of batches) { - if ((batch.navigationVersion ?? 0) !== consumer.navigationVersion || consumer.navigationPending) break; + if (batch.windowEpoch !== undefined && batch.windowEpoch < consumer.windowEpoch) break; let delivery!: Promise; delivery = this.#deliverTranscriptBatch(consumer, batch).finally(() => { deliveries.delete(delivery); @@ -1476,6 +1538,9 @@ export class RuntimeHostSessionObserver { this.#transcriptConsumers.delete(consumer.consumerId); consumer.resetRequested = 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')); } @@ -1497,10 +1562,9 @@ export class RuntimeHostSessionObserver { for (const candidate of replicas) { if (total <= DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES) break; const before = candidate.replica.residentBytes; - const change = candidate.replica.trimDurable( + candidate.replica.trimDurable( Math.max(0, before - (total - DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES)), ); - if (change) this.#sendTranscriptChange(candidate.state, candidate.replica, change); total -= before - candidate.replica.residentBytes; } for (const candidate of replicas) { @@ -1546,10 +1610,6 @@ function encodedTranscriptMessageBytes(message: StoredMessage): number { return Buffer.byteLength(JSON.stringify(message), 'utf8'); } -function encodedTranscriptIdentityBytes(identity: number | string): number { - return Buffer.byteLength(JSON.stringify(identity), 'utf8'); -} - function requireTranscriptRangeBytes(value: number): number { if ( !Number.isSafeInteger(value) || diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2141c40c1a..b0819592be 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -87,7 +87,7 @@ import { type DesktopTranscriptBatch, type DesktopTranscriptHandle, type DesktopTranscriptOpenResult, - type DesktopTranscriptNavigation, + type DesktopTranscriptWindowRead, } from './transcript-contract.js'; import { adoptTranscriptIdentity, @@ -2469,7 +2469,6 @@ const makaBridge = { const channel = `sessions:transcript:${consumerId}`; let identity: DesktopTranscriptIdentity | undefined; let cachedIdentity: DesktopTranscriptIdentity | undefined; - let navigationVersion = 0; const retiredGenerations = new Set(); let closed = false; let requestClose = () => {}; @@ -2489,7 +2488,7 @@ const makaBridge = { host.targetEpoch !== consumerScope.targetEpoch ) return; batch = assertDesktopTranscriptBatch(value); - if ((batch.navigationVersion ?? 0) === navigationVersion && !retiredGenerations.has(batch.generation)) { + if (!retiredGenerations.has(batch.generation)) { const adopted = adoptTranscriptIdentity(identity, batch); if (adopted !== identity) { if (identity && identity.generation !== adopted.generation) retiredGenerations.add(identity.generation); @@ -2556,6 +2555,7 @@ const makaBridge = { return { ...cachedIdentity, sessionId, readThroughMessageId: null, loadBefore: unavailable, loadAfter: unavailable, loadAround: unavailable, + loadLatest: unavailable, close: async () => {}, }; } @@ -2564,17 +2564,15 @@ const makaBridge = { 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', + operation: + | 'sessions:transcript:load-before' + | 'sessions:transcript:load-after' + | 'sessions:transcript:load-around' + | 'sessions:transcript:load-latest', anchorSequence: number | null, - maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation?: DesktopTranscriptNavigation, + maxBytes: number, + navigation: DesktopTranscriptWindowRead, ): Promise => { - const nextNavigation = navigation ?? { - navigationVersion: navigationVersion + 1, - intent: 'history' as const, - }; - if (nextNavigation.navigationVersion < navigationVersion) return Promise.resolve(); - navigationVersion = nextNavigation.navigationVersion; const currentIdentity = identity; if (!currentIdentity) { throw new Error('Desktop transcript identity is unavailable'); @@ -2585,10 +2583,7 @@ const makaBridge = { hostEpoch: currentIdentity.hostEpoch, anchorSequence, maxBytes, - navigationVersion: nextNavigation.navigationVersion, - intent: nextNavigation.intent, - preserveRange: nextNavigation.preserveRange, - readingTurnId: nextNavigation.readingTurnId, + windowEpoch: navigation.windowEpoch, }) as Promise; }; return { @@ -2600,6 +2595,8 @@ const makaBridge = { 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), 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 f729fefdce..40fd2a4ff6 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -19,15 +19,13 @@ export const DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES = 128 * 1024; export const DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES = 512 * 1024; -export const DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS = 10; +/** Turns the Main tail cache keeps for the projector and for the tail the Renderer opens with. */ +export const DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS = 10; export const DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES = 16 * 1024 * 1024; export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; -export interface DesktopTranscriptNavigation { - readonly navigationVersion: number; - readonly intent: 'history' | 'followTail'; - readonly preserveRange?: boolean; - readonly readingTurnId?: string; +export interface DesktopTranscriptWindowRead { + readonly windowEpoch: number; } export interface DesktopTranscriptFragment { @@ -39,18 +37,28 @@ export interface DesktopTranscriptFragment { readonly data: Uint8Array; } +/** + * A batch answering a read carries the epoch of the window that asked for it, + * plus the edge facts its page established. The window refuses an answer from + * an epoch it has left — it navigated, or trimmed away the very edge the read + * was anchored on — because splicing those rows on would leave a hole between + * them and what the window still holds, and a hole is not something an edge + * cursor can name or a later page can fill. + * + * Batches that carry no epoch are not answers to anything the window asked + * for: tail growth, cache trims, and the snapshot Main sends when it has + * replaced the transcript underneath every window. They apply to whatever the + * window holds, under any epoch. + */ export interface DesktopTranscriptBatchPayload { - /** An omitted version is zero, including cached bootstrap snapshots. */ - readonly navigationVersion?: number; + readonly windowEpoch?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; readonly durableThrough: number | null; readonly fragments: readonly DesktopTranscriptFragment[]; - readonly evictedDurableSequences: readonly number[]; - readonly completedOverlayMessageIds: readonly string[]; - readonly hasOlder: boolean; - readonly hasNewer: boolean; + readonly hasOlder?: boolean; + readonly hasNewer?: boolean; readonly reset: boolean; readonly ready: boolean; } @@ -67,10 +75,7 @@ export interface DesktopTranscriptOpenResult { } export interface DesktopTranscriptRangeRequest { - readonly navigationVersion?: number; - readonly intent?: DesktopTranscriptNavigation['intent']; - readonly preserveRange?: boolean; - readonly readingTurnId?: string; + readonly windowEpoch: number; readonly consumerId: string; readonly sessionId: string; readonly hostEpoch: string; @@ -79,9 +84,10 @@ export interface DesktopTranscriptRangeRequest { } export interface DesktopTranscriptHandle extends DesktopTranscriptOpenResult { - loadBefore(anchorSequence: number | null, maxBytes?: number, navigation?: DesktopTranscriptNavigation): Promise; - loadAfter(anchorSequence: number | null, maxBytes?: number, navigation?: DesktopTranscriptNavigation): Promise; - loadAround(sequence: number | null, maxBytes?: number, navigation?: DesktopTranscriptNavigation): Promise; + loadBefore(anchorSequence: number | null, maxBytes: number, navigation: DesktopTranscriptWindowRead): Promise; + loadAfter(anchorSequence: number | null, maxBytes: number, navigation: DesktopTranscriptWindowRead): Promise; + loadAround(sequence: number, maxBytes: number, navigation: DesktopTranscriptWindowRead): Promise; + loadLatest(navigation: DesktopTranscriptWindowRead): Promise; close(): Promise; } @@ -92,22 +98,14 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB const batch = value as Record; if ( typeof batch.sessionId !== 'string' || - (batch.navigationVersion !== undefined && !isSequence(batch.navigationVersion)) || + (batch.windowEpoch !== undefined && !isSequence(batch.windowEpoch)) || !isSequence(batch.deliverySequence) || typeof batch.generation !== 'string' || typeof batch.hostEpoch !== 'string' || (batch.durableThrough !== null && !isSequence(batch.durableThrough)) || !Array.isArray(batch.fragments) || - !Array.isArray(batch.evictedDurableSequences) || - !batch.evictedDurableSequences.every(isSequence) || - batch.evictedDurableSequences.length > 256 || - !Array.isArray(batch.completedOverlayMessageIds) || - !batch.completedOverlayMessageIds.every( - (messageId) => typeof messageId === 'string' && messageId.length > 0 && messageId.length <= 256, - ) || - batch.completedOverlayMessageIds.length > 256 || - typeof batch.hasOlder !== 'boolean' || - typeof batch.hasNewer !== 'boolean' || + (batch.hasOlder !== undefined && typeof batch.hasOlder !== 'boolean') || + (batch.hasNewer !== undefined && typeof batch.hasNewer !== 'boolean') || typeof batch.reset !== 'boolean' || typeof batch.ready !== 'boolean' ) { diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 56564eb0ef..cdc978d743 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -339,12 +339,13 @@ export function useActiveSessionEvents(options: { return next; }); }); + // Reached only from the store subscription, which the effect unsubscribes on + // teardown, so the window it publishes is always a live one. const applyTranscript = useEffectEvent(( sessionId: string, store: desktopTranscript.DesktopTranscriptRangeStore, - isDisposed: () => boolean, ) => { - if (!isDisposed() && options.activeIdRef.current === sessionId) { + if (options.activeIdRef.current === sessionId) { const snapshot = store.snapshot(); options.setMessages([...snapshot.messages]); if (snapshot.ready) { @@ -353,8 +354,8 @@ export function useActiveSessionEvents(options: { } } }); - const applyReadError = useEffectEvent((sessionId: string, error: unknown, isDisposed: () => boolean) => { - if (!isDisposed() && options.activeIdRef.current === sessionId) { + const applyReadError = useEffectEvent((sessionId: string, error: unknown) => { + if (options.activeIdRef.current === sessionId) { const message = messageReadErrorMessage(error, options.uiLocale); options.setMessageLoadErrorBySession((current) => ({ ...current, @@ -408,7 +409,6 @@ export function useActiveSessionEvents(options: { useLayoutEffect(() => { if (!activeId) return; let disposed = false; - const isDisposed = () => disposed; let observationAttempt = 0; let observationFailures = 0; let observationRetryTimer: ReturnType | undefined; @@ -422,15 +422,16 @@ export function useActiveSessionEvents(options: { now: Date.now(), }), })); + const unsubscribeTranscript = transcript.subscribe(() => applyTranscript(activeId, transcript)); const openTranscript = (signal: AbortSignal) => window.maka.transcripts.open( activeId, (batch) => { if (disposed) return; try { - if (transcript.accept(batch)) applyTranscript(activeId, transcript, isDisposed); + transcript.accept(batch); } catch (error) { - applyReadError(activeId, error, isDisposed); + applyReadError(activeId, error); } }, (cancel) => { @@ -442,7 +443,7 @@ export function useActiveSessionEvents(options: { transcript, openTranscript, { - onError: (error) => applyReadError(activeId, error, isDisposed), + onError: (error) => { if (!disposed) applyReadError(activeId, error); }, }, ); options.transcriptRangeRef.current = controller; @@ -498,6 +499,7 @@ export function useActiveSessionEvents(options: { options.transcriptRangeRef.current = undefined; } void controller.close(); + unsubscribeTranscript(); unsubscribeSessionEvents(); markSessionEventStreamClosed(activeId); }; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 5e8182eb33..1c67a5f9fc 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -71,7 +71,6 @@ import { useCommandPalette } from './command-palette'; import { ChatMessageSurface } from './chat-message-surface'; import { useTaskSubmissionReadiness } from './use-task-submission-readiness'; import * as Conversation from './features/conversation'; -import type { TranscriptHistoryPending } from './features/conversation'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { LiveTurnReconciler } from './live-turn-reconciler'; import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; @@ -119,7 +118,6 @@ import type { DesktopSessionSummary, OnboardingSnapshot, } from '../preload/bridge-contract.js'; -import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../preload/transcript-contract.js'; import { ProviderLogo } from './settings/provider-display'; import { ProviderBrandMark } from './settings/provider-brand-marks'; import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js'; @@ -417,7 +415,6 @@ function AppShellContent({ const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); - const [historyLoadPending, setHistoryLoadPending] = useState(); const transcriptReadingCommands = useRef(null); const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ sessionId: string; @@ -2333,8 +2330,6 @@ function AppShellContent({ turnIndex={transcriptTurnIndex} setTurnIndex={setTranscriptTurnIndex} listTurnLandmarks={(sessionId) => window.maka.sessions.listTurnLandmarks(sessionId)} - setHistoryPending={setHistoryLoadPending} - historyPageBytes={DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES} onRestoreError={(error, sessionId) => sessionUiController.setMessageLoadErrorBySession((current) => ({ ...current, [sessionId]: localizedShellErrorMessage(error, desktopConversationCopy.actions.operationFailedFallback, uiLocale), @@ -2494,7 +2489,7 @@ function AppShellContent({ desktopConversationCopy.actions.scrollMainToBottom } onReturnToTail={activeTranscriptRange?.hasNewer - ? () => transcriptReadingCommands.current?.loadHistory('latest') + ? () => transcriptReadingCommands.current?.returnToLatest() : undefined} hidden={workHubActive || navSelection.section !== 'sessions'} composer={ @@ -2690,8 +2685,9 @@ function AppShellContent({ activeSessionId={activeId} hasOlderHistory={activeTranscriptRange?.hasOlder} hasNewerHistory={activeTranscriptRange?.hasNewer} - historyLoadPending={historyLoadPending} - onLoadHistory={(target, anchorTurnId) => transcriptReadingCommands.current?.loadHistory(target, anchorTurnId)} + onPrefetchHistory={(edge) => + transcriptReadingCommands.current?.prefetchHistory(edge) ?? Promise.resolve()} + onRetainWindow={(band) => transcriptReadingCommands.current?.retainWindow(band)} liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} transientMessages={transientMessages} diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index df5534a0a8..bb8eb45fff 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -39,7 +39,6 @@ import { selectLiveTurn } from './use-app-shell-session-ui-reads'; import { useExternalStoreSelector } from './use-external-store-selector'; import { useDeepResearchRun } from './use-deep-research-run'; import { ChatRecoveryNotice, SessionHealthRecoveryNotice } from './chat-recovery-notice'; -import type { TranscriptHistoryPending } from './features/conversation'; const selectShellRunRecord = (state: AppShellSessionUiState, sessionId: string | undefined) => sessionId ? state.shellRunUpdatesBySession[sessionId] : undefined; @@ -63,7 +62,6 @@ interface ChatMessageSurfaceProps extends Omit< | 'liveTurn' | 'shellRunUpdates' | 'goalIndicator' - | 'historyLoadPending' > { /** * #1985: the live projection and the shell-run records are the only session @@ -93,8 +91,8 @@ interface ChatMessageSurfaceProps extends Omit< onSkip: () => Promise | void; hasOlderHistory?: boolean; hasNewerHistory?: boolean; - historyLoadPending?: TranscriptHistoryPending; - onLoadHistory: (target: 'earlier' | 'later' | 'latest', anchorTurnId?: string) => Promise | void; + onPrefetchHistory: (edge: 'older' | 'newer') => Promise; + onRetainWindow: (window: { firstTurnId: string; lastTurnId: string }) => void; } function captureLiveContent(liveTurn: LiveTurnProjection | undefined) { @@ -129,8 +127,8 @@ export function ChatMessageSurface({ onSkip, hasOlderHistory, hasNewerHistory, - historyLoadPending, - onLoadHistory, + onPrefetchHistory, + onRetainWindow, ...chatViewRest }: ChatMessageSurfaceProps) { const locale = useUiLocale(); @@ -249,11 +247,8 @@ export function ChatMessageSurface({ goalIndicator={goalProjection.goalIndicator} hasOlderHistory={hasOlderHistory} hasNewerHistory={hasNewerHistory} - historyLoadPending={historyLoadPending && historyLoadPending.sessionId === activeSessionId - ? historyLoadPending.target === 'earlier' ? 'older' : 'newer' - : undefined} - onLoadEarlierHistory={(anchorTurnId) => onLoadHistory('earlier', anchorTurnId)} - onLoadLaterHistory={(anchorTurnId) => onLoadHistory('later', anchorTurnId)} + onPrefetchHistory={onPrefetchHistory} + onRetainWindow={onRetainWindow} /> )} 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 2f6dd72740..70ab3cf452 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 @@ -24,20 +24,19 @@ import { captureTranscriptReadingAnchor, createTranscriptRestoreLifecycle, currentTranscriptRange, - loadTranscriptHistory, newestDurablePromptSequence, prepareTranscriptForSend, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, - type TranscriptHistoryGate, - type TranscriptHistoryGates, - type TranscriptHistoryPending, - type TranscriptHistoryRequest, } from './transcript-reading-position.js'; type RangeController = NonNullable>[0]['controller']> & { - loadBefore(maxBytes?: number, anchorTurnId?: string): Promise; - loadAfter(maxBytes?: number, anchorTurnId?: string): Promise; + readonly store: { + retain(oldestSequence: number | null, newestSequence: number | null): boolean; + snapshot(): object; + }; + loadBefore(maxBytes?: number): Promise; + loadAfter(maxBytes?: number): Promise; loadLatest(): Promise; }; @@ -50,7 +49,9 @@ interface TurnIndex { export interface TranscriptReadingPositionCommands { prepareSend(sessionId: string): Promise; captureAnchor(turnId?: string): void; - loadHistory(target: TranscriptHistoryRequest['target'], anchorTurnId?: string): Promise; + returnToLatest(): Promise; + prefetchHistory(edge: 'older' | 'newer'): Promise; + retainWindow(window: { firstTurnId: string; lastTurnId: string }): void; } /** The conversation owns restoration lifetime; the shell supplies explicit ports. */ @@ -68,21 +69,15 @@ export function TranscriptReadingPositionController(props: { turnIndex: TurnIndex | undefined; setTurnIndex: Dispatch>; listTurnLandmarks: Parameters>[0]['list']; - setHistoryPending: Dispatch>; - historyPageBytes: number; onRestoreError(error: unknown, sessionId: string): void; onNavigationError(error: unknown, sessionId: string): void; }) { const [lifecycle] = useState(createTranscriptRestoreLifecycle); - const historyGates = useRef(new WeakMap()); + 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 cancelHistory = (sessionId: string) => { - const controller = props.rangeController.current; - if (currentTranscriptRange(controller, sessionId) === undefined) return; - if (controller) historyGates.current.delete(controller); - props.setHistoryPending((current) => current?.sessionId === sessionId ? undefined : current); - }; const cancel = (sessionId: string, clearAnchor = false) => { lifecycle.cancel(sessionId); if (props.searchTarget?.sessionId === sessionId) props.clearSearchTarget(); @@ -93,7 +88,6 @@ export function TranscriptReadingPositionController(props: { }; useImperativeHandle(props.commands, () => ({ prepareSend(sessionId) { - cancelHistory(sessionId); return prepareTranscriptForSend({ sessionId, currentSessionId: props.currentSessionId, controller: props.rangeController, cancel, @@ -102,51 +96,57 @@ export function TranscriptReadingPositionController(props: { }, captureAnchor(turnId) { const { sessionId } = props; - const controller = props.rangeController.current; if (!sessionId || props.currentSessionId.current !== sessionId) return; - const previous = props.sessionUi.transcriptReadingAnchorBySessionRef.current[sessionId]; props.sessionUi.setTranscriptRestoreUnavailable(sessionId, undefined); captureTranscriptReadingAnchor({ - sessionId, currentSessionId: props.currentSessionId.current, turnId, controller, + sessionId, currentSessionId: props.currentSessionId.current, turnId, + controller: props.rangeController.current, setAnchor: props.sessionUi.setTranscriptReadingAnchor, }); - const range = currentTranscriptRange(controller, sessionId); - if (range === undefined) return; - const sequence = turnId ? controller?.store.sequenceForTurn(turnId) : undefined; - // The send command already cleared its bookmark before publishing the - // pin. Its empty-anchor acknowledgement is not another reader intent. - if (previous?.turnId === turnId && previous?.sequence === (sequence ?? undefined)) return; - let navigation: Promise | undefined; - cancelHistory(sessionId); - if (turnId) navigation = controller?.setReadingAnchor(sequence ?? null, turnId); - else if (!turnId && previous && !range.hasNewer) { - cancel(sessionId, true); - navigation = controller?.loadLatest(); + }, + 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. } - void navigation?.catch((error) => { - if (controller && isCurrent(sessionId, controller)) props.onNavigationError(error, sessionId); - }); }, - async loadHistory(target, anchorTurnId) { + /** + * Fills the window at an edge the reader is approaching. 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 — and it answers + * whether the window + * moved, so its caller can tell a filled window from a read that failed or + * was refused as stale and would fail again unchanged. + */ + async prefetchHistory(edge) { + const controller = props.rangeController.current; + const { sessionId } = props; + if (!controller || !sessionId || !isCurrent(sessionId, controller)) return false; + const before = controller.store.snapshot(); + if (edge === 'older') await controller.loadBefore(); + else await controller.loadAfter(); + return controller.store.snapshot() !== before; + }, + async returnToLatest() { const controller = props.rangeController.current; const { sessionId } = props; if (!controller || !sessionId || !isCurrent(sessionId, controller)) return; - cancel(sessionId, target === 'latest'); - // A direct latest command must enter the range controller now, so it - // invalidates older pages rather than waiting behind a paging gate. - if (target === 'latest' || historyGates.current.get(controller)?.active?.target === 'latest') { - cancelHistory(sessionId); + cancel(sessionId, true); + try { + await controller.loadLatest(); + } catch (error) { + if (isCurrent(sessionId, controller)) props.onNavigationError(error, sessionId); } - const gates = historyGates.current; - const gate: TranscriptHistoryGate = gates.get(controller) ?? { pending: false }; - gates.set(controller, gate); - await loadTranscriptHistory({ - gates, sessionId, request: { target, anchorTurnId }, controller, - maxBytes: props.historyPageBytes, - isCurrent: () => isCurrent(sessionId, controller) && gates.get(controller) === gate, - setPending: props.setHistoryPending, - onError: (error) => props.onNavigationError(error, sessionId), - }); }, })); @@ -165,11 +165,6 @@ export function TranscriptReadingPositionController(props: { useEffect(() => () => { lifecycle.deactivate(); }, [props.sessionId, props.profileId, lifecycle]); - useEffect(() => { - if (props.searchTarget) { - cancelHistory(props.searchTarget.sessionId); - } - }, [props.searchTarget?.nonce]); useEffect(() => restoreSessionTranscriptRange({ lifecycle, sessionId: props.sessionId, @@ -180,10 +175,27 @@ export function TranscriptReadingPositionController(props: { : undefined, controller: props.rangeController.current, isCurrent, - isLiveTurn: (sessionId, turnId) => props.sessionUi.liveTurnBySessionRef.current[sessionId]?.turnId === turnId, 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; + // Sequences only mean the same rows within one Host epoch. + if (previous.hostEpoch !== range.hostEpoch) return; + const anchor = props.sessionUi.transcriptReadingAnchorBySessionRef.current[sessionId]; + if (anchor?.sequence === undefined) return; + if (controller.store.sequenceForTurn(anchor.turnId) !== null) return; + void controller.loadAround(anchor.sequence).catch((error) => { + if (isCurrent(sessionId, controller)) props.onNavigationError(error, sessionId); + }); + }, [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 7c12abf6bb..e691b8ee82 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 @@ -21,8 +21,13 @@ import type { TranscriptReadingAnchor } from '../model/session-ui-state.js'; interface TranscriptRangeStore { readonly sessionId: string; - range(): { readonly sessionId: string; readonly hasNewer?: boolean }; - sequenceForTurn(turnId: string): number | null; + range(): { + readonly sessionId: string; + readonly hasNewer?: boolean; + readonly generation?: string; + readonly hostEpoch?: string; + }; + sequenceForTurn(turnId: string, edge?: 'first' | 'last'): number | null; newestDurableUserSequence(): number | null; snapshot(): { readonly messages: readonly Message[] }; } @@ -30,7 +35,6 @@ interface TranscriptRangeStore { interface TranscriptRangeController { readonly store: TranscriptRangeStore; loadAround(sequence: number): Promise; - setReadingAnchor(sequence: number | null, readingTurnId?: string): Promise; } interface SearchTarget { @@ -213,89 +217,6 @@ export function refreshTranscriptTurnLandmarks(options: { }; } -export interface TranscriptHistoryRequest { - readonly target: 'earlier' | 'later' | 'latest'; - readonly anchorTurnId?: string; -} - -export interface TranscriptHistoryPending { - readonly sessionId: string; - readonly target: TranscriptHistoryRequest['target']; -} - -export interface TranscriptHistoryGate { - pending: boolean; - active?: TranscriptHistoryRequest; - queued?: TranscriptHistoryRequest; -} - -function updateTranscriptHistoryPending( - current: TranscriptHistoryPending | undefined, - sessionId: string, - request: TranscriptHistoryRequest | undefined, -): TranscriptHistoryPending | undefined { - if (request) return { sessionId, target: request.target }; - return current?.sessionId === sessionId ? undefined : current; -} - -/** One gate per controller: the shell rebuilds the controller per Session, so - * keying by it keeps Sessions from queuing behind each other's loads. */ -export type TranscriptHistoryGates = WeakMap; - -export async function loadTranscriptHistory(options: { - readonly gates: TranscriptHistoryGates; - readonly sessionId: string; - readonly request: TranscriptHistoryRequest; - readonly controller: { - loadBefore(maxBytes: number, anchorTurnId?: string): Promise; - loadAfter(maxBytes: number, anchorTurnId?: string): Promise; - loadLatest(): Promise; - }; - readonly maxBytes: number; - readonly isCurrent: () => boolean; - readonly setPending: ( - update: ( - current: TranscriptHistoryPending | undefined, - ) => TranscriptHistoryPending | undefined, - ) => void; - readonly onError: (error: unknown) => void; -}): Promise { - const { gates, controller, request } = options; - let gate = gates.get(controller) ?? { pending: false }; - gates.set(controller, gate); - if (gate.pending) { - // The scroller asks on every reader movement; dropping the request behind - // an in-flight load strands the reader until they move again. - if (request.target === 'latest' || gate.queued?.target !== 'latest') gate.queued = request; - return; - } - gate.pending = true; - gate.active = request; - options.setPending((current) => - updateTranscriptHistoryPending(current, options.sessionId, request)); - try { - if (request.target === 'latest') await controller.loadLatest(); - else await controller[request.target === 'earlier' ? 'loadBefore' : 'loadAfter']( - options.maxBytes, request.anchorTurnId, - ); - } catch (error) { - if (options.isCurrent()) options.onError(error); - } finally { - gate.pending = false; - gate.active = undefined; - // A send, search or explicit return to latest may replace this gate while - // its page is in flight. Its cleanup cannot clear the replacement's state - // or replay an older queued direction after the new navigation. - if (gates.get(controller) === gate && options.isCurrent()) { - options.setPending((current) => - updateTranscriptHistoryPending(current, options.sessionId, undefined)); - const queued = gate.queued; - gate.queued = undefined; - if (queued) void loadTranscriptHistory({ ...options, request: queued }); - } - } -} - export function restoreSessionTranscriptRange(options: { readonly lifecycle: TranscriptRestoreLifecycle; readonly sessionId?: string; @@ -304,7 +225,6 @@ export function restoreSessionTranscriptRange(options: { readonly readingAnchor?: TranscriptReadingAnchor; readonly controller?: TranscriptRangeController; readonly isCurrent: (sessionId: string, controller: TranscriptRangeController) => boolean; - readonly isLiveTurn?: (sessionId: string, turnId: string) => boolean; readonly setReadingAnchor: ( sessionId: string, anchor: TranscriptReadingAnchor | undefined, @@ -347,12 +267,11 @@ export function restoreSessionTranscriptRange(options: { const residentSequence = currentTranscriptRange(controller, sessionId) ? controller.store.sequenceForTurn(target.turnId) : null; - const sequence = residentSequence ?? target.sequence; - // Admit intent before awaiting the open handle. Resident and live-only - // targets retain their range while invalidating older navigation requests. - admitted = residentSequence !== null || sequence === undefined - ? controller.setReadingAnchor(sequence ?? null, target.turnId) - : controller.loadAround(sequence); + // A resident target needs no page: the scroller reveals it from the window + // the Renderer already holds. + admitted = residentSequence !== null || target.sequence === undefined + ? Promise.resolve() + : controller.loadAround(target.sequence); } catch (error) { admitted = Promise.reject(error); } @@ -366,7 +285,7 @@ export function restoreSessionTranscriptRange(options: { } return false; } - if (options.isLiveTurn?.(sessionId, target.turnId) || controller.store.snapshot().messages.some((message) => + if (controller.store.snapshot().messages.some((message) => message !== null && typeof message === 'object' && 'turnId' in message && message.turnId === target.turnId, )) { diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 141ad9ddc0..552d716dc2 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -27,9 +27,6 @@ export const transcriptReadingPosition = { restoreTarget: transcriptRestoreTarget, }; -export type { - TranscriptHistoryPending, -} from './controller/transcript-reading-position.js'; export { TranscriptReadingPositionController, type TranscriptReadingPositionCommands, diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index a095efb448..fa076070f5 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -19,10 +19,7 @@ export { createTranscriptRestoreLifecycle, - loadTranscriptHistory, prepareTranscriptForSend, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, - type TranscriptHistoryGates, - type TranscriptHistoryPending, } from './controller/transcript-reading-position.js'; 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 8c9e0efb82..933769b10d 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,24 +19,30 @@ import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; import { markPersisted } from '@maka/core/persisted-value'; -import type { - DesktopTranscriptBatchPayload, - DesktopTranscriptFragment, - DesktopTranscriptHandle, - DesktopTranscriptNavigation, +import { + DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + type DesktopTranscriptBatchPayload, + type DesktopTranscriptFragment, + type DesktopTranscriptHandle, + type DesktopTranscriptWindowRead, } from '../../../preload/transcript-contract.js'; import { projectDesktopStoredMessage } from '../../../shared/desktop-session-projection.js'; import { parseDesktopSessionKey } from '../../../shared/runtime-host-identity.js'; +/** + * The Renderer's window onto one Session transcript. `loadAround` and + * `loadLatest` replace the window and mint a navigation version; `loadBefore` + * and `loadAfter` extend it under the current version. Main answers the + * request and otherwise only broadcasts tail growth. + */ export interface DesktopTranscriptRangeController { readonly store: DesktopTranscriptRangeStore; ready(): Promise; waitForDurableMessage(messageId: string, timeoutMs: number): Promise; - loadBefore(maxBytes?: number, anchorTurnId?: string): Promise; - loadAfter(maxBytes?: number, anchorTurnId?: string): Promise; - loadAround(sequence: number): Promise; - setReadingAnchor(sequence: number | null, readingTurnId?: string): Promise; - loadLatest(maxBytes?: number): Promise; + loadBefore(maxBytes?: number): Promise; + loadAfter(maxBytes?: number): Promise; + loadAround(sequence: number, maxBytes?: number): Promise; + loadLatest(): Promise; reload(): Promise; close(): Promise; } @@ -48,40 +54,55 @@ export function createDesktopTranscriptRangeController( let closed = false; let openController = new AbortController(); let handle = open(openController.signal); - type Navigation = DesktopTranscriptNavigation & { - readonly kind: 'before' | 'after' | 'around' | 'latest' | 'anchor'; - readonly sequence: number | null; - readonly maxBytes?: number; - }; - let navigation: Navigation = { - navigationVersion: 0, intent: 'followTail', kind: 'latest', sequence: null, - }; + const extending: { + older?: { epoch: number; task: Promise }; + newer?: { epoch: number; task: Promise }; + } = {}; const current = async () => { if (closed) throw new Error('Desktop transcript range is closed'); return handle; }; - const dispatch = async (command: Navigation) => { + const command = async ( + replace: boolean, + run: (value: DesktopTranscriptHandle, navigation: DesktopTranscriptWindowRead) => Promise, + ) => { + // Mint before awaiting an open handle or any in-flight page. + const windowEpoch = replace ? store.replaceWindow() : store.windowEpoch(); const opening = handle; - const isCurrent = () => !closed && navigation === command && opening === handle; + // A navigation outlives the band trimming the window under it; it is only + // the next navigation that makes this one obsolete. + const epoch = replace ? () => store.navigationEpoch() : () => store.windowEpoch(); + const isCurrent = () => !closed && epoch() === windowEpoch && opening === handle; try { const value = await current(); if (!isCurrent()) return; - if (command.kind === 'before') { - await value.loadBefore(command.sequence, command.maxBytes, command); - } else if (command.kind === 'after') { - await value.loadAfter(command.sequence, command.maxBytes, command); - } else { - await value.loadAround(command.sequence, command.maxBytes, command); - } + await run(value, { windowEpoch }); } catch (error) { if (isCurrent()) throw error; } }; - const navigate = (command: Omit) => { - navigation = { ...command, navigationVersion: navigation.navigationVersion + 1 }; - // Invalidate before awaiting an open handle or any previous page request. - store.expectNavigation(navigation.navigationVersion); - return dispatch(navigation); + const extend = (edge: 'older' | 'newer', maxBytes: number): Promise => { + let range: DesktopTranscriptRangeState; + try { + range = store.range(); + } catch { + return Promise.resolve(); + } + // Sharing a read only holds while the window it was anchored on does. + const pending = extending[edge]; + if (pending && pending.epoch === store.windowEpoch()) return pending.task; + if (edge === 'older' ? !range.hasOlder : !range.hasNewer) return Promise.resolve(); + const anchor = edge === 'older' ? range.oldestSequence : range.newestSequence; + const epoch = store.windowEpoch(); + const task = command(false, (value, navigation) => + edge === 'older' + ? value.loadBefore(anchor, maxBytes, navigation) + : value.loadAfter(anchor, maxBytes, navigation), + ).finally(() => { + if (extending[edge]?.task === task) extending[edge] = undefined; + }); + extending[edge] = { epoch, task }; + return task; }; return { store, @@ -90,48 +111,17 @@ export function createDesktopTranscriptRangeController( await current(); return store.waitForDurableMessage(messageId, timeoutMs); }, - async loadBefore(maxBytes, anchorTurnId) { - const range = store.range(); - const anchor = anchorTurnId === undefined ? undefined : store.sequenceForTurn(anchorTurnId); - if (anchor === null) { - await navigate({ intent: 'history', kind: 'anchor', sequence: null, - readingTurnId: anchorTurnId, preserveRange: true }); - return; - } - if (!range.hasOlder) return; - await navigate({ - intent: 'history', kind: 'before', maxBytes, - sequence: anchor ?? range.oldestSequence, - readingTurnId: anchorTurnId, - }); - }, - loadAround(sequence) { - return navigate({ intent: 'history', kind: 'around', sequence }); + loadBefore(maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { + return extend('older', maxBytes); }, - async loadAfter(maxBytes, anchorTurnId) { - const range = store.range(); - const anchor = anchorTurnId === undefined ? undefined : store.sequenceForTurn(anchorTurnId, 'last'); - if (anchor === null) { - await navigate({ intent: 'history', kind: 'anchor', sequence: null, - readingTurnId: anchorTurnId, preserveRange: true }); - return; - } - if (!range.hasNewer) return; - await navigate({ - intent: 'history', kind: 'after', maxBytes, - sequence: anchor ?? range.newestSequence, - readingTurnId: anchorTurnId, - }); + loadAfter(maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { + return extend('newer', maxBytes); }, - setReadingAnchor(sequence, readingTurnId) { - if (navigation.kind === 'anchor' && navigation.sequence === sequence && - navigation.readingTurnId === readingTurnId) { - return Promise.resolve(); - } - return navigate({ intent: 'history', kind: 'anchor', sequence, readingTurnId, preserveRange: true }); + loadAround(sequence, maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { + return command(true, (value, navigation) => value.loadAround(sequence, maxBytes, navigation)); }, - loadLatest(maxBytes) { - return navigate({ intent: 'followTail', kind: 'latest', sequence: null, maxBytes }); + loadLatest() { + return command(true, (value, navigation) => value.loadLatest(navigation)); }, async reload() { const previous = handle; @@ -146,13 +136,6 @@ export function createDesktopTranscriptRangeController( }); handle = replacement; await replacement; - if (closed || handle !== replacement) return; - const command = navigation; - // An anchor notification needs a real range read on a replacement handle. - if (command.kind === 'anchor' || command.kind === 'before' || command.kind === 'after') { - navigation = { ...command, kind: 'around', preserveRange: false }; - } - await dispatch(navigation); }, async close() { if (closed) return; @@ -320,7 +303,9 @@ export class DesktopTranscriptRangeStore { readonly #durableOrder: number[] = []; readonly #overlayOrder: string[] = []; readonly #pending = new Map(); - #navigationVersion = 0; + #mintedEpoch = 0; + #windowEpoch = 0; + #navigationEpoch = 0; readonly #retiredGenerations = new Set(); #sourceSessionId: string | undefined; #generation: string | undefined; @@ -336,6 +321,7 @@ export class DesktopTranscriptRangeStore { #batchChanged = false; #snapshot: DesktopTranscriptRangeSnapshot | undefined; readonly #durableWaiters = new Set<() => void>(); + readonly #listeners = new Set<() => void>(); constructor(sessionKey: string) { const { hostId, sessionId } = parseDesktopSessionKey(sessionKey); @@ -344,50 +330,91 @@ export class DesktopTranscriptRangeStore { this.#expectedSessionId = sessionId; } - expectNavigation(navigationVersion: number): void { - if (navigationVersion <= this.#navigationVersion) return; - this.#navigationVersion = navigationVersion; + windowEpoch(): number { + return this.#windowEpoch; + } + + navigationEpoch(): number { + return this.#navigationEpoch; + } + + /** + * Mints the epoch for a window about to be replaced wholesale by a navigation, + * and drops the partially received records of the window being left behind. + */ + replaceWindow(): number { + this.#navigationEpoch = this.#mintWindow(); + return this.#navigationEpoch; + } + + /** + * The window a read must name to be spliced onto. A navigation's answer takes + * this back to the epoch it was issued under (see `#reset`), so the counter is + * kept apart from it: a number, once minted, names one window forever, and a + * read still in flight under a later one stays refusable. + */ + #mintWindow(): number { + this.#mintedEpoch += 1; + this.#windowEpoch = this.#mintedEpoch; this.#pending.clear(); this.#batchChanged = false; + return this.#windowEpoch; } + /** + * A read is answerable only while what it assumed still holds, and the two + * kinds of read assume different things. + * + * An extension splices rows onto one edge, so it assumes that edge: any + * replacement of the window — navigating away, or the band trimming the edge + * out — leaves its answer unable to reach what is left, and no edge cursor + * can name the hole it would open. A replacement assumes nothing about the + * edges, because it discards them; only a newer navigation makes it stale. + * + * Batches that name no epoch answer nothing: tail broadcasts apply to + * whatever the window holds, and a snapshot replacing the transcript + * underneath every window is not this window's answer to refuse. + */ accepts(batch: DesktopTranscriptBatchPayload): boolean { - // A stale reset must be rejected before it can clear the current range. - if ((batch.navigationVersion ?? 0) !== this.#navigationVersion) return false; if (this.#retiredGenerations.has(batch.generation)) return false; - return batch.reset || ( - batch.sessionId === this.#sourceSessionId && + if (batch.reset) { + return batch.windowEpoch === undefined || batch.windowEpoch === this.#navigationEpoch; + } + if (batch.windowEpoch !== undefined && batch.windowEpoch !== this.#windowEpoch) return false; + return batch.sessionId === this.#sourceSessionId && batch.generation === this.#generation && - batch.hostEpoch === this.#hostEpoch - ); + batch.hostEpoch === this.#hostEpoch; } accept(batch: DesktopTranscriptBatchPayload): boolean { if (!this.accepts(batch)) return false; if (batch.reset) this.#reset(batch); - let changed = - batch.reset || - batch.durableThrough !== this.#durableThrough || - batch.hasOlder !== this.#hasOlder || - batch.hasNewer !== this.#hasNewer; - this.#durableThrough = batch.durableThrough; - this.#hasOlder = batch.hasOlder; - this.#hasNewer = batch.hasNewer; - for (const sequence of batch.evictedDurableSequences) { - if (this.#durable.delete(sequence)) { - removeOrdered(this.#durableOrder, sequence); - this.#refreshSequenceBounds(sequence); - changed = true; - } + let changed = batch.reset; + const answersCommand = batch.windowEpoch !== undefined; + if (batch.hasOlder !== undefined && batch.hasOlder !== this.#hasOlder) { + this.#hasOlder = batch.hasOlder; + changed = true; } - for (const messageId of batch.completedOverlayMessageIds) { - if (this.#overlay.delete(messageId)) { - removeOrdered(this.#overlayOrder, messageId); + if (batch.hasNewer !== undefined) { + // A page read before the tail grew cannot close the window's newer edge; + // the rows that landed meanwhile were dropped below, so ask again. + const hasNewer = batch.hasNewer || + (batch.durableThrough !== null && this.#durableThrough !== null && + batch.durableThrough < this.#durableThrough); + if (hasNewer !== this.#hasNewer) { + this.#hasNewer = hasNewer; changed = true; } } + if (batch.durableThrough !== null && + (this.#durableThrough === null || batch.durableThrough > this.#durableThrough)) { + this.#durableThrough = batch.durableThrough; + changed = true; + } + // Tail growth belongs to the window only while the window reaches the tail. + const installDurable = answersCommand || !this.#hasNewer; for (const fragment of batch.fragments) { - changed = this.#acceptFragment(fragment) || changed; + changed = this.#acceptFragment(fragment, installDurable) || changed; } if (batch.ready && !this.#ready) { this.#ready = true; @@ -397,11 +424,55 @@ export class DesktopTranscriptRangeStore { if (!batch.ready) return false; const committed = this.#batchChanged; this.#batchChanged = false; - if (committed) this.#snapshot = this.#createSnapshot(); + if (committed) this.#commit(); for (const notify of this.#durableWaiters) notify(); return committed; } + /** Fires after every committed change to `snapshot()`. */ + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => { this.#listeners.delete(listener); }; + } + + /** + * Drops durable rows outside `[oldestSequence, newestSequence]`. Either edge + * that lost rows becomes a history edge again. + * + * Dropping an edge mints a window epoch: an extension still in flight was + * anchored on that edge, and installing its answer would leave rows missing + * between the answer and what is left — a hole no edge cursor names and no + * later page can fill. + */ + retain(oldestSequence: number | null, newestSequence: number | null): boolean { + let changed = false; + for (const sequence of [...this.#durableOrder]) { + const older = oldestSequence !== null && sequence < oldestSequence; + const newer = newestSequence !== null && sequence > newestSequence; + if (!older && !newer) continue; + this.#durable.delete(sequence); + removeOrdered(this.#durableOrder, sequence); + if (older) this.#hasOlder = true; + else this.#hasNewer = true; + changed = true; + } + if (!changed) return false; + this.#mintWindow(); + this.#oldestSequence = this.#durableOrder[0] ?? null; + this.#newestSequence = this.#durableOrder.at(-1) ?? null; + this.#newestUserSequence = null; + for (const sequence of this.#durableOrder) { + if (this.#durable.get(sequence)?.message.type === 'user') this.#newestUserSequence = sequence; + } + this.#commit(); + return true; + } + + #commit(): void { + this.#snapshot = this.#createSnapshot(); + for (const listener of [...this.#listeners]) listener(); + } + snapshot(): DesktopTranscriptRangeSnapshot { this.#snapshot ??= this.#createSnapshot(); return this.#snapshot; @@ -480,6 +551,10 @@ export class DesktopTranscriptRangeStore { } this.#liveGeneration = batch.generation; } + // The window this navigation was issued under is the window it installs, + // however many epochs the band minted while it was outstanding: it discards + // the edges those trims were protecting. Its remaining fragments extend it. + if (batch.windowEpoch !== undefined) this.#windowEpoch = batch.windowEpoch; this.#durable.clear(); this.#overlay.clear(); this.#durableOrder.length = 0; @@ -492,14 +567,14 @@ export class DesktopTranscriptRangeStore { this.#oldestSequence = null; this.#newestSequence = null; this.#newestUserSequence = null; - this.#hasOlder = batch.hasOlder; - this.#hasNewer = batch.hasNewer; + this.#hasOlder = batch.hasOlder ?? false; + this.#hasNewer = batch.hasNewer ?? false; this.#ready = false; this.#batchChanged = false; this.#snapshot = undefined; } - #acceptFragment(fragment: DesktopTranscriptFragment): boolean { + #acceptFragment(fragment: DesktopTranscriptFragment, installDurable: boolean): boolean { const key = `${fragment.source}:${typeof fragment.identity}:${fragment.identity}`; let pending = this.#pending.get(key); if (!pending) { @@ -534,23 +609,34 @@ export class DesktopTranscriptRangeStore { pending.bytes.set(bytes, fragment.byteOffset); pending.receivedBytes += bytes.byteLength; if (pending.receivedBytes < pending.totalBytes) return false; + this.#pending.delete(key); + // A declined row could still be the durable body of a message this window + // is showing as an overlay, which only its id can tell us. + if (pending.source === 'durable' && !installDurable && this.#overlay.size === 0) return false; const encoded = new TextDecoder('utf-8', { fatal: true }).decode(pending.bytes); const message = freezeTranscriptValue(projectDesktopStoredMessage( { hostId: this.#hostId }, decodeStoredMessage(markPersisted(JSON.parse(encoded))), )); const projected = JSON.stringify(message); - this.#pending.delete(key); if (pending.source === 'durable') { if (!Number.isSafeInteger(pending.identity) || (pending.identity as number) < 0) { throw new Error('Invalid Desktop transcript durable identity'); } + const overlaid = this.#overlay.has(message.id); + if (!installDurable && !overlaid) return false; const sequence = pending.identity as number; const existing = this.#durable.get(sequence); if (existing && existing.encoded !== projected) { throw new Error('Desktop transcript durable record changed'); } - if (existing) return false; + // The durable row retires the overlay it settles, in the same step: a + // window that keeps one without the other loses the message. + if (overlaid) { + this.#overlay.delete(message.id); + removeOrdered(this.#overlayOrder, message.id); + } + if (existing) return overlaid; this.#durable.set(sequence, { message, encoded: projected }); insertOrdered(this.#durableOrder, sequence, (left, right) => left - right); this.#oldestSequence = Math.min(this.#oldestSequence ?? sequence, sequence); @@ -558,7 +644,7 @@ export class DesktopTranscriptRangeStore { if (message.type === 'user') { this.#newestUserSequence = Math.max(this.#newestUserSequence ?? sequence, sequence); } - return !existing; + return true; } if (typeof pending.identity !== 'string' || message.id !== pending.identity) { throw new Error('Desktop transcript overlay identity changed'); @@ -591,12 +677,6 @@ export class DesktopTranscriptRangeStore { return true; } - #refreshSequenceBounds(deletedSequence: number): void { - if (deletedSequence !== this.#oldestSequence && deletedSequence !== this.#newestSequence) return; - this.#oldestSequence = this.#durableOrder[0] ?? null; - this.#newestSequence = this.#durableOrder.at(-1) ?? null; - } - #createSnapshot(): DesktopTranscriptRangeSnapshot { const messages = Object.freeze([ ...this.#durableOrder.map((sequence) => this.#durable.get(sequence)!.message), diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index aa84d5cccd..ece9ceffba 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -52,6 +52,15 @@ overflow-anchor: auto; } +/* These rows mount and unmount around the reader without being anything the + reader is reading, so anchoring on one moves the transcript for no content: + a send that is not durable yet, the placeholder held until the live Turn + exists. */ +[data-transient-message-id], +.maka-turn[data-live-streaming='true']:not([data-turn-id]) { + overflow-anchor: none; +} + .maka-transcript-turn { display: flex; width: 100%; @@ -378,8 +387,3 @@ color: var(--foreground); padding: var(--space-0-5) var(--space-1-5); } -.maka-transcript-gap-row { - width: min(var(--maka-reading-measure), 100%); - margin: var(--space-2) auto; - padding-block: var(--space-1); -} diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 16e4bd739a..f175e54e4f 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1972,50 +1972,22 @@ function PartialHistoryHarness() { onLoadTranscriptTurn: () => setReadingEarlier(true), hasOlderHistory: !readingEarlier, hasNewerHistory: readingEarlier, - onLoadLaterHistory: () => setReadingEarlier(false), + onPrefetchHistory: async (edge) => { + if (edge === 'newer') setReadingEarlier(false); + return true; + }, }} /> ); } -function historyGapPresentation(gap: HTMLElement) { - const style = getComputedStyle(gap); - const box = gap.getBoundingClientRect(); - const composer = document.querySelector('.maka-composer-astryx'); - const frame = gap.closest('.appFrame'); - if (!composer || !frame) throw new Error('The shell geometry is incomplete'); - const composerBox = composer.getBoundingClientRect(); - const frameBox = frame.getBoundingClientRect(); - return { - backgroundColor: style.backgroundColor, - borderWidths: [ - style.borderTopWidth, - style.borderRightWidth, - style.borderBottomWidth, - style.borderLeftWidth, - ], - display: style.display, - flexWrap: style.flexWrap, - justifyContent: style.justifyContent, - widthDelta: Math.abs(box.width - composerBox.width), - centerDelta: Math.abs( - (box.left + box.right) / 2 - (composerBox.left + composerBox.right) / 2, - ), - fitsFrame: box.left >= frameBox.left && box.right <= frameBox.right, - clientWidth: gap.clientWidth, - scrollWidth: gap.scrollWidth, - hasHorizontalOverflow: gap.scrollWidth > gap.clientWidth, - }; -} -// Real path: selecting a prompt outside the loaded transcript range, then -// loading the newer range. Each boundary stays a quiet reading-column -// control and every inactive prompt-rail tick uses one neutral treatment. +// 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. export const PartialHistoryNotice: Story = { render: () => , play: async ({ canvasElement }) => { - expect(canvasElement.querySelector('[data-transcript-gap="older"]')).not.toBeNull(); - expect(canvasElement.querySelector('[data-transcript-gap="newer"]')).toBeNull(); const firstPrompt = canvasElement.querySelector( '.maka-prompt-rail-tick[data-prompt-turn-id="turn-scroll-1"]', ); @@ -2023,22 +1995,9 @@ export const PartialHistoryNotice: Story = { firstPrompt.click(); await waitFor(() => { - expect(canvasElement.querySelector('[data-transcript-gap="newer"]')).not.toBeNull(); + expect(canvasElement.querySelector('[data-turn-id="turn-scroll-1"]')).not.toBeNull(); }); - const gap = canvasElement.querySelector('[data-transcript-gap="newer"]'); - if (!gap) throw new Error('The newer transcript gap did not render'); - expect(gap.textContent).toContain('下方还有未加载的较新消息'); - expect(gap.textContent).toContain('加载较新消息'); - - const regular = historyGapPresentation(gap); - expect(regular.backgroundColor).toBe('rgba(0, 0, 0, 0)'); - expect(regular.borderWidths).toEqual(['0px', '0px', '0px', '0px']); - expect(regular.display).toBe('flex'); - expect(regular.flexWrap).toBe('wrap'); - expect(regular.justifyContent).toBe('center'); - expect(regular.widthDelta).toBeLessThanOrEqual(1); - expect(regular.centerDelta).toBeLessThanOrEqual(1); - expect(regular.hasHorizontalOverflow, JSON.stringify(regular)).toBe(false); + expect(canvasElement.querySelectorAll('[data-transcript-gap]')).toHaveLength(0); const neutralPaint = [ ...canvasElement.querySelectorAll('.maka-prompt-rail-tick'), @@ -2063,21 +2022,6 @@ export const PartialHistoryNotice: Story = { [...sheet.cssRules].filter((rule) => rule.cssText.includes('data-resident'))), ).toHaveLength(0); - const frame = canvasElement.querySelector('.appFrame'); - if (!frame) throw new Error('Shell frame did not render'); - frame.style.width = '520px'; - await painted(2); - const narrow = historyGapPresentation(gap); - expect(narrow.centerDelta).toBeLessThanOrEqual(1); - expect(narrow.fitsFrame).toBe(true); - expect(narrow.hasHorizontalOverflow, JSON.stringify(narrow)).toBe(false); - - const loadNewerButton = within(gap).getByRole('button', { name: '加载较新消息' }); - loadNewerButton.click(); - await waitFor(() => { - expect(canvasElement.querySelector('[data-transcript-gap="newer"]')).toBeNull(); - expect(canvasElement.querySelector('[data-turn-id="turn-scroll-8"]')).not.toBeNull(); - }); }, }; @@ -2285,14 +2229,12 @@ export const SubmittedPromptSettlesWithoutReversing: Story = { /** Lets a play function drive props React owns. One story renders per page. */ let appendTurn: (() => void) | undefined; -/** Every `onLoadEarlierHistory` the transcript asked for, anchor turn first. */ +/** Every older fill the transcript asked for, oldest resident turn first. */ const historyLoads: string[] = []; const HISTORY_BATCH = 4; -// More than any story here consumes. Running the history out retires the -// "earlier history" notice, and that removal is a height change above the -// reader with no arrival to explain it. +// 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. */ @@ -2313,7 +2255,13 @@ function SettledTranscriptHarness({ return ; } -/** The history seam is two props: `hasOlderHistory`, and a loader that prepends. */ +/** + * 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. + */ function HistoryHarness({ turns }: { turns: number }) { const [range, setRange] = useState({ from: 0, count: turns }); useEffect(() => { @@ -2324,23 +2272,38 @@ function HistoryHarness({ turns }: { turns: number }) { chat={{ messages: transcriptTurns(range.from, range.count), hasOlderHistory: range.from > -HISTORY_BATCH * HISTORY_BATCHES_AVAILABLE, - onLoadEarlierHistory: (anchorTurnId) => { - historyLoads.push(anchorTurnId ?? '(none)'); + onPrefetchHistory: async (edge) => { + if (edge !== 'older') return false; + historyLoads.push(firstResidentTurnId() ?? '(none)'); setRange((current) => ({ from: current.from - HISTORY_BATCH, count: current.count + HISTORY_BATCH, })); + await painted(2); + return true; }, }} /> ); } -/** The band inside which the transcript treats a reader move as asking. */ +/** 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 { + await waitFor(() => { + const settled = tailMetrics(); + expect(settled.scrollTop, JSON.stringify(settled)).toBeGreaterThan(loadBand()); + 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); +} + export const TailFollowsGrowthOutsideTurns: Story = { render: () => , play: async () => { @@ -2465,11 +2428,7 @@ export const DockAffordanceReturnsToTail: Story = { export const NestedScrollerNearHistoryBoundaryAsksForNothing: Story = { render: () => , play: async () => { - await waitFor(() => { - const settled = tailMetrics(); - expect(settled.scrollTop, JSON.stringify(settled)).toBeLessThanOrEqual(loadBand()); - expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); - }); + await historySettled(); const nested = injectNestedScroller(messageList()); await painted(6); @@ -2850,19 +2809,24 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { * assertions here are geometric. * * Tick count comes from `transcriptTurnIndex`, not from mounted Turns: the - * transcript holds only the Host's active range 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. + * 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; -/** `DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS`, restated to keep stories off preload. */ -const PROMPT_RAIL_ACTIVE_RANGE = 10; +/** + * 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_ACTIVE_RANGE + 1; +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}`, @@ -2870,7 +2834,7 @@ const promptRailIndex = Array.from({ length: PROMPT_RAIL_TURN_COUNT }, (_, offse label: `第 ${offset + 1} 个问题`, })); -const promptRailMessages = transcriptTurns(PROMPT_RAIL_TAIL_RANGE_START, PROMPT_RAIL_ACTIVE_RANGE); +const promptRailMessages = transcriptTurns(PROMPT_RAIL_TAIL_RANGE_START, PROMPT_RAIL_TAIL_TURNS); function PromptRailHarness() { return ( @@ -3030,7 +2994,7 @@ export const ActiveTurnsKeepStableDomIdentities: Story = { const sourceCount = Number( messageList().getAttribute('data-turn-source-count'), ); - expect(sourceCount).toBe(PROMPT_RAIL_ACTIVE_RANGE); + 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 @@ -3132,7 +3096,7 @@ function PromptRailNavigationHarness() { return ( setFirstIndex(target.sequence), }} diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index af7c8b7898..bc95235a25 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -242,7 +242,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, HStack, Spinner, Text | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner, Text) | 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/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 | | `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem, Tooltip | raw ` ); }); -test('labels incomplete transcript boundaries without inventing missing Turn counts', () => { - assert.deepEqual(getConversationCopy('zh-CN').chat.transcriptGap, { - olderDescription: '上方还有未加载的较早消息', - olderAction: '加载较早消息', - newerDescription: '下方还有未加载的较新消息', - newerAction: '加载较新消息', - }); - assert.deepEqual(getConversationCopy('zh-TW').chat.transcriptGap, { - olderDescription: '上方還有未載入的較早訊息', - olderAction: '載入較早訊息', - newerDescription: '下方還有未載入的較新訊息', - newerAction: '載入較新訊息', - }); - assert.deepEqual(getConversationCopy('en').chat.transcriptGap, { - olderDescription: 'Earlier messages above are not loaded.', - olderAction: 'Load earlier messages', - newerDescription: 'Newer messages below are not loaded.', - newerAction: 'Load newer messages', - }); -}); - test('context usage explains missing data without exposing provider internals', () => { assert.equal( getConversationCopy('zh-CN').messages.systemNotes.contextUsageUnavailable, diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index 26eac1c61d..e27762aa59 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -23,356 +23,62 @@ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { LocaleProvider } from '../locale-context.js'; import { - holdJumpDestination, mergePromptAnchorRailTurns, observeActivePromptRailVisibility, PromptAnchorRail, - selectPromptRailActiveTurn, - selectPromptRailTickForMountedTurn, - type PromptRailFrameScheduler, + selectPromptRailTick, } from '../prompt-anchor-rail.js'; - -/** - * The e2e suite cannot stage what these cover. Whether a jump survives depends - * on which frame the requested Host range lands, and the e2e case went green - * against a renderer that did not survive it. Driving the frames here makes it - * deterministic. - */ -function railHoldHarness() { - // The helper builds its selector with `CSS.escape`, which the renderer has - // and Node does not. Stubbed rather than worked around in the source: the - // escaping is what keeps a turn id safe inside a selector, and a fallback - // that only exists for tests would be the wrong thing to ship. - const priorCss = (globalThis as { CSS?: unknown }).CSS; - (globalThis as { CSS?: unknown }).CSS = { escape: (value: string) => value }; - - const frames: Array<() => void> = []; - const scheduler: PromptRailFrameScheduler = { - request: (callback) => { - frames.push(callback); - return frames.length; - }, - cancel: () => {}, - }; - const scrolled: Array = []; - const listeners = new Map(); - const state = { - scrollHeight: 1_000, - scrollTop: 900, - /** The target's top edge, relative to the scrollport's. 0 = landed. */ - targetTop: 600, - targetPresent: true, - queried: '', - settled: 0, - }; - const target = { - getBoundingClientRect: () => ({ top: state.targetTop }) as DOMRect, - scrollIntoView: (options?: ScrollIntoViewOptions) => { - scrolled.push(options); - // A real scroller lands the target at the top and moves to get there. - state.scrollTop += state.targetTop; - state.targetTop = 0; - }, - }; - const root = { - get scrollHeight() { - return state.scrollHeight; - }, - get scrollTop() { - return state.scrollTop; - }, - getBoundingClientRect: () => ({ top: 0 }) as DOMRect, - querySelector: (selector: string) => { - state.queried = selector; - return state.targetPresent ? target : null; - }, - addEventListener: (type: string, listener: EventListener) => listeners.set(type, listener), - removeEventListener: (type: string) => listeners.delete(type), - } as unknown as Element; - - return { - root, - scheduler, - scrolled, - state, - listeners, - /** Runs the frame the hold has queued, and only that one. */ - runFrame: (): void => frames.shift()?.(), - restore: (): void => { - (globalThis as { CSS?: unknown }).CSS = priorCss; - }, - }; -} - -test('a jump re-aims at its destination every time the transcript grows', () => { - const harness = railHoldHarness(); - const { root, scheduler, scrolled, state } = harness; - - const release = holdJumpDestination({ - root, - readTargetId: () => 'turn-7', - onSettled: () => { - state.settled += 1; - }, - scheduler, - }); - - state.scrollHeight = 1_400; - harness.runFrame(); - assert.equal(scrolled.length, 1, 'a fill step re-aims the jump'); - assert.deepEqual(scrolled[0], { behavior: 'auto', block: 'start' }); - assert.equal(state.queried, '[data-turn-id="turn-7"]'); - - harness.runFrame(); - assert.equal(scrolled.length, 1, 'a target already at the top is left alone'); - - // Every later fill step moves it again, and every one is followed back. - state.scrollHeight = 1_800; - state.targetTop = 320; - harness.runFrame(); - assert.equal(scrolled.length, 2, 'every later fill step re-aims too'); - - // A scroll that was cancelled part-way leaves the target off-target on an - // otherwise still frame. That is the other way a jump used to be lost. - state.targetTop = 240; - harness.runFrame(); - assert.equal(scrolled.length, 3, 'a stalled jump is corrected, not accepted'); - - release(); - harness.restore(); -}); - -test('a jump holds until the mounted destination is still', () => { - const harness = railHoldHarness(); - const { root, scheduler, state } = harness; - - holdJumpDestination({ - root, - readTargetId: () => 'turn-7', - onSettled: () => { - state.settled += 1; - }, - scheduler, - }); - - // Landed already, so nothing below is a correction — only the boundary. - state.targetTop = 0; - - harness.runFrame(); - harness.runFrame(); - assert.equal(state.settled, 0, 'settling waits for a few still frames'); - harness.runFrame(); - assert.equal(state.settled, 1, 'a mounted and still destination ends the hold'); - - harness.restore(); -}); - -test('a jump waits for an unloaded destination before settling', () => { - const harness = railHoldHarness(); - const { root, scheduler, state } = harness; - state.targetPresent = false; - - holdJumpDestination({ - root, - readTargetId: () => 'turn-7', - onSettled: () => { - state.settled += 1; - }, - scheduler, - }); - - for (let index = 0; index < 10; index += 1) harness.runFrame(); - assert.equal(state.settled, 0, 'a sparse transcript cannot settle before the target arrives'); - - state.targetPresent = true; - harness.runFrame(); - assert.equal(harness.scrolled.length, 1, 'the arriving target is placed at the top'); - harness.runFrame(); - harness.runFrame(); - harness.runFrame(); - assert.equal(state.settled, 1, 'the landed target settles normally'); - - harness.restore(); -}); - -test('a jump gives the transcript back the moment the reader touches it', () => { - const harness = railHoldHarness(); - const { root, scheduler, state, listeners } = harness; - - holdJumpDestination({ - root, - readTargetId: () => 'turn-7', - onSettled: () => { - state.settled += 1; - }, - scheduler, - }); - - assert.deepEqual( - [...listeners.keys()], - ['wheel', 'touchstart', 'pointerdown', 'keydown'], - 'every way a reader can take over is listened for', - ); - - state.targetTop = 0; - listeners.get('wheel')?.(new Event('wheel')); - assert.equal(state.settled, 1, 'a wheel ends the hold even mid-fill'); - assert.equal(listeners.size, 0, 'and the hold stops listening'); - - // Whatever the transcript does next is no longer the jump's business. - state.scrollHeight = 9_000; - harness.runFrame(); - assert.equal(harness.scrolled.length, 0, 'a released hold does not re-aim'); - assert.equal(state.settled, 1, 'and settles exactly once'); - - harness.restore(); -}); - -const turnIndexById = new Map([ - ['turn-1', 0], - ['turn-2', 1], - ['turn-3', 2], - ['turn-4', 3], -]); - -test('the transcript tail selects the latest mounted Turn', () => { - assert.equal(selectPromptRailActiveTurn({ - atEnd: true, - mountedTurnIds: ['turn-3', 'turn-1', 'turn-4'], - readingBandTurnIds: ['turn-1'], - scrollportTurnIds: ['turn-1', 'turn-3'], - turnIndexById, - }), 'turn-4'); +import { TranscriptScrollAuthorityProvider } from '../transcript-scroll-authority.js'; + +const orderedTurnIds = Array.from({ length: 120 }, (_, index) => `turn-${index + 1}`); +const sampledRailTurnIds = Array.from({ length: 64 }, (_, railIndex) => + orderedTurnIds[Math.round(railIndex * 119 / 63)]!, +); + +test('a sampled rail projects the reading Turn onto the tick that stands for it', () => { + assert.equal(selectPromptRailTick({ + readingTurnId: 'turn-67', + orderedTurnIds, + railTurnIds: sampledRailTurnIds, + previousRailTurnId: null, + }), 'turn-67', 'a sampled Turn is its own tick'); + // 120 Turns over 64 ticks: turn-66 has none of its own. + assert.equal(sampledRailTurnIds.includes('turn-66'), false); + assert.equal(selectPromptRailTick({ + readingTurnId: 'turn-66', + orderedTurnIds, + railTurnIds: sampledRailTurnIds, + previousRailTurnId: null, + }), 'turn-65'); }); -test('the reading band selects its earliest Turn by transcript order', () => { - assert.equal(selectPromptRailActiveTurn({ - atEnd: false, - mountedTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'], - readingBandTurnIds: ['turn-4', 'turn-2', 'turn-3'], - scrollportTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'], - turnIndexById, +test('every Turn is its own tick while the rail is under its cap', () => { + assert.equal(selectPromptRailTick({ + readingTurnId: 'turn-2', + orderedTurnIds: ['turn-1', 'turn-2', 'turn-3'], + railTurnIds: ['turn-1', 'turn-2', 'turn-3'], + previousRailTurnId: 'turn-1', }), 'turn-2'); }); -test('an empty reading band falls back to the earliest Turn in the scrollport', () => { - assert.equal(selectPromptRailActiveTurn({ - atEnd: false, - mountedTurnIds: ['turn-1', 'turn-2', 'turn-3', 'turn-4'], - readingBandTurnIds: [], - scrollportTurnIds: ['turn-4', 'turn-3'], - turnIndexById, - }), 'turn-3'); +test('a reading position the rail has no landmark for keeps the current tick', () => { + assert.equal(selectPromptRailTick({ + readingTurnId: 'unindexed-turn', + orderedTurnIds: ['turn-1', 'turn-2', 'turn-3'], + railTurnIds: ['turn-1', 'turn-2', 'turn-3'], + previousRailTurnId: 'turn-2', + }), 'turn-2'); }); -test('no eligible Turn leaves the selection unresolved', () => { - assert.equal(selectPromptRailActiveTurn({ - atEnd: false, - mountedTurnIds: ['unknown-turn'], - readingBandTurnIds: [], - scrollportTurnIds: [], - turnIndexById, +test('no reading position and no usable current tick leaves the rail unmarked', () => { + assert.equal(selectPromptRailTick({ + readingTurnId: undefined, + orderedTurnIds: ['turn-1', 'turn-2', 'turn-3'], + railTurnIds: ['turn-1', 'turn-2', 'turn-3'], + previousRailTurnId: 'turn-from-another-session', }), null); }); -test('an unsampled mounted Turn maps through the two nearest durable landmarks', () => { - const railTurns = Array.from({ length: 64 }, (_, railIndex) => { - const turnIndex = Math.round(railIndex * 119 / 63); - return { turnId: `turn-${turnIndex + 1}`, label: '', sequence: turnIndex * 2 }; - }); - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'turn-66', - mountedTurnIds: ['turn-66', 'turn-67', 'turn-68', 'turn-69'], - railTurns, - previousRailTurnId: 'turn-67', - atEnd: false, - }), 'turn-65'); -}); - -test('uneven sequence gaps choose a nearby real landmark instead of a linear tick position', () => { - const railTurns = [ - { turnId: 'turn-a', label: '', sequence: 0 }, - { turnId: 'turn-b', label: '', sequence: 10 }, - { turnId: 'turn-c', label: '', sequence: 1_000 }, - { turnId: 'turn-d', label: '', sequence: 1_010 }, - ]; - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['turn-b', 'active', 'turn-c'], - railTurns, - previousRailTurnId: 'turn-b', - atEnd: false, - }), 'turn-c'); -}); - -test('one-sided sequence extrapolation cannot skip past the adjacent tick', () => { - const railTurns = [ - { turnId: 'turn-a', label: '', sequence: 0 }, - { turnId: 'turn-b', label: '', sequence: 10 }, - { turnId: 'turn-c', label: '', sequence: 1_000 }, - { turnId: 'turn-d', label: '', sequence: 1_010 }, - ]; - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['active', 'turn-b', 'turn-c'], - railTurns, - previousRailTurnId: 'turn-d', - atEnd: false, - }), 'turn-a'); -}); - -test('a prompt-less mounted tail uses the nearest loaded prompt before the index arrives', () => { - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['turn-a', 'turn-b', 'active'], - railTurns: [ - { turnId: 'turn-a', label: '' }, - { turnId: 'turn-b', label: '' }, - ], - previousRailTurnId: null, - atEnd: true, - }), 'turn-b'); -}); - -test('a prompt-less tail without a mounted landmark uses the final rail tick', () => { - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['active'], - railTurns: [ - { turnId: 'turn-a', label: '', sequence: 0 }, - { turnId: 'turn-b', label: '', sequence: 10 }, - ], - previousRailTurnId: null, - atEnd: true, - }), 'turn-b'); -}); - -test('a window without a sampled wrapper preserves its previous current tick', () => { - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['active', 'neighbor'], - railTurns: [ - { turnId: 'turn-a', label: '', sequence: 0 }, - { turnId: 'turn-b', label: '', sequence: 10 }, - ], - previousRailTurnId: 'turn-a', - atEnd: false, - }), 'turn-a'); -}); - -test('a window without landmarks replaces a stale current with a current rail tick', () => { - assert.equal(selectPromptRailTickForMountedTurn({ - activeTurnId: 'active', - mountedTurnIds: ['active'], - railTurns: [ - { turnId: 'turn-a', label: '', sequence: 0 }, - { turnId: 'turn-b', label: '', sequence: 10 }, - ], - previousRailTurnId: 'stale-turn', - atEnd: false, - }), 'turn-a'); -}); - test('keeps the active tick visible when the rail viewport resizes', () => { let railBox = box(0, 600); let tickBox = box(570, 590); @@ -484,13 +190,17 @@ test('updates landmark content when its body enters a later resident range', () test('keeps unloaded landmarks visually uniform and actionable', () => { const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', - children: 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 }, - ], - scrollRef: { current: null }, + // The rail reads its current tick from the scroll authority, so it only + // renders under one — the same contract ChatView states about its layout. + children: createElement(TranscriptScrollAuthorityProvider, { + children: 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 }, + ], + scrollRef: { current: null }, + }), }), })); diff --git a/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx b/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx similarity index 50% rename from packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx rename to packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx index 7680f0d5f0..3b3856977e 100644 --- a/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx +++ b/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx @@ -18,23 +18,9 @@ */ /** - * The prompt rail observes every mounted Turn in the transcript. What that - * observer is for is Turn identity and order, and a streaming answer delivers - * several deltas a second that change neither — so a delta must not tear the - * observer down and rebuild it over the whole conversation. - * - * Two things hold that in series: ChatView hands the rail the previous entry - * array back when no persisted prompt or answer text moved, and the rail keys - * the observer's lifetime on the Turn id list rather than on its props. Either - * one alone keeps the count at 1, which is why this asserts the composed - * outcome the way the deleted E2E case did rather than probing one of them. - * - * A probe, not a story: this counts constructions and reads the init a - * constructor was handed, and both are only observable from before the rail's - * own observer exists. Installing `IntersectionObserver` on the global here is - * what makes the positive control real — a story mounting the rail first can - * only watch the observer it already has, so the `rootMargin` assertion below - * would pass against any literal. + * The rail's current tick is the reading position the scroll authority + * publishes, and nothing else. Mounted through the real layout, because that + * is what hands the authority the scroller the reader scrolls. */ import assert from 'node:assert/strict'; @@ -47,14 +33,12 @@ 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 { READING_BAND_TOP_PERCENT } from '../prompt-anchor-rail.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, @@ -79,6 +63,8 @@ afterEach(async () => { }); const TURN_COUNT = 6; +const TURN_HEIGHT = 400; +const SCROLLPORT_HEIGHT = 600; const activeSession: SessionSummary = { id: 'session-rail', @@ -97,7 +83,7 @@ const activeSession: SessionSummary = { permissionMode: 'ask', }; -function turnMessages(answerText: (index: number) => string): StoredMessage[] { +function turnMessages(): StoredMessage[] { return Array.from({ length: TURN_COUNT }, (_, index): StoredMessage[] => [ { type: 'user', @@ -111,56 +97,46 @@ function turnMessages(answerText: (index: number) => string): StoredMessage[] { id: `assistant-${index}`, turnId: `turn-${index}`, ts: index * 2 + 1, - text: answerText(index), + text: '答案', modelId: 'claude-sonnet-4-5', }, ]).flat(); } -interface ObservedInit { - root: unknown; - rootMargin?: string; - threshold?: number | number[]; +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 }); } +/** linkedom lays nothing out, so every box this reads is stated here. */ function harness() { const { document, window } = parseHTML('
'); - const inits: ObservedInit[] = []; - class CountingIntersectionObserver { - constructor(_callback: IntersectionObserverCallback, init?: IntersectionObserverInit) { - inits.push({ - root: init?.root, - rootMargin: init?.rootMargin, - threshold: init?.threshold as number | number[] | undefined, - }); - } - observe(): void {} - unobserve(): void {} - disconnect(): void {} - takeRecords(): IntersectionObserverEntry[] { - return []; - } - } + 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 {} } - // linkedom lays nothing out, so every box is zero-sized. The rail reads - // geometry only to pick which tick is current; the observer it builds to do - // that is what this test is about, and a constructor call does not need a - // layout to be counted. - 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; Object.assign(globalThis, { - CSS: { supports: () => false }, + CSS: { supports: () => false, escape: (value: string) => value }, document, Element: window.Element, HTMLElement: window.HTMLElement, - IntersectionObserver: CountingIntersectionObserver, MutationObserver: window.MutationObserver, Node: window.Node, ResizeObserver: InertResizeObserver, @@ -179,62 +155,51 @@ function harness() { }); const mount = document.querySelector('#mount'); assert.ok(mount); - return { inits, 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; + }, + }; } -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 activeTickTurnId(mount: HTMLElement): string | null { + return mount.querySelector('.maka-prompt-rail-tick[data-active="true"]') + ?.getAttribute('data-prompt-turn-id') ?? null; } -test('streaming deltas do not reconstruct the prompt rail observer', async () => { - const { inits, mount } = harness(); - const root = createRoot(mount); +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(() => '答案'))); + root.render(view(turnMessages())); }); - assert.equal(inits.length, 1, 'the rail observes the transcript once on mount'); - - // Ten deltas on the tail answer. Every one of them hands ChatView a fresh - // message array and fresh turn records — which is exactly the shape that - // used to rebuild the observer over the whole transcript per frame. - for (let delta = 1; delta <= 10; delta += 1) { - await act(() => { - root.render( - view( - turnMessages((index) => - index === TURN_COUNT - 1 ? `答案${'。'.repeat(delta)}` : '答案', - ), - ), - ); - }); - } - assert.equal(inits.length, 1, `the observer was rebuilt ${inits.length - 1} times`); -}); + const scroller = probe.scroller(); -test('the rail observes its reading band, not the whole scrollport', async () => { - const { inits, mount } = harness(); - const root = createRoot(mount); - mountedRoot = root; + // The reader takes the transcript to the third Turn's box. await act(() => { - root.render(view(turnMessages(() => '答案'))); + probe.viewport.scrollTop = TURN_HEIGHT * 2 + 100; + scroller.dispatchEvent(new probe.window.Event('scroll')); }); + assert.equal(activeTickTurnId(probe.mount), 'turn-2'); - const init = inits[0]; - assert.ok(init); - // The band is the top slice of the scrollport, so the bottom inset is its - // complement. Both spellings come from one constant; a rail that observed - // the whole scrollport would call every Turn on screen "being read". - assert.equal(init.rootMargin, `0px 0px -${100 - READING_BAND_TOP_PERCENT}% 0px`); - assert.ok(READING_BAND_TOP_PERCENT > 0 && READING_BAND_TOP_PERCENT < 100); - // Zero alone reports a boundary touch as an intersection; the second, - // positive threshold is what distinguishes real overlap from that. - assert.deepEqual(init.threshold, [0, 0.000_001]); + await act(() => { + probe.viewport.scrollTop = TURN_HEIGHT * 4; + scroller.dispatchEvent(new probe.window.Event('scroll')); + }); + assert.equal(activeTickTurnId(probe.mount), 'turn-4'); }); diff --git a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx index a7e95eb5b3..2a18d4600a 100644 --- a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx +++ b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx @@ -197,8 +197,6 @@ function harness(options: { readonly onClick: () => Promise | void }): Ret scrollBehavior: 'auto' as const, hasOlderHistory: true, hasNewerHistory: true, - onLoadEarlierHistory: () => undefined, - onLoadLaterHistory: () => undefined, onReadingAnchorChange: (turnId?: string) => { anchors.push(turnId); }, diff --git a/packages/ui/src/__tests__/transcript-history-notice.test.tsx b/packages/ui/src/__tests__/transcript-history-notice.test.tsx deleted file mode 100644 index 13d67ad977..0000000000 --- a/packages/ui/src/__tests__/transcript-history-notice.test.tsx +++ /dev/null @@ -1,62 +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 { renderToStaticMarkup } from 'react-dom/server'; -import { TranscriptHistoryGapRow } from '../chat-view.js'; - -function renderGap( - direction: 'older' | 'newer', - isPending: boolean, -): string { - return renderToStaticMarkup( - undefined} - />, - ); -} - -test('presents an older boundary gap as an in-flow transcript row', () => { - const markup = renderGap('older', false); - - assert.match(markup, /role="status"/); - assert.match(markup, /aria-live="polite"/); - assert.match(markup, /aria-atomic="true"/); - assert.match(markup, /data-transcript-gap="older"/); - assert.match(markup, /maka-transcript-gap-row/); - assert.match(markup, /Earlier messages are not loaded/); - assert.match(markup, /Load earlier messages/); - assert.doesNotMatch(markup, / { - const markup = renderGap('newer', true); - - assert.match(markup, /data-transcript-gap="newer"/); - assert.match(markup, /Newer messages are not loaded/); - assert.match(markup, /Load newer messages/); - assert.doesNotMatch(markup, /disabled/); - assert.match(markup, /aria-busy="true"/); -}); diff --git a/packages/ui/src/__tests__/transcript-row-projection.test.ts b/packages/ui/src/__tests__/transcript-row-projection.test.ts deleted file mode 100644 index 495252fa89..0000000000 --- a/packages/ui/src/__tests__/transcript-row-projection.test.ts +++ /dev/null @@ -1,95 +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 { describe, test } from 'node:test'; -import { projectTranscriptRows } from '../transcript-row-projection.js'; - -interface TurnStub { - turnId: string; -} - -const turns: readonly TurnStub[] = [ - { turnId: 'turn-2' }, - { turnId: 'turn-3' }, - { turnId: 'turn-4' }, -]; - -function rowKeys(input: ReturnType>): string[] { - return input.map((row) => row.kind === 'turn' ? row.turn.turnId : `gap:${row.direction}`); -} - -describe('transcript boundary row projection', () => { - test('preserves the resident Turn order when both boundaries are complete', () => { - const rows = projectTranscriptRows({ turns, hasOlder: false, hasNewer: false }); - - assert.deepEqual(rowKeys(rows), ['turn-2', 'turn-3', 'turn-4']); - assert.strictEqual(rows[0]?.kind === 'turn' ? rows[0].turn : undefined, turns[0]); - }); - - test('places one older gap before the resident window', () => { - const rows = projectTranscriptRows({ turns, hasOlder: true, hasNewer: false }); - - assert.deepEqual(rowKeys(rows), ['gap:older', 'turn-2', 'turn-3', 'turn-4']); - }); - - test('places one newer gap after a resident window without an active Turn', () => { - const rows = projectTranscriptRows({ turns, hasOlder: false, hasNewer: true }); - - assert.deepEqual(rowKeys(rows), ['turn-2', 'turn-3', 'turn-4', 'gap:newer']); - }); - - test('places the newer gap immediately before the separately rendered active Turn', () => { - const rows = projectTranscriptRows({ - turns, - hasOlder: true, - hasNewer: true, - activeTurnId: 'turn-4', - }); - - assert.deepEqual(rowKeys(rows), [ - 'gap:older', - 'turn-2', - 'turn-3', - 'gap:newer', - 'turn-4', - ]); - }); - - test('keeps a newer gap at the trailing boundary when the active Turn is not resident', () => { - const rows = projectTranscriptRows({ - turns, - hasOlder: false, - hasNewer: true, - activeTurnId: 'turn-live', - }); - - assert.deepEqual(rowKeys(rows), ['turn-2', 'turn-3', 'turn-4', 'gap:newer']); - }); - - test('projects only the two truthful boundaries for an empty resident window', () => { - const rows = projectTranscriptRows({ - turns: [], - hasOlder: true, - hasNewer: true, - }); - - assert.deepEqual(rowKeys(rows), ['gap:older', 'gap:newer']); - }); -}); diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index a0034c82b4..b5e79bfdb2 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -23,6 +23,13 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createTranscriptScrollAuthority } from '../transcript-scroll-authority.js'; +interface FakeTurn { + turnId: string; + /** Offset within the scrolled content, which `scrollTop` then shifts. */ + top: number; + height: number; +} + interface FakeRoot { ownerDocument: EventTarget; style: { overflowAnchor: string }; @@ -31,6 +38,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; @@ -55,6 +66,15 @@ 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) => ({ + 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); @@ -99,8 +119,9 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F * * End-of-operation frame callbacks are advanced explicitly. */ -function withObservers(run: (resize: () => void, frame: () => void) => T): T { +function withObservers(run: (resize: () => void, frame: () => void, mutate: () => void) => T): T { const observers = new Set<() => void>(); + const mutations = new Set<() => void>(); const frames: FrameRequestCallback[] = []; const globals = globalThis as { ResizeObserver?: unknown; MutationObserver?: unknown; requestAnimationFrame?: unknown }; const originalResize = globals.ResizeObserver; @@ -122,13 +143,20 @@ function withObservers(run: (resize: () => void, frame: () => void) => T): T // 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 { - observe(): void {} - disconnect(): void {} + constructor(private readonly callback: () => void) {} + observe(): void { + mutations.add(this.callback); + } + disconnect(): void { + mutations.delete(this.callback); + } }; try { return run(() => { for (const observer of [...observers]) observer(); - }, () => { for (const callback of frames.splice(0)) callback(0); }); + }, () => { for (const callback of frames.splice(0)) callback(0); }, () => { + for (const mutation of [...mutations]) mutation(); + }); } finally { globals.ResizeObserver = originalResize; globals.MutationObserver = originalMutation; @@ -457,6 +485,57 @@ test('content landing above a released reader does not re-pin them', () => { }); }); +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); + 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'); + + root.input(-100); + root.scrollTop = 1_200; + root.emitScroll(); + 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); + + // 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(); + assert.equal(authority.getSnapshot().readingTurnId, 'turn-2'); + root.scrollTop = 400; + resize(); + assert.equal(authority.getSnapshot().readingTurnId, 'turn-0'); + }); +}); + +test('a transcript without Turns has no reading position', () => { + withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(authority.getSnapshot().readingTurnId, undefined); + }); +}); + test('only the reader\'s own movement reaches a reader-scroll listener', () => { withObservers(() => { const root = fakeRoot(); diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index 70759d1579..f609f1cba8 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -51,7 +51,10 @@ let mountedRoot: ReturnType | undefined; function wheel(target: HTMLElement, deltaY: number): void { const event = new window.Event('wheel', { bubbles: true }); - Object.defineProperty(event, 'deltaY', { value: deltaY }); + Object.defineProperties(event, { + deltaY: { value: deltaY }, + composedPath: { value: () => [target] }, + }); target.dispatchEvent(event); } @@ -111,6 +114,7 @@ const installScrollTestEnvironment = ( document, Element: window.Element, HTMLElement: window.HTMLElement, + getComputedStyle: () => ({ overflowY: 'visible' }), MutationObserver: TestMutationObserver, Node: window.Node, ResizeObserver: TestResizeObserver, @@ -121,94 +125,360 @@ const installScrollTestEnvironment = ( return { frames, resizeCallbacks }; }; -test('pages only toward reader input, including wheels at a bounded edge', async () => { - const { document, window } = parseHTML('
'); - const scroller = document.querySelector('#scroller')!; +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); let scrollTop = 0; + let turnCount = options.turnCount; + const contentHeight = (): number => + Math.max(options.clientHeight, turnCount * options.turnHeight); Object.defineProperties(scroller, { - clientHeight: { value: 600 }, - scrollHeight: { value: 2400 }, + clientHeight: { value: options.clientHeight }, + scrollHeight: { get: () => contentHeight() }, scrollTop: { get: () => scrollTop, - set: (value: number) => { scrollTop = Math.max(0, Math.min(value, 1800)); }, + set: (value: number) => { + scrollTop = Math.max(0, Math.min(value, contentHeight() - options.clientHeight)); + }, }, }); - scroller.getBoundingClientRect = () => ({ top: 0, bottom: 600 } as DOMRect); - for (let index = 0; index < 4; index++) { - const turn = document.createElement('article'); - turn.dataset.turnId = `turn-${index}`; - turn.getBoundingClientRect = () => ({ - top: index * 600 - scrollTop, bottom: (index + 1) * 600 - scrollTop, - } as DOMRect); - scroller.append(turn); - } - class Observer { disconnect() {} observe() {} } - Object.assign(globalThis, { - document, window, HTMLElement: window.HTMLElement, Element: window.Element, - MutationObserver: Observer, ResizeObserver: Observer, - getComputedStyle: () => ({ overflowY: 'auto' }), - IS_REACT_ACT_ENVIRONMENT: true, + scroller.getBoundingClientRect = () => boxOf(0, options.clientHeight); + const install = (): 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); + } + }; + install(); + return { + scroller, + get scrollTop(): number { return scrollTop; }, + setTurnCount(next: number): void { + turnCount = next; + install(); + // A real scroller clamps its offset the moment its content shrinks. + scroller.scrollTop = scrollTop; + }, + /** 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); + 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, }); - const calls: Array<{ direction: string; anchor?: string }> = []; - let authority!: TranscriptScrollAuthority; - function Harness({ more }: { more: boolean }) { - const scrollRef = useRef(scroller); - authority = useTranscriptScrollAuthority(); + + const calls: string[] = []; + const resolvers: Array<() => void> = []; + const load = (direction: string) => (): Promise => { + calls.push(direction); + return new Promise((resolve) => resolvers.push(resolve)); + }; + let history = { older: true, newer: true }; + function Harness({ older, newer }: { older: boolean; newer: boolean }) { + const scrollRef = useRef(transcript.scroller); useChatScroll({ - scrollRef, sessionId: 'guest', messages: [], behavior: 'auto', - hasOlderHistory: more, hasNewerHistory: more, - onLoadEarlierHistory: (anchor) => { calls.push({ direction: 'up', anchor }); }, - onLoadLaterHistory: (anchor) => { calls.push({ direction: 'down', anchor }); }, + 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 (more: boolean) => act(() => mountedRoot!.render( - , + const render = async (): Promise => act(() => mountedRoot?.render( + + + , )); - await render(true); - assert.deepEqual(calls, [], 'mounting at a partial tail is not a request'); - wheel(scroller, -100); - scroller.scrollTop = 900; - scroller.dispatchEvent(new window.Event('scroll')); - wheel(scroller, 100); - scroller.scrollTop = 1000; - scroller.dispatchEvent(new window.Event('scroll')); - assert.deepEqual(calls, [ - { direction: 'up', anchor: 'turn-1' }, - { direction: 'down', anchor: 'turn-2' }, - { direction: 'down', anchor: 'turn-2' }, // Input and its resulting scroll. - ], 'overlapping edge bands must not reverse the requested direction'); - scroller.scrollTop = 1800; - scroller.dispatchEvent(new window.Event('scroll')); - assert.equal(authority.getSnapshot().pinned, false, 'a partial tail must not follow a page fill'); - calls.length = 0; - wheel(scroller, 100); - assert.deepEqual(calls, [{ direction: 'down', anchor: 'turn-3' }]); + 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']); - const nested = document.createElement('div'); - Object.defineProperties(nested, { - clientHeight: { value: 100 }, scrollHeight: { value: 500 }, scrollTop: { value: 100 }, + 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(); }); - scroller.append(nested); calls.length = 0; - wheel(nested, 100); - assert.deepEqual(calls, [], 'scrolling a nested tool output must not page the transcript'); + 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']); +}); - scroller.scrollTop = 0; - scroller.dispatchEvent(new window.Event('scroll')); - calls.length = 0; - wheel(scroller, -100); - assert.deepEqual(calls, [{ direction: 'up', anchor: 'turn-0' }]); - assert.equal(scroller.scrollTop, 1, 'keep native anchoring enabled at the start'); - await render(false); - calls.length = 0; - wheel(scroller, -100); - scroller.scrollTop = 1800; - scroller.dispatchEvent(new window.Event('scroll')); - wheel(scroller, 100); - assert.deepEqual(calls, [], 'do not request beyond authoritative history edges'); +test('a fill that moved nothing 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-refused', + messages: [{ id: 'message-1' }] as StoredMessage[], + behavior: 'auto', + hasOlderHistory: true, + onPrefetchHistory: () => { + requests += 1; + return Promise.resolve(false); + }, + }); + return null; + } + mountedRoot = createRoot(document.querySelector('#mount')!); + await act(() => mountedRoot?.render( + , + )); + + await act(async () => { transcript.readerScrollTo(0); }); + // A read refused as stale leaves the window exactly as it was, so asking + // again in its own callback would ask forever. + assert.equal(requests, 1); + await act(async () => {}); + assert.equal(requests, 1); +}); + +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('an older request at offset zero restores the browser anchoring the reader depends on', 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-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, 1, 'keep native anchoring enabled at the start'); +}); + +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, + }); + + 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( + , + )); + + await render([{ id: 'message-1' }] as StoredMessage[]); + assert.equal(requests, 0); + assert.equal(transcript.scrollTop, 4_200); + + // 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); +}); + +test('the retained window is the band around the reader, and 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, + }); + + 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.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 session switch restores a Turn anchor after async fill and preserves tail intent', async () => { @@ -233,17 +503,7 @@ test('a session switch restores a Turn anchor after async fill and preserves tai }, }, }); - scroller.getBoundingClientRect = () => ({ - bottom: 600, - height: 600, - left: 0, - right: 800, - top: 0, - width: 800, - x: 0, - y: 0, - toJSON: () => undefined, - }); + scroller.getBoundingClientRect = () => boxOf(0, 600); const { frames, resizeCallbacks } = installScrollTestEnvironment(document, window); @@ -256,17 +516,8 @@ test('a session switch restores a Turn anchor after async fill and preserves tai for (const turn of turns) { const element = document.createElement('article'); element.dataset.turnId = turn.id; - element.getBoundingClientRect = () => ({ - bottom: turn.start + turn.height - scrollTop, - height: turn.height, - left: 0, - right: 800, - top: turn.start - scrollTop, - width: 800, - x: 0, - y: turn.start - scrollTop, - toJSON: () => undefined, - }); + 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' @@ -297,8 +548,6 @@ test('a session switch restores a Turn anchor after async fill and preserves tai const handledTargets: number[] = []; const viewportNavigation = createTranscriptViewportNavigation(); const unavailableRestores = new Map(); - const historyRequests: Array<{ direction: 'up' | 'down'; anchor?: string }> = []; - let historyPaging = false; let authority: TranscriptScrollAuthority | undefined; let messageRevision = 0; let target: { turnId: string; nonce: number } | undefined; @@ -324,10 +573,6 @@ test('a session switch restores a Turn anchor after async fill and preserves tai else anchors.delete(sessionId); }, behavior: 'auto', - hasOlderHistory: historyPaging, - hasNewerHistory: historyPaging, - onLoadEarlierHistory: (anchor) => { historyRequests.push({ direction: 'up', anchor }); }, - onLoadLaterHistory: (anchor) => { historyRequests.push({ direction: 'down', anchor }); }, }); return null; } @@ -472,146 +717,56 @@ test('a session switch restores a Turn anchor after async fill and preserves tai 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'); - - // Either adjacent-page gesture supersedes an activation's unfinished - // bookmark. A late fill must not move the reader back to that old target. - historyPaging = true; - for (const direction of ['up', 'down'] as const) { - const sessionId = `session-page-${direction}`; - const bookmark = `bookmark-${direction}`; - anchors.set(sessionId, bookmark); - collapseTranscript(); - installTranscript(3_000, [{ id: 'resident', start: 0, height: 3_000 }]); - await renderSession(sessionId); - assert.equal(authority?.getSnapshot().pinned, false); - scroller.scrollTop = direction === 'up' ? 0 : 2_400; - const wheel = new window.Event('wheel', { bubbles: true }); - Object.defineProperty(wheel, 'deltaY', { value: direction === 'up' ? -100 : 100 }); - scroller.dispatchEvent(wheel); - assert.deepEqual(historyRequests.at(-1), { direction, anchor: 'resident' }); - const readerTop: number = scroller.scrollTop; - - installTranscript(3_000, [ - { id: 'resident-before', start: 0, height: 800 }, - { id: bookmark, start: 800, height: 600 }, - { id: 'resident-after', start: 1_400, height: 1_600 }, - ]); - await renderSession(sessionId); - await flushFrames(); - assert.equal(scroller.scrollTop, readerTop, `${direction} paging consumes the pending restore`); - assert.equal(authority?.getSnapshot().pinned, false); - } }); -/** - * A bookmark left on a Turn the last range evicted makes the restore effect - * load around it over the range paging just published, and the transcript - * stops advancing — the stall the E2E paging guard sees as a timeout. - */ -test('a wheel at the top edge reports its anchor before it loads earlier history', async () => { +test('a target lands on the render that mounts its Turn, whatever moved the range', async () => { const { document, window } = parseHTML( '
', ); - const mount = document.querySelector('#mount'); - const scroller = document.querySelector('#scroller'); - assert.ok(mount); - assert.ok(scroller); - - let scrollHeight = 1_600; - let scrollTop = 0; - Object.defineProperties(scroller, { - clientHeight: { value: 600 }, - scrollHeight: { get: () => scrollHeight }, - scrollTop: { - get: () => scrollTop, - // No scroll event follows a write: that is the edge under test. - set: (value: number) => { - scrollTop = Math.max(0, Math.min(value, scrollHeight - 600)); - }, - }, + const { frames } = installScrollTestEnvironment(document, window); + const transcript = createTranscript(document, window, { + clientHeight: 600, turnHeight: 600, turnCount: 3, }); - scroller.getBoundingClientRect = () => ({ - bottom: 600, - height: 600, - left: 0, - right: 800, - top: 0, - width: 800, - x: 0, - y: 0, - toJSON: () => undefined, - }); - - installScrollTestEnvironment(document, window, { queueFrames: false }); - const installTurns = (ids: readonly string[]): void => { - scrollHeight = ids.length * 800; - scroller.replaceChildren(); - ids.forEach((id, index) => { - const element = document.createElement('article'); - element.dataset.turnId = id; - const start = index * 800; - element.getBoundingClientRect = () => ({ - bottom: start + 800 - scrollTop, - height: 800, - left: 0, - right: 800, - width: 800, - x: 0, - top: start - scrollTop, - y: start - scrollTop, - toJSON: () => undefined, - }); - element.scrollIntoView = () => { - scroller.scrollTop = start; - }; - scroller.append(element); - }); - }; - let anchor: string | undefined; - const loads: Array<{ anchorTurnId?: string; anchorWhenAsked?: string }> = []; + // 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[]; + const handledTargets: number[] = []; + let highlighted: string | null = null; function Harness() { - const scrollRef = useRef(scroller); - useChatScroll({ + const scrollRef = useRef(transcript.scroller); + const result = useChatScroll({ scrollRef, - sessionId: 'session-paging', - messages: [{ id: 'message-1' }] as StoredMessage[], - // A remembered position leaves the hook unpinned, as paging back does. - restoreTarget: { turnId: 'turn-0' }, - onReadingAnchorChange: (turnId) => { - anchor = turnId; - }, + sessionId: 'session-jump', + messages, + target: { turnId: 'turn-5', nonce: 7 }, + onTargetHandled: (nonce) => handledTargets.push(nonce), behavior: 'auto', - hasOlderHistory: true, - onLoadEarlierHistory: (anchorTurnId) => { - loads.push({ anchorTurnId, anchorWhenAsked: anchor }); - }, }); + highlighted = result.highlightedTurnId; return null; } - - installTurns(['turn-0', 'turn-1']); - mountedRoot = createRoot(mount); - await act(() => mountedRoot?.render( - - - , + const render = async (): Promise => act(() => mountedRoot?.render( + , )); - // Settle the authority on "unpinned, away from the tail", where the wheel's - // own release publishes nothing and so refreshes no anchor. - scroller.dispatchEvent(new window.Event('scroll')); - assert.equal(anchor, 'turn-0', 'the restored position is the reading anchor'); - - installTurns(['turn-earlier', 'turn-0']); - scroller.scrollTop = 0; + const flushFrames = async (): Promise => { + await act(() => { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(0); + }); + }; - const wheel = new window.Event('wheel'); - Object.assign(wheel, { deltaY: -120, composedPath: () => [scroller] }); - scroller.dispatchEvent(wheel); + mountedRoot = createRoot(document.querySelector('#mount')!); + await render(); + await flushFrames(); + assert.equal(highlighted, null, 'a Turn that is not mounted cannot be revealed yet'); + assert.deepEqual(handledTargets, []); - assert.deepEqual( - loads.at(-1), - { anchorTurnId: 'turn-earlier', anchorWhenAsked: 'turn-earlier' }, - 'the wheel bookmarks the Turn it anchors the load to', - ); + transcript.setTurnCount(8); + await render(); + await flushFrames(); + assert.equal(highlighted, 'turn-5'); + assert.deepEqual(handledTargets, [7]); + assert.equal(transcript.scrollTop, 3_000, 'the reveal puts the Turn at the top edge'); }); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index a764d921b3..84627edaf7 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -61,7 +61,6 @@ import { useChatScroll } from './use-chat-scroll.js'; import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; import type { TranscriptViewportNavigation } from './transcript-viewport-navigation.js'; import { placeChatConversationItems } from './chat-conversation-items.js'; -import { projectTranscriptRows } from './transcript-row-projection.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import { SessionContextLayer, type SessionContextGoal } from './session-context-layer.js'; @@ -75,16 +74,6 @@ export interface LiveContentActivationSnapshot { entries: ReadonlyMap; } -export type TranscriptHistoryLoadDirection = 'older' | 'newer'; - -export interface TranscriptHistoryGapRowProps { - direction: TranscriptHistoryLoadDirection; - description: string; - actionLabel: string; - isPending: boolean; - onActivate(): Promise | void; -} - export interface ChatViewGoalIndicatorProps { /** * Active autonomous-goal indicator for the session, or undefined when no @@ -126,41 +115,6 @@ export function resolveRailAlignedTarget - {description} -