diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index f321c5f8bc..8ff976c6a6 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1,5 +1,9 @@ # Playwright E2E run artifacts: traces, videos, screenshots, last-run state. test-results/ +# Throwaway drivers written beside the suite while investigating a scenario by +# hand. They resolve `@playwright/test` from here, which is why they live in the +# tree at all, and they are nobody's to keep. +e2e/.scratch/ resources/workers/ .maka-dev/ .maka-dev.staging/ diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index ef8b2d1bcf..39c83efbda 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..226d0367e0 100644 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -19,22 +19,17 @@ 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); - await expect(page.locator('.maka-transcript-history-controls')).toHaveCount(0); + await expect(page.locator(TURN).first()).toBeVisible(); + 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 +37,22 @@ 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); - 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 }); const returnToLatest = page.getByRole('button', { name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, @@ -77,8 +60,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..388ad4546b 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -37,12 +37,29 @@ 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'; +/** + * Generous on purpose: the property worth guarding is that paging through the + * whole history stops adding Turns, and a range that kept everything it paged + * in would mount all of them. + */ +const MOUNTED_TURNS_MAX = 40; + +/** + * How far a range boundary is allowed to move the reader, in CSS pixels. + * + * A boundary both installs a page and drops the far side of the band, and the + * two settle within the same quiet frame, so what is measurable is their sum. + * Not a tolerance for "close enough" motion: anchoring holds that sum to a + * fraction of a Turn, where a frame that lost the reader lands a Turn away or + * more. + */ +const BOUNDARY_DISPLACEMENT_MAX_PX = 40; + declare global { interface Window { __makaTranscriptCost?: { @@ -51,9 +68,31 @@ declare global { skipped: WeakSet; skippedCount: number; }; + __makaTranscriptDisplacement?: { + boundaries: TranscriptBoundary[]; + record(on: boolean): void; + stop(): void; + }; } } +/** + * One frame where the mounted range changed: a page installed, or the band + * trimmed, or both. + */ +interface TranscriptBoundary { + readonly firstBefore: string; + readonly firstAfter: string; + readonly mountedBefore: number; + readonly mountedAfter: number; + readonly grewPx: number; + readonly scrolledPx: number; + /** Turns present in both frames, so a reader position can be compared. */ + readonly carried: number; + readonly worstTurnId: string | null; + readonly worstPx: number; +} + /** * Real wheel input at the centre of the scroller. Relative by construction: a * wheel tick asks the compositor to move by a delta from wherever the scroller @@ -125,6 +164,131 @@ 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); + } + return { + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + tops, + key: [...tops.keys()].join(','), + }; + }; + // Only frames the reader is not 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. Which frames those are is + // told, not inferred — the gesture and the rAF that reads it land in the + // same frame in an order nothing here controls, and a reading that catches + // one tick reports exactly one tick of displacement. + let recording = false; + const state: { + boundaries: unknown[]; + record(on: boolean): void; + stop(): void; + } = { + boundaries: [], + record: (on: boolean) => { + recording = on; + previous = read(); + settled = null; + }, + stop: () => { running = false; }, + }; + let running = true; + let previous = read(); + // The last frame before the range started changing. Held across a run of + // changing frames so the measurement spans settled state to settled state: + // scroll anchoring corrects after layout, so a reading taken inside the + // change would report a correction that never reached the screen. + let settled: ReturnType | null = null; + const tick = (): void => { + if (!running) return; + const current = read(); + if (!recording) { + settled = null; + previous = current; + requestAnimationFrame(tick); + return; + } + if (current.key !== previous.key) { + if (!settled) settled = previous; + } else if (settled) { + const before = settled; + settled = null; + const scrolled = current.scrollTop - before.scrollTop; + let carried = 0; + let worstPx = 0; + let worstTurnId: string | null = null; + for (const [turnId, top] of current.tops) { + const wasAt = before.tops.get(turnId); + if (wasAt === undefined) continue; + carried += 1; + const displaced = Math.abs(top - wasAt); + if (displaced > worstPx) { + worstPx = displaced; + worstTurnId = turnId; + } + } + state.boundaries.push({ + firstBefore: before.key.split(',')[0] ?? '', + firstAfter: current.key.split(',')[0] ?? '', + mountedBefore: before.tops.size, + mountedAfter: current.tops.size, + grewPx: current.scrollHeight - before.scrollHeight, + scrolledPx: scrolled, + carried, + worstTurnId, + worstPx, + }); + } + previous = current; + requestAnimationFrame(tick); + }; + window.__makaTranscriptDisplacement = state as never; + requestAnimationFrame(tick); + }, SCROLLER); +} + +/** Opens the measurement window, or closes it around the reader's own gesture. */ +async function recordDisplacement(page: Page, on: boolean): Promise { + await page.evaluate((value) => { + const state = window.__makaTranscriptDisplacement; + if (!state) throw new Error('the transcript displacement probe is missing'); + state.record(value); + }, on); +} + +async function displacement(page: Page): Promise { + return page.evaluate(() => { + const state = window.__makaTranscriptDisplacement; + if (!state) throw new Error('the transcript displacement probe is missing'); + state.stop(); + return state.boundaries; + }); +} + interface CostSample { transitionRuns: number; animationStarts: number; @@ -149,7 +313,35 @@ 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. + */ +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 +424,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 +460,72 @@ 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 recordDisplacement(page, false); + await wheel(page, cdp, { ticks: 12, deltaY: -120 }); + // The hand comes off the wheel here. A page requested by the gesture + // lands in the quiet that follows — which is also when a reader would + // see it move — so that quiet is the whole of what is measured. + await recordDisplacement(page, true); + await page.waitForTimeout(150); + return turns.first().getAttribute('data-turn-id'); + }) + .not.toBe(firstBefore); + } + + const boundaries = await displacement(page); + // The probe has to have seen the thing it measures: a run that paged nothing, + // or one where every boundary replaced the range wholesale and carried no + // Turn across, proves nothing about the reader. + expect(boundaries.length).toBeGreaterThan(0); + expect(boundaries.filter((boundary) => boundary.carried > 0).length).toBeGreaterThan(0); + + const displaced = boundaries.filter((boundary) => boundary.worstPx > BOUNDARY_DISPLACEMENT_MAX_PX); + expect(displaced, `range boundaries moved the reader: ${JSON.stringify(displaced)}`) + .toEqual([]); }); diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index 48e45c0adb..ff9364d83f 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -368,7 +368,11 @@ test('WorkHub keeps the submitted prompt visible while its agent is still runnin await workhub.locator(COMPOSER_INPUT).fill('立即调整方向,保持当前任务'); await workhub.locator(COMPOSER_INPUT).press('Shift+Enter'); await expect(workhub.locator('.maka-bubble-streaming')).toContainText('Acknowledged steering: 立即调整方向,保持当前任务'); - await expect(workhub.locator('.maka-user-message').filter({ hasText: '立即调整方向,保持当前任务' })).toHaveCount(1); + const steered = workhub.locator('.maka-user-message').filter({ hasText: '立即调整方向,保持当前任务' }); + await expect(steered).toHaveCount(1); + // A queued message is only ever durable at the tail, so sending one has to + // take the window and the reader there. + await expect(steered).toBeInViewport(); await expect(followups).toHaveText(queuedTexts); await expect(stop).toBeVisible(); await stop.click(); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 93dd6b5ced..800dc4b634 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": 13695 + "importSpecifiers": 107, + "nonTriviaTokens": 13669 }, "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..d472fe61eb 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,10 @@ import { encodeDesktopTranscriptSnapshot, } from '../desktop-transcript-ipc.js'; import { - DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, } from '../../preload/transcript-contract.js'; import { createDesktopTranscriptReconnectRecovery, @@ -36,9 +37,10 @@ import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore, } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; +import { TranscriptReadSupersededError } from '../../renderer/features/conversation/index.js'; import { mergeSettledMessages } from '../../renderer/settled-message-merge.js'; import { readSettledMessages } from '../../renderer/session-message-settlement.js'; -import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; +import { DesktopTranscriptReplica, type DesktopTranscriptReplicaChange } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; test('merges a settled tail without dropping earlier messages', () => { @@ -121,12 +123,9 @@ test('moves a fragmented overlay record to durable storage without duplicating i assert.equal(store.hasDurableMessage(message.id), false); const change = [...encodeDesktopTranscriptChange(identity, { + coversFrom: null, 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 +135,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', @@ -158,14 +157,15 @@ test('retains the newest observed durable prompt across eviction', () => { assert.equal(store.newestDurableUserSequence(), 3); for (const batch of encodeDesktopTranscriptChange(identity, { + coversFrom: 3, 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', () => { @@ -197,13 +197,9 @@ test('drops stale transcript batches after a generation reset', () => { const staleChange = [...encodeDesktopTranscriptChange( { sessionId: 'session-1', generation: 'old', hostEpoch: 'host-1' }, { + coversFrom: 2, 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,12 +214,12 @@ 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, navigation?: number) => { for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, generation, navigationVersion, durableThrough: 1, + ...identity, generation, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage(text) }], overlay: [], hasOlder: false, hasNewer: false, - })) deliveries.push({ generation, accepted: store.accept(batch) }); + }, navigation)) deliveries.push({ generation, accepted: store.accept(batch) }); }; const controller = createDesktopTranscriptRangeController(store, async () => { opens += 1; @@ -235,10 +231,14 @@ test('cached reload snapshots allow the same live transcript generation to resum publish(identity.generation, `live-${opens}`); return { ...identity, readThroughMessageId: null, + async acknowledgeTail() {}, async loadBefore() {}, async loadAfter() {}, async loadAround(_sequence, _maxBytes, navigation) { - publish(identity.generation, `live-${opens}`, navigation?.navigationVersion); + publish(identity.generation, `live-${opens}`, navigation); + }, + async loadLatest(navigation) { + publish(identity.generation, `live-${opens}`, navigation); }, async close() {}, }; @@ -256,11 +256,9 @@ 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, { + coversFrom: 1, 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(); @@ -285,11 +283,9 @@ test('a replacement live generation retires the previous replica through cached for (const batch of encodeDesktopTranscriptChange({ sessionId: 'session-1', generation, hostEpoch: 'host-1', }, { + coversFrom: 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')]); @@ -321,13 +317,9 @@ test('keeps unchanged message references stable across immutable range snapshots assert.ok(Object.isFrozen(first.messages[0])); for (const batch of encodeDesktopTranscriptChange(identity, { + coversFrom: 1, 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 +356,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 +473,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 +615,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,152 +768,83 @@ 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) => ({ +test('a window opened between catch-up pages can join the change that follows', async () => { + const bootstrap = [0, 1, 2].map((sequence) => ({ + identity: sequence, + message: assistantMessage(String(sequence), `assistant-${sequence}`), + })); + const firstPage = [3, 4, 5].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 secondPage = [{ identity: 6, message: assistantMessage('6', 'assistant-6') }]; + const page = (nextCursor: string | null, throughSequence: number) => ({ + kind: 'page' as const, + sessionId: 'session-1', + source: 'durable' as const, + direction: 'newer' as const, + throughSequence, + rawBytes: 1, + fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, + nextCursor, + }); + const bootstrapPage = page(null, 2); + const first = page('more', 6); + const second = page(null, 6); + let releaseSecond: () => void = () => {}; + const secondGate = new Promise((resolve) => { releaseSecond = resolve; }); + let signalSecond: () => void = () => {}; + const secondEntered = new Promise((resolve) => { signalSecond = resolve; }); + const changes: DesktopTranscriptReplicaChange[] = []; const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, transcriptBootstrap: { - throughSequence: 4, + throughSequence: 2, overlayMessageCount: 0, durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, 4), source: 'overlay' }, + overlay: { ...page(null, 2), 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; + decodeTranscriptPage: async (candidate) => candidate === bootstrapPage + ? { messages: bootstrap, nextCursor: null } + : candidate === first + ? { messages: firstPage, nextCursor: 'more' } + : { messages: secondPage, nextCursor: null }, + loadTranscriptPage: async (request) => { + if (request.cursor === null) return first; + signalSecond(); + await secondGate; + return second; }, async close() {}, }); const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes: 128 * 1024, + maxResidentBytes: 1024 * 1024, + onChange: (_replica, change) => changes.push(change), }); - 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); -}); + const advancing = replica.advance(6); + await secondEntered; + // The first page is installed; the second is pending. A window opening now + // must be told the watermark its rows actually reach. + const opened = replica.snapshot(); + assert.deepEqual(opened.durable.map(({ sequence }) => sequence), [0, 1, 2, 3, 4, 5]); + assert.equal(opened.durableThrough, 5); + releaseSecond(); + await advancing; -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); + const store = transcriptStore(); + for (const batch of encodeDesktopTranscriptSnapshot(opened)) store.accept(batch); + const identity = { sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch }; + for (const change of changes.slice(1)) { + for (const batch of encodeDesktopTranscriptChange(identity, change)) store.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(); + assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [0, 1, 2, 3, 4, 5, 6]); + assert.equal(store.range().hasNewer, false); }); test('rejects an overlay that exceeds its cache budget', async () => { @@ -1386,40 +870,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'); @@ -1493,9 +943,11 @@ test('reopens a failed transcript range with a fresh generation', async () => { generation: 'reloaded', hostEpoch: 'host-2', readThroughMessageId: null, + async acknowledgeTail() {}, async loadBefore() {}, async loadAfter() {}, async loadAround() {}, + async loadLatest() {}, async close() {}, }; }); @@ -1546,6 +998,8 @@ test('retries a failed transcript recovery after a newer observation becomes rea test('forwards a larger logical history range without changing batch size', async () => { const store = transcriptStore(); + // Rows below an open newer edge only install as a command's answer, so this + // snapshot has to be one: it is the window a navigation asked for. for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'session-1', generation: 'generation-1', @@ -1565,13 +1019,14 @@ test('forwards a larger logical history range without changing batch size', asyn overlay: [], hasOlder: true, hasNewer: true, - })) store.accept(batch); + }, store.navigate())) store.accept(batch); let request: { anchorSequence: number | null; maxBytes?: number } | undefined; const controller = createDesktopTranscriptRangeController(store, async () => ({ sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: 'assistant-1', + async acknowledgeTail() {}, async loadBefore(anchorSequence, maxBytes) { request = { anchorSequence, maxBytes }; }, @@ -1579,17 +1034,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(); }); @@ -1610,17 +1065,154 @@ test('waits for the required durable message on the current transcript generatio })) store.accept(batch); const waiting = store.waitForDurableMessage('assistant-1', 100); for (const batch of encodeDesktopTranscriptChange(identity, { + coversFrom: null, durableThrough: 0, - durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - })) store.accept(batch); + durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], })) store.accept(batch); assert.equal(await waiting, true); }); +test('the window does not change while an answer is still being assembled', () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], + overlay: [], hasOlder: false, hasNewer: false, + })) store.accept(batch); + const installed = store.snapshot(); + + const change = [...encodeDesktopTranscriptChange(identity, { + coversFrom: 1, durableThrough: 2, + durableUpserts: [{ + sequence: 2, + message: assistantMessage('x'.repeat(300 * 1024), 'assistant-2'), + }], + })]; + assert.ok(change.length > 1, 'the answer has to span more than one batch'); + for (const batch of change.slice(0, -1)) assert.equal(store.accept(batch), false); + assert.strictEqual(store.snapshot(), installed, 'the screen is never a half-installed answer'); + + assert.equal(store.accept(change.at(-1)!), true); + assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [1, 2]); +}); + +for (const coversFrom of [7, undefined]) { + test(`tail rows a window cannot join are dropped, and its ${coversFrom === undefined ? 'uncovered' : 'mismatched'} watermark still moves`, () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], + overlay: [], hasOlder: false, hasNewer: false, + })) store.accept(batch); + assert.equal(store.range().hasNewer, false); + + for (const batch of encodeDesktopTranscriptChange(identity, { + coversFrom, durableThrough: 9, + durableUpserts: [{ sequence: 9, message: assistantMessage('stranded', 'assistant-9') }], + })) store.accept(batch); + + assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [1], + 'nothing proves 9 adjacent to what the window holds'); + assert.equal(store.range().durableThrough, 9); + assert.equal(store.range().hasNewer, true, 'so the window knows to read forward itself'); + }); +} + +test('a reset the reader has navigated past moves the watermark and nothing else', () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], + overlay: [], hasOlder: false, hasNewer: false, + })) store.accept(batch); + + const answer = [...encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 6, + durable: [ + { sequence: 5, message: assistantMessage('x'.repeat(300 * 1024), 'assistant-5') }, + { sequence: 6, message: assistantMessage('jumped', 'assistant-6') }, + ], + overlay: [], hasOlder: true, hasNewer: false, + }, store.navigate())]; + assert.ok(answer.length > 1); + store.accept(answer[0]!); + // The reader asked to be somewhere else before the first answer finished. + store.navigate(); + for (const batch of answer.slice(1)) store.accept(batch); + + assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [1]); + assert.equal(store.range().durableThrough, 6); + assert.equal(store.pendingNavigation(), 2, 'the jump the reader is waiting for still stands'); +}); + +test('a fill is issued once per window and again as soon as the window moves', async () => { + const store = transcriptStore(); + let reads = 0; + const controller = createDesktopTranscriptRangeController(store, async () => ({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', + readThroughMessageId: null, + async acknowledgeTail() {}, + async loadBefore() { reads += 1; }, + async loadAfter() {}, async loadAround() {}, async loadLatest() {}, async close() {}, + })); + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', + durableThrough: 2, hasOlder: true, hasNewer: false, overlay: [], + durable: [ + { sequence: 1, message: assistantMessage('first') }, + { sequence: 2, message: assistantMessage('second', 'assistant-2') }, + ], + })) store.accept(batch); + + assert.equal(await controller.loadBefore(), true); + assert.equal(await controller.loadBefore(), false, 'the same window answers the same way'); + assert.equal(reads, 1); + + assert.equal(store.retain(2, 2), true); + assert.equal(await controller.loadBefore(), true); + assert.equal(reads, 2); + await controller.close(); +}); + +test('reports each tail the window reaches once, and none while it is parked', async () => { + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + const store = transcriptStore(); + const acknowledged: number[] = []; + // The visible reader path: only a controller that acknowledges reports a tail. + const controller = createRecoveringDesktopTranscriptRangeController(store, async () => ({ + ...identity, readThroughMessageId: null, + async acknowledgeTail(through) { acknowledged.push(through); }, + async loadBefore() {}, async loadAfter() {}, async loadAround() {}, + async loadLatest() {}, async close() {}, + }), { onError() {} }); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + + // Opening a Session at the tail: the read marker still moves on open. + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, hasOlder: true, hasNewer: false, overlay: [], + durable: [{ sequence: 1, message: assistantMessage('first') }], + })) store.accept(batch); + await settle(); + assert.deepEqual(acknowledged, [1]); + + for (const batch of encodeDesktopTranscriptChange(identity, { + coversFrom: 1, durableThrough: 2, + durableUpserts: [{ sequence: 2, message: assistantMessage('second', 'assistant-2') }], + })) store.accept(batch); + await settle(); + assert.deepEqual(acknowledged, [1, 2], 'a window that joined the tail reports it once'); + + // A trim reopens the newer edge, so the next change cannot join the window. + assert.equal(store.retain(1, 1), true); + for (const batch of encodeDesktopTranscriptChange(identity, { + coversFrom: 2, durableThrough: 3, + durableUpserts: [{ sequence: 3, message: assistantMessage('third', 'assistant-3') }], + })) store.accept(batch); + await settle(); + assert.deepEqual(acknowledged, [1, 2], 'a parked window reports no tail'); + await controller.close(); +}); + test('cancels a transcript open that is still waiting for a Host', async () => { const store = transcriptStore(); let openSignal: AbortSignal | undefined; @@ -1687,26 +1279,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 +1302,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, @@ -1786,8 +1344,9 @@ test('cached fallback remains readable and retries once per observation generati })) store.accept(batch); return { ...identity, readThroughMessageId: null, + acknowledgeTail: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, loadAround: async () => {}, - close: async () => {}, + loadLatest: async () => {}, close: async () => {}, }; }, { onError: (error) => errors.push(error) }); const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -1811,6 +1370,42 @@ test('cached fallback remains readable and retries once per observation generati await controller.close(); }); +test('a read refused for a Host epoch that moved is superseded, not failed', async () => { + const store = transcriptStore(); + const errors: unknown[] = []; + let opens = 0; + const otherFailure = new Error('the older page failed'); + const controller = createRecoveringDesktopTranscriptRangeController(store, async () => { + opens += 1; + const identity = { sessionId: 'session-1', generation: 'live-generation', hostEpoch: 'host-1' }; + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, + durable: [{ sequence: 1, message: assistantMessage('live') }], + overlay: [], hasOlder: true, hasNewer: false, + })) store.accept(batch); + return { + ...identity, readThroughMessageId: null, + acknowledgeTail: async () => {}, + loadBefore: async () => { throw otherFailure; }, + loadAfter: async () => {}, + loadAround: async () => { + throw new Error(`Error invoking remote method 'sessions:transcript:load-around': Error: ${DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE}: Desktop transcript host epoch changed; reopen the transcript`); + }, + loadLatest: async () => {}, close: async () => {}, + }; + }, { onError: (error) => errors.push(error) }); + try { + await controller.ready(); + await assert.rejects(controller.loadAround(1), TranscriptReadSupersededError); + await assert.rejects(controller.loadBefore(), (error) => error === otherFailure); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(errors, [], 'a superseded read must not reach the error surface'); + assert.equal(opens, 1, 'the replacement reset carries the new epoch, so nothing is reopened'); + } finally { + await controller.close(); + } +}); + test('live transcript open failures without cache still report the original error', async () => { const failure = new Error('no Host and no cache'); const errors: unknown[] = []; 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 73716ce6c6..f5cd1ad49f 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 @@ -96,25 +96,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, navigation: 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, navigation: undefined }), + /Invalid Desktop transcript navigation/, + ); + assert.equal(calls.length, 2); }); test('treats pending Session observation teardown as IPC cancellation', async () => { @@ -164,6 +172,8 @@ test('treats pending transcript teardown as IPC cancellation', async () => { async loadTranscriptBefore() {}, async loadTranscriptAround() {}, async loadTranscriptAfter() {}, + async loadTranscriptLatest() {}, + acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const ipc = observationIpcHarness(observations); @@ -207,6 +217,8 @@ for (const teardown of ['forgetSession', 'close'] as const) { async loadTranscriptBefore() {}, async loadTranscriptAround() {}, async loadTranscriptAfter() {}, + async loadTranscriptLatest() {}, + acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const ipc = observationIpcHarness(observations); @@ -247,6 +259,8 @@ test('preserves genuine Session observation initialization failures', async () = async loadTranscriptBefore() {}, async loadTranscriptAround() {}, async loadTranscriptAfter() {}, + async loadTranscriptLatest() {}, + acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const ipc = observationIpcHarness(observations); @@ -262,6 +276,28 @@ test('preserves genuine Session observation initialization failures', async () = await observations.close(); }); +test('releases a transcript registration whose source lacks the window contract', async () => { + const observations = new RuntimeHostSessionObservationRegistry(); + await observations.attach({ + async observe() {}, + async unobserve() {}, + }); + const ipc = observationIpcHarness(observations); + + await assert.rejects( + ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), + /transcript source is unavailable/, + ); + assert.deepEqual(observations.trackedSessionIds(), []); + // Reusing the consumer id must reach the same missing-source failure rather + // than the duplicate-identity guard, which only a leaked registration trips. + await assert.rejects( + ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), + /transcript source is unavailable/, + ); + await observations.close(); +}); + test('returns explicit ready results for Session observation IPC', async () => { const observations = new RuntimeHostSessionObservationRegistry(); const transcript = { @@ -279,6 +315,8 @@ test('returns explicit ready results for Session observation IPC', async () => { async loadTranscriptBefore() {}, async loadTranscriptAround() {}, async loadTranscriptAfter() {}, + async loadTranscriptLatest() {}, + acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const ipc = observationIpcHarness(observations); 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..e937792aa3 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,8 @@ test('restores transcript consumers across Host replacement', async () => { async loadTranscriptBefore() {}, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, + acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const first = source('first'); @@ -511,6 +510,8 @@ test('does not hold Host observation recovery on transcript replay', async () => async loadTranscriptBefore() {}, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, + acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const first = source('first'); @@ -541,6 +542,8 @@ test('does not hold Host observation recovery on transcript replay', async () => }, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, + acknowledgeTranscriptTail() {}, acknowledgeTranscript() { transcriptAcknowledged = true; }, @@ -565,6 +568,7 @@ test('does not hold Host observation recovery on transcript replay', async () => hostEpoch: 'host-second', anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigation: 1, }, transcriptTarget.id, ); @@ -605,6 +609,8 @@ test('fences transcript range failures to the current registration and Host sour loadTranscriptBefore, async loadTranscriptAfter() {}, async loadTranscriptAround() {}, + async loadTranscriptLatest() {}, + acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const request = (consumerId: string, generation: string) => ({ @@ -613,6 +619,7 @@ test('fences transcript range failures to the current registration and Host sour hostEpoch: `host-${generation}`, anchorSequence: null, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigation: 1, }); const closedFailure = deferred(); @@ -729,6 +736,7 @@ test('fences transcript range failures across same-source replica recovery', asy hostEpoch, anchorSequence: 1, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigation: 1, }); const target: RuntimeHostTranscriptTarget = { id: 20, @@ -758,12 +766,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 +777,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(); }); @@ -860,14 +867,26 @@ test('broadcasts durable admission and transcript changes from the same message' id: 19 + index, send(_channel, batch) { batches.push(batch); - queueMicrotask(() => + queueMicrotask(() => { observer.acknowledgeTranscript( consumerId, batch.generation, batch.deliverySequence!, 19 + index, - ), - ); + ); + // Stand in for a Renderer window that installs what it is sent. + if (batch.ready && batch.durableThrough !== null) { + observer.acknowledgeTranscriptTail( + { + consumerId, + sessionId: 'session-1', + hostEpoch: batch.hostEpoch, + through: batch.durableThrough, + }, + 19 + index, + ); + } + }); }, once() {}, off() {}, @@ -884,10 +903,10 @@ test('broadcasts durable admission and transcript changes from the same message' throughSequence: 0, }); await waitFor(() => - markers.length === 1 && transcriptBatches.every((batches) => batches.length > 0), + markers.length > 0 && transcriptBatches.every((batches) => batches.length > 0), ); - assert.deepEqual(markers, ['ticket-1']); + assert.deepEqual([...new Set(markers)], ['ticket-1']); assert.deepEqual(transcriptBatches[1], transcriptBatches[0]); assert.deepEqual( eventConsumer.events @@ -898,6 +917,102 @@ test('broadcasts durable admission and transcript changes from the same message' await observer.close(); }); +test('moves the read marker only as far as the Renderer window reports reaching', async () => { + const events = new AsyncFrameQueue(); + const markers: string[] = []; + const rows: StoredMessage[] = [ + { type: 'assistant', id: 'answer-1', turnId: 'turn-1', ts: 1, text: 'One', modelId: 'test-model' }, + { type: 'assistant', id: 'answer-2', turnId: 'turn-2', ts: 2, text: 'Two', modelId: 'test-model' }, + ]; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + loadTranscriptPage: async (input) => ({ + kind: 'page', + sessionId: 'session-1', + source: 'durable', + direction: 'newer', + throughSequence: input.throughSequence ?? null, + rawBytes: 1, + fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, + nextCursor: null, + }), + // One durable row per catch-up target; the bootstrap page carries none. + decodeTranscriptPage: async (page) => { + const identity = page.throughSequence; + return identity === null + ? { messages: [], nextCursor: null } + : { messages: [{ identity, message: rows[identity]! }], nextCursor: null }; + }, + async close() { + events.end(); + }, + }), + setSessionReadMarker: async (_sessionId, messageId) => { + markers.push(messageId); + return undefined as never; + }, + }, + emitSessionsChanged() {}, + }); + const batches: DesktopTranscriptBatch[] = []; + const consumer: RuntimeHostTranscriptTarget = { + id: 31, + send(_channel, batch) { + batches.push(batch); + queueMicrotask(() => + observer.acknowledgeTranscript( + 'consumer-parked', + batch.generation, + batch.deliverySequence!, + 31, + ), + ); + }, + once() {}, + off() {}, + }; + const opened = await observer.openTranscript('session-1', 'consumer-parked', consumer); + const acknowledgeTail = (through: number) => + observer.acknowledgeTranscriptTail( + { consumerId: 'consumer-parked', sessionId: 'session-1', hostEpoch: opened.hostEpoch, through }, + 31, + ); + const advance = async (sequence: number, throughSequence: number) => { + const delivered = batches.length; + events.push({ + kind: 'subscription.transcript_advanced', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sessionId: 'session-1', + sequence, + throughSequence, + }); + await waitFor(() => batches.length > delivered); + }; + + await advance(1, 0); + assert.deepEqual(markers, [], 'delivery alone is not proof the reader reached the tail'); + acknowledgeTail(0); + assert.deepEqual(markers, ['answer-1']); + + // The reader is parked off the tail: the change is broadcast, but the window + // it names never joins, so nothing acknowledges the new watermark. + await advance(2, 1); + acknowledgeTail(0); + assert.deepEqual(markers, ['answer-1'], 'an unread Turn stays unread while the reader is parked'); + + acknowledgeTail(1); + assert.deepEqual(markers, ['answer-1', 'answer-2']); + await observer.close(); +}); + test('keeps a bounded transcript batch window in flight until the renderer acknowledges it', async () => { const events = new AsyncFrameQueue(); const message: StoredMessage = { @@ -1125,6 +1240,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'host-1', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigation: 1, }, 22, ), @@ -1143,6 +1259,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'other-host', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigation: 1, }, 22, ), @@ -1157,6 +1274,7 @@ test('finishes transcript open and replays a stale range request after replaceme hostEpoch: 'host-1', anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigation: 1, }, 22, ), @@ -1258,6 +1376,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]!.navigation, undefined, 'tail growth is a broadcast, not an answer'); + assert.equal(batches[1]!.hasOlder, undefined); + assert.equal(batches[1]!.hasNewer, undefined); observer.acknowledgeTranscript( consumerId, batches[1]!.generation, @@ -1267,6 +1388,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 = (navigation: number, anchorSequence: number | null) => ({ + consumerId, + sessionId: 'session-1', + hostEpoch: 'host-1', + anchorSequence, + maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigation, + }); + 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]!.navigation, 1); + assert.equal(batches[0]!.reset, false, 'extending the window does not replace it'); + assert.equal(batches[0]!.hasOlder, true); + assert.equal(batches[0]!.hasNewer, undefined, 'an older page establishes only its older edge'); + + // The same version extends the window the Renderer already holds. + await observer.loadTranscriptAfter(request(1, 1), 26); + assert.equal(batches.length, 2); + assert.equal(batches[1]!.navigation, 1); + assert.equal(batches[1]!.reset, false); + assert.equal(batches[1]!.hasNewer, false); + + await observer.loadTranscriptAround(request(2, 1), 26); + assert.equal(batches.length, 3); + assert.equal(batches[2]!.navigation, 2); + assert.equal(batches[2]!.reset, true, 'a window-replacing command resets the Renderer'); + assert.equal(batches[2]!.hasOlder, false); + assert.equal(batches[2]!.hasNewer, true); + + 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 +1698,7 @@ test('keeps a transcript consumer available after a delivery fails', async () => hostEpoch: opened.hostEpoch, anchorSequence: 0, maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigation: 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..859efc6285 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,30 @@ 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); + // An oversized Turn fills a client range on its own, so a reset anchored + // on the oldest row ends at that range boundary rather than at the tail. + // Reachability is carried by the newer edge and the page behind it. + const around = await replica.loadAround(first[0]!.sequence, PAGE_BYTES); + assert.ok(around); + assert.deepEqual(around.durable, first); + assert.equal(around.hasOlder, false); + assert.equal(around.hasNewer, true); + const newer = await replica.loadAfter(first.at(-1)!.sequence, PAGE_BYTES); + assert.ok(newer); + assert.deepEqual(newer.durable, second, 'the page past the boundary reaches the current tail'); + assert.equal(newer.hasNewer, false); assertRecords(replica, second); - 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 +125,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..896320173b 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts @@ -19,26 +19,27 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { deferred } from '@maka/core/test-only/async-primitives'; import type { StoredMessage } from '@maka/core/session'; import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol'; -import type { DesktopTranscriptBatch, DesktopTranscriptHandle, 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 +49,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 +59,66 @@ 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]]); +test('a global cache trim empties the tail without publishing or reading history', async () => { + const bootstrap = page(1); + const decoded = new Map>([[bootstrap, record(1)]]); + const requests: number[] = []; + const changes: number[] = []; const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, transcriptBootstrap: { - throughSequence: 7, overlayMessageCount: 0, + throughSequence: 1, 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 }; - }, + decodeTranscriptPage: async (candidate) => ({ messages: [decoded.get(candidate)!], nextCursor: null }), loadTranscriptPage: async (request) => { - const candidate = page(7); - pages.set(candidate, request.anchorSequence! - 1); + assert.ok(request.throughSequence !== null); + requests.push(request.throughSequence); + const candidate = page(request.throughSequence); + decoded.set(candidate, record(request.throughSequence)); return candidate; }, async close() {}, - }), { maxResidentBytes: 64, 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); - } + }), { maxResidentBytes: 64, onChange: (_replica, change) => changes.push(change.durableUpserts.length) }); + try { + await replica.advance(2); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2], + 'catch-up keeps the newest Turn and drops the oldest'); + const published = changes.length; + requests.length = 0; + + replica.trimDurable(0); + + assert.deepEqual(replica.snapshot().durable, []); + assert.equal(changes.length, published, 'a cache trim is not a transcript change'); + assert.deepEqual(requests, [], 'a cache trim reads nothing back'); + } finally { + replica.close(); } - 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 tail the global cache trim emptied is read back before it answers follow latest', async () => { const bootstrap = page(1); - const decoded = new Map>([[bootstrap, record(1)]]); - const requests: number[] = []; + const tail = page(1); + const reads: Array<{ direction: string; anchorSequence: number | null }> = []; const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, @@ -124,44 +127,98 @@ test('memory trimming cannot turn an already durable reading anchor into an unre durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, }, loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => ({ messages: [decoded.get(candidate)!], nextCursor: null }), + decodeTranscriptPage: async (candidate) => ({ + messages: [record(1)], nextCursor: candidate === bootstrap ? 'older' : null, + }), loadTranscriptPage: async (request) => { - assert.ok(request.throughSequence !== null); - requests.push(request.throughSequence); - const candidate = page(request.throughSequence); - decoded.set(candidate, record(request.throughSequence)); - return candidate; + reads.push({ direction: request.direction, anchorSequence: request.anchorSequence }); + return tail; }, async close() {}, - }), { maxResidentBytes: 64 }); + })); 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]); 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.deepEqual(replica.snapshot().durable, [], 'global memory pressure empties the tail'); + + await replica.refillTail(PAGE_BYTES); + + assert.deepEqual(reads, [{ direction: 'older', anchorSequence: 2 }], + 'the refill reads the newest page, not history'); + const snapshot = replica.snapshot(); + assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [1]); + assert.equal(snapshot.durableThrough, replica.durableThrough); + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + for (const batch of encodeDesktopTranscriptSnapshot(snapshot)) store.accept(batch); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); + assert.equal(store.range().hasNewer, false, 'the reader is at the tail, not short of it'); + + reads.length = 0; + await replica.refillTail(PAGE_BYTES); + assert.deepEqual(reads, [], 'a cache holding the whole transcript answers on its own'); } finally { replica.close(); } }); +test('return to latest answers with a tail after reclaim emptied the cache', async () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + const eventsClosed = deferred(); + const bootstrap = page(1); + const tail = page(1); + const reads: Array<{ direction: string; anchorSequence: number | null }> = []; + const observer = new RuntimeHostSessionObserver({ + client: { openSession: async () => runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, + transcriptBootstrap: { + throughSequence: 1, overlayMessageCount: 0, + durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + // What global reclaim leaves behind: the watermark stands, the rows are gone. + decodeTranscriptPage: async (candidate) => candidate === bootstrap + ? { messages: [], nextCursor: 'older' } + : { messages: [record(1)], nextCursor: null }, + loadTranscriptPage: async (request) => { + reads.push({ direction: request.direction, anchorSequence: request.anchorSequence }); + return tail; + }, + async close() { eventsClosed.resolve(); }, + }) }, + emitSessionsChanged() {}, + }); + await observer.openTranscript('session-1', 'consumer-1', { + id: 1, once() {}, off() {}, + send(_channel, batch) { + store.accept(batch); + queueMicrotask(() => observer.acknowledgeTranscript('consumer-1', batch.generation, batch.deliverySequence, 1)); + }, + }); + assert.deepEqual(store.snapshot().messages, [], 'the window opens on the emptied cache'); + + const navigation = store.navigate(); + await observer.loadTranscriptLatest({ + consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', + anchorSequence: null, maxBytes: PAGE_BYTES, navigation, + }, 1); + + assert.deepEqual(reads, [{ direction: 'older', anchorSequence: 2 }]); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); + assert.equal(store.range().hasNewer, false, 'the return-to-latest affordance is gone because the rows arrived'); + await observer.close(); +}); + test('a superseded fragmented reset cannot clear or complete the next navigation', () => { const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - acceptSnapshot(store, 0, 'generation-1', [record(1)]); - store.expectNavigation(1); + acceptSnapshot(store, undefined, 'generation-1', [record(1)]); + store.navigate(); const stale = [...encodeDesktopTranscriptSnapshot({ - ...identity, navigationVersion: 1, durableThrough: 1, + ...identity, durableThrough: 1, durable: [{ sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }], overlay: [], hasOlder: false, hasNewer: true, - })]; + }, 1)]; assert.equal(store.accept(stale[0]!), false); - store.expectNavigation(2); + store.navigate(); acceptSnapshot(store, 2, 'generation-2', [record(1)]); const committed = store.snapshot(); for (const batch of stale) assert.equal(store.accept(batch), false); @@ -172,56 +229,181 @@ 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, undefined, 'generation-1', [record(1)]); + store.navigate(); + const replacement = [...encodeDesktopTranscriptSnapshot({ + ...identity, generation: 'generation-2', durableThrough: 1, + durable: [ + { sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }, + { sequence: 1, message: record(1).message }, + ], + 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 fill landing under a pending jump joins the window it was anchored on', async () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + const arrive = deferred(); + const handle: DesktopTranscriptHandle = { + ...identity, readThroughMessageId: null, acknowledgeTail: async () => {}, + async loadBefore(anchor) { + for (const batch of encodeDesktopTranscriptPage(identity, { + durableThrough: 1, durable: [{ sequence: 0, message: record(0).message }], hasOlder: false, + }, { direction: 'older', anchor })) store.accept(batch); + }, + async loadAfter() { assert.fail('unexpected'); }, + async loadAround(_anchor, _bytes, navigation) { + await arrive.promise; + acceptSnapshot(store, navigation, 'generation-1', [record(9)]); + }, + async loadLatest() { assert.fail('unexpected'); }, + async close() {}, + }; + const controller = createDesktopTranscriptRangeController(store, async () => handle); + acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2)]); + const navigation = controller.loadAround(9); + await Promise.resolve(); + + assert.equal(await controller.loadBefore(), true); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), + ['message-0', 'message-1', 'message-2'], + 'the fill is anchored on an edge of the window still on screen, and reaches it'); + + arrive.resolve(); + await navigation; + // The jump replaces the window whole, so the fill leaves no trace in it. + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-9']); + await controller.close(); +}); + +test('a fill that left an edge where it found it is not asked again', async () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + let reads = 0; + const handle: DesktopTranscriptHandle = { + ...identity, readThroughMessageId: null, acknowledgeTail: async () => {}, + // The window still has history, but this answer reaches none of it: the + // Host read past a retired generation, or the page came back refused. + async loadBefore() { reads += 1; }, + async loadAfter() { assert.fail('unexpected'); }, + async loadAround() { assert.fail('unexpected'); }, + async loadLatest() { assert.fail('unexpected'); }, + async close() {}, + }; + const controller = createDesktopTranscriptRangeController(store, async () => handle); + acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2)]); + assert.equal(store.range().hasOlder, true); + + await controller.loadBefore(); + await controller.loadBefore(); + await controller.loadBefore(); + assert.equal(reads, 1, 'the same edge is not read twice'); + + // Anything that moves the edge makes it worth asking again. + assert.equal(store.retain(2, 2), true); + await controller.loadBefore(); + assert.equal(reads, 2); + await controller.close(); +}); + +test('a fill anchored on the window a navigation replaced cannot splice onto it', () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + acceptSnapshot(store, undefined, 'generation-1', [record(8), record(9)]); + const navigating = store.navigate(); + acceptSnapshot(store, navigating, 'generation-1', [record(1), record(2)]); + for (const batch of encodeDesktopTranscriptPage(identity, { + durableThrough: 9, durable: [{ sequence: 9, message: record(9).message }], hasNewer: false, + }, { direction: 'newer', anchor: 9 })) store.accept(batch); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1', 'message-2']); +}); + +test('a navigation outlives the band trimming the window it was issued under', () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2)]); + const navigating = store.navigate(); + assert.equal(store.retain(2, 2), true); + const replacement = [...encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, + durable: [ + { sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }, + { sequence: 1, message: record(1).message }, + ], + overlay: [], hasOlder: false, hasNewer: false, + }, navigating)]; + assert.ok(replacement.length > 1, 'the replacement has to span more than its reset batch'); + for (const batch of replacement) store.accept(batch); + assert.equal(store.range().ready, true); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-0', 'message-1']); + // The fill the band left in flight is anchored on an edge nothing here has. + for (const batch of encodeDesktopTranscriptPage(identity, { + durableThrough: 3, durable: [{ sequence: 3, message: record(3).message }], hasNewer: false, + }, { direction: 'newer', anchor: 2 })) store.accept(batch); + assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [0, 1]); + assert.equal(store.range().hasNewer, true, 'the watermark it carried still moved'); +}); + +test('a page anchored on an edge the band has since dropped is refused', () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2), record(3)]); + assert.equal(store.retain(3, 3), true); + for (const batch of encodeDesktopTranscriptPage(identity, { + durableThrough: 3, durable: [{ sequence: 0, message: record(0).message }], + hasOlder: false, + }, { direction: 'older', anchor: 1 })) store.accept(batch); + // Installing it would leave 1..2 missing between the answer and the window, + // and no edge cursor can name a hole in the middle. + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-3']); + assert.equal(store.range().hasOlder, true); +}); + +test('follow latest invalidates an in-flight history navigation before open resolves', async () => { const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); const opening = deferred(); - const requests: Array<{ generation: string; anchor: number | null; navigation?: DesktopTranscriptNavigation }> = []; + const requests: Array<{ command: 'around' | 'latest'; anchor: number | null; navigation: number }> = []; const handle = (generation: string): DesktopTranscriptHandle => ({ - ...identity, generation, readThroughMessageId: null, + ...identity, generation, readThroughMessageId: null, acknowledgeTail: async () => {}, async loadBefore() { assert.fail('an obsolete history request was replayed'); }, async loadAfter() { assert.fail('an obsolete newer request was replayed'); }, async loadAround(anchor, _bytes, navigation) { - requests.push({ generation, anchor, navigation }); - acceptSnapshot(store, navigation?.navigationVersion ?? 0, generation, [record(1)]); + requests.push({ command: 'around', anchor, navigation }); + acceptSnapshot(store, navigation, generation, [record(0)]); + }, + async loadLatest(navigation) { + requests.push({ command: 'latest', anchor: null, navigation }); + acceptSnapshot(store, navigation, generation, [record(1)]); }, async close() {}, }); - 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, navigation: 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; const historyResult = new Promise((_resolve, reject) => { rejectHistory = reject; }); const controller = createDesktopTranscriptRangeController(store, async () => ({ - ...identity, readThroughMessageId: null, + ...identity, readThroughMessageId: null, acknowledgeTail: async () => {}, 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, identity.generation, [record(1)]); }, async close() {}, })); @@ -234,7 +416,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 +451,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.navigation === 1 && !releaseAcks) { blocked.push(batch); firstOldBatch.resolve(); } else queueMicrotask(() => ack(batch)); @@ -277,99 +459,89 @@ 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, navigation: 1, }; - store.expectNavigation(1); + store.navigate(); 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.navigate(); + const following = observer.loadTranscriptLatest({ ...request, navigation: 2, anchorSequence: null }, 1); releaseAcks = true; for (const batch of blocked) ack(batch); await Promise.all([history, following]); assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); const snapshot = store.snapshot(); - for (const batch of blocked) assert.equal(store.accept(batch), false); + // Replaying the reset of the answer the reader navigated away from: it names + // a navigation that is over, so it cannot reinstall the window it was read for. + for (const batch of blocked.filter(({ reset }) => reset)) { + assert.equal(store.accept(batch), false); + } assert.strictEqual(store.snapshot(), snapshot); await observer.close(); }); -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 fill in flight does not discard the replacement it was issued under', 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, navigation: 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.navigate(); + const navigation = observer.loadTranscriptAround(request, 1); + await entered.promise; + // A fill issued while the jump is still reading names the same navigation: + // it extends the window, and nothing about it abandons the jump. + const filling = observer.loadTranscriptBefore({ ...request, anchorSequence: 1 }, 1); + release.resolve(); + await Promise.all([navigation, filling]); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-0']); + await observer.close(); }); -} -} const identity = { sessionId: 'session-1', hostEpoch: 'host-1', generation: 'generation-1' }; -function acceptSnapshot(store: DesktopTranscriptRangeStore, navigationVersion: number, generation: string, records: Array>) { +function acceptSnapshot(store: DesktopTranscriptRangeStore, navigation: number | undefined, generation: string, records: Array>) { for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, navigationVersion, generation, durableThrough: 1, + ...identity, generation, durableThrough: 1, durable: records.map(({ identity: sequence, message }) => ({ sequence, message })), overlay: [], hasOlder: true, hasNewer: false, - })) store.accept(batch); + }, navigation)) store.accept(batch); } function record(identity: number) { const message: StoredMessage = { type: 'assistant', id: `message-${identity}`, turnId: `turn-${identity}`, ts: 1, text: String(identity), modelId: 'test' }; @@ -385,9 +557,3 @@ function continuitySnapshot() { projectionRevision: 1, rootTurn: null, goal: null, queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] } }; } -function deferred() { - let resolve!: (value: T | PromiseLike) => void; - 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..ba7289008f 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,9 +32,12 @@ 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 { encodeDesktopTranscriptChange, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; +import { + encodeDesktopTranscriptChange, + encodeDesktopTranscriptPage, + encodeDesktopTranscriptSnapshot, +} from '../desktop-transcript-ipc.js'; import { DesktopTranscriptReplica, type DesktopTranscriptReplicaChange } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; import { openTranscriptNavigationLedger } from './transcript-navigation-test-fixture.js'; @@ -48,38 +51,82 @@ 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 reads the completed Turn back through its own edge', 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; + assert.ok(oldest !== null); + renderer.retain(oldest, oldest); + assert.equal(renderer.range().hasNewer, true); + assert.equal( + renderer.snapshot().messages.some(({ id }) => id === 'answer-b'), false, + 'the overlay is a fact about the tail, and this window no longer reaches it', + ); + + await fixture.advance(B_COMPLETED_THROUGH); + await fixture.advance(C_COMPLETED_THROUGH); + + assert.deepEqual( + renderer.durableEntries().map(({ sequence }) => sequence), [oldest], + 'tail growth has nothing to join onto, so the window stays the range it was trimmed to', + ); + assert.equal(renderer.range().hasNewer, true); + + // Paging back: each read is anchored on the edge the last one left, which + // is the only thing that makes the rows spliceable. + for (let read = 0; read < 8 && renderer.range().hasNewer; read += 1) { + const anchor = renderer.range().newestSequence; + const page = await replica.loadAfter(anchor, PAGE_BYTES); + assert.ok(page); + for (const batch of encodeDesktopTranscriptPage({ + sessionId: replica.sessionId, + generation: replica.generation, + hostEpoch: replica.hostEpoch, + }, page, { direction: 'newer', anchor })) renderer.accept(batch); + } + + assert.deepEqual( + renderer.snapshot().messages.flatMap((message) => + message.type === 'assistant' && message.turnId === 'b' ? [message.text] : []), + ['B partial and completed answer'], + 'reading forward from the edge brings the completed body back', + ); + 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 +154,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 +296,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 +309,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 +341,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 +348,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-parked-completion-probe.test.ts b/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts new file mode 100644 index 0000000000..413fd09ff8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { StoredMessage } from '@maka/core/session'; +import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; +import { + encodeDesktopTranscriptChange, + encodeDesktopTranscriptPage, + encodeDesktopTranscriptSnapshot, +} from '../desktop-transcript-ipc.js'; + +const identity = { sessionId: 'session-1', hostEpoch: 'host-1', generation: 'generation-1' }; +const message = (sequence: number, text = String(sequence)): StoredMessage => + ({ type: 'assistant', id: `message-${sequence}`, turnId: `turn-${sequence}`, ts: 1, text, modelId: 'test' }); + +test('a Turn completing at the tail does not splice into a window parked far from it', () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + // A jump to 5 during a run: loadAround's snapshot carries the live overlay (21). + const navigation = store.navigate(); + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 20, + durable: [{ sequence: 5, message: message(5) }, { sequence: 6, message: message(6) }], + overlay: [message(21, 'partial')], hasOlder: true, hasNewer: true, + }, navigation)) store.accept(batch); + // 21 completes; the tail broadcast carries its durable row. + for (const batch of encodeDesktopTranscriptChange(identity, { + coversFrom: 20, durableThrough: 21, + durableUpserts: [{ sequence: 21, message: message(21, 'partial and completed') }], + })) store.accept(batch); + // 7..20 are not in the window, so 21 cannot join its durable range contiguously. + assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [5, 6]); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-5', 'message-6']); + + // Reading forward to the tail is what brings 21 in. It arrives once, as the + // completed durable row: seeing that row retired the overlay the jump + // installed, even though the window could not keep it at the time. + for (const batch of encodeDesktopTranscriptPage(identity, { + durableThrough: 21, hasOlder: true, hasNewer: false, + durable: Array.from({ length: 15 }, (_, index) => ({ + sequence: index + 7, + message: message(index + 7, index === 14 ? 'partial and completed' : undefined), + })), + }, { direction: 'newer', anchor: 6 })) store.accept(batch); + const ids = store.snapshot().messages.map(({ id }) => id); + assert.deepEqual(ids.slice(-2), ['message-20', 'message-21']); + assert.equal(ids.length, 17, 'the settled overlay is gone, so 21 is shown once'); +}); diff --git a/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts b/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts new file mode 100644 index 0000000000..935ec21439 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { StoredMessage } from '@maka/core/session'; +import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol'; +import type { DesktopTranscriptBatch, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; +import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; +import { RuntimeHostSessionObserver } from '../runtime-host-session-observer.js'; +import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; + +// Drives the real observer, replica and Renderer store with a fake Host whose +// jump read (loadAround) is held until a fill has been queued behind it. +const PAGE_BYTES = 128 * 1024; +const THROUGH = 20; + +async function harness() { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + const eventsClosed = deferred(); + const aroundEntered = deferred(); + const releaseAround = deferred(); + const bootstrap = page(); + const decoded = new Map>; nextCursor: string | null }>([ + [bootstrap, { messages: [record(18), record(19), record(20)], nextCursor: 'older' }], + ]); + const observer = new RuntimeHostSessionObserver({ + client: { openSession: async () => runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, + transcriptBootstrap: { throughSequence: THROUGH, overlayMessageCount: 0, durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' } }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (candidate) => decoded.get(candidate)!, + loadTranscriptPage: async (request) => { + const candidate = page(); + if (request.direction === 'newer') { + aroundEntered.resolve(); + await releaseAround.promise; + decoded.set(candidate, { messages: [record(5), record(6)], nextCursor: 'newer' }); + } else if (request.maxBytes === 1) { + decoded.set(candidate, { messages: [record(4)], nextCursor: 'older' }); + } else { + decoded.set(candidate, { messages: [record(request.anchorSequence! - 1)], nextCursor: 'older' }); + } + return candidate; + }, + async close() { eventsClosed.resolve(); }, + }) }, + emitSessionsChanged() {}, + }); + const ack = (batch: DesktopTranscriptBatch) => observer.acknowledgeTranscript('consumer-1', batch.generation, batch.deliverySequence, 1); + await observer.openTranscript('session-1', 'consumer-1', { + id: 1, once() {}, off() {}, + send(_channel, batch) { store.accept(batch); queueMicrotask(() => ack(batch)); }, + }); + const request = (navigation: number, anchorSequence: number): DesktopTranscriptRangeRequest => ({ + consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', anchorSequence, maxBytes: PAGE_BYTES, navigation, + }); + const sequences = () => store.durableEntries().map(({ sequence }) => sequence); + return { store, observer, request, sequences, aroundEntered, releaseAround }; +} + +test('a fill issued while a jump is pending does not splice the old edge onto the new window', async () => { + const h = await harness(); + try { + assert.deepEqual(h.sequences(), [18, 19, 20]); + const navigation = h.store.navigate(); + const jump = h.observer.loadTranscriptAround(h.request(navigation, 5), 1); + await h.aroundEntered.promise; + // Anchored on the window still on screen (18), under the jump's navigation. + const fill = h.observer.loadTranscriptBefore(h.request(navigation, h.store.range().oldestSequence!), 1); + h.releaseAround.resolve(); + await Promise.all([jump, fill]); + assert.deepEqual(h.sequences(), [5, 6]); + } finally { + await h.observer.close(); + } +}); + +test('a trim and a fill while a jump is pending do not make Main drop the jump', async () => { + const h = await harness(); + try { + const navigation = h.store.navigate(); + const jump = h.observer.loadTranscriptAround(h.request(navigation, 5), 1); + await h.aroundEntered.promise; + assert.equal(h.store.retain(19, 20), true); + const fill = h.observer.loadTranscriptBefore(h.request(navigation, h.store.range().oldestSequence!), 1); + h.releaseAround.resolve(); + await Promise.all([jump, fill]); + assert.ok(h.sequences().includes(5), `the jump target never arrived: ${JSON.stringify(h.sequences())}`); + } finally { + await h.observer.close(); + } +}); + +function record(identity: number) { + const message: StoredMessage = { type: 'assistant', id: `message-${identity}`, turnId: `turn-${identity}`, ts: 1, text: String(identity), modelId: 'test' }; + return { identity, message }; +} +function page(): SessionTranscriptPage { + return { kind: 'page', sessionId: 'session-1', source: 'durable', direction: 'older', throughSequence: THROUGH, + rawBytes: 1, fragments: [], rangeBoundarySequence: null, protectedTurnSequence: null, nextCursor: null }; +} +function continuitySnapshot() { + return { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { sessionId: 'session-1', metadataRevision: 1, status: 'running' as const, createdAt: 1, isArchived: false }, + projectionRevision: 1, rootTurn: null, goal: null, + queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] } }; +} diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index 8b16db2505..6ddbb6a8df 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,14 @@ 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 } 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, + TranscriptReadSupersededError, } from '../../renderer/features/conversation/index.js'; import { createTranscriptRestoreLifecycle, @@ -46,21 +46,24 @@ 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: number }> = []; + const publish = (sequence: number | null, navigation: number) => { + requests.push({ sequence, navigation }); + const turnId = sequence === null ? 'b' : 'a'; + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', + durableThrough: 20, + durable: [{ sequence: sequence ?? 20, message: { + type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', + } }], overlay: [], hasOlder: true, hasNewer: sequence !== null, + }, navigation)) store.accept(batch); + }; const handle: DesktopTranscriptHandle = { sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, + acknowledgeTail: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, close: async () => {}, - async loadAround(sequence, _maxBytes, navigation) { - 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,16 +86,16 @@ 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]), [[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, + durableThrough: 20, durable: [{ sequence: 10, message: { type: 'assistant', id: 'answer-a', turnId: 'a', text: 'a', ts: 1, modelId: 'fixture', } }], overlay: [], hasOlder: false, hasNewer: true, - })) assert.equal(store.accept(batch), false); + }, 1)) assert.equal(store.accept(batch), false); assert.strictEqual(store.snapshot(), latest, 'a late history response must not replace the latest range'); } finally { opening.resolve(handle); @@ -100,100 +103,22 @@ 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', + 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, + acknowledgeTail: async () => {}, 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 +126,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,173 +136,143 @@ 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 () => { +for (const known of [true, false]) { +test(`a bookmark ${known ? 'survives' : 'cannot outlive'} a Host epoch change`, async () => { const fixture = controllerFixture(); - const first = deferred(); - const replacement = deferred(); - fixture.controller.loadBefore = () => first.promise; + const landmarks = known ? [{ turnId: 'turn-t', sequence: 77, label: 'T' }] : []; + let range = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + fixture.controller.store.range = () => range; + fixture.props.listTurnLandmarks = async () => ({ throughSequence: 80, landmarks }); + const loaded: number[] = []; + fixture.controller.loadAround = async (sequence: number) => { loaded.push(sequence); }; await fixture.render(); - const loadingFirst = fixture.commands.current!.loadHistory('earlier'); + fixture.props.sessionUi.setTranscriptReadingAnchor('session-1', { turnId: 'turn-t', sequence: 10 }); - 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' }) }, - }; + range = { sessionId: 'session-1', generation: 'generation-2', hostEpoch: 'host-2' }; + fixture.props.messages = []; await fixture.render(); - fixture.props.currentSessionId.current = 'session-1'; - fixture.props.sessionId = 'session-1'; - fixture.props.rangeController.current = { - ...fixture.controller, - loadBefore: () => replacement.promise, + await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); + + // The old epoch's sequence names a different row, so only the Turn resolved + // in the new epoch may be navigated to. + assert.deepEqual(loaded, known ? [77] : []); + assert.deepEqual( + fixture.props.sessionUi.transcriptReadingAnchorBySessionRef.current['session-1'], + { turnId: 'turn-t', sequence: known ? 77 : 10 }, + ); +}); +} + +test('a read superseded by a Host epoch change leaves the bookmark alone', async () => { + const sessionId = 'session-1'; + const lifecycle = createTranscriptRestoreLifecycle(); + const controller = { + store: { + sessionId, + range: () => ({ sessionId }), + sequenceForTurn: () => null, + newestDurableUserSequence: () => null, + snapshot: () => ({ messages: [] }), + }, + loadAround: async () => { + throw new TranscriptReadSupersededError('Desktop transcript host epoch changed; reopen the transcript'); + }, + }; + restoreSessionTranscriptRange({ + lifecycle, sessionId, controller, readingAnchor: { turnId: 'turn-t', sequence: 10 }, + isCurrent: () => true, + setReadingAnchor: () => assert.fail('a superseded read must not clear the bookmark'), + onRestoreUnavailable: () => assert.fail('a superseded read decides nothing about the bookmark'), + onError: (error) => assert.fail(String(error)), + }); + await new Promise((resolve) => setImmediate(resolve)); +}); + +test('retaining the reader window trims the store to the visible Turns', async () => { + const fixture = controllerFixture(); + const retained: Array<[number | null, number | null]> = []; + fixture.controller.store.sequenceForTurn = (turnId: string, edge?: 'first' | 'last') => + turnId === 'first' ? 10 : turnId === 'last' ? (edge === 'last' ? 21 : 20) : null; + fixture.controller.store.retain = (oldest, newest) => { + retained.push([oldest, newest]); + return true; }; 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() { const { root } = installReactRenderer(); const commands = createRef(); const controller = { - loadAround: async () => {}, - loadBefore: async () => {}, - loadAfter: async () => {}, + loadAround: async (_sequence: number) => {}, + loadBefore: async () => true, + loadAfter: async () => true, 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 +285,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..f805ffb9bc 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); @@ -249,11 +242,12 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { addTurn('latest', 1800, 1200); let reads = 0; const controller = { - loadAround: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, - loadLatest: async () => { reads += 1; }, setReadingAnchor: async (_sequence: number | null, _turnId?: string) => {}, + loadAround: async () => {}, loadBefore: async () => true, loadAfter: async () => true, + 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 a1bc6ea29e..991ed66b73 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 @@ -29,8 +29,9 @@ import type { StoredMessage } from '@maka/core/session'; import type { MakaBridge } from '../../preload/bridge-contract.js'; import type { DesktopTranscriptBatch, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; import { createDesktopWorkHubServices } from '../../renderer/platform/desktop/create-workhub-services.js'; +import type { WorkHubTranscriptSnapshot } from '../../renderer/features/workhub/index.js'; import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; -import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import type { AttachmentRef } from '@maka/core/events'; import { MESSAGE_QUEUE_MAX_ENTRIES } from '@maka/runtime-host/protocol'; @@ -142,7 +143,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, durable: [{ sequence: 1, message: result }], })) onBatch({ ...batch, deliverySequence: 1 }); return { ...snapshot, readThroughMessageId: result.id, @@ -162,6 +163,69 @@ test('WorkHub projects the exact delegated Turn status and bounded assistant res }]); }); +test('delegation feedback does not advance the target Session read marker', async (t) => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); + t.after(() => { + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); + else Reflect.deleteProperty(globalThis, 'window'); + }); + const sessionId = desktopSessionKey({ hostId: 'owner-host', sessionId: 'target-session' }); + const result: StoredMessage = { + type: 'assistant', id: 'answer', turnId: 'owned-turn', ts: 3, + modelId: 'model', text: 'The delegated task finished with this exact result.', + }; + const later: StoredMessage = { + type: 'user', id: 'later', turnId: 'next-turn', ts: 4, text: 'A later turn nobody has read.', + }; + const acknowledged: number[] = []; + const services = createDesktopWorkHubServices({ + attachments: {}, + sessions: { + async list() { + return [{ + id: sessionId, name: 'Target task', isFlagged: false, isArchived: false, + labels: [], hasUnread: true, status: 'active', runningTurnIds: [], revision: 1, + }]; + }, + async listTurns() { + return [{ turnId: 'owned-turn', firstSequence: 1, status: 'completed', statusSource: 'recorded' }]; + }, + async queryMessageExecutions() { + return { resolutions: [{ messageId: 'delegated-message', state: 'owned', turnId: 'owned-turn', runId: 'run' }] }; + }, + }, + transcripts: { + async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { + // The real open answers over IPC, so its first batches reach a consumer + // that is already listening. + await Promise.resolve(); + const snapshot = { + sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', + durableThrough: 2, overlay: [], hasOlder: false, hasNewer: false, + }; + for (const batch of encodeDesktopTranscriptSnapshot({ + ...snapshot, + durable: [{ sequence: 1, message: result }, { sequence: 2, message: later }], + })) onBatch({ ...batch, deliverySequence: 1 }); + return { + ...snapshot, readThroughMessageId: later.id, + async acknowledgeTail(through: number) { acknowledged.push(through); }, + loadBefore: async () => undefined, loadAfter: async () => undefined, + loadAround: async () => undefined, close: async () => undefined, + }; + }, + }, + } as unknown as Parameters[0]); + + assert.equal((await services.delegationFeedback([{ + id: 'delegation-record', targetSessionId: sessionId, + targetMessageId: 'delegated-message', targetTurnId: 'initial-turn', + }]))[0]?.resultPreview, result.text); + await new Promise((resolve) => { setImmediate(resolve); }); + assert.deepEqual(acknowledged, [], 'a result projection is not a reader of the target Session'); +}); + test('WorkHub proves a long historical Turn tail before caching its final result', async (t) => { const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); @@ -204,31 +268,38 @@ 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, + navigation: number | undefined, durable: Array<{ sequence: number; message: StoredMessage }>, hasOlder: boolean, hasNewer: boolean, ) => { for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 4, overlay: [], hasOlder, hasNewer, navigationVersion, durable, - })) onBatch({ ...batch, deliverySequence: ++deliverySequence }); + durableThrough: 4, overlay: [], hasOlder, hasNewer, durable, + }, navigation)) onBatch({ ...batch, deliverySequence: ++deliverySequence }); }; - emit(0, [{ sequence: 4, message: { ...next, id: 'tail', ts: 4 } }], true, false); + emit(undefined, [{ sequence: 4, message: { ...next, id: 'tail', ts: 4 } }], true, false); return { sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', durableThrough: 4, hasOlder: true, hasNewer: false, readThroughMessageId: 'tail', loadBefore: async () => undefined, - async loadAround(_sequence: number | null, _maxBytes: number | undefined, navigation: { navigationVersion: number }) { - emit(navigation.navigationVersion, [{ sequence: 1, message: intermediate }], false, true); + async loadAround(_sequence: number | null, _maxBytes: number | undefined, navigation: number) { + emit(navigation, [{ sequence: 1, message: intermediate }], false, true); }, - async loadAfter(anchor: number | null, _maxBytes: number | undefined, navigation: { navigationVersion: number }) { + async loadAfter(anchor: number | null, _maxBytes: number | undefined, navigation: number) { loadAfters += 1; assert.equal(anchor, 1); - emit(navigation.navigationVersion, [ - { sequence: 2, message: final }, - { sequence: 3, message: next }, - ], false, true); + // An extension splices onto the window; only a navigation replaces it. + for (const batch of encodeDesktopTranscriptPage({ + sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', + navigation, + }, { + durableThrough: 4, hasNewer: true, + durable: [ + { sequence: 2, message: final }, + { sequence: 3, message: next }, + ], + }, { direction: 'newer', anchor })) onBatch({ ...batch, deliverySequence: ++deliverySequence }); }, close: async () => undefined, }; @@ -309,8 +380,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 +421,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, durable: [] })) { deliver(batch); } return { kind: 'ready', value: { ...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 +434,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, durable: [{ sequence: 7, message }], - })) { + }, request.navigation)) { 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 +502,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]!.navigation, 1); assert.equal(requests[0]!.anchorSequence, null); assert.deepEqual(partialProjectionCounts, [1, 1]); assert.deepEqual(projections, [[], ['latest-message']]); @@ -485,18 +551,19 @@ 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 = (navigation?: number) => { for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, navigationVersion, + ...snapshot, 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 }); + }, navigation)) onBatch({ ...batch, deliverySequence: 1 }); }; deliver(); 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), + acknowledgeTail: async () => {}, + loadBefore: unavailable, loadAfter: unavailable, loadLatest: unavailable, + loadAround: cached ? unavailable : async (_sequence, _maxBytes, navigation) => deliver(navigation), close: async () => { closedCount++; }, }; }, @@ -521,3 +588,77 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] } }); } + +test('WorkHub fills and trims its transcript window through the reader band', async (t) => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); + t.after(() => { + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); + else Reflect.deleteProperty(globalThis, 'window'); + }); + const sessionId = desktopSessionKey({ hostId: 'owner-host', sessionId: 'coordination' }); + const identity = { sessionId: 'coordination', generation: 'generation-1', hostEpoch: 'epoch-1' }; + const row = (sequence: number, turnId: string): { sequence: number; message: StoredMessage } => ({ + sequence, message: { type: 'user', id: `message-${sequence}`, turnId, ts: sequence, text: `Record ${sequence}` }, + }); + let deliverySequence = 0; + let newerReads = 0; + let olderReads = 0; + let snapshots: WorkHubTranscriptSnapshot[] = []; + const services = createDesktopWorkHubServices({ + attachments: {}, + transcripts: { + async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 4, overlay: [], hasOlder: true, hasNewer: true, + durable: [row(2, 'turn-a'), row(3, 'turn-b')], + })) onBatch({ ...batch, deliverySequence: ++deliverySequence }); + return { + ...identity, durableThrough: 4, hasOlder: true, hasNewer: true, readThroughMessageId: 'message-3', + acknowledgeTail: async () => {}, + loadBefore: async () => { olderReads += 1; }, + loadAround: async () => {}, + loadLatest: async () => {}, + async loadAfter(anchor: number | null, _maxBytes: number | undefined, navigation: number) { + newerReads += 1; + assert.equal(anchor, 3); + for (const batch of encodeDesktopTranscriptPage( + { ...identity, navigation }, + { durableThrough: 4, hasNewer: false, durable: [row(4, 'turn-c')] }, + { direction: 'newer', anchor }, + )) onBatch({ ...batch, deliverySequence: ++deliverySequence }); + }, + close: async () => undefined, + }; + }, + } satisfies Pick, + } as unknown as Parameters[0]); + const handle = await services.openTranscript( + sessionId, + (snapshot) => { snapshots.push(snapshot); }, + new AbortController().signal, + (error) => { throw error; }, + ); + const latest = () => snapshots.at(-1)!; + try { + await waitFor(() => latest()?.ready === true, { timeoutMs: 5_000 }); + assert.deepEqual(latest().messages.map(({ turnId }) => turnId), ['turn-a', 'turn-b']); + assert.equal(await handle.prefetchHistory('newer'), true); + assert.deepEqual(latest().messages.map(({ turnId }) => turnId), ['turn-a', 'turn-b', 'turn-c']); + assert.equal(latest().hasNewer, false); + assert.equal(await handle.prefetchHistory('newer'), false, 'a window at the tail has no newer edge to read'); + assert.equal(newerReads, 1); + assert.equal(await handle.prefetchHistory('older'), true); + assert.equal(await handle.prefetchHistory('older'), false, 'the same window answers an older read the same way'); + assert.equal(olderReads, 1); + snapshots = []; + handle.retain({ firstTurnId: 'turn-b', lastTurnId: 'turn-c' }); + assert.deepEqual(latest().messages.map(({ turnId }) => turnId), ['turn-b', 'turn-c']); + assert.equal(latest().hasOlder, true, 'a trimmed edge becomes history again'); + // A trim moves the window, so the edge it re-opened is worth asking again. + assert.equal(await handle.prefetchHistory('older'), true); + assert.equal(olderReads, 2); + } finally { + await handle.close(); + } +}); diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index 67edc84ba1..8d3e4c705a 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -44,6 +44,8 @@ async function mountController(failFirstRead = false) { let publish!: (snapshot: WorkHubTranscriptSnapshot) => void; let observe!: Parameters[1]; let loadLatestCount = 0; + const prefetched: Array<'older' | 'newer'> = []; + const retained: Array<{ firstTurnId: string; lastTurnId: string }> = []; let admission = deferred<{ turnId: string }>(); const latestRead = deferred(); const requests: Array[1]> = []; @@ -94,7 +96,13 @@ async function mountController(failFirstRead = false) { if (failFirstRead && openCount === 1) throw new Error('transient initial read failure'); publish = handler; handler({ messages: [], ready: true, hasOlder: false, hasNewer: false }); - return { observationChanged: () => {}, loadOlder: async () => {}, loadLatest: () => { loadLatestCount += 1; return latestRead.promise; }, close: async () => {} }; + return { + observationChanged: () => {}, + prefetchHistory: async (edge: 'older' | 'newer') => { prefetched.push(edge); return true; }, + retain: (window: { firstTurnId: string; lastTurnId: string }) => { retained.push(window); }, + loadLatest: () => { loadLatestCount += 1; return latestRead.promise; }, + close: async () => {}, + }; }, retractQueueEntry: async (...input: Parameters) => { queueMutations.push(['retract', ...input]); }, promoteQueueEntry: async (...input: Parameters) => { queueMutations.push(['promote', ...input]); }, @@ -125,6 +133,7 @@ async function mountController(failFirstRead = false) { resetAdmission() { admission = deferred<{ turnId: string }>(); }, admit(turnId: string) { rootTurn = { turnId, runId: `run:${turnId}`, status: 'running' }; }, get loadLatestCount() { return loadLatestCount; }, + prefetched, retained, emit(event: Parameters[0]) { observe(event); }, publish(messages: StoredMessage[]) { publish({ messages, ready: true, hasOlder: false, hasNewer: false }); }, }; @@ -521,6 +530,32 @@ test('WorkHub defaults to follow-up and moves each message into its admitted suc h.latestRead.resolve(); }); +test('a queued follow-up returns the window to the tail so its own retry guard can clear', async () => { + const h = await mountController(); + await act(() => h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' })); + h.setSteerResult('unknown'); + await act(async () => { assert.equal(await h.controller.send('queued while parked in history', []), false); }); + const messageId = h.steers[0]![1]; + assert.equal(h.loadLatestCount, 1, 'an uncertain enqueue still has to reach the tail to be observed'); + await act(async () => { assert.equal(await h.controller.send('a different follow-up', []), false); }); + assert.equal(h.steers.length, 1, 'an unobserved attempt refuses the next follow-up'); + await act(() => h.publish([{ type: 'user', id: messageId, turnId: 'successor', text: 'queued while parked in history', ts: 2 }])); + h.setSteerResult('admitted'); + await act(async () => { assert.equal(await h.controller.send('a different follow-up', []), true); }); + assert.equal(h.steers.length, 2, 'the observed row releases the guard'); + h.latestRead.resolve(); +}); + +test('the transcript band reaches WorkHub’s window', async () => { + const h = await mountController(); + assert.equal(await h.controller.prefetchHistory('older'), true); + assert.equal(await h.controller.prefetchHistory('newer'), true); + assert.deepEqual(h.prefetched, ['older', 'newer']); + h.controller.retainWindow({ firstTurnId: 'turn-b', lastTurnId: 'turn-c' }); + assert.deepEqual(h.retained, [{ firstTurnId: 'turn-b', lastTurnId: 'turn-c' }]); + h.latestRead.resolve(); +}); + test('follow-up admission before an uncertain response keeps its successor placement', async () => { const h = await mountController(); await act(() => h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' })); diff --git a/apps/desktop/src/main/desktop-transcript-ipc.ts b/apps/desktop/src/main/desktop-transcript-ipc.ts index 26c7efb2bc..1559c7abf5 100644 --- a/apps/desktop/src/main/desktop-transcript-ipc.ts +++ b/apps/desktop/src/main/desktop-transcript-ipc.ts @@ -21,16 +21,18 @@ import type { StoredMessage } from '@maka/core/session'; import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, type DesktopTranscriptBatchPayload, + type DesktopTranscriptExtension, type DesktopTranscriptFragment, } from '../preload/transcript-contract.js'; import type { DesktopSequencedTranscriptMessage, DesktopTranscriptReplicaChange, + DesktopTranscriptReplicaPage, DesktopTranscriptReplicaSnapshot, } from './desktop-transcript-replica.js'; interface TranscriptBatchIdentity { - readonly navigationVersion?: number; + readonly navigation?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; @@ -40,40 +42,54 @@ 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 extends?: DesktopTranscriptExtension; + readonly coversFrom?: number | null; readonly reset: boolean; } export function encodeDesktopTranscriptSnapshot( snapshot: DesktopTranscriptReplicaSnapshot, + navigation?: number, ): Iterable { - return encodeDesktopTranscriptBatches(snapshot, { + return encodeDesktopTranscriptBatches({ ...snapshot, navigation }, { 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, + extension: DesktopTranscriptExtension, +): Iterable { + return encodeDesktopTranscriptBatches(identity, { + durableThrough: page.durableThrough, + durable: page.durable, + overlay: [], + hasOlder: page.hasOlder, + hasNewer: page.hasNewer, + extends: extension, + reset: false, + }); +} + export function encodeDesktopTranscriptChange( identity: TranscriptBatchIdentity, - change: DesktopTranscriptReplicaChange, + // A merge that had to drop rows carries no `coversFrom` at all: it claims + // nothing about adjacency and only moves the watermark. + change: Omit & { readonly coversFrom?: number | null }, ): Iterable { return encodeDesktopTranscriptBatches(identity, { durableThrough: change.durableThrough, durable: change.durableUpserts, overlay: [], - evictedDurableSequences: change.evictedDurableSequences, - completedOverlayMessageIds: change.completedOverlayMessageIds, - hasOlder: change.hasOlder, - hasNewer: change.hasNewer, + coversFrom: change.coversFrom, reset: false, }); } @@ -84,15 +100,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 +113,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.navigation === undefined ? {} : { navigation: identity.navigation }), + ...(content.extends === undefined ? {} : { extends: content.extends }), + ...(content.coversFrom === undefined ? {} : { coversFrom: content.coversFrom }), + 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..09513df657 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,6 @@ export interface DesktopSequencedTranscriptMessage { } export interface DesktopTranscriptReplicaSnapshot { - readonly navigationVersion?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; @@ -66,19 +64,35 @@ 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. `coversFrom` is the watermark + * the read that produced these rows started at; `null` means the read started + * at the beginning of the transcript. + */ export interface DesktopTranscriptReplicaChange { + readonly coversFrom: number | null; 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 +112,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 +131,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 +159,6 @@ export class DesktopTranscriptReplica { }); replica.#evictToBudget( undefined, - 'oldest', handle.transcriptBootstrap.durable.protectedTurnSequence ?? replica.#durableThrough ?? undefined, @@ -202,7 +207,7 @@ export class DesktopTranscriptReplica { durable: this.#orderedDurable(false), overlay: [...this.#overlay.values()], hasOlder: this.#hasOlder, - hasNewer: this.#hasNewer, + hasNewer: false, }; } @@ -233,271 +238,160 @@ 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)); + /** + * Reads the newest page back into the tail cache when global reclaim has + * trimmed it below a tail. Follow-tail is answered from this cache, so + * without the refill a reader returning to latest is shown whatever reclaim + * happened to leave — down to nothing. + */ + refillTail(maxBytes: number, isCurrent: () => boolean = () => true): Promise { + return this.#enqueue(async () => { + if (!this.#isLive() || !isCurrent() || !this.#tailIsShort()) return; + const throughSequence = this.#durableThrough; + if (throughSequence === null) return; + const page = await this.#handle.loadTranscriptPage({ + source: 'durable', + direction: 'older', + throughSequence, + cursor: null, + anchorSequence: throughSequence + 1, + maxBytes, + }); + await this.#withDecodedPage(page, (decoded) => { + if (!this.#isLive() || !isCurrent()) return; + this.#acceptRange(decoded.messages); + this.#installDurable(decoded.messages); + this.#hasOlder = decoded.nextCursor !== null; + this.#evictToBudget( + undefined, + page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, + ); + }); + }); } - 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); + /** + * Whether the cache holds less than the tail it is meant to hold. `#hasOlder` + * settles the case a Turn count cannot: a short Session whose whole durable + * transcript is resident is never short, however few Turns that is. + */ + #tailIsShort(): boolean { + if (!this.#hasOlder) return false; + const turns = new Set(); + for (const entry of this.#durable.values()) turns.add(residentTurnKey(entry)); + return turns.size < this.#maxResidentTurns; } - 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 +408,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 +416,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 +447,18 @@ 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; + // What each publish is spliceable onto: where the read that produced it + // started, which is the watermark the previous publish ended at. + let published = anchorSequence; do { - if (!this.#isNavigationCurrent(token)) return; + if (!this.#isLive()) return; const page: SessionTranscriptPage = await this.#handle.loadTranscriptPage({ source: 'durable', direction: 'newer', @@ -594,7 +468,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 +485,24 @@ 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); + // The watermark moves with every page, not only at the end: a window + // opening mid-catch-up takes a snapshot whose rows must agree with the + // `durableThrough` it names, or the next change cannot join it. + const through = decoded.messages.at(-1)?.identity ?? published; + if (through !== null) this.#durableThrough = through; + this.#publish(published, through, decoded.messages); + published = through; 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(published, target, []); } } @@ -660,73 +515,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 +536,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( @@ -779,114 +574,60 @@ export class DesktopTranscriptReplica { } #publish( + coversFrom: number | null, + durableThrough: number | null, messages: readonly { 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 { - 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, - }; + this.#onChange(this, { + coversFrom, + durableThrough, + // 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 +639,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 +693,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 +707,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 e32b2d7e40..1bf4d099f5 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 @@ -64,7 +64,10 @@ import { type RuntimeHostSessionObserverTarget, type RuntimeHostTranscriptTarget, } from "./runtime-host-session-observer.js"; -import type { DesktopTranscriptRangeRequest } from '../preload/transcript-contract.js'; +import type { + DesktopTranscriptRangeRequest, + DesktopTranscriptTailAcknowledgement, +} from '../preload/transcript-contract.js'; import type { DesktopSessionStopResult } from '../preload/bridge-contract.js'; import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; import { mergeWorkspaceFileInlineReferences } from "./session-workspace-inline-references.js"; @@ -178,9 +181,11 @@ export interface RuntimeHostSessionExecutionIpcDeps { export interface RuntimeHostSessionObservationIpcDeps { observations: Pick< RuntimeHostSessionObservationRegistry, + | 'acknowledgeTranscriptTail' | 'loadTranscriptAround' | 'loadTranscriptBefore' | 'loadTranscriptAfter' + | 'loadTranscriptLatest' | 'observe' | 'openTranscript' >; @@ -236,6 +241,18 @@ 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, + ); + }); + ipcMain.handle('sessions:transcript:acknowledge-tail', async (event, input: unknown) => { + await deps.observations.acknowledgeTranscriptTail( + normalizeTranscriptTailAcknowledgement(input), + event.sender.id, + ); + }); } async function observationIpcResult( @@ -845,12 +862,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.navigation) || (value.navigation as number) < 0 ) { throw new Error('Invalid Desktop transcript navigation'); } @@ -860,10 +872,25 @@ 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, + navigation: value.navigation as number, + }; +} + +function normalizeTranscriptTailAcknowledgement( + input: unknown, +): DesktopTranscriptTailAcknowledgement { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Invalid Desktop transcript tail acknowledgement'); + } + const value = input as Record; + if (!Number.isSafeInteger(value.through) || (value.through as number) < 0) { + throw new Error('Invalid Desktop transcript tail watermark'); + } + return { + consumerId: requiredId(value.consumerId, 'Transcript consumer'), + sessionId: requiredId(value.sessionId, 'Session'), + hostEpoch: requiredId(value.hostEpoch, 'Host epoch'), + through: value.through 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 86e82640a3..a9ad243c86 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -28,6 +28,7 @@ import type { import type { DesktopTranscriptOpenResult, DesktopTranscriptRangeRequest, + DesktopTranscriptTailAcknowledgement, } from '../preload/transcript-contract.js'; type SessionObservationSource = Pick & { @@ -37,10 +38,12 @@ type SessionObservationSource = Pick & Pick< RuntimeHostSessionObserver, | 'acknowledgeTranscript' + | 'acknowledgeTranscriptTail' | 'closeTranscript' | 'loadTranscriptAround' | 'loadTranscriptBefore' | 'loadTranscriptAfter' + | 'loadTranscriptLatest' | 'openTranscript' > >; @@ -48,10 +51,12 @@ type SessionObservationSource = Pick & type TranscriptSource = Required< Pick< RuntimeHostSessionObserver, + | 'acknowledgeTranscriptTail' | 'closeTranscript' | 'loadTranscriptAround' | 'loadTranscriptBefore' | 'loadTranscriptAfter' + | 'loadTranscriptLatest' | 'openTranscript' > >; @@ -75,9 +80,11 @@ function requireTranscriptSource( ): SessionObservationSource & TranscriptSource { if ( !source?.openTranscript || + !source.acknowledgeTranscriptTail || !source.loadTranscriptBefore || !source.loadTranscriptAfter || !source.loadTranscriptAround || + !source.loadTranscriptLatest || !source.closeTranscript ) { throw new Error('Runtime Host transcript source is unavailable'); @@ -99,19 +106,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; @@ -128,14 +122,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 { @@ -384,16 +371,15 @@ export class RuntimeHostSessionObservationRegistry { target.once('destroyed', destroyedListener); const source = this.#source; if (!source) return ready.promise; - const transcriptSource = requireTranscriptSource(source); try { + const transcriptSource = requireTranscriptSource(source); const result = await transcriptSource.openTranscript( sessionId, consumerId, - this.#bindTranscriptTarget(target), + this.#bindTarget(target), ); 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); @@ -412,10 +398,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), ); } @@ -423,8 +407,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), ); } @@ -432,13 +416,29 @@ 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), + ); + } + + async acknowledgeTranscriptTail( + request: DesktopTranscriptTailAcknowledgement, + targetId?: number, + ): Promise { + await this.#runTranscriptOperation(request, async (source) => { + source.acknowledgeTranscriptTail(request, targetId); + }); + } + acknowledgeTranscript( consumerId: string, generation: string, @@ -527,47 +527,26 @@ export class RuntimeHostSessionObservationRegistry { } async #runTranscriptOperation( - request: DesktopTranscriptRangeRequest, - operation: ( - source: SessionObservationSource & TranscriptSource, - accepted: DesktopTranscriptRangeRequest, - ) => Promise, + request: { readonly consumerId: string }, + 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; @@ -596,30 +575,14 @@ export class RuntimeHostSessionObservationRegistry { const result = await transcriptSource.openTranscript( registration.sessionId, consumerId, - this.#bindTranscriptTarget(registration.target), + this.#bindTarget(registration.target), ); if ( this.#source === source && 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; @@ -668,10 +631,6 @@ export class RuntimeHostSessionObservationRegistry { registration.ready.reject(error); } - #bindTranscriptTarget(target: RuntimeHostTranscriptTarget): RuntimeHostTranscriptTarget { - return this.#bindTarget(target); - } - #assertOpen(): void { if (this.#closed) { throw new Error("Runtime Host Session observation registry is closed"); diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 2993097826..0b99011a5e 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -39,11 +39,13 @@ import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES, + DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, type DesktopTranscriptBatch, type DesktopTranscriptBatchPayload, type DesktopTranscriptOpenResult, type DesktopTranscriptRangeRequest, + type DesktopTranscriptTailAcknowledgement, } from '../preload/transcript-contract.js'; import { type PreparedSessionSubscription, @@ -58,6 +60,7 @@ import { } from './desktop-transcript-replica.js'; import { encodeDesktopTranscriptChange, + encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot, } from './desktop-transcript-ipc.js'; @@ -129,13 +132,21 @@ interface TranscriptConsumer { readonly consumerId: string; readonly target: RuntimeHostTranscriptTarget; generation: string; - navigationVersion: number; - navigationPending: boolean; - navigationRequest?: DesktopTranscriptRangeRequest; + /** The standing replacement; a read naming an older one has been abandoned. */ + navigation: number; deliverySequence: number; deliveryBytes: number; deliveryTask?: Promise; resetRequested: boolean; + /** + * Set only when the reset answers a navigation command, and stamped on that + * snapshot so the window can tell its own answer from a replacement it did + * not ask for. A reset from recovery or from an error carries no version: + * the window applies it to whatever it holds, under any navigation. + */ + resetNavigation?: number; + /** Page answers queued behind the delivery loop so they never interleave with a change. */ + readonly pendingPages: PendingTranscriptPage[]; pendingChange?: PendingTranscriptChange; readonly pendingDeliveries: Map; - readonly evictedDurableSequences: Set; - readonly completedOverlayMessageIds: Set; - hasOlder: boolean; - hasNewer: boolean; encodedBytes: number; } +interface PendingTranscriptPage { + readonly navigation: number; + readonly generation: string; + readonly batches: Iterable; + readonly encodedBytes: number; +} + interface PendingTranscriptUpsert { readonly entry: DesktopSequencedTranscriptMessage; readonly encodedBytes: number; @@ -296,11 +311,11 @@ export class RuntimeHostSessionObserver { consumerId, target, generation: replica.generation, - navigationVersion: 0, - navigationPending: false, + navigation: 0, deliverySequence: 0, deliveryBytes: 0, resetRequested: false, + pendingPages: [], pendingDeliveries: new Map(), }; state.transcriptConsumers.set(consumerId, consumer); @@ -318,7 +333,6 @@ export class RuntimeHostSessionObserver { throw new Error('Desktop transcript replica changed while opening'); } this.#touchReplica(state); - this.#markTranscriptRead(state, currentReplica); const readThroughMessageId = currentReplica.latestDurableVisibleMessageId(); return { sessionId, @@ -337,90 +351,171 @@ 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, { + direction: 'older', anchor: request.anchorSequence, + }), + 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, { + direction: 'newer', anchor: request.anchorSequence, + }), + 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, request.navigation), + bytes: [...snapshot.durable, ...snapshot.overlay.map((message) => ({ message }))], + }; + }); + } + + async loadTranscriptLatest( + request: DesktopTranscriptRangeRequest, + targetId?: number, + ): Promise { + const { state, replica, consumer } = this.#admitTranscriptNavigation(request, targetId, true); + if (!consumer) return; + // This answer is the tail cache, and global reclaim trims that cache even + // while the Session is open (`#touchReplica`). Refill it first or a reader + // returning to latest is answered with less than a tail. + await replica.refillTail( + requireTranscriptRangeBytes(request.maxBytes), + this.#transcriptReadIsCurrent(state, replica, consumer, request), ); + if (!this.#transcriptReadIsCurrent(state, replica, consumer, request)()) return; + consumer.resetRequested = true; + consumer.resetNavigation = request.navigation; + await this.#scheduleTranscriptDelivery(state, consumer); + this.#touchReplica(state); } - async #runTranscriptRangeOperation( + #pageIdentity(replica: DesktopTranscriptReplica, request: DesktopTranscriptRangeRequest) { + return { + sessionId: replica.sessionId, + generation: replica.generation, + hostEpoch: replica.hostEpoch, + navigation: request.navigation, + }; + } + + /** + * Main's navigation number is a cancellation hint and nothing more: dropping + * it would leave the system correct, because the Renderer decides what its + * window can splice from the anchors the answers carry. What it buys is not + * reading and shipping pages for a window the reader has already left. + */ + #admitTranscriptNavigation( request: DesktopTranscriptRangeRequest, targetId: number | undefined, - operation: (replica: DesktopTranscriptReplica, token: number) => Promise, - ): Promise { + replaces: boolean, + ): { state: ObservedSessionState; replica: DesktopTranscriptReplica; consumer?: TranscriptConsumer } { const { state, replica, consumer } = this.#requireTranscriptConsumer(request, targetId); - const version = request.navigationVersion ?? consumer.navigationVersion; - 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'); - } - 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'); - const isCurrent = () => + const navigation = request.navigation; + if (!Number.isSafeInteger(navigation) || navigation < 0) throw new Error('Invalid transcript navigation version'); + if (navigation < consumer.navigation) return { state, replica }; + if (replaces && navigation > consumer.navigation) { + consumer.navigation = navigation; + // Every queued answer was read for a window this replacement discards. + consumer.pendingPages.splice(0).forEach((page) => + this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes), + ); + } + return { state, replica, consumer }; + } + + /** Asked before a read is started and again before its answer is sent. */ + #deliversTranscriptPage(consumer: TranscriptConsumer, navigation: number): boolean { + return navigation >= consumer.navigation; + } + + /** Whether a Host read still belongs to the window that asked for it. */ + #transcriptReadIsCurrent( + state: ObservedSessionState, + replica: DesktopTranscriptReplica, + consumer: TranscriptConsumer, + request: DesktopTranscriptRangeRequest, + ): () => boolean { + return () => state.replica === replica && state.transcriptConsumers.get(request.consumerId) === consumer && - consumer.navigationRequest === request; + this.#deliversTranscriptPage(consumer, request.navigation); + } + + async #runTranscriptRangeOperation( + request: DesktopTranscriptRangeRequest, + targetId: number | undefined, + replaces: boolean, + operation: ( + replica: DesktopTranscriptReplica, + isCurrent: () => boolean, + ) => Promise< + | { batches: Iterable; bytes: readonly { readonly message: StoredMessage }[] } + | undefined + >, + ): Promise { + const { state, replica, consumer } = this.#admitTranscriptNavigation(request, targetId, replaces); + if (!consumer) return; + const isCurrent = this.#transcriptReadIsCurrent(state, replica, consumer, request); + let answer: Awaited>; 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({ + navigation: request.navigation, + generation: replica.generation, + batches: answer.batches, + encodedBytes, + }); + await this.#scheduleTranscriptDelivery(state, consumer); if (isCurrent()) this.#touchReplica(state); } @@ -445,6 +540,29 @@ export class RuntimeHostSessionObserver { await this.#closeIfIdle(state); } + /** + * The Renderer window reached `through`. Only this proves the reader received + * the rows: a change a parked window refuses still leaves it off the tail, so + * the read marker moves here and nowhere along delivery. + */ + acknowledgeTranscriptTail( + request: DesktopTranscriptTailAcknowledgement, + targetId?: number, + ): void { + const state = this.#transcriptConsumers.get(request.consumerId); + const consumer = state?.transcriptConsumers.get(request.consumerId); + const replica = state?.replica; + if (!state || !consumer || !replica?.resident) return; + if (targetId !== undefined && consumer.target.id !== targetId) { + throw new Error('Desktop transcript consumer belongs to another renderer'); + } + // Sequences only name the same rows within one Session and Host epoch. + if (state.sessionId !== request.sessionId || replica.hostEpoch !== request.hostEpoch) return; + const durableThrough = replica.durableThrough; + if (durableThrough === null || request.through < durableThrough) return; + this.#markTranscriptRead(state, replica); + } + acknowledgeTranscript( consumerId: string, generation: string, @@ -1159,9 +1277,6 @@ export class RuntimeHostSessionObserver { this.#broadcast(state.sessionId, event); } this.#sendTranscriptChange(state, replica, change); - if (!change.hasNewer && change.durableUpserts.length > 0) { - this.#markTranscriptRead(state, replica); - } this.#touchReplica(state); void this.#closeIfIdle(state); } @@ -1184,14 +1299,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 +1312,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 resetNavigation = consumer.resetNavigation; + consumer.resetNavigation = undefined; this.#clearPendingTranscriptChange(consumer); const replica = state.replica; if (!replica?.resident || state.closing) return; @@ -1218,16 +1327,28 @@ export class RuntimeHostSessionObserver { try { await this.#sendTranscriptBatches( consumer, - encodeDesktopTranscriptSnapshot({ - ...replica.snapshot(), - navigationVersion: consumer.navigationVersion, - }), + encodeDesktopTranscriptSnapshot(replica.snapshot(), resetNavigation), ); } finally { this.#adjustTranscriptDeliveryBytes(consumer, -deliveryBytes); } continue; } + const page = consumer.pendingPages.shift(); + if (page) { + try { + if ( + this.#deliversTranscriptPage(consumer, page.navigation) && + page.generation === consumer.generation && + state.replica?.generation === consumer.generation + ) { + await this.#sendTranscriptBatches(consumer, page.batches); + } + } finally { + this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes); + } + continue; + } const pending = consumer.pendingChange; if (!pending) return; consumer.pendingChange = undefined; @@ -1244,15 +1365,11 @@ export class RuntimeHostSessionObserver { sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch, - navigationVersion: consumer.navigationVersion, }, { + coversFrom: pending.coversFrom, durableThrough: pending.durableThrough, durableUpserts: [...pending.durableUpserts.values()].map(({ entry }) => entry), - evictedDurableSequences: [...pending.evictedDurableSequences], - completedOverlayMessageIds: [...pending.completedOverlayMessageIds], - hasOlder: pending.hasOlder, - hasNewer: pending.hasNewer, }, ), ); @@ -1280,50 +1397,39 @@ export class RuntimeHostSessionObserver { void this.#scheduleTranscriptDelivery(state, consumer).catch(() => undefined); } + /** + * Coalesces tail growth for one consumer. A merged change can only keep rows + * while each change starts where the last one ended; where it does not, the + * rows go and the merge carries nothing but the watermark, which is enough + * for the window to learn it has fallen behind and read forward itself. + */ #mergeTranscriptChange( consumer: TranscriptConsumer, change: DesktopTranscriptReplicaChange, ): boolean { if (consumer.resetRequested) return true; - const pending = consumer.pendingChange ?? { + const existing = consumer.pendingChange; + const pending = existing ?? { + coversFrom: change.coversFrom, durableThrough: change.durableThrough, durableUpserts: new Map(), - evictedDurableSequences: new Set(), - completedOverlayMessageIds: new Set(), - hasOlder: change.hasOlder, - hasNewer: change.hasNewer, encodedBytes: 0, }; let byteDelta = 0; + const joins = !existing || + (pending.coversFrom !== undefined && pending.durableThrough === change.coversFrom); + if (!joins) { + for (const { encodedBytes } of pending.durableUpserts.values()) byteDelta -= encodedBytes; + pending.durableUpserts.clear(); + pending.coversFrom = undefined; + } pending.durableThrough = change.durableThrough; - pending.hasOlder = change.hasOlder; - pending.hasNewer = change.hasNewer; - for (const entry of change.durableUpserts) { + for (const entry of joins ? 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; @@ -1418,8 +1524,9 @@ export class RuntimeHostSessionObserver { batches: Iterable, ): Promise { const deliveries = new Set>(); + // One answer goes out whole or not at all: a window assembles it as a unit, + // and a run cut short in the middle would never complete into one. for (const batch of batches) { - if ((batch.navigationVersion ?? 0) !== consumer.navigationVersion || consumer.navigationPending) break; let delivery!: Promise; delivery = this.#deliverTranscriptBatch(consumer, batch).finally(() => { deliveries.delete(delivery); @@ -1462,7 +1569,9 @@ export class RuntimeHostSessionObserver { throw new Error('Desktop transcript consumer belongs to another session'); } if (replica.hostEpoch !== request.hostEpoch) { - throw new Error('Desktop transcript host epoch changed; reopen the transcript'); + throw new Error( + `${DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE}: Desktop transcript host epoch changed; reopen the transcript`, + ); } return { state, replica, consumer }; } @@ -1476,6 +1585,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 +1609,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) { @@ -1520,7 +1631,6 @@ export class RuntimeHostSessionObserver { } #markTranscriptRead(state: ObservedSessionState, replica: DesktopTranscriptReplica): void { - if (state.transcriptConsumers.size === 0) return; const messageId = replica.latestDurableVisibleMessageId(); if (!messageId) return; const update = this.#client.setSessionReadMarker?.(state.sessionId, messageId); @@ -1546,10 +1656,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 25c3193945..57d097c7e1 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -88,7 +88,6 @@ import { type DesktopTranscriptBatch, type DesktopTranscriptHandle, type DesktopTranscriptOpenResult, - type DesktopTranscriptNavigation, } from './transcript-contract.js'; import { adoptTranscriptIdentity, @@ -2482,7 +2481,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 = () => {}; @@ -2502,7 +2500,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); @@ -2568,7 +2566,9 @@ const makaBridge = { const unavailable = async () => { throw new Error('Reconnect the Host to load uncached history'); }; return { ...cachedIdentity, sessionId, readThroughMessageId: null, + acknowledgeTail: unavailable, loadBefore: unavailable, loadAfter: unavailable, loadAround: unavailable, + loadLatest: unavailable, close: async () => {}, }; } @@ -2583,17 +2583,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: number, ): 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'); @@ -2604,21 +2602,32 @@ const makaBridge = { hostEpoch: currentIdentity.hostEpoch, anchorSequence, maxBytes, - navigationVersion: nextNavigation.navigationVersion, - intent: nextNavigation.intent, - preserveRange: nextNavigation.preserveRange, - readingTurnId: nextNavigation.readingTurnId, + navigation, }) as Promise; }; return { ...opened, sessionId, + acknowledgeTail: (through) => { + const currentIdentity = identity; + if (!currentIdentity) { + throw new Error('Desktop transcript identity is unavailable'); + } + return ipcRenderer.invoke('sessions:transcript:acknowledge-tail', consumerScope, { + consumerId, + sessionId: opened.sessionId, + hostEpoch: currentIdentity.hostEpoch, + through, + }) as Promise; + }, loadBefore: (anchorSequence, maxBytes, navigation) => range('sessions:transcript:load-before', anchorSequence, maxBytes, navigation), loadAfter: (anchorSequence, maxBytes, navigation) => range('sessions:transcript:load-after', anchorSequence, maxBytes, navigation), loadAround: (sequence, maxBytes, navigation) => range('sessions:transcript:load-around', sequence, maxBytes, navigation), + loadLatest: (navigation) => + range('sessions:transcript:load-latest', null, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, navigation), 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..515d3f0b51 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -19,17 +19,17 @@ 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; +/** + * Main rejects a read whose Host epoch moved under it. `ipcRenderer.invoke` + * carries nothing across but the Error's message, so both sides name the + * rejection by this code rather than by matching prose. + */ +export const DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE = 'DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED'; export const DESKTOP_TRANSCRIPT_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 DesktopTranscriptFragment { readonly source: 'durable' | 'overlay'; readonly identity: number | string; @@ -39,22 +39,37 @@ export interface DesktopTranscriptFragment { readonly data: Uint8Array; } +/** + * Every batch carries what the rows in it are anchored on, because adjacency + * cannot be read off durable sequence numbers: they advance by a stride, so + * only the Host read that produced a row proves what it is contiguous with. + * + * - `extends` names the edge a page read started from. + * - `coversFrom` names the watermark a tail change read forward from; absent + * means the batch claims no contiguity and only moves the watermark. + * - `navigation` appears on the reset answering `loadAround` / `loadLatest`, + * which replaces the window outright instead of splicing onto it. + */ export interface DesktopTranscriptBatchPayload { - /** An omitted version is zero, including cached bootstrap snapshots. */ - readonly navigationVersion?: number; + readonly navigation?: number; + readonly extends?: DesktopTranscriptExtension; + readonly coversFrom?: number | null; 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; } +export interface DesktopTranscriptExtension { + readonly direction: 'older' | 'newer'; + readonly anchor: number | null; +} + export interface DesktopTranscriptBatch extends DesktopTranscriptBatchPayload { readonly deliverySequence: number; } @@ -67,10 +82,7 @@ export interface DesktopTranscriptOpenResult { } export interface DesktopTranscriptRangeRequest { - readonly navigationVersion?: number; - readonly intent?: DesktopTranscriptNavigation['intent']; - readonly preserveRange?: boolean; - readonly readingTurnId?: string; + readonly navigation: number; readonly consumerId: string; readonly sessionId: string; readonly hostEpoch: string; @@ -78,10 +90,24 @@ export interface DesktopTranscriptRangeRequest { readonly maxBytes: number; } +/** + * The Renderer reporting that its window now holds every durable row through + * `through`. Main cannot derive this: a consumer only proves the Session is + * open, and a tail change a parked window refuses moves no window. + */ +export interface DesktopTranscriptTailAcknowledgement { + readonly consumerId: string; + readonly sessionId: string; + readonly hostEpoch: string; + readonly through: number; +} + 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; + acknowledgeTail(through: number): Promise; + loadBefore(anchorSequence: number | null, maxBytes: number, navigation: number): Promise; + loadAfter(anchorSequence: number | null, maxBytes: number, navigation: number): Promise; + loadAround(sequence: number, maxBytes: number, navigation: number): Promise; + loadLatest(navigation: number): Promise; close(): Promise; } @@ -92,22 +118,16 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB const batch = value as Record; if ( typeof batch.sessionId !== 'string' || - (batch.navigationVersion !== undefined && !isSequence(batch.navigationVersion)) || + (batch.navigation !== undefined && !isSequence(batch.navigation)) || + !isExtension(batch.extends) || + (batch.coversFrom !== undefined && batch.coversFrom !== null && !isSequence(batch.coversFrom)) || !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' ) { @@ -153,3 +173,11 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB function isSequence(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) >= 0; } + +function isExtension(value: unknown): boolean { + if (value === undefined) return true; + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const extension = value as Record; + return (extension.direction === 'older' || extension.direction === 'newer') && + (extension.anchor === null || isSequence(extension.anchor)); +} diff --git a/apps/desktop/src/renderer/app-shell-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 01cb74b7e5..182396a33e 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'; @@ -418,7 +416,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; @@ -2332,8 +2329,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), @@ -2493,7 +2488,7 @@ function AppShellContent({ desktopConversationCopy.actions.scrollMainToBottom } onReturnToTail={activeTranscriptRange?.hasNewer - ? () => transcriptReadingCommands.current?.loadHistory('latest') + ? () => transcriptReadingCommands.current?.returnToLatest() : undefined} hidden={workHubActive || !sessionsSelected} composer={ @@ -2693,8 +2688,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(false)} + 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..a468bcbb56 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,8 +62,9 @@ interface ChatMessageSurfaceProps extends Omit< | 'liveTurn' | 'shellRunUpdates' | 'goalIndicator' - | 'historyLoadPending' -> { + | 'onPrefetchHistory' + | 'onRetainWindow' +>, Required, 'onPrefetchHistory' | 'onRetainWindow'>> { /** * #1985: the live projection and the shell-run records are the only session * UI state that changes per streamed token, and this surface is their only @@ -91,10 +91,6 @@ interface ChatMessageSurfaceProps extends Omit< connections: LlmConnection[]; onRefreshConnections: () => Promise | void; onSkip: () => Promise | void; - hasOlderHistory?: boolean; - hasNewerHistory?: boolean; - historyLoadPending?: TranscriptHistoryPending; - onLoadHistory: (target: 'earlier' | 'later' | 'latest', anchorTurnId?: string) => Promise | void; } function captureLiveContent(liveTurn: LiveTurnProjection | undefined) { @@ -127,10 +123,6 @@ export function ChatMessageSurface({ connections, onRefreshConnections, onSkip, - hasOlderHistory, - hasNewerHistory, - historyLoadPending, - onLoadHistory, ...chatViewRest }: ChatMessageSurfaceProps) { const locale = useUiLocale(); @@ -247,13 +239,6 @@ export function ChatMessageSurface({ deepResearchRun={deepResearchRun} emptyOverride={emptyOverride} 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)} /> )} 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..f8391b0f7f 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,20 @@ import { captureTranscriptReadingAnchor, createTranscriptRestoreLifecycle, currentTranscriptRange, - loadTranscriptHistory, newestDurablePromptSequence, prepareTranscriptForSend, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, - type TranscriptHistoryGate, - type TranscriptHistoryGates, - type TranscriptHistoryPending, - type TranscriptHistoryRequest, + TranscriptReadSupersededError, } 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 +50,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,20 +70,18 @@ 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 reportNavigationError = (error: unknown, sessionId: string, controller: object) => { + if (error instanceof TranscriptReadSupersededError) return; + if (isCurrent(sessionId, controller)) props.onNavigationError(error, sessionId); }; const cancel = (sessionId: string, clearAnchor = false) => { lifecycle.cancel(sessionId); @@ -93,7 +93,6 @@ export function TranscriptReadingPositionController(props: { }; useImperativeHandle(props.commands, () => ({ prepareSend(sessionId) { - cancelHistory(sessionId); return prepareTranscriptForSend({ sessionId, currentSessionId: props.currentSessionId, controller: props.rangeController, cancel, @@ -102,51 +101,55 @@ 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) { + /** + * Deliberately not `returnToLatest`: that one cancels restoration and + * clears the search target, because a reader who asks to go somewhere has + * decided where to be. Filling decides nothing, so it must leave an + * outstanding jump alone — the page it is waiting for can still be in + * flight. + * + * Safe to ask on every frame the geometry wants it: the range controller + * refuses a read against a window it has already read, and answers whether + * it issued one. + */ + async prefetchHistory(edge) { + const controller = props.rangeController.current; + const { sessionId } = props; + if (!controller || !sessionId || !isCurrent(sessionId, controller)) return false; + return edge === 'older' ? controller.loadBefore() : controller.loadAfter(); + }, + 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) { + reportNavigationError(error, sessionId, controller); } - 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 +168,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 +178,48 @@ 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; + const anchor = props.sessionUi.transcriptReadingAnchorBySessionRef.current[sessionId]; + if (!anchor || controller.store.sequenceForTurn(anchor.turnId) !== null) return; + const navigate = (sequence: number) => { + void controller.loadAround(sequence).catch((error) => { + reportNavigationError(error, sessionId, controller); + }); + }; + if (previous.hostEpoch === range.hostEpoch) { + if (anchor.sequence !== undefined) navigate(anchor.sequence); + return; + } + // Sequences only name the same rows within one Host epoch, so a bookmark + // carried across one has to be found again by Turn. The landmark index in + // hand still names the epoch that is gone, hence the refresh first. + if (landmarkSessionId !== sessionId) return; + const { turnId } = anchor; + let disposed = false; + void props.listTurnLandmarks(sessionId).then((snapshot) => { + if (disposed || !isCurrent(sessionId, controller)) return; + props.setTurnIndex({ sessionId, throughSequence: snapshot.throughSequence, turns: snapshot.landmarks }); + // A reader who has gone somewhere else since owns the position now. + if (props.sessionUi.transcriptReadingAnchorBySessionRef.current[sessionId]?.turnId !== turnId) return; + const landmark = snapshot.landmarks.find((turn) => turn.turnId === turnId); + // A Turn the new epoch does not name leaves the reader where the reset put them. + if (!landmark) return; + props.sessionUi.setTranscriptReadingAnchor(sessionId, { turnId, sequence: landmark.sequence }); + navigate(landmark.sequence); + }, () => undefined); + return () => { disposed = true; }; + }, [props.sessionId, props.messages]); return null; } diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts index 7c12abf6bb..4d88727bf1 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,21 @@ interface TranscriptRangeStore { interface TranscriptRangeController { readonly store: TranscriptRangeStore; loadAround(sequence: number): Promise; - setReadingAnchor(sequence: number | null, readingTurnId?: string): Promise; +} + +/** + * A read the Host refused because the window it was stamped for belongs to a + * Runtime Host epoch that is gone. Nothing the reader asked for failed: the + * replacement reset carries the new epoch, and the cross-epoch re-anchor finds + * the bookmarked Turn in it. The platform adapter that speaks to the Host + * raises this; every reading-position path treats it as a read that was + * superseded rather than one that went wrong. + */ +export class TranscriptReadSupersededError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'TranscriptReadSupersededError'; + } } interface SearchTarget { @@ -213,89 +232,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 +240,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 +282,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 +300,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, )) { @@ -385,6 +319,13 @@ export function restoreSessionTranscriptRange(options: { } }) .catch((error) => { + if (error instanceof TranscriptReadSupersededError) { + // The sequence this command carries names a row of the epoch that is + // gone, so retrying it would land somewhere else entirely. Consume the + // command and leave the position to the cross-epoch re-anchor. + command.completed = true; + return; + } if (current()) options.onError(error, sessionId); }) .finally(() => { diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 5708cc0c59..6a98e47187 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -22,14 +22,13 @@ import { transcriptRestoreTarget, } from './controller/transcript-reading-position.js'; +export { TranscriptReadSupersededError } from './controller/transcript-reading-position.js'; + export const transcriptReadingPosition = { currentRange: currentTranscriptRange, 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/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts index 78ca778d5c..6f405f05d8 100644 --- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts +++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts @@ -387,6 +387,13 @@ export function useWorkHubController() { id: attempt.messageId, hostTurnId: queuedTurnId, text, attachments: [...attachments], ts: Date.now(), transientPlacement: attempt.placement, pendingSteering: attempt.placement === 'current_turn', }]); + viewportNavigation.followLatest(target); + // A queued message becomes visible only where the tail is, and its own + // retry guard waits on seeing it. Issue the read before admission so an + // uncertain enqueue — the case that arms the guard — is covered too. + void range.current?.loadLatest().catch((reason: unknown) => { + if (currentSessionId.current === target) report(reason); + }); const result = await services.enqueueMessage(target, attempt.messageId, text, attachments, attempt.placement); if (result === 'rejected' && pendingQueued.current === attempt) { pendingQueued.current = undefined; @@ -394,9 +401,6 @@ export function useWorkHubController() { } if (result !== 'admitted' && !attempt.observed) throw new Error(workHubLiveCopy[localeRef.current][result === 'unknown' ? 'sendUnknown' : 'sendNotAdmitted']); if (pendingQueued.current === attempt) pendingQueued.current = undefined; - if (currentSessionId.current === target) { - viewportNavigation.followLatest(target); - } return true; } const previous = pendingSend.current; @@ -523,7 +527,10 @@ export function useWorkHubController() { void send(attempt.input.text, attempt.input.attachments ?? []); } else retryResolution.current(); }, - loadOlder: () => range.current?.loadOlder(), + prefetchHistory: (edge: 'older' | 'newer') => + range.current?.prefetchHistory(edge) ?? Promise.resolve(false), + retainWindow: (window: { firstTurnId: string; lastTurnId: string }) => + range.current?.retain(window), loadLatest: () => range.current?.loadLatest(), report, streamingSettled(messageId?: string) { diff --git a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts index 04ed11d84f..55a7723ef1 100644 --- a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts +++ b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts @@ -37,7 +37,6 @@ export const workHubLiveCopy = { expandConversation: 'Expand conversation', retry: 'Retry', reloadRequired: 'WorkHub needs to reload', - older: 'Load earlier messages', welcome: 'What can I help with?', hint: 'Ask a question, manage your tasks, or ask me to work in Maka.', floating: 'WorkHub is in a floating window', @@ -64,7 +63,6 @@ export const workHubLiveCopy = { expandConversation: '展开对话', retry: '重试', reloadRequired: '工作台需要重新加载', - older: '加载更早的消息', welcome: '有什么可以帮你?', hint: '问个问题、管理任务,或让我帮你操作 Maka。', floating: '工作台已在浮窗中打开', @@ -91,7 +89,6 @@ export const workHubLiveCopy = { expandConversation: '展開對話', retry: '重試', reloadRequired: '工作台需要重新載入', - older: '載入更早的訊息', welcome: '有什麼可以幫你?', hint: '問個問題、管理任務,或讓我幫你操作 Maka。', floating: '工作台已在浮動視窗中開啟', diff --git a/apps/desktop/src/renderer/features/workhub/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts index da55cde5e9..5ba6d3f61e 100644 --- a/apps/desktop/src/renderer/features/workhub/ports.ts +++ b/apps/desktop/src/renderer/features/workhub/ports.ts @@ -41,7 +41,10 @@ export interface WorkHubTranscriptSnapshot { } export interface WorkHubTranscript { observationChanged(phase: 'pending' | 'ready'): void; - loadOlder(): Promise; + /** Fills the window at an edge the reader approaches; resolves to whether a read was issued. */ + prefetchHistory(edge: 'older' | 'newer'): Promise; + /** Trims the window to the Turns the reader's band still covers. */ + retain(window: { firstTurnId: string; lastTurnId: string }): void; loadLatest(): Promise; close(): Promise; } diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx index 72bfd887c4..72593460fe 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx @@ -310,16 +310,6 @@ export function WorkHubRoot() { } >
- {transcript.hasOlder && ( -