diff --git a/apps/desktop/e2e/native-transcript-perf.spec.ts b/apps/desktop/e2e/native-transcript-perf.spec.ts new file mode 100644 index 0000000000..4c7eafb09b --- /dev/null +++ b/apps/desktop/e2e/native-transcript-perf.spec.ts @@ -0,0 +1,441 @@ +/* + * 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 type { CDPSession, Page } from '@playwright/test'; +import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; +import * as transcriptContract from '../src/preload/transcript-contract'; +import { ensureSidebarExpanded, expect, test } from './fixtures'; + +const PERF_ENABLED = process.env.MAKA_TRANSCRIPT_PERF === '1'; +const STRESS_ENABLED = process.env.MAKA_TRANSCRIPT_STRESS === '1'; +const SCROLLER = '[data-chat-scroll-container="true"]'; + +interface BrowserCounters { + heapBytes: number; + nodes: number; + documents: number; + jsEventListeners: number; +} + +interface FrameSample { + intervals: number[]; + loafDurations: number[]; + loafSupported: boolean; +} + +interface StressSample extends BrowserCounters { + sweep: number; + iteration: number; + firstTurnId: string | null; + lastTurnId: string | null; + mountedTurns: number; +} + +interface StressSweep { + sweep: number; + successfulPages: number; + samples: StressSample[]; +} + +interface HeapGrowth { + endpointRatio: number; + slopeBytesPerPage: number; + projectedRatio: number; +} + +/** The predeclared secondary heap/DOM release gate permits at most 10% growth. */ +const SECONDARY_RESOURCE_GROWTH_RATIO = 0.1; + +function positiveHeapGrowth(samples: readonly StressSample[]): HeapGrowth { + const first = samples[0]!; + const last = samples.at(-1)!; + const meanIteration = samples.reduce((sum, sample) => sum + sample.iteration, 0) + / samples.length; + const meanHeap = samples.reduce((sum, sample) => sum + sample.heapBytes, 0) + / samples.length; + const slopeNumerator = samples.reduce( + (sum, sample) => sum + (sample.iteration - meanIteration) * (sample.heapBytes - meanHeap), + 0, + ); + const slopeDenominator = samples.reduce( + (sum, sample) => sum + (sample.iteration - meanIteration) ** 2, + 0, + ); + const slopeBytesPerPage = slopeDenominator === 0 ? 0 : slopeNumerator / slopeDenominator; + const iterationSpan = last.iteration - first.iteration; + return { + endpointRatio: Math.max(0, last.heapBytes - first.heapBytes) / first.heapBytes, + slopeBytesPerPage, + projectedRatio: Math.max(0, slopeBytesPerPage * iterationSpan) / first.heapBytes, + }; +} + +function percentile(values: readonly number[], probability: number): number { + if (values.length === 0) return 0; + const ordered = [...values].sort((left, right) => left - right); + return ordered[Math.min(ordered.length - 1, Math.ceil(probability * ordered.length) - 1)]!; +} + +async function collectGarbage(cdp: CDPSession): Promise { + await cdp.send('HeapProfiler.enable'); + await cdp.send('HeapProfiler.collectGarbage'); +} + +async function browserCounters(cdp: CDPSession): Promise { + const [heap, dom] = await Promise.all([ + cdp.send('Runtime.getHeapUsage'), + cdp.send('Memory.getDOMCounters'), + ]); + return { + heapBytes: heap.usedSize, + nodes: dom.nodes, + documents: dom.documents, + jsEventListeners: dom.jsEventListeners, + }; +} + +async function performanceMetrics(cdp: CDPSession): Promise> { + const result = await cdp.send('Performance.getMetrics'); + return new Map(result.metrics.map(({ name, value }) => [name, value])); +} + +function metricDelta( + before: ReadonlyMap, + after: ReadonlyMap, + name: string, +): number { + return (after.get(name) ?? 0) - (before.get(name) ?? 0); +} + +async function prepareFrameRecorder(page: Page): Promise { + await page.evaluate(() => { + const state: FrameSample & { lastFrame: number | null; running: boolean } = { + intervals: [], + loafDurations: [], + loafSupported: PerformanceObserver.supportedEntryTypes + .includes('long-animation-frame'), + lastFrame: null, + running: false, + }; + Object.assign(window, { __makaTranscriptPerf: state }); + if (state.loafSupported) { + try { + const observer = new PerformanceObserver((list) => { + if (!state.running) return; + state.loafDurations.push(...list.getEntries().map((entry) => entry.duration)); + }); + observer.observe({ type: 'long-animation-frame', buffered: false }); + } catch { + state.loafSupported = false; + } + } + }); +} + +async function scrollGesture(page: Page, delta: number, frames = 240): Promise { + return page.evaluate(async ({ selector, delta, frames }) => { + type Recorder = FrameSample & { lastFrame: number | null; running: boolean }; + const root = document.querySelector(selector); + const recorder = (window as Window & { __makaTranscriptPerf?: Recorder }) + .__makaTranscriptPerf; + if (!root || !recorder) throw new Error('the transcript performance probe is missing'); + recorder.intervals.length = 0; + recorder.loafDurations.length = 0; + recorder.lastFrame = null; + recorder.running = true; + const start = root.scrollTop; + await new Promise((resolve) => { + let completed = 0; + const tick = (now: number) => { + if (recorder.lastFrame !== null) recorder.intervals.push(now - recorder.lastFrame); + recorder.lastFrame = now; + completed += 1; + root.scrollTop = start + (delta * completed) / frames; + if (completed >= frames) { + resolve(); + return; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + recorder.running = false; + return { + intervals: [...recorder.intervals], + loafDurations: [...recorder.loafDurations], + loafSupported: recorder.loafSupported, + }; + }, { selector: SCROLLER, delta, frames }); +} + +async function moveToTail(page: Page): Promise { + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = root.scrollHeight; + }, SCROLLER); +} + +async function returnToLatest(page: Page): Promise { + const returnLatest = page.getByRole('button', { + name: /^(?:返回最新消息|Return to latest)$/, + }); + if (await returnLatest.isVisible()) await returnLatest.click(); + else await page.locator('.maka-prompt-rail-tick').last().click({ force: true }); +} + +async function traverseFullHistoryAndReturnToTail(page: Page): Promise { + for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { + const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); + if (firstBefore?.endsWith('-1')) break; + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = 0; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + }, SCROLLER); + await expect.poll(async () => + page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), + ).not.toBe(firstBefore); + } + await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1); + await returnToLatest(page); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1); +} + +async function measureSessionSwitch(page: Page): Promise { + await ensureSidebarExpanded(page); + const rows = page.locator('.maka-session-row'); + const selected = rows.locator('button.astryx-side-nav-item.selected'); + const originalId = await selected.evaluate((button) => + button.closest('.maka-session-row')?.getAttribute('data-session-id'), + ); + if (!originalId) throw new Error('the prompt-rail Session is not selected'); + const otherId = await rows.evaluateAll( + (elements, current) => elements + .map((element) => element.getAttribute('data-session-id')) + .find((sessionId) => sessionId !== current) ?? null, + originalId, + ); + if (!otherId) throw new Error('the fixture has no second Session'); + const start = performance.now(); + await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button`) + .first() + .click(); + await expect(page.locator( + `.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button.selected`, + )).toHaveCount(1); + await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(originalId)}] button`) + .first() + .click(); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + return performance.now() - start; +} + +test('LoAF capability is explicit when Chromium cannot measure it', async ({ + promptRailWindow: page, +}) => { + test.skip(!PERF_ENABLED, 'manual same-build CDP A/B harness'); + await page.evaluate(() => { + Object.defineProperty(PerformanceObserver, 'supportedEntryTypes', { + configurable: true, + value: [], + }); + }); + await prepareFrameRecorder(page); + const frames = await scrollGesture(page, -1, 2); + expect(frames.loafSupported).toBe(false); +}); + +test('warm native transcript scroll metrics', async ({ promptRailWindow: page }) => { + test.skip(!PERF_ENABLED, 'manual same-build CDP A/B harness'); + test.setTimeout(90_000); + await page.setViewportSize({ width: 1_000, height: 700 }); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Performance.enable'); + await prepareFrameRecorder(page); + await traverseFullHistoryAndReturnToTail(page); + await moveToTail(page); + + // Warm Chromium, React and the transcript path in both directions before sampling. + await scrollGesture(page, -600, 120); + await scrollGesture(page, 600, 120); + await moveToTail(page); + await collectGarbage(cdp); + await page.evaluate(() => new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + )); + const before = await performanceMetrics(cdp); + const frames = await scrollGesture(page, -600); + expect( + frames.loafSupported, + 'Chromium does not support the long-animation-frame release metric', + ).toBe(true); + const after = await performanceMetrics(cdp); + await collectGarbage(cdp); + const counters = await browserCounters(cdp); + const sourceTurns = await page.locator('[data-turn-source-count]').first() + .getAttribute('data-turn-source-count'); + const mountedTurns = await page.locator('[data-turn-id]').count(); + const domElements = await page.locator('*').count(); + const sessionSwitchMs = await measureSessionSwitch(page); + const result = { + sourceTurns: Number(sourceTurns), + mountedTurns, + domElements, + taskMs: metricDelta(before, after, 'TaskDuration') * 1_000, + scriptMs: metricDelta(before, after, 'ScriptDuration') * 1_000, + layoutMs: metricDelta(before, after, 'LayoutDuration') * 1_000, + recalcStyleMs: metricDelta(before, after, 'RecalcStyleDuration') * 1_000, + heapBytes: counters.heapBytes, + nodes: counters.nodes, + documents: counters.documents, + jsEventListeners: counters.jsEventListeners, + frameCount: frames.intervals.length, + frameP95Ms: percentile(frames.intervals, 0.95), + frameP99Ms: percentile(frames.intervals, 0.99), + frameMaxMs: Math.max(...frames.intervals), + framesOver12_5Ms: frames.intervals.filter((duration) => duration > 12.5).length, + loafOver50Ms: frames.loafDurations.filter((duration) => duration > 50).length, + loafMaxMs: Math.max(0, ...frames.loafDurations), + loafSupported: frames.loafSupported, + sessionSwitchMs, + }; + console.log(`TRANSCRIPT_PERF ${JSON.stringify(result)}`); +}); + +test('600+ Turn repeated paging keeps the active range on a memory plateau', async ({ + promptRailWindow: page, +}) => { + test.skip(!STRESS_ENABLED, 'manual 600+ Turn stress harness'); + test.setTimeout(180_000); + await page.setViewportSize({ width: 1_000, height: 700 }); + const cdp = await page.context().newCDPSession(page); + expect(PROMPT_RAIL_PROMPT_COUNT).toBeGreaterThanOrEqual(600); + const sweeps: StressSweep[] = []; + const heapGrowth: Array = []; + const captureSample = async (sweep: number, iteration: number): Promise => { + await collectGarbage(cdp); + const counters = await browserCounters(cdp); + const sample = { + sweep, + iteration, + firstTurnId: await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), + lastTurnId: await page.locator('[data-turn-id]').last().getAttribute('data-turn-id'), + mountedTurns: await page.locator('[data-turn-id]').count(), + ...counters, + }; + return sample; + }; + + // Warm the paging composition once, independent of the fixture's history depth. + const latestFirstTurn = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = 0; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + }, SCROLLER); + await expect.poll(async () => + page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), + ).not.toBe(latestFirstTurn); + await returnToLatest(page); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1); + + for (let sweep = 1; sweep <= 2; sweep += 1) { + const samples: StressSample[] = [await captureSample(sweep, 0)]; + let successfulPages = 0; + for (let iteration = 1; iteration <= PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { + const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); + if (firstBefore?.endsWith('-1')) break; + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = 0; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + }, SCROLLER); + await expect.poll(async () => + page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), + ).not.toBe(firstBefore); + successfulPages += 1; + if (iteration % 10 !== 0) continue; + samples.push(await captureSample(sweep, iteration)); + } + await expect(page.locator('[data-turn-id]').first()) + .toHaveAttribute('data-turn-id', 'turn-prompt-rail-1'); + if (samples.at(-1)!.iteration !== successfulPages) { + samples.push(await captureSample(sweep, successfulPages)); + } + sweeps.push({ sweep, successfulPages, samples }); + const growth = { sweep, ...positiveHeapGrowth(samples) }; + heapGrowth.push(growth); + console.log(`TRANSCRIPT_STRESS_SWEEP ${JSON.stringify({ + sweep, + successfulPages, + samples, + heapGrowth: growth, + })}`); + expect(growth.endpointRatio).toBeLessThanOrEqual(SECONDARY_RESOURCE_GROWTH_RATIO); + expect(growth.projectedRatio).toBeLessThanOrEqual(SECONDARY_RESOURCE_GROWTH_RATIO); + if (sweep < 2) { + await returnToLatest(page); + await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) + .toHaveCount(1); + } + } + + const firstSweep = sweeps[0]!; + const secondSweep = sweeps[1]!; + expect(firstSweep.successfulPages).toBeGreaterThan(0); + expect(secondSweep.successfulPages).toBeGreaterThan(0); + const firstNodesByIteration = new Map( + firstSweep.samples.map((sample) => [sample.iteration, sample.nodes]), + ); + let nodeMaxSecondToFirstRatio = 0; + for (const [index, sample] of secondSweep.samples.entries()) { + const firstNodes = index === secondSweep.samples.length - 1 + ? firstSweep.samples.at(-1)!.nodes + : firstNodesByIteration.get(sample.iteration); + expect(firstNodes).toBeDefined(); + const ratio = sample.nodes / firstNodes!; + nodeMaxSecondToFirstRatio = Math.max(nodeMaxSecondToFirstRatio, ratio); + } + + const allSamples = sweeps.flatMap((sweep) => sweep.samples); + const mountedMax = Math.max(...allSamples.map((sample) => sample.mountedTurns)); + console.log(`TRANSCRIPT_STRESS ${JSON.stringify({ + fixtureTurns: PROMPT_RAIL_PROMPT_COUNT, + sweeps, + mountedMax, + nodeMin: Math.min(...allSamples.map((sample) => sample.nodes)), + nodeMax: Math.max(...allSamples.map((sample) => sample.nodes)), + nodeMaxSecondToFirstRatio, + heapGrowth, + })}`); + expect(mountedMax).toBeLessThanOrEqual( + transcriptContract.DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, + ); + expect(nodeMaxSecondToFirstRatio).toBeLessThanOrEqual( + 1 + SECONDARY_RESOURCE_GROWTH_RATIO, + ); +}); diff --git a/apps/desktop/e2e/onboarding-viewport.spec.ts b/apps/desktop/e2e/onboarding-viewport.spec.ts index aec6a1683f..0605dd3b49 100644 --- a/apps/desktop/e2e/onboarding-viewport.spec.ts +++ b/apps/desktop/e2e/onboarding-viewport.spec.ts @@ -41,6 +41,7 @@ test('first-run onboarding stays within the chat viewport', async ({ onboardingW surfaceBottom: surfaceRect.bottom, viewportTop: scrollRect.top, viewportBottom: scrollRect.bottom, + onboardingLayout: scrollContainer.dataset.makaOnboarding, cardTop: cardRect.top, cardBottom: cardRect.bottom, }; @@ -50,6 +51,7 @@ test('first-run onboarding stays within the chat viewport', async ({ onboardingW geometry.pageClientHeight, ); expect(geometry.scrollHeight, JSON.stringify(geometry, null, 2)).toBe(geometry.clientHeight); + expect(geometry.onboardingLayout).toBe('true'); expect(geometry.surfaceTop).toBeGreaterThanOrEqual(geometry.viewportTop); expect(geometry.surfaceBottom).toBeLessThanOrEqual(geometry.viewportBottom); expect(geometry.cardTop).toBeGreaterThanOrEqual(geometry.viewportTop); diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index 9596088c71..efbcec3dd0 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -18,7 +18,8 @@ */ import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; -import { expect, test } from './fixtures'; +import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS } from '../src/preload/transcript-contract'; +import { ensureSidebarExpanded, expect, test } from './fixtures'; import type { Page } from '@playwright/test'; const MAX_PROMPT_RAIL_TICKS = 64; @@ -95,6 +96,20 @@ async function scrollTranscriptTo(page: Page, position: 'top' | 'bottom'): Promi await waitForPaintedFrames(page); } +async function scrollTranscriptAwayFromTail(page: Page): Promise { + await page.evaluate(() => { + const root = document.querySelector('[data-chat-scroll-container="true"]'); + if (!root) throw new Error('the chat scroll container is missing'); + const historyLoadBand = Math.max(640, root.clientHeight * 2); + root.scrollTop = Math.min( + root.scrollHeight - root.clientHeight - 100, + historyLoadBand + 200, + ); + root.dispatchEvent(new Event('scroll')); + }); + await waitForPaintedFrames(page); +} + async function waitForPaintedFrames(page: Page, count = 2): Promise { await page.evaluate((frames) => new Promise((resolve) => { const tick = (left: number) => { @@ -116,15 +131,6 @@ function notifyTranscriptScrolled(page: Page): Promise { }); } -async function loadPromptRailBeyondVirtualWindow(page: Page): Promise { - const transcript = page.locator('.maka-chat-message-list'); - await scrollTranscriptTo(page, 'top'); - await transcript.hover(); - await page.mouse.wheel(0, -100); - await expect.poll(async () => Number(await transcript.getAttribute('data-turn-source-count'))) - .toBeGreaterThan(100); -} - test('every tick paints a bar with a real box', async ({ promptRailWindow: page }) => { // Measured over ALL ticks, not a sample: a helper that skips what it cannot // evaluate creates its blind spot exactly where a regression lives. @@ -247,9 +253,11 @@ test('the first click of a session lands on its prompt and holds', async ({ // Bounded on both sides: below is the turn never arriving, above is it // arriving and then being pulled off the top of the scrollport. await expect - .poll(async () => (await landing())?.offset, { message: 'the clicked prompt reaches the top' }) - .toBeGreaterThan(-24); - expect((await landing())?.offset).toBeLessThan(24); + .poll(async () => { + const offset = (await landing())?.offset; + return offset === undefined ? Number.POSITIVE_INFINITY : Math.abs(offset); + }, { message: 'the clicked prompt reaches the top' }) + .toBeLessThan(24); expect((await landing())?.tickIsCurrent).toBe(true); // And stays: turns keep resolving their content and remeasuring after the @@ -262,43 +270,37 @@ test('the first click of a session lands on its prompt and holds', async ({ expect(settled?.tickIsCurrent).toBe(true); }); -test('long transcripts keep a bounded mounted turn window', async ({ +test('active transcript Turns keep stable DOM identities while scrolling', async ({ promptRailWindow: page, }) => { - const count = async () => page.locator('[data-virtual-turn-id]').count(); - await page.locator('[data-virtual-turn-id]').first().waitFor(); - await loadPromptRailBeyondVirtualWindow(page); - expect(await page.evaluate(() => { - const transcript = document.querySelector('.maka-chat-message-list'); - const rows = transcript?.firstElementChild; - const turn = document.querySelector('[data-virtual-turn-id]'); - if (!rows || !turn) throw new Error('the virtual transcript is missing'); - return { - list: Number.parseFloat(getComputedStyle(rows).rowGap), - turn: Number.parseFloat(getComputedStyle(turn).rowGap), - }; - })).toEqual({ list: 16, turn: 16 }); - expect(await count()).toBeGreaterThan(0); - expect(await count()).toBeLessThanOrEqual(100); - await page.locator('.maka-prompt-rail-tick').first().click({ force: true }); - await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1); - expect(await count()).toBeGreaterThan(0); - expect(await count()).toBeLessThanOrEqual(100); + const sourceCount = Number( + await page.locator('.maka-chat-message-list').getAttribute('data-turn-source-count'), + ); + expect(sourceCount).toBe(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); + expect(await page.locator('[data-turn-id]').count()).toBe(sourceCount); + await page.evaluate(() => { + for (const turn of document.querySelectorAll('[data-turn-id]')) { + turn.dataset.stableMountProbe = turn.dataset.turnId; + } + }); + + await scrollTranscriptTo(page, 'bottom'); + await scrollTranscriptAwayFromTail(page); + + expect(await page.locator('[data-turn-id]').count()).toBe(sourceCount); + expect(await page.locator('[data-turn-id][data-stable-mount-probe]').count()).toBe(sourceCount); }); -test('evicting a turn-owned sibling interaction hands focus back to the transcript', async ({ +test('scrolling away preserves a turn-owned focus and selection', async ({ promptRailWindow: page, }) => { - const scroller = page.locator('[data-chat-scroll-container="true"]'); - await page.locator('[data-virtual-turn-id]').first().waitFor(); - await loadPromptRailBeyondVirtualWindow(page); await scrollTranscriptTo(page, 'bottom'); - await expect(page.locator('[data-virtual-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); - const retainedTurnId = await page.evaluate(() => { - const turns = document.querySelectorAll('[data-virtual-turn-id]'); - const turn = turns.item(turns.length - 1); - if (!turn?.dataset.virtualTurnId) throw new Error('the mounted turn is missing'); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + await page.evaluate(() => { + const turn = document.querySelector('[data-turn-id="turn-prompt-rail-120"]'); + if (!turn) throw new Error('the tail Turn is missing'); const turnOwnedAction = document.createElement('button'); + turnOwnedAction.dataset.turnOwnedAction = 'true'; turnOwnedAction.textContent = 'Turn-owned action'; turn.append(turnOwnedAction); turnOwnedAction.focus(); @@ -307,41 +309,77 @@ test('evicting a turn-owned sibling interaction hands focus back to the transcri const selection = document.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); - return turn.dataset.virtualTurnId; }); + await scrollTranscriptAwayFromTail(page); - // The injected control resizes the tail Turn. That can queue a scroll-anchor - // restore captured at the bottom. Let that setup-only restore finish before - // the one-shot jump so the assertion still catches any later restore that - // would pin the viewport back on the retained Turn (#3121). - await waitForPaintedFrames(page); - await scrollTranscriptTo(page, 'top'); - await notifyTranscriptScrolled(page); - await expect.poll(async () => page.evaluate((turnId) => { - const root = document.querySelector('[data-chat-scroll-container="true"]'); - if (!root) throw new Error('the chat scroll container is missing'); - const mounted = [...document.querySelectorAll('[data-virtual-turn-id]')] - .map((turn) => turn.dataset.virtualTurnId ?? ''); + await expect.poll(async () => page.evaluate(() => { const active = document.activeElement; return { - retained: mounted.includes(turnId), - scrollTop: Math.round(root.scrollTop), - firstMounted: mounted[0] ?? null, - lastMounted: mounted.at(-1) ?? null, - focusOnTranscript: active instanceof HTMLElement - && active.classList.contains('maka-chat-message-list'), - selectionCollapsed: document.getSelection()?.isCollapsed ?? true, + retained: document.querySelector('[data-turn-id="turn-prompt-rail-120"]') !== null, + focusRetained: active instanceof HTMLElement + && active.dataset.turnOwnedAction === 'true', + selectionRetained: document.getSelection()?.isCollapsed === false, }; - }, retainedTurnId), { - message: 'the retained tail turn leaves after one jump to the top', - }).toMatchObject({ - retained: false, - scrollTop: 0, - focusOnTranscript: true, - selectionCollapsed: true, + })).toEqual({ + retained: true, + focusRetained: true, + selectionRetained: true, }); }); +test('offscreen active Turns remain findable and accessible', async ({ + promptRailWindow: page, +}) => { + const firstTurnId = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); + const turnNumber = Number(firstTurnId?.split('-').at(-1)); + expect(turnNumber).toBeGreaterThan(0); + const needle = `第 ${turnNumber} 个问题`; + await scrollTranscriptTo(page, 'bottom'); + + const found = await page.evaluate((text) => { + document.getSelection()?.removeAllRanges(); + return (window as Window & { find(text: string): boolean }).find(text); + }, needle); + expect(found).toBe(true); + expect(await page.evaluate(() => document.getSelection()?.toString() ?? '')).toContain(needle); + + const cdp = await page.context().newCDPSession(page); + const tree = await cdp.send('Accessibility.getFullAXTree'); + expect(tree.nodes.some((node) => node.name?.value?.includes(needle))).toBe(true); +}); + +test('switching sessions reconstructs only the Host active range', async ({ + promptRailWindow: page, +}) => { + await ensureSidebarExpanded(page); + const rows = page.locator('.maka-session-row'); + const selected = rows.locator('button.astryx-side-nav-item.selected'); + const originalId = await selected + .evaluate((button) => button.closest('.maka-session-row')?.getAttribute('data-session-id')); + if (!originalId) throw new Error('the prompt-rail Session is not selected'); + const otherId = await rows.evaluateAll( + (rows, selected) => rows + .map((row) => row.getAttribute('data-session-id')) + .find((sessionId) => sessionId !== selected) ?? null, + originalId, + ); + if (!otherId) throw new Error('the fixture has no second Session'); + await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button`) + .first() + .click(); + await expect(page.locator( + `.maka-session-row[data-session-id=${JSON.stringify(otherId)}] button.selected`, + )) + .toHaveCount(1); + + await page.locator(`.maka-session-row[data-session-id=${JSON.stringify(originalId)}] button`) + .first() + .click(); + await expect(page.locator('[data-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); + expect(await page.locator('[data-turn-id]').count()) + .toBe(DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS); +}); + test('a tick is what the pointer lands on, not the scrollbar', async ({ promptRailWindow: page, }) => { diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index f8ac41f8c6..acc885b2d7 100644 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -235,7 +235,7 @@ test('content that grows outside the turn wrappers is followed too', async ({ grown.dataset.outsideTurnGrowth = 'true'; grown.style.height = '600px'; list.append(grown); - return grown.closest('[data-virtual-turn-id]') === null; + return grown.closest('[data-transcript-turn-id]') === null; }); await waitForPaintedFrames(page); @@ -353,6 +353,68 @@ test('a gesture a nested scroller consumed does not release the tail', async ({ expect(await scrollButtonOffered(page)).toBe(false); }); +test('a nested scroller near the history boundary does not request an earlier range', async ({ + promptRailWindow: page, +}) => { + await page.setViewportSize({ width: 900, height: 1500 }); + await waitForPaintedFrames(page, 6); + const metrics = await scrollMetrics(page); + expect(metrics.scrollTop).toBeLessThanOrEqual(Math.max(640, metrics.clientHeight * 2)); + expect(metrics.distance).toBeLessThanOrEqual(4); + + const nestedBefore = await page.evaluate((selector) => { + const root = document.querySelector(selector); + const list = root?.querySelector('.maka-chat-message-list'); + if (!root || !list) throw new Error('the active transcript range is missing'); + const box = document.createElement('div'); + box.dataset.nestedHistoryScroller = 'true'; + box.style.cssText = [ + 'position:fixed', + 'top:160px', + 'left:160px', + 'width:240px', + 'height:120px', + 'overflow-y:auto', + 'z-index:9999', + ].join(';'); + const filler = document.createElement('div'); + filler.style.height = '2000px'; + box.append(filler); + // A Turn uses `content-visibility:auto`, whose paint containment prevents + // a fixed descendant from reliably winning hit testing over sibling Turns + // on Linux/Xvfb. Keep the fixture inside the transcript event path without + // putting it inside the product containment boundary being tested. + list.append(box); + box.scrollTop = 600; + return box.scrollTop; + }, SCROLLER); + + const nested = page.locator('[data-nested-history-scroller="true"]'); + const box = await nested.boundingBox(); + if (!box) throw new Error('the nested history scroller is not rendered'); + const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + expect(await page.evaluate(({ x, y }) => + document.elementFromPoint(x, y)?.closest('[data-nested-history-scroller="true"]') !== null, + point)).toBe(true); + await page.mouse.move(point.x, point.y); + await page.mouse.wheel(0, -400); + await waitForPaintedFrames(page); + + const nestedAfter = await page.evaluate(() => + document.querySelector('[data-nested-history-scroller="true"]')?.scrollTop ?? -1, + ); + expect(nestedAfter).toBeLessThan(nestedBefore); + await page.evaluate(() => { + const list = document.querySelector('.maka-chat-message-list'); + if (!list) throw new Error('the transcript content box is missing'); + const grown = document.createElement('div'); + grown.style.height = '600px'; + list.append(grown); + }); + await waitForPaintedFrames(page, 6); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); +}); + test('the dock affordance returns the reader to the tail', async ({ window: page }) => { test.slow(); await page.setViewportSize({ width: 900, height: 700 }); @@ -374,14 +436,15 @@ test('the dock affordance returns the reader to the tail', async ({ window: page test('earlier history lands above the turn the reader is on', async ({ promptRailWindow: page, }) => { - const loadedTurns = () => - page.locator('.maka-chat-message-list').getAttribute('data-turn-source-count').then((value) => Number(value)); - const loadedBefore = await loadedTurns(); - - // Just short of the band that asks for more, so the virtual window has - // mounted turns around the reader before the load starts. Landing straight on - // zero puts the viewport inside the leading spacer, where there is no turn to - // be reading and nothing to hold still. + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); + + // Just short of the band that asks for more, so the active range has painted + // turns around the reader before the load starts. Landing straight on zero + // leaves no visible turn above the load boundary to identify as the anchor. await page.evaluate((selector) => { const root = document.querySelector(selector); if (!root) throw new Error('the chat scroll container is missing'); @@ -390,40 +453,44 @@ test('earlier history lands above the turn the reader is on', async ({ await waitForPaintedFrames(page, 6); // The move that asks for earlier history, and the reading of where the - // reader is, in one task: the scroll event that starts the load is dispatched - // afterwards, so the app anchors on the same position this reads. + // reader is, in one task. Keep the reader near the active range's head: an + // anchor near its tail can already have a complete bounded range around it, + // so a valid load has no reason to move the first resident Turn. const anchor = await page.evaluate((selector) => { const root = document.querySelector(selector); if (!root) throw new Error('the chat scroll container is missing'); - root.scrollTop = Math.max(640, root.clientHeight * 2) - 100; + root.scrollTop = Math.min(300, root.scrollHeight - root.clientHeight); const rootTop = root.getBoundingClientRect().top; const turn = [...root.querySelectorAll('[data-turn-id]')].find( (candidate) => candidate.getBoundingClientRect().bottom > rootTop, ); const turnId = turn?.dataset.turnId; if (!turn || !turnId) throw new Error('no turn is on screen'); - return { turnId, top: Math.round(turn.getBoundingClientRect().top) }; + const anchor = { turnId, top: Math.round(turn.getBoundingClientRect().top) }; + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + return anchor; }, SCROLLER); - await expect.poll(loadedTurns, { timeout: 20_000 }).toBeGreaterThan(loadedBefore); + await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore); await waitForPaintedFrames(page); // The turns that arrived went above the reader, and the reader did not go // with them. Asserting the element rather than a `scrollTop` delta is the // point: a compensation computed from `scrollHeight` satisfies the delta // while putting the reader somewhere else entirely. - expect(Math.abs((await turnTop(page, anchor.turnId)) - anchor.top)).toBeLessThanOrEqual(4); + await expect.poll( + async () => Math.abs((await turnTop(page, anchor.turnId)) - anchor.top), + ).toBeLessThanOrEqual(4); }); test('history asked for at the very top of the scroller still lands above the reader', async ({ promptRailWindow: page, }) => { - const loadedTurns = () => - page - .locator('.maka-chat-message-list') - .getAttribute('data-turn-source-count') - .then((value) => Number(value)); - const loadedBefore = await loadedTurns(); + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); // The one position where the browser declines to anchor, and the one the // wheel-to-load path puts the reader in. @@ -433,7 +500,7 @@ test('history asked for at the very top of the scroller still lands above the re root.scrollTop = 0; }, SCROLLER); - await expect.poll(loadedTurns, { timeout: 20_000 }).toBeGreaterThan(loadedBefore); + await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore); await waitForPaintedFrames(page); // Anchoring resumes at an offset of one pixel, so the offset itself is the @@ -465,12 +532,11 @@ test('history asked for at the very top of the scroller still lands above the re test('following the tail does not ask for the history above it', async ({ promptRailWindow: page, }) => { - const loadedTurns = () => - page - .locator('.maka-chat-message-list') - .getAttribute('data-turn-source-count') - .then((value) => Number(value)); - const loadedBefore = await loadedTurns(); + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); // Tall enough that the tail sits inside `max(640, clientHeight * 2)`. The // resize itself is a growth signal, so the pin writes the tail and that write @@ -487,20 +553,19 @@ test('following the tail does not ask for the history above it', async ({ // Nothing arrived that the reader did not ask for. await waitForPaintedFrames(page, 12); - expect(await loadedTurns()).toBe(loadedBefore); + expect(await firstLoadedTurn()).toBe(firstBefore); }); -test('a wheel the scroller cannot act on still asks for history', async ({ +test('a wheel a short scroller cannot act on still asks for history', async ({ promptRailWindow: page, }) => { - const loadedTurns = () => - page - .locator('.maka-chat-message-list') - .getAttribute('data-turn-source-count') - .then((value) => Number(value)); - const loadedBefore = await loadedTurns(); + const firstLoadedTurn = () => page + .locator('[data-transcript-turn-id]') + .first() + .getAttribute('data-transcript-turn-id'); + const firstBefore = await firstLoadedTurn(); - await page.setViewportSize({ width: 900, height: 1500 }); + await page.setViewportSize({ width: 900, height: 4000 }); await waitForPaintedFrames(page, 6); const asked = await scrollMetrics(page); expect(asked.distance, JSON.stringify(asked)).toBeLessThanOrEqual(4); @@ -515,11 +580,5 @@ test('a wheel the scroller cannot act on still asks for history', async ({ root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); }, SCROLLER); - await expect.poll(loadedTurns, { timeout: 20_000 }).toBeGreaterThan(loadedBefore); - - // And it landed above the reader: they are where they were, with more above - // them than before. - const after = await scrollMetrics(page); - expect(after.scrollTop, `${JSON.stringify(asked)} then ${JSON.stringify(after)}`) - .toBeGreaterThan(asked.scrollTop); + await expect.poll(firstLoadedTurn, { timeout: 20_000 }).not.toBe(firstBefore); }); 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 561672602f..22b6d176bd 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,6 +26,7 @@ import { encodeDesktopTranscriptSnapshot, } from '../desktop-transcript-ipc.js'; import { + DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, } from '../../preload/transcript-contract.js'; @@ -206,10 +207,275 @@ test('drops stale transcript batches after a generation reset', () => { assert.deepEqual(store.snapshot().messages, [nextMessage]); }); +test('keeps unchanged message references stable across immutable range snapshots', () => { + const identity = { + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + }; + const firstMessage = userMessage('first', 'user-1'); + const secondMessage = assistantMessage('second', 'assistant-2'); + const store = transcriptStore(); + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, + durableThrough: 1, + durable: [{ sequence: 1, message: firstMessage }], + overlay: [], + hasOlder: false, + hasNewer: false, + })) store.accept(batch); + + const first = store.snapshot(); + assert.strictEqual(store.snapshot(), first); + assert.ok(Object.isFrozen(first)); + assert.ok(Object.isFrozen(first.messages)); + assert.ok(Object.isFrozen(first.messages[0])); + + for (const batch of encodeDesktopTranscriptChange(identity, { + durableThrough: 2, + durableUpserts: [{ sequence: 2, message: secondMessage }], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: false, + hasNewer: false, + })) store.accept(batch); + + const second = store.snapshot(); + assert.notStrictEqual(second, first); + assert.strictEqual(second.messages[0], first.messages[0]); + assert.deepEqual(second.messages, [firstMessage, secondMessage]); +}); + +test('bounds the default active transcript range by Turn identities', async () => { + const messages = Array.from({ length: 200 }, (_, sequence) => ({ + identity: sequence, + message: { + ...assistantMessage(String(sequence), `assistant-${sequence}`), + turnId: `turn-${sequence}`, + }, + })); + const bootstrapPage = transcriptPage('older', null, messages.length - 1); + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: messages.length - 1, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, messages.length - 1), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async () => ({ messages, nextCursor: null }), + async close() {}, + }); + + const replica = await DesktopTranscriptReplica.prepare(handle); + + const snapshot = replica.snapshot(); + assert.equal( + new Set(snapshot.durable.map(({ message }) => message.turnId)).size, + DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, + ); + assert.equal( + snapshot.durable[0]?.sequence, + messages.length - DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, + ); + assert.equal(snapshot.durable.at(-1)?.sequence, 199); + assert.equal(snapshot.hasOlder, true); + assert.equal(snapshot.hasNewer, false); +}); + +test('bounds the default active transcript range by presentation bytes', async () => { + const messages = syntheticLargeTranscript(); + const bootstrapPage = transcriptPage('older', null, messages.length - 1); + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: messages.length - 1, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, messages.length - 1), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async () => ({ messages, nextCursor: null }), + async close() {}, + }); + + const replica = await DesktopTranscriptReplica.prepare(handle); + + const snapshot = replica.snapshot(); + const bytes = snapshot.durable.reduce( + (total, { message }) => total + Buffer.byteLength(JSON.stringify(message), 'utf8'), + 0, + ); + assert.ok(bytes <= DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [12, 13, 14, 15]); + assert.equal(snapshot.hasOlder, true); + assert.equal(snapshot.hasNewer, false); +}); + +test('keeps an oversized latest Turn visible after bootstrap eviction', async () => { + const older = { + identity: 0, + message: { ...assistantMessage('older', 'assistant-0'), turnId: 'turn-0' }, + }; + const latest = { + identity: 1, + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'), + }; + const bootstrapPage = transcriptPage('older', null, latest.identity); + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: latest.identity, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, latest.identity), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async () => ({ messages: [older, latest], nextCursor: null }), + async close() {}, + }); + + const replica = await DesktopTranscriptReplica.prepare(handle); + + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [latest.identity]); + assert.equal(replica.snapshot().hasOlder, true); +}); + +test('keeps an oversized latest Turn visible before a trailing session note', async () => { + const latest = { + identity: 0, + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-0'), + }; + const trailingNote = { + identity: 1, + message: { + type: 'system_note' as const, + id: 'mode-change-1', + ts: 2, + kind: 'mode_change' as const, + }, + }; + const bootstrapPage = { + ...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, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { + ...bootstrapPage, + source: 'overlay', + rangeBoundarySequence: null, + protectedTurnSequence: null, + }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async () => ({ messages: [latest, trailingNote], nextCursor: null }), + async close() {}, + }); + + const replica = await DesktopTranscriptReplica.prepare(handle); + + 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, + durableCoverage: 'complete', + 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}`), + message: { + ...assistantMessage(String(sequence), `assistant-${sequence}`), + turnId: `turn-${sequence}`, + }, })); const page = (nextCursor: string | null) => ({ kind: 'page' as const, @@ -219,6 +485,8 @@ test('keeps a bounded contiguous window while moving between history and the tai throughSequence: 4, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor, }); const bootstrapPage = page('older'); @@ -239,19 +507,23 @@ test('keeps a bounded contiguous window while moving between history and the tai decodeTranscriptPage: async (candidate) => candidate === bootstrapPage ? { messages: messages.slice(3), nextCursor: 'older' } : candidate === olderPage - ? { messages: messages.slice(2, 4), nextCursor: 'older' } + ? { messages: messages.slice(1, 3), nextCursor: 'older' } : { messages: messages.slice(4), nextCursor: null }, - loadTranscriptPage: async (input) => input.anchorSequence === 4 ? olderPage : latestPage, + loadTranscriptPage: async (input) => input.anchorSequence === 3 ? olderPage : latestPage, async close() {}, }); - const maxResidentBytes = Buffer.byteLength(JSON.stringify(messages[0]!.message), 'utf8') + 1; + 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), [4]); - await replica.loadBefore(4, 128 * 1024); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2]); + 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); @@ -260,6 +532,48 @@ test('keeps a bounded contiguous window while moving between history and the tai 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, + durableCoverage: 'complete', + 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); +}); + test('delivers a mid-session tail append even while a history window is resident', async () => { // Reproduces the "active session does not show the newest message until you // switch away and back" bug. Once the resident window has been trimmed off @@ -270,7 +584,10 @@ test('delivers a mid-session tail append even while a history window is resident // and only a fresh subscription (session switch) re-read it. const messages = [0, 1, 2, 3, 4].map((sequence) => ({ identity: sequence, - message: assistantMessage(String(sequence), `assistant-${sequence}`), + message: { + ...assistantMessage(String(sequence), `assistant-${sequence}`), + turnId: `turn-${sequence}`, + }, })); const appended = { identity: 5, message: assistantMessage('5', 'assistant-5') }; const page = (nextCursor: string | null) => ({ @@ -281,6 +598,8 @@ test('delivers a mid-session tail append even while a history window is resident throughSequence: 4, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor, }); const bootstrapPage = page('older'); @@ -305,17 +624,21 @@ test('delivers a mid-session tail append even while a history window is resident ? { messages: messages.slice(3), nextCursor: 'older' } : candidate === tailPage ? { messages: [appended], nextCursor: 'older' } - : { messages: messages.slice(2, 4), 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') + 1; + 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(4, 128 * 1024); + await replica.loadBefore(3, 128 * 1024); assert.equal(replica.snapshot().hasNewer, true); changes.splice(0); @@ -327,6 +650,100 @@ test('delivers a mid-session tail append even while a history window is resident assert.equal(replica.durableThrough, 5); }); +test('keeps an oversized streaming Turn visible when its overlay settles', async () => { + const older = { + identity: 0, + message: { ...assistantMessage('older', 'assistant-0'), turnId: 'turn-0' }, + }; + const latest = { + identity: 1, + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'), + }; + const bootstrapPage = transcriptPage('older', null, older.identity); + const newerPage = { + ...transcriptPage('newer', null, latest.identity), + rangeBoundarySequence: latest.identity, + protectedTurnSequence: latest.identity, + }; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: older.identity, + durableCoverage: 'complete', + overlayMessageCount: 1, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, older.identity), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [latest.message], + decodeTranscriptPage: async (page) => page === bootstrapPage + ? { messages: [older], nextCursor: null } + : { messages: [latest], nextCursor: null }, + loadTranscriptPage: async () => newerPage, + async close() {}, + }); + const replica = await DesktopTranscriptReplica.prepare(handle); + assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), [latest.message.id]); + + await replica.advance(latest.identity); + + const snapshot = replica.snapshot(); + assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [latest.identity]); + assert.deepEqual(snapshot.overlay, []); +}); + +test('keeps an oversized settled Turn visible before a trailing session note', async () => { + const older = { + identity: 0, + message: { ...assistantMessage('older', 'assistant-0'), turnId: 'turn-0' }, + }; + const latest = { + identity: 1, + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'), + }; + const trailingNote = { + identity: 2, + message: { + type: 'system_note' as const, + id: 'mode-change-2', + ts: 3, + kind: 'mode_change' as const, + }, + }; + const bootstrapPage = transcriptPage('older', null, older.identity); + const newerPage = { + ...transcriptPage('newer', null, trailingNote.identity), + rangeBoundarySequence: trailingNote.identity, + protectedTurnSequence: latest.identity, + }; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: older.identity, + durableCoverage: 'complete', + overlayMessageCount: 1, + durable: bootstrapPage, + overlay: { ...bootstrapPage, source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [latest.message], + decodeTranscriptPage: async (page) => page === bootstrapPage + ? { messages: [older], nextCursor: null } + : { messages: [latest, trailingNote], nextCursor: null }, + loadTranscriptPage: async () => newerPage, + async close() {}, + }); + const replica = await DesktopTranscriptReplica.prepare(handle); + + await replica.advance(trailingNote.identity); + + const snapshot = replica.snapshot(); + assert.ok(snapshot.durable.some(({ sequence }) => sequence === latest.identity)); + 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 @@ -335,7 +752,10 @@ test('does not resurrect a discarded replica when a tail re-anchor is in flight' // 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}`), + message: { + ...assistantMessage(String(sequence), `assistant-${sequence}`), + turnId: `turn-${sequence}`, + }, })); const appended = { identity: 5, message: assistantMessage('5', 'assistant-5') }; const page = (nextCursor: string | null) => ({ @@ -346,6 +766,8 @@ test('does not resurrect a discarded replica when a tail re-anchor is in flight' throughSequence: 4, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor, }); const bootstrapPage = page('older'); @@ -376,7 +798,7 @@ test('does not resurrect a discarded replica when a tail re-anchor is in flight' ? { messages: messages.slice(3), nextCursor: 'older' } : candidate === tailPage ? { messages: [appended], nextCursor: 'older' } - : { messages: messages.slice(2, 4), 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, @@ -389,13 +811,17 @@ test('does not resurrect a discarded replica when a tail re-anchor is in flight' }, async close() {}, }); - const maxResidentBytes = Buffer.byteLength(JSON.stringify(messages[0]!.message), 'utf8') + 1; + 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(4, 128 * 1024); + await replica.loadBefore(3, 128 * 1024); assert.equal(replica.snapshot().hasNewer, true); changes.splice(0); @@ -430,6 +856,8 @@ test('does not resurrect a discarded replica when a history load is in flight', throughSequence: 4, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor, }); const bootstrapPage = page('older'); @@ -503,6 +931,8 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is throughSequence, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor, }); const bootstrapPage = page(null, 4); @@ -849,8 +1279,14 @@ test('forwards a larger logical history range without changing batch size', asyn sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: 1, - durable: [{ sequence: 1, message: assistantMessage('latest') }], + durableThrough: 2, + durable: [ + { sequence: 1, message: assistantMessage('earlier') }, + { + sequence: 2, + message: { ...assistantMessage('latest', 'assistant-2'), turnId: 'turn-2' }, + }, + ], overlay: [], hasOlder: true, hasNewer: false, @@ -868,9 +1304,9 @@ test('forwards a larger logical history range without changing batch size', asyn async close() {}, })); - await controller.loadBefore(512 * 1024); + await controller.loadBefore(512 * 1024, 'turn-2'); - assert.deepEqual(request, { anchorSequence: 1, maxBytes: 512 * 1024 }); + assert.deepEqual(request, { anchorSequence: 2, maxBytes: 512 * 1024 }); await controller.close(); }); @@ -962,6 +1398,8 @@ function transcriptPage( throughSequence, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor, }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts index 4318b9d004..0b82aef122 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts @@ -210,6 +210,8 @@ function emptyTranscriptPage(sessionId: string, source: 'durable' | 'overlay') { throughSequence: null, rawBytes: 0, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }; } 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 4af7966a1b..2bad979733 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 @@ -586,6 +586,8 @@ test('fences transcript range failures across same-source replica recovery', asy throughSequence: 1, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: 'older', }; return runtimeHostSessionFixture({ @@ -726,6 +728,8 @@ test('broadcasts durable admission and transcript changes from the same message' throughSequence: 0, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }), decodeTranscriptPage: async () => ({ @@ -898,6 +902,8 @@ test('finishes transcript open and replays a stale range request after replaceme throughSequence: 0, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: 'older', }, overlay: { @@ -908,6 +914,8 @@ test('finishes transcript open and replays a stale range request after replaceme throughSequence: null, rawBytes: 0, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }, }, @@ -927,6 +935,8 @@ test('finishes transcript open and replays a stale range request after replaceme throughSequence: input.throughSequence, rawBytes: 0, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }; }, @@ -1070,6 +1080,8 @@ test('coalesces transcript changes into one bounded delta while renderer deliver throughSequence: input.throughSequence, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }), decodeTranscriptPage: async (page) => { @@ -1170,6 +1182,8 @@ test('does not let one backpressured transcript consumer block another', async ( throughSequence: input.throughSequence, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }), decodeTranscriptPage: async (page) => { @@ -1275,6 +1289,8 @@ test('keeps a transcript consumer available after a delivery fails', async () => throughSequence: input.throughSequence, rawBytes: 1, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }), decodeTranscriptPage: async (page) => ({ @@ -1781,6 +1797,8 @@ test("recovers when transcript paging loses the active subscription", async () = throughSequence: 0, rawBytes: 0, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }; }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts index 6ea30bb2d9..8a22cd60a3 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts @@ -74,6 +74,8 @@ function emptyPage(sessionId: string, source: 'durable' | 'overlay'): SessionTra throughSequence: null, rawBytes: 0, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }; } diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index ba7690b948..0988ac8275 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -110,7 +110,7 @@ describe('single live-turn handoff', () => { onNew() {}, } satisfies Parameters[0])); - assert.equal((markup.match(/data-virtual-turn-id=/g) ?? []).length, 1); + assert.equal((markup.match(/data-transcript-turn-id=/g) ?? []).length, 1); assert.match(markup, /data-transient-message-id="message-pending"/); assert.match(markup, />send now { assert.doesNotMatch(markup, /maka-chat-message-loading/); assert.ok(markup.indexOf('send now') < markup.indexOf('data-turn-id="turn-1"')); assert.equal((markup.match(/data-transient-message-id="turn-1"/g) ?? []).length, 1); - assert.equal((markup.match(/data-virtual-turn-id="turn-1"/g) ?? []).length, 1); + assert.equal((markup.match(/data-transcript-turn-id="turn-1"/g) ?? []).length, 1); }); it('keeps an unresolved root transient before a live Turn that arrived before IPC settled', () => { diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index c41b994945..0730da8906 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -24,11 +24,14 @@ import { type RuntimeHostSessionProjectionSeed, } from '@maka/runtime-host/adapter'; import { RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; -import type { SessionTranscriptPage } from '@maka/runtime-host/protocol'; import { - DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, + SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + type SessionTranscriptPage, +} from '@maka/runtime-host/protocol'; +import { + DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES, - DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES, + DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, } from '../preload/transcript-contract.js'; import type { DesktopRuntimeHostSession } from './runtime-host-client.js'; @@ -36,6 +39,7 @@ export interface DesktopTranscriptReplicaOptions { readonly generation?: string; readonly maxMessageBytes?: number; readonly maxResidentBytes?: number; + readonly maxResidentTurns?: number; readonly maxOverlayBytes?: number; readonly accountPreparationBytes?: (deltaBytes: number) => void; readonly onChange?: ( @@ -79,6 +83,7 @@ export class DesktopTranscriptReplica { readonly hostEpoch: string; readonly #handle: DesktopRuntimeHostSession; readonly #maxResidentBytes: number; + readonly #maxResidentTurns: number; readonly #maxOverlayBytes: number; readonly #maxMessageBytes: number; readonly #accountPreparationBytes: (deltaBytes: number) => void; @@ -109,10 +114,12 @@ export class DesktopTranscriptReplica { this.generation = options.generation ?? randomUUID(); this.hostEpoch = handle.hostEpoch; this.#maxResidentBytes = - options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES; + options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES; + this.#maxResidentTurns = + options.maxResidentTurns ?? DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS; this.#maxOverlayBytes = options.maxOverlayBytes ?? DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES; - this.#maxMessageBytes = options.maxMessageBytes ?? DESKTOP_TRANSCRIPT_MESSAGE_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; @@ -135,7 +142,13 @@ export class DesktopTranscriptReplica { replica.#installDurable(durable.messages); replica.#hasOlder = durable.nextCursor !== null; }); - replica.#evictToBudget(); + replica.#evictToBudget( + undefined, + 'oldest', + handle.transcriptBootstrap.durable.protectedTurnSequence ?? + replica.#durableThrough ?? + undefined, + ); if (replica.#overlayBytes > replica.#maxOverlayBytes) { throw new RangeError('Desktop transcript overlay exceeds the session cache limit'); } @@ -248,7 +261,11 @@ export class DesktopTranscriptReplica { } const completedOverlayMessageIds = this.#installDurable(decoded.messages); this.#hasOlder = decoded.nextCursor !== null; - const evictedDurableSequences = this.#evictToBudget(undefined, 'newest'); + const evictedDurableSequences = this.#evictToBudget( + undefined, + 'newest', + anchor ?? undefined, + ); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); }); } @@ -303,7 +320,11 @@ export class DesktopTranscriptReplica { this.#hasOlder = loadTail ? decoded.nextCursor !== null : sequence > 0; this.#hasNewer = loadTail ? false : decoded.nextCursor !== null; evictedDurableSequences.push( - ...this.#evictToBudget(undefined, loadTail ? 'oldest' : 'newest'), + ...this.#evictToBudget( + undefined, + loadTail ? 'oldest' : 'newest', + loadTail ? (page.protectedTurnSequence ?? sequence) : sequence, + ), ); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); }); @@ -413,7 +434,11 @@ export class DesktopTranscriptReplica { expectedSequence = decoded.messages.at(-1)!.identity + 1; } const completedOverlayMessageIds = this.#installDurable(decoded.messages); - const evictedDurableSequences = this.#evictToBudget(); + const evictedDurableSequences = this.#evictToBudget( + undefined, + 'oldest', + page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, + ); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); cursor = decoded.nextCursor; }); @@ -525,19 +550,71 @@ export class DesktopTranscriptReplica { #evictToBudget( budget: number | undefined = undefined, edge: 'oldest' | 'newest' = 'oldest', + protectedSequence?: number, ): number[] { const residentBudget = budget ?? this.#maxResidentBytes + this.#overlayBytes; const evicted: number[] = []; - const direction = edge === 'oldest' ? 1 : -1; - for (const sequence of [...this.#durable.keys()].sort((left, right) => direction * (left - right))) { - if (this.#residentBytes <= residentBudget) break; + 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; - this.#durable.delete(sequence); - this.#adjustResidentBytes(-entry.encodedBytes); - if (edge === 'oldest') this.#hasOlder = true; + const turnKey = residentTurnKey(entry); + const group = turnGroups.get(turnKey); + if (group) group.push(sequence); + else turnGroups.set(turnKey, [sequence]); + } + const orderedTurns = [...turnGroups.entries()]; + let oldestIndex = 0; + let newestIndex = orderedTurns.length - 1; + let residentTurns = orderedTurns.length; + const protectedEntry = protectedSequence === undefined + ? undefined + : this.#durable.get(protectedSequence); + const protectedTurnKey = protectedEntry === undefined + ? undefined + : residentTurnKey(protectedEntry); + const protectedIndex = protectedTurnKey === undefined + ? -1 + : orderedTurns.findIndex(([turnKey]) => turnKey === protectedTurnKey); + 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 || turn[0] === protectedTurnKey) return undefined; + if (candidateEdge === 'oldest') oldestIndex += 1; + else newestIndex -= 1; + return turn; + }; + while ( + this.#residentBytes > residentBudget + || residentTurns > this.#maxResidentTurns + ) { + let evictionEdge = protectedIndex < 0 + ? edge + : protectedIndex - oldestIndex > newestIndex - protectedIndex + ? 'oldest' + : protectedIndex - oldestIndex < newestIndex - protectedIndex + ? 'newest' + : edge; + let turn = take(evictionEdge); + if (turn === undefined) { + evictionEdge = edge === 'oldest' ? 'newest' : 'oldest'; + turn = take(evictionEdge); + } + if (turn === undefined) break; + for (const sequence of turn[1]) { + 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; - evicted.push(sequence); } return evicted; } @@ -634,6 +711,16 @@ function encodedMessageBytes(message: StoredMessage): number { return Buffer.byteLength(JSON.stringify(message), 'utf8'); } +function residentTurnKey(entry: ResidentMessage): string { + const turnId = messageTurnId(entry.message); + return turnId === undefined ? `sequence:${entry.sequence}` : `turn:${turnId}`; +} + +function messageTurnId(message: StoredMessage): string | undefined { + const turnId = 'turnId' in message ? message.turnId : undefined; + return typeof turnId === 'string' ? turnId : undefined; +} + function correlationError(message: string): RuntimeHostSubscriptionError { return new RuntimeHostSubscriptionError('correlation_changed', message); } diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index a6020dc291..d38ecfaf78 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -35,8 +35,10 @@ export const E2E_FIXTURE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0); export const TURN_SESSION_ID = 'e2e-fixture-turn'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; export const PARTIAL_HISTORY_SESSION_ID = 'e2e-fixture-partial-history'; -/** Exceeds both the 64-tick rail and 100-turn mounted-window bounds. */ -export const PROMPT_RAIL_PROMPT_COUNT = 120; +/** Exceeds both the 64-tick rail and the bounded active transcript range. */ +export const PROMPT_RAIL_PROMPT_COUNT = process.env.MAKA_TRANSCRIPT_STRESS === '1' + ? 640 + : 120; export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-'; export const LONG_SIDEBAR_SESSION_COUNT = 60; export const LONG_SIDEBAR_PROJECT_ID = 'e2e-fixture-project'; diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index c14addfd01..5eef5d7571 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -26,19 +26,19 @@ import { isRuntimeHostTerminalTurn as isTerminalTurn, projectRuntimeHostInteractionRequest, } from "@maka/runtime-host/adapter"; -import type { - InteractionAnsweredSnapshot, - InteractionPendingSnapshot, - SessionDomainChange, - SessionContinuitySnapshot, - SubscriptionFrame, +import { + SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + type InteractionAnsweredSnapshot, + type InteractionPendingSnapshot, + type SessionDomainChange, + type SessionContinuitySnapshot, + type SubscriptionFrame, } from "@maka/runtime-host/protocol"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES, - DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, type DesktopTranscriptBatch, type DesktopTranscriptBatchPayload, @@ -1460,7 +1460,7 @@ function requireTranscriptRangeBytes(value: number): number { function resetDeliveryWorkingSetBytes(residentBytes: number): number { return ( - Math.min(residentBytes, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES) + + Math.min(residentBytes, SESSION_TRANSCRIPT_RANGE_MAX_BYTES) + Math.min( residentBytes, (TRANSCRIPT_DELIVERY_WINDOW * 2 + 1) * DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts index 51b1880233..41f00924c7 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -19,10 +19,9 @@ export const DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES = 128 * 1024; export const DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES = 512 * 1024; -export const DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES = 20 * 1024 * 1024; +export const DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS = 10; export const DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES = 16 * 1024 * 1024; export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; -export const DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES = 16 * 1024 * 1024; export interface DesktopTranscriptFragment { readonly source: 'durable' | 'overlay'; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f6bded74bc..b899ce429f 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2618,7 +2618,7 @@ function AppShellContent({ } catch { activeTranscriptRange = undefined; } - async function loadTranscriptHistory(target: 'earlier' | 'latest') { + async function loadTranscriptHistory(target: 'earlier' | 'latest', anchorTurnId?: string) { const controller = transcriptRangeRef.current; const sessionId = activeId; if (!controller || !sessionId || historyLoadPendingRef.current) return; @@ -2626,7 +2626,7 @@ function AppShellContent({ setHistoryLoadPendingSessionId(sessionId); try { if (target === 'earlier') { - await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchorTurnId); } else { await controller.loadLatest(); } @@ -2902,6 +2902,7 @@ function AppShellContent({ // authority there, and the composer never remounts for any of // them — its contenteditable DOM carries the live draft. scrollOwner="host" + data-maka-onboarding={showOnboardingHero ? 'true' : undefined} scrollToBottomLabel={ desktopConversationCopy.actions.scrollMainToBottom } @@ -3098,7 +3099,8 @@ function AppShellContent({ hasOlderHistory={activeTranscriptRange?.hasOlder === true} hasNewerHistory={activeTranscriptRange?.hasNewer === true} historyLoadPending={historyLoadPendingSessionId === activeId} - onLoadEarlierHistory={() => loadTranscriptHistory('earlier')} + onLoadEarlierHistory={(anchorTurnId) => + loadTranscriptHistory('earlier', anchorTurnId)} onReturnToLatestHistory={() => loadTranscriptHistory('latest')} liveContentSeedRevision={liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index d993aca8aa..26ab8a7e4e 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -91,7 +91,7 @@ interface ChatMessageSurfaceProps extends Omit< hasOlderHistory: boolean; hasNewerHistory: boolean; historyLoadPending: boolean; - onLoadEarlierHistory: () => Promise | void; + onLoadEarlierHistory: (anchorTurnId?: string) => Promise | void; onReturnToLatestHistory: () => Promise | void; } diff --git a/apps/desktop/src/renderer/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/desktop-transcript-range-store.ts index de5500bd3a..1738e40d37 100644 --- a/apps/desktop/src/renderer/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/desktop-transcript-range-store.ts @@ -31,7 +31,7 @@ export interface DesktopTranscriptRangeController { readonly store: DesktopTranscriptRangeStore; ready(): Promise; waitForDurableMessage(messageId: string, timeoutMs: number): Promise; - loadBefore(maxBytes?: number): Promise; + loadBefore(maxBytes?: number, anchorTurnId?: string): Promise; loadAround(sequence: number): Promise; loadLatest(): Promise; reload(): Promise; @@ -58,10 +58,15 @@ export function createDesktopTranscriptRangeController( await current(); return store.waitForDurableMessage(messageId, timeoutMs); }, - async loadBefore(maxBytes) { + async loadBefore(maxBytes, anchorTurnId) { const range = store.range(); if (!range.hasOlder) return; - await (await current()).loadBefore(range.oldestSequence, maxBytes); + await (await current()).loadBefore( + anchorTurnId === undefined + ? range.oldestSequence + : store.sequenceForTurn(anchorTurnId) ?? range.oldestSequence, + maxBytes, + ); }, async loadAround(sequence) { await (await current()).loadAround(sequence); @@ -104,6 +109,7 @@ interface PendingRecord { interface StoredRecord { readonly message: StoredMessage; + readonly encoded: string; } interface OverlayRecord extends StoredRecord { @@ -132,6 +138,8 @@ export class DesktopTranscriptRangeStore { readonly #expectedSessionId: string; readonly #durable = new Map(); readonly #overlay = new Map(); + readonly #durableOrder: number[] = []; + readonly #overlayOrder: string[] = []; readonly #pending = new Map(); #sourceSessionId: string | undefined; #generation: string | undefined; @@ -144,6 +152,7 @@ export class DesktopTranscriptRangeStore { #hasNewer = false; #ready = false; #batchChanged = false; + #snapshot: DesktopTranscriptRangeSnapshot | undefined; readonly #durableWaiters = new Set<() => void>(); constructor(sessionKey: string) { @@ -172,12 +181,16 @@ export class DesktopTranscriptRangeStore { this.#hasNewer = batch.hasNewer; for (const sequence of batch.evictedDurableSequences) { if (this.#durable.delete(sequence)) { + removeOrdered(this.#durableOrder, sequence); this.#refreshSequenceBounds(sequence); changed = true; } } for (const messageId of batch.completedOverlayMessageIds) { - changed = this.#overlay.delete(messageId) || changed; + if (this.#overlay.delete(messageId)) { + removeOrdered(this.#overlayOrder, messageId); + changed = true; + } } for (const fragment of batch.fragments) { changed = this.#acceptFragment(fragment) || changed; @@ -190,20 +203,14 @@ export class DesktopTranscriptRangeStore { if (!batch.ready) return false; const committed = this.#batchChanged; this.#batchChanged = false; + if (committed) this.#snapshot = this.#createSnapshot(); for (const notify of this.#durableWaiters) notify(); return committed; } snapshot(): DesktopTranscriptRangeSnapshot { - const range = this.range(); - const durable = [...this.#durable.entries()].sort(([left], [right]) => left - right); - const overlay = [...this.#overlay.values()].sort((left, right) => left.order - right.order); - return { - ...range, - messages: durable - .map(([, record]) => structuredClone(record.message)) - .concat(overlay.map((record) => structuredClone(record.message))), - }; + this.#snapshot ??= this.#createSnapshot(); + return this.#snapshot; } range(): DesktopTranscriptRangeState { @@ -234,6 +241,13 @@ export class DesktopTranscriptRangeStore { return this.#newestUserSequence; } + sequenceForTurn(turnId: string): number | null { + for (const sequence of this.#durableOrder) { + if (this.#durable.get(sequence)?.message.turnId === turnId) return sequence; + } + return null; + } + waitForDurableMessage(messageId: string, timeoutMs: number): Promise { if (this.hasDurableMessage(messageId)) return Promise.resolve(true); return new Promise((resolve) => { @@ -257,6 +271,8 @@ export class DesktopTranscriptRangeStore { } this.#durable.clear(); this.#overlay.clear(); + this.#durableOrder.length = 0; + this.#overlayOrder.length = 0; this.#pending.clear(); this.#sourceSessionId = batch.sessionId; this.#generation = batch.generation; @@ -269,6 +285,7 @@ export class DesktopTranscriptRangeStore { this.#hasNewer = batch.hasNewer; this.#ready = false; this.#batchChanged = false; + this.#snapshot = undefined; } #acceptFragment(fragment: DesktopTranscriptFragment): boolean { @@ -307,10 +324,10 @@ export class DesktopTranscriptRangeStore { pending.receivedBytes += bytes.byteLength; if (pending.receivedBytes < pending.totalBytes) return false; const encoded = new TextDecoder('utf-8', { fatal: true }).decode(pending.bytes); - const message = projectDesktopStoredMessage( + const message = freezeTranscriptValue(projectDesktopStoredMessage( { hostId: this.#hostId }, decodeStoredMessage(markPersisted(JSON.parse(encoded))), - ); + )); const projected = JSON.stringify(message); this.#pending.delete(key); if (pending.source === 'durable') { @@ -319,10 +336,12 @@ export class DesktopTranscriptRangeStore { } const sequence = pending.identity as number; const existing = this.#durable.get(sequence); - if (existing && JSON.stringify(existing.message) !== projected) { + if (existing && existing.encoded !== projected) { throw new Error('Desktop transcript durable record changed'); } - this.#durable.set(sequence, { message }); + if (existing) return false; + this.#durable.set(sequence, { message, encoded: projected }); + insertOrdered(this.#durableOrder, sequence, (left, right) => left - right); this.#oldestSequence = Math.min(this.#oldestSequence ?? sequence, sequence); this.#newestSequence = Math.max(this.#newestSequence ?? sequence, sequence); if (message.type === 'user') { @@ -337,24 +356,70 @@ export class DesktopTranscriptRangeStore { throw new Error('Invalid Desktop transcript overlay order'); } const existing = this.#overlay.get(pending.identity); + if ( + existing + && existing.encoded === projected + && existing.order === pending.order + ) { + return false; + } + if (existing) removeOrdered(this.#overlayOrder, pending.identity); this.#overlay.set(pending.identity, { message, + encoded: projected, order: pending.order, }); - return ( - !existing || - JSON.stringify(existing.message) !== projected || - existing.order !== pending.order + insertOrdered( + this.#overlayOrder, + pending.identity, + (left, right) => { + const order = this.#overlay.get(left)!.order - this.#overlay.get(right)!.order; + return order === 0 ? left.localeCompare(right) : order; + }, ); + return true; } #refreshSequenceBounds(deletedSequence: number): void { if (deletedSequence !== this.#oldestSequence && deletedSequence !== this.#newestSequence) return; - this.#oldestSequence = null; - this.#newestSequence = null; - for (const sequence of this.#durable.keys()) { - this.#oldestSequence = Math.min(this.#oldestSequence ?? sequence, sequence); - this.#newestSequence = Math.max(this.#newestSequence ?? sequence, sequence); - } + this.#oldestSequence = this.#durableOrder[0] ?? null; + this.#newestSequence = this.#durableOrder.at(-1) ?? null; } + + #createSnapshot(): DesktopTranscriptRangeSnapshot { + const messages = Object.freeze([ + ...this.#durableOrder.map((sequence) => this.#durable.get(sequence)!.message), + ...this.#overlayOrder.map((messageId) => this.#overlay.get(messageId)!.message), + ]); + return Object.freeze({ + ...this.range(), + messages, + }); + } +} + +function freezeTranscriptValue(value: T): T { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) freezeTranscriptValue(child); + return Object.freeze(value); +} + +function insertOrdered( + items: T[], + value: T, + compare: (left: T, right: T) => number, +): void { + let low = 0; + let high = items.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (compare(items[middle]!, value) <= 0) low = middle + 1; + else high = middle; + } + items.splice(low, 0, value); +} + +function removeOrdered(items: T[], value: T): void { + const index = items.indexOf(value); + if (index >= 0) items.splice(index, 1); } diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index dfddf83464..9fa775b629 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -52,12 +52,13 @@ overflow-anchor: auto; } -.maka-turn-virtual-item { +.maka-transcript-turn { display: flex; width: 100%; flex-direction: column; gap: var(--spacing-4); - contain: layout style; + content-visibility: auto; + contain-intrinsic-block-size: auto 280px; } .maka-chat-message-loading { diff --git a/apps/desktop/src/renderer/styles/onboarding.css b/apps/desktop/src/renderer/styles/onboarding.css index 6f1c9a6f4a..a096d564a0 100644 --- a/apps/desktop/src/renderer/styles/onboarding.css +++ b/apps/desktop/src/renderer/styles/onboarding.css @@ -56,12 +56,17 @@ an empty flex spacer before its empty state so ordinary chat heroes settle low in the message flow; here that spacer gives the full-height onboarding surface only half of the available block size. The surface then overflows - ChatLayout and its self-scroll root exposes a phantom page scrollbar. */ -.maka-chat-layout:has(.maka-onboarding-surface) { + ChatLayout and its self-scroll root exposes a phantom page scrollbar. + + AppShell already owns `showOnboardingHero` and projects it onto ChatLayout. + Keep that direct composition state here: a descendant `:has()` made every + prompt-rail `aria-current` change invalidate the entire chat subtree during + scrolling. */ +.maka-chat-layout[data-maka-onboarding='true'] { overflow-y: hidden; } -.maka-chat-layout:has(.maka-onboarding-surface) > :first-child { +.maka-chat-layout[data-maka-onboarding='true'] > :first-child { flex: 1 1 auto; padding-block-end: 0; overflow: hidden; @@ -73,9 +78,8 @@ /* ChatLayout always paints its frosted composer dock, even when the Composer itself is hidden. Onboarding owns the whole empty surface, so that dock has - no content to support and would blur the card actions behind it. Derive the - opt-out from the mounted surface instead of duplicating onboarding state. */ -.maka-chat-layout:has(.maka-onboarding-surface) > :last-child { + no content to support and would blur the card actions behind it. */ +.maka-chat-layout[data-maka-onboarding='true'] > :last-child { display: none; } diff --git a/apps/desktop/stories/onboarding.stories.tsx b/apps/desktop/stories/onboarding.stories.tsx index 09efc25f79..886e3e2cf8 100644 --- a/apps/desktop/stories/onboarding.stories.tsx +++ b/apps/desktop/stories/onboarding.stories.tsx @@ -85,7 +85,11 @@ function DetailPane(props: { children?: ReactNode }) { >
- + undefined} emptyOverride={emptyOverride} />
diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts index f6c0715795..3abca22312 100644 --- a/packages/runtime-host/src/__tests__/connection-session.test.ts +++ b/packages/runtime-host/src/__tests__/connection-session.test.ts @@ -178,6 +178,8 @@ test('transcript pages are serialized per connection before their responses are throughSequence: input.throughSequence, rawBytes: 0, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }, }; @@ -1651,6 +1653,8 @@ function transcriptBootstrapFor(sessionId: string) { data: contents.toString('base64'), }, ], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }, overlay: { @@ -1661,6 +1665,8 @@ function transcriptBootstrapFor(sessionId: string) { throughSequence: 0, rawBytes: 0, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }, }; diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts index 6ca64650b9..bca8eaf612 100644 --- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts +++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts @@ -544,6 +544,90 @@ test('decodes one bounded page without walking the remaining transcript', async assert.deepEqual(requests, []); }); +test('assembles the complete edge Turn while paging newer transcript', async () => { + const prompt = { + type: 'user' as const, + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'prompt', + }; + const answer = { + type: 'assistant' as const, + id: 'assistant-1', + turnId: 'turn-1', + ts: 2, + text: 'answer', + modelId: 'model-1', + }; + const promptBytes = Buffer.from(JSON.stringify(prompt), 'utf8'); + const answerBytes = Buffer.from(JSON.stringify(answer), 'utf8'); + const requests: string[] = []; + const initial: SessionTranscriptPage = { + ...transcriptPage({ + rawBytes: promptBytes.byteLength, + fragments: [ + { + kind: 'durable', + sequence: 0, + byteOffset: 0, + totalBytes: promptBytes.byteLength, + payloadDigest: null, + data: promptBytes.toString('base64'), + }, + ], + nextCursor: 'answer', + }), + direction: 'newer', + throughSequence: 1, + rangeBoundarySequence: 1, + protectedTurnSequence: 1, + }; + const subscription = new ClientSessionSubscription( + openResult('host-1', 'subscription-newer-turn', { + throughSequence: 1, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: initial, + overlay: { ...transcriptPage({ source: 'overlay' }), throughSequence: 1 }, + }), + async () => undefined, + async (input) => { + requests.push(input.cursor!); + return { + ...transcriptPage({ + rawBytes: answerBytes.byteLength, + fragments: [ + { + kind: 'durable', + sequence: 1, + byteOffset: 0, + totalBytes: answerBytes.byteLength, + payloadDigest: null, + data: answerBytes.toString('base64'), + }, + ], + }), + direction: 'newer', + throughSequence: 1, + rangeBoundarySequence: 1, + protectedTurnSequence: 1, + }; + }, + ); + + const decoded = await subscription.decodeTranscriptPage(initial, decodeStoredMessage); + + assert.deepEqual( + decoded.messages.map(({ identity, message }) => [identity, message.id]), + [ + [0, 'user-1'], + [1, 'assistant-1'], + ], + ); + assert.deepEqual(requests, ['answer']); +}); + test('loads and releases only the active overlay', async () => { const overlay = { type: 'user' as const, @@ -1409,6 +1493,8 @@ function transcriptPage( throughSequence: 0, rawBytes: options.rawBytes ?? 0, fragments: options.fragments ?? [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: options.nextCursor ?? null, }; } diff --git a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts index 9dd2b10fcd..e3fd95a6ba 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts @@ -19,7 +19,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { StoredMessage } from '@maka/core/session'; +import { + decodeStoredMessage as decodePersistedStoredMessage, + type StoredMessage, +} from '@maka/core/session'; +import { markPersisted } from '@maka/core/persisted-value'; +import { ClientSessionSubscription } from '../client/session-subscription.js'; +import { SESSION_CONTINUITY_SCHEMA_VERSION } from '../protocol/index.js'; import { createSessionTranscriptBootstrap, prepareSessionTranscriptOverlay, @@ -67,6 +73,8 @@ test('reads newly durable messages forward from an announced watermark', async ( ), [2, 3], ); + assert.equal(page.rangeBoundarySequence, 3); + assert.equal(page.protectedTurnSequence, 3); assert.equal(page.nextCursor, null); }); @@ -282,6 +290,295 @@ test('keeps a durable continuation when overlay bytes reduce the bootstrap budge assert.ok(bootstrap.durable.nextCursor); }); +test('opens the complete latest Turn when bootstrap starts inside its assistant', async () => { + const prompt = { ...userMessage(0, 'hello'), turnId: 'turn-1' }; + const assistant = { + ...assistantMessage(1), + turnId: 'turn-1', + text: 'x'.repeat(20 * 1024), + }; + const reader = transcriptReader([prompt, assistant]); + const { bootstrap, state } = await createSessionTranscriptBootstrap({ + reader, + sessionId: 'session-1', + subscriptionId: 'subscription-1', + throughSequence: 1, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'running', + }, + activeAssistantStreams: [], + maxBytes: 16 * 1024, + projection: 'owner', + }); + + assert.equal(bootstrap.durable.rangeBoundarySequence, 0); + assert.equal(bootstrap.durable.protectedTurnSequence, 1); + assert.ok(bootstrap.durable.nextCursor); + const subscription = new ClientSessionSubscription( + { + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + nextSequence: 1, + activeAssistantStreams: [], + transcript: bootstrap, + snapshot: { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { + sessionId: 'session-1', + metadataRevision: 1, + status: 'running', + createdAt: 1, + isArchived: false, + }, + projectionRevision: 1, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'running', + }, + goal: null, + queue: { hostEpoch: 'host-1', queueRevision: 1, steering: [], followup: [] }, + interactions: { pending: [] }, + }, + }, + async () => undefined, + (request) => readSessionTranscriptPage({ reader, state, request }), + ); + const decodeStoredMessage = (value: unknown): StoredMessage => + decodePersistedStoredMessage(markPersisted(value)); + + const decoded = await subscription.decodeTranscriptPage(bootstrap.durable, decodeStoredMessage); + + assert.deepEqual(decoded.messages, [ + { identity: 0, message: prompt }, + { identity: 1, message: assistant }, + ]); + assert.equal(decoded.nextCursor, null); +}); + +test('rejects a latest Turn that exceeds the Host range message bound', async () => { + const durable = Array.from({ length: 257 }, (_, index) => ({ + ...assistantMessage(index), + turnId: 'turn-1', + })); + + await assert.rejects( + createSessionTranscriptBootstrap({ + reader: transcriptReader(durable), + sessionId: 'session-1', + subscriptionId: 'subscription-1', + throughSequence: durable.length - 1, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'running', + }, + activeAssistantStreams: [], + maxBytes: 16 * 1024, + projection: 'owner', + }), + /Turn range exceeds its capacity limit/, + ); +}); + +test('admits a latest Turn exactly at the Host range message bound', async () => { + const durable: StoredMessage[] = [ + { ...assistantMessage(0), turnId: 'turn-before' }, + ...Array.from({ length: 256 }, (_, index) => ({ + ...assistantMessage(index + 1), + turnId: 'turn-latest', + })), + ]; + + for (const projection of ['owner', 'shared'] as const) { + const { bootstrap } = await createSessionTranscriptBootstrap({ + reader: transcriptReader(durable), + sessionId: 'session-1', + subscriptionId: `subscription-${projection}`, + throughSequence: durable.length - 1, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-before', + runId: 'run-1', + status: 'running', + }, + activeAssistantStreams: [], + maxBytes: 16 * 1024, + projection, + }); + + assert.equal(bootstrap.durable.rangeBoundarySequence, 1); + assert.equal(bootstrap.durable.protectedTurnSequence, durable.length - 1); + } +}); + +test('admits a forward Turn exactly at the Host range message bound', async () => { + for (const projection of ['owner', 'shared'] as const) { + const durable: StoredMessage[] = [{ ...assistantMessage(0), turnId: 'turn-before' }]; + const reader = transcriptReader(durable); + const subscriptionId = `subscription-${projection}`; + const { state } = await createSessionTranscriptBootstrap({ + reader, + sessionId: 'session-1', + subscriptionId, + throughSequence: 0, + rootTurn: null, + activeAssistantStreams: [], + maxBytes: 16 * 1024, + projection, + }); + durable.push( + ...Array.from({ length: 256 }, (_, index) => ({ + ...assistantMessage(index + 1), + turnId: 'turn-page', + })), + { ...assistantMessage(257), turnId: 'turn-after' }, + ); + assert.equal(updateSubscriberTranscriptHighWater(state, durable.length - 1), true); + + const page = await readSessionTranscriptPage({ + reader, + state, + request: { + subscriptionId, + source: 'durable', + direction: 'newer', + throughSequence: durable.length - 1, + cursor: null, + anchorSequence: 0, + maxBytes: 512 * 1024, + }, + }); + + assert.equal(page.rangeBoundarySequence, 256); + assert.equal(page.protectedTurnSequence, 256); + assert.ok(page.nextCursor); + } +}); + +test('protects the latest Turn when a forward page ends in a session note', async () => { + const durable: StoredMessage[] = [{ ...assistantMessage(0), turnId: 'turn-before' }]; + const reader = transcriptReader(durable); + const { state } = await createSessionTranscriptBootstrap({ + reader, + sessionId: 'session-1', + subscriptionId: 'subscription-1', + throughSequence: 0, + rootTurn: null, + activeAssistantStreams: [], + maxBytes: 16 * 1024, + projection: 'owner', + }); + durable.push( + { ...assistantMessage(1), turnId: 'turn-latest' }, + { type: 'system_note', id: 'mode-change-2', ts: 3, kind: 'mode_change' }, + ); + assert.equal(updateSubscriberTranscriptHighWater(state, 2), true); + + const page = await readSessionTranscriptPage({ + reader, + state, + request: { + subscriptionId: 'subscription-1', + source: 'durable', + direction: 'newer', + throughSequence: 2, + cursor: null, + anchorSequence: 0, + maxBytes: 512 * 1024, + }, + }); + + assert.equal(page.rangeBoundarySequence, 2); + assert.equal(page.protectedTurnSequence, 1); +}); + +test('shared paging skips a full hidden storage batch before a visible message', async () => { + const hidden = Array.from( + { length: 257 }, + (_, index): StoredMessage => ({ + type: 'permission_decision', + id: `permission-${index}`, + turnId: `turn-${index}`, + ts: index + 1, + toolUseId: `tool-${index}`, + toolName: 'write_file', + decision: 'allow', + }), + ); + const visible = userMessage(hidden.length, 'visible'); + const { bootstrap } = await createSessionTranscriptBootstrap({ + reader: transcriptReader([...hidden, visible]), + sessionId: 'session-1', + subscriptionId: 'subscription-1', + throughSequence: hidden.length, + rootTurn: null, + activeAssistantStreams: [], + maxBytes: 16 * 1024, + projection: 'shared', + }); + + assert.deepEqual( + decodeBootstrap(bootstrap.durable).map(({ id }) => id), + [visible.id], + ); + assert.equal(bootstrap.durable.nextCursor, null); +}); + +test('shared range edges cross a hidden storage batch between visible messages', async () => { + const durable: StoredMessage[] = [ + userMessage(0, 'before'), + ...Array.from( + { length: 257 }, + (_, index): StoredMessage => ({ + type: 'permission_decision', + id: `permission-between-${index}`, + turnId: `turn-hidden-${index}`, + ts: index + 2, + toolUseId: `tool-between-${index}`, + toolName: 'write_file', + decision: 'allow', + }), + ), + userMessage(258, 'after'), + ]; + const reader = transcriptReader(durable); + const { bootstrap, state } = await createSessionTranscriptBootstrap({ + reader, + sessionId: 'session-1', + subscriptionId: 'subscription-1', + throughSequence: durable.length - 1, + rootTurn: null, + activeAssistantStreams: [], + maxBytes: 16 * 1024, + projection: 'shared', + }); + + assert.equal(bootstrap.durable.rangeBoundarySequence, 0); + assert.equal(bootstrap.durable.protectedTurnSequence, 258); + + const forward = await readSessionTranscriptPage({ + reader, + state, + request: { + subscriptionId: 'subscription-1', + source: 'durable', + direction: 'newer', + throughSequence: durable.length - 1, + cursor: null, + anchorSequence: null, + maxBytes: 512 * 1024, + }, + }); + assert.equal(forward.rangeBoundarySequence, 258); + assert.equal(forward.protectedTurnSequence, 258); +}); + test('shrinks the raw bootstrap until it fits its aggregate encoded budget', async () => { const durable = Array.from({ length: 100 }, (_, index) => userMessage(index, `message-${index}`)); const { bootstrap } = await createSessionTranscriptBootstrap({ diff --git a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts index 78e73ce6b4..da33e1ce1f 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts @@ -58,6 +58,8 @@ const page = { data: Buffer.from('test').toString('base64'), }, ], + rangeBoundarySequence: 2, + protectedTurnSequence: 2, nextCursor: 'opaque-cursor', }; @@ -81,6 +83,8 @@ test('Session transcript protocol accepts bounded correlated pages and bootstrap throughSequence: 3, rawBytes: 0, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }, }; @@ -162,6 +166,27 @@ test('Session transcript protocol rejects malformed and uncorrelated values', () () => decodeSessionTranscriptPageInput({ ...input, cursor: 'cursor', anchorSequence: 2 }), isProtocolError, ); + assert.throws( + () => decodeSessionTranscriptPage({ ...page, rangeBoundarySequence: 4 }), + isProtocolError, + ); + assert.throws( + () => decodeSessionTranscriptPage({ ...page, protectedTurnSequence: 4 }), + isProtocolError, + ); + assert.throws( + () => + decodeSessionTranscriptPage({ + ...page, + source: 'overlay', + rawBytes: 0, + fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: 2, + nextCursor: null, + }), + isProtocolError, + ); assert.throws( () => decodeSessionTranscriptPage({ diff --git a/packages/runtime-host/src/client/session-subscription.ts b/packages/runtime-host/src/client/session-subscription.ts index 3a61369a59..a458dc9743 100644 --- a/packages/runtime-host/src/client/session-subscription.ts +++ b/packages/runtime-host/src/client/session-subscription.ts @@ -23,6 +23,8 @@ import { type SessionAssistantStreamIdentity, type SessionContinuitySnapshot, SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, type SubscriptionFrame, type SubscriptionOpenResult, type SessionTranscriptBootstrap, @@ -235,7 +237,15 @@ export class ClientSessionSubscription try { assembler.accept(page.fragments); let cursor = page.nextCursor; - while (assembler.continuationBytes !== null) { + let rangeBytes = page.fragments.reduce((total, fragment) => total + fragment.totalBytes, 0); + const rangeIdentities = new Set( + page.fragments.map((fragment) => + fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex, + ), + ); + let reachedBoundary = + page.rangeBoundarySequence === null || rangeIdentities.has(page.rangeBoundarySequence); + while (assembler.continuationBytes !== null || !reachedBoundary) { if (cursor === null) { throw new RuntimeHostSubscriptionError( 'correlation_changed', @@ -249,7 +259,10 @@ export class ClientSessionSubscription throughSequence: page.throughSequence, cursor, anchorSequence: null, - maxBytes: Math.min(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, assembler.continuationBytes), + maxBytes: + assembler.continuationBytes === null + ? SESSION_TRANSCRIPT_PAGE_MAX_BYTES + : Math.min(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, assembler.continuationBytes), }); if (continuation.nextCursor === requestedCursor) { throw new RuntimeHostSubscriptionError( @@ -257,7 +270,22 @@ export class ClientSessionSubscription 'Session transcript cursor did not advance', ); } + for (const fragment of continuation.fragments) { + const identity = fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex; + if (!rangeIdentities.has(identity)) { + rangeIdentities.add(identity); + rangeBytes += fragment.totalBytes; + } + } + if ( + rangeBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES || + rangeIdentities.size > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES + ) { + throw new RangeError('Session transcript range exceeds the local capacity limit'); + } assembler.accept(continuation.fragments); + reachedBoundary = + page.rangeBoundarySequence === null || rangeIdentities.has(page.rangeBoundarySequence); cursor = continuation.nextCursor; } return { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 7d9f06ddf4..8b03815a81 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 72 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 73 as const; +// 73: Transcript pages carry a Host-owned Turn range boundary. Older peers +// cannot preserve both the complete edge Turn and the bounded projection. // 71: Session Guests can submit durable exact Turn access requests and Owners // can decide them. Older peers do not understand this execution-authority flow. // 70: Session Guest connections receive resource-scoped shared catalog and diff --git a/packages/runtime-host/src/protocol/session-transcript.ts b/packages/runtime-host/src/protocol/session-transcript.ts index d54f791491..e50b0d03e9 100644 --- a/packages/runtime-host/src/protocol/session-transcript.ts +++ b/packages/runtime-host/src/protocol/session-transcript.ts @@ -32,6 +32,8 @@ import { defineOperation } from './operation-spec.js'; export const SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES = 16 * 1024; export const SESSION_TRANSCRIPT_PAGE_MAX_BYTES = 512 * 1024; export const SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES = 256; +export const SESSION_TRANSCRIPT_RANGE_MAX_BYTES = 16 * 1024 * 1024; +export const SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES = SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES; export const SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES = 4_096; export const SESSION_TRANSCRIPT_PAGE_RESULT_MAX_BYTES = 744 * 1024; export const SESSION_TRANSCRIPT_CURSOR_MAX_BYTES = 1024; @@ -64,6 +66,10 @@ export interface SessionTranscriptPage { readonly throughSequence: number | null; readonly rawBytes: number; readonly fragments: readonly SessionTranscriptFragment[]; + /** Host-selected far edge that the client must assemble before publishing this range. */ + readonly rangeBoundarySequence: number | null; + /** Host-selected Turn identity that bounded consumers must retain while trimming this range. */ + readonly protectedTurnSequence: number | null; readonly nextCursor: string | null; } @@ -245,6 +251,8 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa 'throughSequence', 'rawBytes', 'fragments', + 'rangeBoundarySequence', + 'protectedTurnSequence', 'nextCursor', ]); if (result.kind !== 'page') throw invalidProtocolFrame('Invalid Session transcript page kind'); @@ -282,6 +290,28 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa 'Session transcript cursor', SESSION_TRANSCRIPT_CURSOR_MAX_BYTES, ); + const rangeBoundarySequence = + result.rangeBoundarySequence === null + ? null + : requireCount(result.rangeBoundarySequence, 'Session transcript range boundary sequence'); + const protectedTurnSequence = + result.protectedTurnSequence === null + ? null + : requireCount(result.protectedTurnSequence, 'Session transcript protected Turn sequence'); + if ( + (rangeBoundarySequence !== null && source !== 'durable') || + (rangeBoundarySequence !== null && + (throughSequence === null || rangeBoundarySequence > throughSequence)) + ) { + throw invalidProtocolFrame('Invalid Session transcript range boundary'); + } + if ( + (protectedTurnSequence !== null && source !== 'durable') || + (protectedTurnSequence !== null && + (throughSequence === null || protectedTurnSequence > throughSequence)) + ) { + throw invalidProtocolFrame('Invalid Session transcript protected Turn sequence'); + } if (fragments.length === 0 && (rawBytes !== 0 || nextCursor !== null)) { throw invalidProtocolFrame('Invalid empty Session transcript page'); } @@ -293,6 +323,8 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa throughSequence, rawBytes, fragments, + rangeBoundarySequence, + protectedTurnSequence, nextCursor, }; } diff --git a/packages/runtime-host/src/server/session-transcript-pager.ts b/packages/runtime-host/src/server/session-transcript-pager.ts index 5c7dba3e0e..d1e20d605d 100644 --- a/packages/runtime-host/src/server/session-transcript-pager.ts +++ b/packages/runtime-host/src/server/session-transcript-pager.ts @@ -21,6 +21,8 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import type { StoredMessage } from '@maka/core/session'; import { SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, + SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, type SessionTranscriptBootstrap, type SessionTranscriptFragment, type SessionTranscriptPage, @@ -38,6 +40,11 @@ import { projectSharedSessionTranscriptMessage } from './shared-session-transcri type SessionTranscriptProjection = 'owner' | 'shared'; +// Leave one identity for completing the far-edge Turn without exceeding the +// 256-message active range. The full-range scan below admits an exactly-full +// Turn and rejects only when the lookahead proves that the Turn continues. +const DURABLE_RANGE_SEED_MAX_MESSAGES = SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES - 1; + interface TranscriptCursorState { readonly version: 1; readonly subscriptionId: string; @@ -47,6 +54,7 @@ interface TranscriptCursorState { readonly throughSequence: number | null; readonly position: number; readonly byteOffset: number | null; + readonly rangeBoundarySequence: number | null; } export interface SubscriberTranscriptState { @@ -109,7 +117,7 @@ export async function createSessionTranscriptBootstrap(input: { direction: 'older', throughSequence: input.throughSequence, maxBytes: durableBudget, - maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, + maxMessages: DURABLE_RANGE_SEED_MAX_MESSAGES, } as const; const durableStorage = projection === 'shared' @@ -127,11 +135,27 @@ export async function createSessionTranscriptBootstrap(input: { cursorSecret, projection, }; + const durableSelection = storageSelection(durableStorage); + const rangeEdges = await readRangeEdges({ + reader: input.reader, + state, + direction: 'older', + throughSequence: input.throughSequence, + selected: durableSelection, + }); const bootstrap: SessionTranscriptBootstrap = { throughSequence: input.throughSequence, durableCoverage: projection === 'shared' ? 'projected' : 'complete', overlayMessageCount: overlayMessages.length, - durable: pageFromSelection(state, 'durable', 'older', storageSelection(durableStorage)), + durable: pageFromSelection( + state, + 'durable', + 'older', + durableSelection, + input.throughSequence, + rangeEdges.rangeBoundarySequence, + rangeEdges.protectedTurnSequence, + ), overlay: pageFromSelection(state, 'overlay', 'older', selectedOverlay), }; const encodedBytes = Buffer.byteLength(JSON.stringify(bootstrap), 'utf8'); @@ -200,7 +224,7 @@ export async function readSessionTranscriptPage(input: { position.position, position.byteOffset, request.maxBytes, - SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, + continuationMessageLimit(position), ); return pageFromSelection( state, @@ -217,83 +241,281 @@ export async function readSessionTranscriptPage(input: { position: position.position, ...(position.byteOffset === null ? {} : { byteOffset: position.byteOffset }), maxBytes: request.maxBytes, - maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, + maxMessages: continuationMessageLimit(position), } as const; const storage = state.projection === 'shared' - ? await readSharedDurablePage(input.reader, state.sessionId, durableRequest) + ? await readSharedDurablePage( + input.reader, + state.sessionId, + durableRequest, + position.rangeBoundarySequence, + ) : await input.reader.readDurablePage(state.sessionId, durableRequest); + const selected = storageSelection(storage); + const rangeEdges = await readRangeEdges({ + reader: input.reader, + state, + direction: request.direction, + throughSequence: request.throughSequence, + selected, + }); return pageFromSelection( state, 'durable', request.direction, - storageSelection(storage), + selected, request.throughSequence, + rangeEdges.rangeBoundarySequence, + rangeEdges.protectedTurnSequence, + ); +} + +async function readRangeEdges(input: { + reader: SessionTranscriptReader; + state: SubscriberTranscriptState; + direction: SessionTranscriptPageDirection; + throughSequence: number | null; + selected: SelectedFragments; +}): Promise<{ + readonly rangeBoundarySequence: number | null; + readonly protectedTurnSequence: number | null; +}> { + if (input.throughSequence === null) { + return { rangeBoundarySequence: null, protectedTurnSequence: null }; + } + const selectedSequences = input.selected.fragments.flatMap((fragment) => + fragment.kind === 'durable' ? [fragment.sequence] : [], ); + if (selectedSequences.length === 0) { + return { rangeBoundarySequence: null, protectedTurnSequence: null }; + } + const boundaryCandidate = + input.direction === 'older' ? Math.min(...selectedSequences) : Math.max(...selectedSequences); + const scanPosition = + input.direction === 'older' ? Math.max(...selectedSequences) : Math.min(...selectedSequences); + let targetTurnId: string | undefined; + let boundary: number | null = null; + let protectedTurnId: string | undefined; + let protectedTurnSequence: number | null = null; + let latestTurnSequence: number | null = null; + let rangeBytes = 0; + let rangeMessages = 0; + let hiddenBytes = 0; + let reachedFarEdge = false; + let position: number | null = scanPosition; + while (position !== null && !reachedFarEdge) { + const scanned = await input.reader.readDurableRecords(input.state.sessionId, { + direction: input.direction, + throughSequence: input.throughSequence, + position, + maxStoredBytes: SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + maxMessages: SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, + }); + for (const record of scanned.records) { + const message = + input.state.projection === 'shared' + ? projectSharedSessionTranscriptMessage(record.message, input.state.sessionId) + : record.message; + if (!message) { + hiddenBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); + if (hiddenBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES) { + throw new RangeError('Session transcript projection scan exceeds its capacity limit'); + } + continue; + } + const turnId = messageTurnId(message); + if (boundary !== null && turnId !== targetTurnId) { + reachedFarEdge = true; + break; + } + rangeMessages += 1; + rangeBytes += Buffer.byteLength(JSON.stringify(message), 'utf8'); + if ( + rangeMessages > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES || + rangeBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES + ) { + throw new RangeError('Session transcript Turn range exceeds its capacity limit'); + } + if (input.direction === 'newer' && turnId !== undefined) { + latestTurnSequence = record.sequence; + } + if (input.direction === 'older' && protectedTurnSequence === null) { + if (protectedTurnId === undefined && turnId !== undefined) protectedTurnId = turnId; + if (turnId === protectedTurnId) protectedTurnSequence = record.sequence; + } + if (targetTurnId === undefined && record.sequence === boundaryCandidate) { + if (turnId === undefined) { + boundary = record.sequence; + reachedFarEdge = true; + break; + } + targetTurnId = turnId; + } + if (turnId === targetTurnId) { + boundary = record.sequence; + } + } + if (reachedFarEdge || scanned.nextPosition === null) { + position = scanned.nextPosition; + break; + } + if (scanned.nextPosition === position) { + throw new Error('Session transcript projection scan did not advance'); + } + position = scanned.nextPosition; + } + if (boundary === null) { + throw new Error('Session transcript range did not reach its authoritative Turn'); + } + if (!reachedFarEdge && position !== null) { + const lookahead = await readNextProjectedDurableRecord({ + reader: input.reader, + state: input.state, + direction: input.direction, + throughSequence: input.throughSequence, + position, + }); + if (lookahead && messageTurnId(lookahead.message) === targetTurnId) { + throw new RangeError('Session transcript Turn range exceeds its capacity limit'); + } + } + return { + rangeBoundarySequence: boundary, + protectedTurnSequence: + input.direction === 'older' + ? (protectedTurnSequence ?? boundary) + : (latestTurnSequence ?? boundary), + }; +} + +async function readNextProjectedDurableRecord(input: { + reader: SessionTranscriptReader; + state: SubscriberTranscriptState; + direction: SessionTranscriptPageDirection; + throughSequence: number; + position: number; +}): Promise<{ readonly sequence: number; readonly message: StoredMessage } | null> { + let position: number | null = input.position; + let hiddenBytes = 0; + while (position !== null) { + const scanned = await input.reader.readDurableRecords(input.state.sessionId, { + direction: input.direction, + throughSequence: input.throughSequence, + position, + maxStoredBytes: SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + maxMessages: SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, + }); + for (const record of scanned.records) { + const projected = + input.state.projection === 'shared' + ? projectSharedSessionTranscriptMessage(record.message, input.state.sessionId) + : record.message; + if (projected) return { sequence: record.sequence, message: projected }; + hiddenBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); + if (hiddenBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES) { + throw new RangeError('Session transcript projection scan exceeds its capacity limit'); + } + } + if (scanned.nextPosition === position) { + throw new Error('Session transcript projection scan did not advance'); + } + position = scanned.nextPosition; + } + return null; +} + +function messageTurnId(message: StoredMessage): string | undefined { + const turnId = 'turnId' in message ? message.turnId : undefined; + return typeof turnId === 'string' ? turnId : undefined; } async function readSharedDurablePage( reader: SessionTranscriptReader, sessionId: string, request: Parameters[1], + rangeBoundarySequence: number | null = null, ): ReturnType { const position = request.position ?? (request.direction === 'older' ? (request.throughSequence ?? undefined) : 0); - const scanned = await reader.readDurableRecords(sessionId, { - direction: request.direction, - ...(request.throughSequence === undefined ? {} : { throughSequence: request.throughSequence }), - ...(position === undefined ? {} : { position }), - maxStoredBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, - maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, - }); const fragments: Awaited< ReturnType >['fragments'][number][] = []; let rawBytes = 0; + let hiddenBytes = 0; let next: { position: number; byteOffset: number | null } | null = null; - let recordIndex = 0; - for (; recordIndex < scanned.records.length; recordIndex += 1) { - const record = scanned.records[recordIndex]!; - const projected = projectSharedSessionTranscriptMessage(record.message, sessionId); - if (!projected) continue; - const bytes = Buffer.from(JSON.stringify(projected), 'utf8'); - const continuationOffset = - record.sequence === position && request.byteOffset !== undefined ? request.byteOffset : null; - const selected = selectBuffer( - bytes, - request.direction, - continuationOffset, - request.maxBytes - rawBytes, - ); - if (!selected) break; - fragments.push({ - sequence: record.sequence, - byteOffset: selected.byteOffset, - totalBytes: bytes.byteLength, - payloadDigest: null, - data: selected.data, + let scanPosition = position; + let throughSequence = request.throughSequence ?? null; + while (scanPosition !== undefined && next === null) { + const scanned = await reader.readDurableRecords(sessionId, { + direction: request.direction, + ...(request.throughSequence === undefined + ? {} + : { throughSequence: request.throughSequence }), + position: scanPosition, + maxStoredBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, + maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, }); - rawBytes += selected.data.byteLength; - if (!selected.complete) { - next = { position: record.sequence, byteOffset: selected.nextOffset }; - break; + throughSequence = scanned.throughSequence; + let recordIndex = 0; + for (; recordIndex < scanned.records.length; recordIndex += 1) { + const record = scanned.records[recordIndex]!; + const projected = projectSharedSessionTranscriptMessage(record.message, sessionId); + if (!projected) { + hiddenBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); + if (hiddenBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES) { + throw new RangeError('Session transcript projection scan exceeds its capacity limit'); + } + continue; + } + const bytes = Buffer.from(JSON.stringify(projected), 'utf8'); + const continuationOffset = + record.sequence === position && request.byteOffset !== undefined + ? request.byteOffset + : null; + const selected = selectBuffer( + bytes, + request.direction, + continuationOffset, + request.maxBytes - rawBytes, + ); + if (!selected) { + next = { position: record.sequence, byteOffset: null }; + break; + } + fragments.push({ + sequence: record.sequence, + byteOffset: selected.byteOffset, + totalBytes: bytes.byteLength, + payloadDigest: null, + data: selected.data, + }); + rawBytes += selected.data.byteLength; + if (!selected.complete) { + next = { position: record.sequence, byteOffset: selected.nextOffset }; + break; + } + if (record.sequence === rangeBoundarySequence) { + const following = scanned.records[recordIndex + 1]?.sequence ?? scanned.nextPosition; + next = following === null ? null : { position: following, byteOffset: null }; + break; + } + if (fragments.length === request.maxMessages || rawBytes === request.maxBytes) { + const following = scanned.records[recordIndex + 1]?.sequence ?? scanned.nextPosition; + next = following === null ? null : { position: following, byteOffset: null }; + break; + } } - if (fragments.length === request.maxMessages || rawBytes === request.maxBytes) { - recordIndex += 1; - break; + if (next !== null) break; + if (scanned.nextPosition === null) break; + if (scanned.nextPosition === scanPosition) { + throw new Error('Session transcript projection scan did not advance'); } - } - if (!next) { - next = - recordIndex < scanned.records.length - ? { position: scanned.records[recordIndex]!.sequence, byteOffset: null } - : scanned.nextPosition === null - ? null - : { position: scanned.nextPosition, byteOffset: null }; + scanPosition = scanned.nextPosition; } return { - throughSequence: scanned.throughSequence, + throughSequence, fragments, rawBytes, next, @@ -327,7 +549,11 @@ export class TranscriptPageRequestError extends Error { function resolvePosition( state: SubscriberTranscriptState, request: SessionTranscriptPageInput, -): { position: number; byteOffset: number | null } | null { +): { + position: number; + byteOffset: number | null; + rangeBoundarySequence: number | null; +} | null { if (request.cursor !== null) { const cursor = decodeCursor(request.cursor, state.cursorSecret); if ( @@ -339,7 +565,11 @@ function resolvePosition( ) { throw new TranscriptPageRequestError('Transcript cursor does not match request'); } - return { position: cursor.position, byteOffset: cursor.byteOffset }; + return { + position: cursor.position, + byteOffset: cursor.byteOffset, + rangeBoundarySequence: cursor.rangeBoundarySequence, + }; } if (request.source === 'overlay') { const overlayMessages = state.overlayMessages; @@ -349,14 +579,28 @@ function resolvePosition( request.direction === 'older' ? (anchor ?? overlayMessages.length) - 1 : (anchor ?? -1) + 1; return position < 0 || position >= overlayMessages.length ? null - : { position, byteOffset: null }; + : { position, byteOffset: null, rangeBoundarySequence: null }; } if (request.throughSequence === null) return null; const position = request.direction === 'older' ? (request.anchorSequence ?? request.throughSequence + 1) - 1 : (request.anchorSequence ?? -1) + 1; - return position < 0 || position > request.throughSequence ? null : { position, byteOffset: null }; + return position < 0 || position > request.throughSequence + ? null + : { position, byteOffset: null, rangeBoundarySequence: null }; +} + +function continuationMessageLimit(position: { + position: number; + rangeBoundarySequence: number | null; +}): number { + return position.rangeBoundarySequence === null + ? DURABLE_RANGE_SEED_MAX_MESSAGES + : Math.min( + DURABLE_RANGE_SEED_MAX_MESSAGES, + Math.abs(position.position - position.rangeBoundarySequence) + 1, + ); } function storageSelection( @@ -465,6 +709,8 @@ function pageFromSelection( direction: SessionTranscriptPageDirection, selected: SelectedFragments, throughSequence: number | null = state.openedThroughSequence, + rangeBoundarySequence: number | null = null, + protectedTurnSequence: number | null = null, ): SessionTranscriptPage { return { kind: 'page', @@ -474,6 +720,8 @@ function pageFromSelection( throughSequence, rawBytes: selected.rawBytes, fragments: selected.fragments, + rangeBoundarySequence, + protectedTurnSequence, nextCursor: selected.next ? encodeCursor( { @@ -483,6 +731,7 @@ function pageFromSelection( source, direction, throughSequence, + rangeBoundarySequence, ...selected.next, }, state.cursorSecret, @@ -503,6 +752,8 @@ function emptyPage( throughSequence: request.throughSequence, rawBytes: 0, fragments: [], + rangeBoundarySequence: null, + protectedTurnSequence: null, nextCursor: null, }; } @@ -546,6 +797,7 @@ function decodeCursor(value: string, secret: Buffer): TranscriptCursorState { 'throughSequence', 'position', 'byteOffset', + 'rangeBoundarySequence', ]; if ( Object.keys(cursor).length !== keys.length || @@ -561,7 +813,8 @@ function decodeCursor(value: string, secret: Buffer): TranscriptCursorState { (cursor.direction !== 'older' && cursor.direction !== 'newer') || (cursor.throughSequence !== null && !isCount(cursor.throughSequence)) || !isCount(cursor.position) || - (cursor.byteOffset !== null && !isCount(cursor.byteOffset)) + (cursor.byteOffset !== null && !isCount(cursor.byteOffset)) || + (cursor.rangeBoundarySequence !== null && !isCount(cursor.rangeBoundarySequence)) ) { throw new TranscriptPageRequestError('Invalid transcript cursor values'); } diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index 3812b11441..b1e806c628 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -32,7 +32,7 @@ import { /** * The e2e suite cannot stage what these cover. Whether a jump survives depends - * on which frame the virtual window lands on, and the e2e case went green + * on which frame the requested Host range lands, and the e2e case went green * against a renderer that did not survive it. Driving the frames here makes it * deterministic. */ diff --git a/packages/ui/src/__tests__/turn-height-index.test.ts b/packages/ui/src/__tests__/turn-height-index.test.ts deleted file mode 100644 index 1bb1365ec4..0000000000 --- a/packages/ui/src/__tests__/turn-height-index.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { createTurnHeightIndex } from '../turn-height-index.js'; - -describe('turn height index', () => { - it('keeps measurements for the current layout and bounds old sessions and turns', () => { - const index = createTurnHeightIndex(2, 2); - assert.equal(index.record('s1', 'wide', 'a', 100), true); - assert.equal(index.record('s1', 'wide', 'a', 100.2), false); - index.record('s1', 'wide', 'b', 200); - index.record('s1', 'wide', 'c', 300); - assert.equal(index.lookup('s1', 'wide')?.has('a'), false); - index.record('s2', 'wide', 'a', 100); - index.record('s3', 'wide', 'a', 100); - assert.equal(index.lookup('s1', 'wide'), undefined); - }); - - it('does not reuse heights after the layout changes', () => { - const index = createTurnHeightIndex(); - index.record('s1', 'wide', 'a', 100); - index.record('s1', 'narrow', 'a', 200); - assert.equal(index.lookup('s1', 'wide'), undefined); - assert.equal(index.lookup('s1', 'narrow')?.get('a'), 200); - }); -}); diff --git a/packages/ui/src/__tests__/turn-virtualizer.test.ts b/packages/ui/src/__tests__/turn-virtualizer.test.ts deleted file mode 100644 index 59ab222da3..0000000000 --- a/packages/ui/src/__tests__/turn-virtualizer.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { - buildTurnVirtualLayout, - estimatedTurnHeight, - initialTurnVirtualWindow, - reconcileTurnVirtualWindow, - stableTurnVirtualWindowForViewport, - turnVirtualWindowForRange, - turnVirtualWindowForViewport, - turnVirtualizationRequired, -} from '../turn-virtualizer.js'; - -const ids = (count: number) => Array.from({ length: count }, (_, index) => `t${index}`); - -describe('turn virtualizer', () => { - it('uses observed turns to estimate unmeasured history', () => { - assert.equal(estimatedTurnHeight(undefined), 280); - assert.equal(estimatedTurnHeight(new Map([['short', 100]])), 190); - assert.equal(estimatedTurnHeight(new Map([['a', 600], ['b', 1_000]])), 1_880 / 3); - }); - - it('starts with a bounded tail and exact spacer geometry', () => { - const layout = buildTurnVirtualLayout(ids(200), undefined, { estimatedHeight: 100, gap: 4 }); - const window = initialTurnVirtualWindow(layout, 40); - assert.deepEqual({ start: window.start, end: window.end }, { start: 160, end: 200 }); - assert.equal(window.beforeHeight, (160 * 100) + (159 * 4)); - assert.equal(window.afterHeight, 0); - }); - - it('recomputes spacer geometry without changing the mounted range', () => { - const turnIds = ids(100); - const estimated = buildTurnVirtualLayout(turnIds, undefined, { estimatedHeight: 100 }); - const initial = initialTurnVirtualWindow(estimated, 40); - const measured = buildTurnVirtualLayout( - turnIds, - new Map(turnIds.slice(0, 60).map((id) => [id, 200])), - { estimatedHeight: 100 }, - ); - const updated = turnVirtualWindowForRange(measured, initial.start, initial.end); - assert.deepEqual( - { start: updated.start, end: updated.end }, - { start: initial.start, end: initial.end }, - ); - assert.ok(updated.beforeHeight > initial.beforeHeight); - }); - - it('uses measurements and keeps a pixel overscan without exceeding the hard cap', () => { - const turnIds = ids(500); - const heights = new Map(turnIds.map((id, index) => [id, index % 2 === 0 ? 40 : 400])); - const layout = buildTurnVirtualLayout(turnIds, heights); - const window = turnVirtualWindowForViewport( - layout, - { scrollTop: 40_000, clientHeight: 900 }, - { overscanPx: 1_200, preferredTurns: 60, maxTurns: 100 }, - ); - assert.ok(window.end - window.start >= 60); - assert.ok(window.end - window.start <= 100); - assert.ok(layout.offsets[window.start]! <= 40_000); - assert.ok(layout.offsets[window.end]! >= 40_900); - }); - - it('keeps the mounted window stable until the viewport reaches its overscan edge', () => { - const layout = buildTurnVirtualLayout( - ids(120), - undefined, - { estimatedHeight: 200, gap: 4 }, - ); - const current = turnVirtualWindowForViewport( - layout, - { scrollTop: 0, clientHeight: 800 }, - ); - assert.deepEqual([current.start, current.end], [0, 60]); - - const inside = stableTurnVirtualWindowForViewport( - layout, - current, - { scrollTop: 8_000, clientHeight: 800 }, - ); - assert.deepEqual([inside.start, inside.end], [0, 60]); - - const crossed = stableTurnVirtualWindowForViewport( - layout, - current, - { scrollTop: 11_000, clientHeight: 800 }, - ); - assert.equal(crossed.end - crossed.start, 60); - assert.equal(crossed.start, current.start + 8); - }); - - it('retains focused and selected turns when a directional shift fits the hard cap', () => { - const layout = buildTurnVirtualLayout( - ids(120), - undefined, - { estimatedHeight: 200, gap: 4 }, - ); - const current = turnVirtualWindowForRange(layout, 0, 60); - const shifted = stableTurnVirtualWindowForViewport( - layout, - current, - { scrollTop: 11_000, clientHeight: 800 }, - { retainRange: { start: 0, end: 8 } }, - ); - - assert.deepEqual([shifted.start, shifted.end], [0, 68]); - }); - - it('keeps visible identities across prepends and batched appends', () => { - const beforeIds = ids(100); - const beforeLayout = buildTurnVirtualLayout(beforeIds, undefined); - const before = turnVirtualWindowForViewport(beforeLayout, { scrollTop: 8_000, clientHeight: 800 }); - const prepended = ['p0', 'p1', ...beforeIds]; - const afterPrepend = reconcileTurnVirtualWindow( - beforeIds, - buildTurnVirtualLayout(prepended, undefined), - before, - ); - assert.equal(prepended[afterPrepend.start], beforeIds[before.start]); - assert.equal(prepended[afterPrepend.end - 1], beforeIds[before.end - 1]); - - const tail = initialTurnVirtualWindow(beforeLayout, 40); - const appended = [...beforeIds, ...ids(80).map((id) => `a${id}`)]; - const afterAppend = reconcileTurnVirtualWindow( - beforeIds, - buildTurnVirtualLayout(appended, undefined), - tail, - ); - assert.equal(appended[afterAppend.start], beforeIds[tail.start]); - assert.equal(appended[afterAppend.end - 1], beforeIds[tail.end - 1]); - assert.equal(afterAppend.end - afterAppend.start, 40); - }); - - it('virtualizes by rendered distance before the turn-count cap', () => { - const compact = buildTurnVirtualLayout(ids(8), undefined, { estimatedHeight: 90 }); - const tall = buildTurnVirtualLayout(ids(8), undefined, { estimatedHeight: 500 }); - - assert.equal(turnVirtualizationRequired(compact, 800), false); - assert.equal(turnVirtualizationRequired(compact, 800, 4_000), true); - assert.equal(turnVirtualizationRequired(tall, 800), true); - assert.equal(turnVirtualizationRequired(buildTurnVirtualLayout(ids(101), undefined), undefined), true); - }); - - it('expands a window that cannot cover the viewport without oscillating', () => { - const layout = buildTurnVirtualLayout( - ids(10), - new Map([ - ['t4', 4_800], - ['t5', 1_000], - ]), - ); - const undersized = turnVirtualWindowForRange(layout, 4, 5); - const recovered = stableTurnVirtualWindowForViewport( - layout, - undersized, - { scrollTop: 5_000, clientHeight: 800 }, - ); - - assert.deepEqual([recovered.start, recovered.end], [0, 10]); - assert.deepEqual( - stableTurnVirtualWindowForViewport( - layout, - recovered, - { scrollTop: 5_000, clientHeight: 800 }, - ), - recovered, - ); - }); - - it('brings an explicit target into the bounded window', () => { - const turnIds = ids(1_000); - const layout = buildTurnVirtualLayout(turnIds, undefined); - const tail = initialTurnVirtualWindow(layout, 40); - const target = reconcileTurnVirtualWindow(turnIds, layout, tail, 10); - assert.ok(target.start <= 10 && target.end > 10); - assert.ok(target.end - target.start <= 100); - }); - - it('keeps the viewport bounded when a retained turn is too far away', () => { - const layout = buildTurnVirtualLayout(ids(1_000), undefined, { estimatedHeight: 100 }); - const window = turnVirtualWindowForViewport( - layout, - { scrollTop: 80_000, clientHeight: 800 }, - { maxTurns: 100, ensureIndex: 10 }, - ); - assert.equal(window.end - window.start, 100); - assert.ok(layout.offsets[window.start]! <= 80_000); - assert.ok(layout.offsets[window.end]! >= 80_800); - }); -}); diff --git a/packages/ui/src/__tests__/use-turn-virtualizer.test.tsx b/packages/ui/src/__tests__/use-turn-virtualizer.test.tsx deleted file mode 100644 index 177ee68518..0000000000 --- a/packages/ui/src/__tests__/use-turn-virtualizer.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { afterEach, test } from 'node:test'; -import { act, useRef } from 'react'; -import { createRoot } from 'react-dom/client'; -import { parseHTML } from 'linkedom'; -import { useTurnVirtualizer } from '../use-turn-virtualizer.js'; - -const originalGlobals = { - document: globalThis.document, - Element: globalThis.Element, - HTMLElement: globalThis.HTMLElement, - MutationObserver: globalThis.MutationObserver, - Node: globalThis.Node, - requestAnimationFrame: globalThis.requestAnimationFrame, - cancelAnimationFrame: globalThis.cancelAnimationFrame, - ResizeObserver: globalThis.ResizeObserver, - window: globalThis.window, -}; -const originalActEnvironment = (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; -}).IS_REACT_ACT_ENVIRONMENT; - -let mountedRoot: ReturnType | undefined; - -afterEach(async () => { - if (mountedRoot) await act(() => mountedRoot?.unmount()); - mountedRoot = undefined; - Object.assign(globalThis, { - ...originalGlobals, - IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, - }); -}); - -test('remeasures a short transcript when tall turns require virtualization', async () => { - const { document, window } = parseHTML('
'); - const root = document.querySelector('#root'); - assert.ok(root); - let scrollHeight = 0; - Object.defineProperties(root, { - clientHeight: { value: 800 }, - clientWidth: { value: 800 }, - scrollHeight: { get: () => scrollHeight }, - scrollTop: { value: 0, writable: true }, - }); - Object.defineProperty(document, 'getSelection', { value: () => null }); - - const frames = new Map(); - let nextFrame = 1; - class TestResizeObserver { - static latest: TestResizeObserver | undefined; - constructor(private readonly callback: ResizeObserverCallback) { - TestResizeObserver.latest = this; - } - disconnect() {} - observe() {} - unobserve() {} - emit(entries: ResizeObserverEntry[]) { - this.callback(entries, this as unknown as ResizeObserver); - } - } - class TestMutationObserver { - disconnect() {} - observe() {} - takeRecords(): MutationRecord[] { return []; } - } - Object.assign(globalThis, { - document, - Element: window.Element, - HTMLElement: window.HTMLElement, - MutationObserver: TestMutationObserver, - Node: window.Node, - requestAnimationFrame: (callback: FrameRequestCallback) => { - const id = nextFrame; - nextFrame += 1; - frames.set(id, callback); - return id; - }, - cancelAnimationFrame: (id: number) => frames.delete(id), - ResizeObserver: TestResizeObserver, - window, - IS_REACT_ACT_ENVIRONMENT: true, - }); - - const turnIds = Array.from({ length: 8 }, (_, index) => `turn-${index}`); - function Harness() { - const scrollRef = useRef(root); - const range = useTurnVirtualizer({ sessionId: 'tall-session', turnIds, scrollRef }); - return ( -
- {turnIds.slice(range.start, range.end).map((turnId) => ( -
- ))} -
- ); - } - - mountedRoot = createRoot(root); - await act(() => mountedRoot?.render()); - assert.equal(root.querySelector('[data-range]')?.getAttribute('data-range'), '0:8'); - - const observer = TestResizeObserver.latest; - assert.ok(observer); - scrollHeight = 4_800; - const entries = Array.from(root.querySelectorAll('[data-virtual-turn-id]')).map( - (target) => ({ target, borderBoxSize: [{ blockSize: 600 }] }) as unknown as ResizeObserverEntry, - ); - await act(() => observer.emit(entries)); - await act(() => { - const pending = [...frames.values()]; - frames.clear(); - for (const callback of pending) callback(0); - }); - - assert.notEqual(root.querySelector('[data-range]')?.getAttribute('data-range'), '0:8'); -}); diff --git a/packages/ui/src/chat-surface-layout.tsx b/packages/ui/src/chat-surface-layout.tsx index ab3be1b6b5..35e60da9a5 100644 --- a/packages/ui/src/chat-surface-layout.tsx +++ b/packages/ui/src/chat-surface-layout.tsx @@ -40,7 +40,7 @@ export type ChatSurfaceLayoutProps = Omit, 'au * their own content rather than a `ChatView`. `host` turns Astryx's scroll * layer off entirely — no listeners, no spring — and hands `scrollTop` to * Maka's single authority, which is what a `ChatView` transcript needs: it - * knows turn identity, the virtual window and the navigation the reader + * knows turn identity, the Host active range and the navigation the reader * asked for, none of which a generic scroll container can see. */ scrollOwner?: 'astryx' | 'host'; diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 2eb8c288d4..80be6a215a 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -58,7 +58,6 @@ import { } from './chat-turn.js'; import { useChatScroll } from './use-chat-scroll.js'; import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; -import { useTurnVirtualizer } from './use-turn-virtualizer.js'; import { placeChatConversationItems } from './chat-conversation-items.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; @@ -264,7 +263,7 @@ export function ChatView(props: { scrollTargetTurn?: { turnId: string; nonce: number }; scrollBehavior: ScrollBehavior; hasOlderHistory?: boolean; - onLoadEarlierHistory?(): Promise | void; + onLoadEarlierHistory?(anchorTurnId?: string): Promise | void; returnToLatest?: { title: string; label: string; @@ -505,31 +504,14 @@ export function ChatView(props: { } const scrollRef = chatLayout.scrollContainerRef; const scrollAuthority = useTranscriptScrollAuthority(); - const orderedTurnIds = useMemo(() => turns.map((turn) => turn.turnId), [turns]); - const sessionId = props.activeSession?.id; - const { - start: mountStart, - end: mountEnd, - beforeHeight, - afterHeight, - revealTurn, - } = useTurnVirtualizer({ - sessionId, - turnIds: orderedTurnIds, - scrollRef, - targetTurnId: props.scrollTargetTurn?.turnId, - targetKey: props.scrollTargetTurn?.nonce, - }); const navigatePromptRailFallback = useCallback((turn: PromptAnchorRailTurn) => { - if (turnIdsRef.current.has(turn.turnId)) revealTurn(turn.turnId); - else if (turn.sequence !== undefined) { + if (!turnIdsRef.current.has(turn.turnId) && turn.sequence !== undefined) { loadTranscriptTurnRef.current?.({ turnId: turn.turnId, sequence: turn.sequence }); } - }, [revealTurn]); - const mountedTurns = turns.slice(mountStart, mountEnd); + }, []); const inlineTransientMessages = tailTurnId ? transientMessages.filter((message) => { - const turn = mountedTurns.find((candidate) => candidate.turnId === tailTurnId); + const turn = turns.find((candidate) => candidate.turnId === tailTurnId); if ( turn === undefined || turn.user !== undefined @@ -719,19 +701,12 @@ export function ChatView(props: { {showEmptyState ? null : ( <> {chat.length === 0 && !streamingActive ? emptyContent : null} - {beforeHeight > 0 && ( -