From c2cc37428feb776d5402a9bdbe8232f76593aca5 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 16 Sep 2026 09:22:46 +0800 Subject: [PATCH 01/40] fix(ui): keep fenced code blocks at the code leading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Astryx's CodeBlock splits a block over 100 lines into chunks of 20 and gives each chunk `content-visibility: auto` with a `contain-intrinsic- block-size: 20lh` placeholder. `lh` resolves against the box's own inherited line-height, and Maka's markdown contract sets 1.6 on the prose root while stripping Astryx's `:where(code, pre)` reset (scripts/build-astryx-theme.mjs), so every chunk reserved 12% more height than its lines need. Chromium then shrank the block by ~48px per chunk as it laid each one out, moving whatever the reader was on below it — the residual scroll jump that survived virtualization. Declaring the code leading on `pre` makes the placeholder and the laid out chunk the same height. Verified against Astryx 0.6.2: its CodeBlock is byte-identical here, so upgrading does not fix it. Generated-by: Claude Code --- packages/ui/src/styles.css | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index b2212f445e..891c4f2a41 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -465,14 +465,24 @@ /* Prose reads differently from chrome. Han glyphs fill the whole em box, so the 20px body leading that suits a control row leaves CJK paragraphs with no air between lines; 1.6 is the common CJK reading ratio. Inherited from - the document root: headings and fenced code declare their own leading and - keep it, inline code follows the line it sits in, which is what keeps that - line's box even. `text-autospace` puts the conventional hair space between - Han and Latin or digit runs when the model did not type one. */ + the document root: headings declare their own leading and keep it, inline + code follows the line it sits in, which is what keeps that line's box even. + `text-autospace` puts the conventional hair space between Han and Latin or + digit runs when the model did not type one. */ [data-maka-contract="markdown"] .astryx-markdown { line-height: 1.6; text-autospace: normal; } +/* Only a fenced block's own lines declare the code leading. CodeBlock wraps + every 20 of them in a `content-visibility: auto` box whose placeholder is + `20lh`, and `lh` there resolves against what the box inherits — this prose + leading, 12% taller than the lines it stands in for. The block then shrinks + by 48px per chunk as Chromium lays each one out, which moves whatever the + reader is on below it. Astryx's own `:where(code, pre)` rule would carry the + leading down, but Maka strips that reset (scripts/build-astryx-theme.mjs). */ +[data-maka-contract="markdown"] .astryx-markdown pre { + line-height: var(--text-code-leading); +} /* Han faces have no italic, so `*emphasis*` in Chinese renders as a synthetic shear of PingFang. Chinese typography marks emphasis with a dot under each character instead. Keyed on the script of the turn's own prose (MarkdownBody From 89860064beade2816c6d823025ee4438541c53df Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 16 Sep 2026 09:23:02 +0800 Subject: [PATCH 02/40] refactor(desktop): load the transcript whole and virtualize it in the renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Desktop transcript was a sliding window: the Main replica paged durable history in and out under the reader, and the renderer kept a range store, gap rows, prefetch bands and eviction to match. Every scroll near a boundary could change what existed, so the scrollbar resized under the thumb and both directions stuttered — the symptom #5315 reduced but did not remove, because the authority for what the reader can see was still moving. Move the boundary. The renderer now reads the whole durable transcript once (bounded by DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES, 64 MiB, on the first read) and never evicts, so the document's size is fixed for the session and the scrollbar means one thing. Anything past that bound is reachable through an explicit "载入更早的记录" control rather than by scrolling into it. Only the DOM is virtualized, by virtua, which keeps the mounted row count bounded without making the document elastic. That deletes the mechanisms the window needed: the server-side transcript pager, the windowed store queries and their contract, the renderer range store, gap rows, prefetch, eviction and the pin bookkeeping that existed to survive them. The wire loses the turn-range operation and its cursors, which a peer at epoch 156 still asks for, so the compatibility epoch moves to 157. Two costs are accepted rather than hidden. Offscreen Turns are no longer in the DOM, so the browser's own in-page find cannot reach them (the story asserting otherwise is deleted); transcript search already goes through the indexed path. And a load of earlier history has to hold the reader itself: virtua only shifts for a prepend while it still counts itself as scrolling, so chat-view captures the Turn under the reader on click and lands it back at the same place, including the start margin the retiring control takes with it and the size corrections the arriving rows make over the following frames. The e2e fixture grows to 500 Turns of varied prose and periodic code, because uniform rows are the one case where the virtualizer's estimate for an unmounted row is always right. Generated-by: Claude Code --- apps/desktop/e2e/fixtures.ts | 4 +- .../e2e/partial-history-notice.spec.ts | 106 +- .../e2e/transcript-scroll-cost.spec.ts | 390 ++---- apps/desktop/renderer-architecture.json | 7 +- .../licenses/npm/THIRD_PARTY_NOTICES.txt | 30 + .../app-shell-chat-actions-fixture.ts | 2 +- .../app-shell-first-send-cleanup.test.ts | 69 +- .../app-shell-session-ui-state.test.ts | 150 +-- .../desktop-transcript-range-store.test.ts | 1055 ++++++--------- .../__tests__/latest-request-usage.test.ts | 21 +- .../__tests__/quote-companion-retry.test.ts | 20 +- .../__tests__/runtime-host-client.test.ts | 25 - ...me-host-session-execution-ipc-main.test.ts | 81 +- .../runtime-host-session-observer.test.ts | 635 +++++---- .../runtime-host-session-test-fixture.ts | 2 - .../src/main/__tests__/session-local.test.ts | 2 - .../session-workspace-action-identity.test.ts | 26 +- .../main/__tests__/streaming-handoff.test.ts | 40 +- ...est.ts => transcript-history-read.test.ts} | 74 +- .../__tests__/transcript-identity.test.ts | 4 +- ...e.ts => transcript-ledger-test-fixture.ts} | 2 +- .../transcript-navigation-race.test.ts | 559 -------- .../transcript-overlay-settlement.test.ts | 72 +- ...transcript-parked-completion-probe.test.ts | 65 - .../transcript-pending-jump-probe.test.ts | 126 -- ...script-reading-position-controller.test.ts | 323 ++--- .../transcript-send-viewport.test.ts | 139 +- ...est.ts => transcript-tail-restore.test.ts} | 270 ++-- .../src/main/__tests__/transcript-test-dom.ts | 127 ++ .../transient-message-projection.test.ts | 15 - .../__tests__/workhub-anchor-rail.test.ts | 5 +- ...ub-coordination-transcript-preload.test.ts | 367 +---- .../__tests__/workhub-send-visibility.test.ts | 71 +- .../src/main/desktop-transcript-ipc.ts | 49 +- .../src/main/desktop-transcript-replica.ts | 262 ++-- .../src/main/e2e-fixture/scenarios-chat.ts | 16 +- .../src/main/e2e-fixture/seed-helpers.ts | 5 +- apps/desktop/src/main/runtime-host-boot.ts | 4 + apps/desktop/src/main/runtime-host-client.ts | 10 - .../main/runtime-host-desktop-candidate.ts | 2 + ...runtime-host-session-execution-ipc-main.ts | 80 +- ...ntime-host-session-observation-registry.ts | 63 +- .../src/main/runtime-host-session-observer.ts | 485 +++---- apps/desktop/src/preload/bridge-contract.d.ts | 8 +- apps/desktop/src/preload/preload.ts | 55 +- .../src/preload/transcript-contract.ts | 66 +- .../src/renderer/app-shell-chat-actions.ts | 4 +- .../desktop/src/renderer/app-shell-effects.ts | 2 +- apps/desktop/src/renderer/app-shell.tsx | 34 +- .../session-inspector/latest-request-usage.ts | 4 +- .../contracts/transient-message-projection.ts | 2 - .../src/renderer/chat-message-surface.tsx | 4 +- ...transcript-reading-position-controller.tsx | 141 +- .../controller/transcript-reading-position.ts | 250 +--- .../use-app-shell-session-ui-state.ts | 15 +- .../renderer/features/conversation/index.ts | 2 - .../conversation/model/session-ui-state.ts | 9 +- .../renderer/features/conversation/testing.ts | 1 - .../controller/use-workhub-controller.ts | 46 +- .../workhub/locales/workhub-live-copy.ts | 6 +- .../src/renderer/features/workhub/ports.ts | 7 +- .../workhub/ui/workhub-conversation.tsx | 17 +- .../features/workhub/ui/workhub-root.tsx | 9 +- .../desktop/create-workhub-services.ts | 81 +- .../desktop/desktop-transcript-range-store.ts | 674 +++------- .../desktop/session-message-settlement.ts | 64 +- .../src/renderer/session-workspace-actions.ts | 9 +- apps/desktop/stories/app-shell.stories.tsx | 392 ++---- apps/desktop/stories/workhub.stories.tsx | 10 +- docs/astryx-surface-file-inventory.md | 2 +- package-lock.json | 33 +- .../__tests__/authenticated-websocket.test.ts | 5 +- .../src/__tests__/connection-session.test.ts | 6 - .../fixtures/session-transcript-reader.ts | 16 +- .../session-catalog-coordinator.test.ts | 1 - .../session-subscription-client.test.ts | 43 +- .../session-transcript-pager.test.ts | 391 +----- .../session-transcript-protocol.test.ts | 32 +- .../session-transcript-reader.test.ts | 7 - .../src/__tests__/session-turns.test.ts | 22 - .../src/client/session-subscription.ts | 32 +- packages/runtime-host/src/protocol/index.ts | 5 +- .../runtime-host/src/protocol/operations.ts | 1 - .../src/protocol/session-transcript.ts | 41 +- .../src/protocol/session-turns.ts | 107 -- .../src/server/access-credential-store.ts | 7 +- .../src/server/session-catalog-coordinator.ts | 36 +- .../src/server/session-transcript-pager.ts | 332 +---- .../src/server/session-transcript-reader.ts | 36 - packages/storage/src/execution-stores.ts | 9 +- .../storage/src/runtime-transcript-query.ts | 76 -- .../storage/src/session-store-contract.ts | 11 - packages/storage/src/session-store.ts | 2 - packages/storage/src/sqlite-runtime-store.ts | 13 - .../src/test-only/memory-execution-runtime.ts | 25 - packages/ui/package.json | 3 +- .../chat-turn-steering-order.test.ts | 9 +- .../chat-view-empty-compaction.test.tsx | 41 +- .../__tests__/chat-view-load-earlier.test.tsx | 174 +++ .../src/__tests__/prompt-anchor-rail.test.ts | 68 - .../prompt-rail-reading-position.test.tsx | 221 +-- .../__tests__/rail-alignment-claim.test.ts | 56 - .../__tests__/return-to-latest-pin.test.tsx | 296 ---- .../transcript-scroll-authority.test.ts | 401 ++---- .../ui/src/__tests__/transcript-test-dom.ts | 153 +++ .../ui/src/__tests__/use-chat-scroll.test.tsx | 1188 +++-------------- packages/ui/src/chat-surface-layout.tsx | 5 +- packages/ui/src/chat-view.tsx | 367 +++-- packages/ui/src/conversation-copy.ts | 7 +- packages/ui/src/prompt-anchor-rail.tsx | 61 +- .../ui/src/transcript-scroll-authority.tsx | 161 +-- .../ui/src/transcript-viewport-navigation.ts | 37 +- packages/ui/src/use-chat-scroll.ts | 419 +++--- .../transcript-scroll-intent.stories.tsx | 14 +- scripts/check-app-shell-hooks.mjs | 2 +- 115 files changed, 3670 insertions(+), 9076 deletions(-) rename apps/desktop/src/main/__tests__/{transcript-navigation-pager.test.ts => transcript-history-read.test.ts} (80%) rename apps/desktop/src/main/__tests__/{transcript-navigation-test-fixture.ts => transcript-ledger-test-fixture.ts} (98%) delete mode 100644 apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts delete mode 100644 apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts delete mode 100644 apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts rename apps/desktop/src/main/__tests__/{transcript-navigation-regression.test.ts => transcript-tail-restore.test.ts} (58%) create mode 100644 apps/desktop/src/main/__tests__/transcript-test-dom.ts create mode 100644 packages/ui/src/__tests__/chat-view-load-earlier.test.tsx delete mode 100644 packages/ui/src/__tests__/rail-alignment-claim.test.ts delete mode 100644 packages/ui/src/__tests__/return-to-latest-pin.test.tsx create mode 100644 packages/ui/src/__tests__/transcript-test-dom.ts diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 64682d8af6..a83e44d2f8 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -665,8 +665,8 @@ export const test = base.extend({ showWindow: true, }, use); }, - // A transcript larger than the bounded Desktop range. Clicking an unloaded - // prompt exercises the real load-around path and its partial-history UI. + // A transcript larger than the Desktop history budget, so earlier Turns load + // only through the load-earlier control. partialHistoryWindow: async ({}, use) => { await withE2eWindow({ seed: false, diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts index 226d0367e0..2360e0ac45 100644 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -17,53 +17,97 @@ * under the License. */ +import type { Page } from '@playwright/test'; import { expect, test } from './fixtures'; -const TURN = '.maka-transcript-turn'; +const SCROLLER = '[data-chat-scroll-container="true"]'; +const TICK = '.maka-prompt-rail-tick'; /** Turns the partial-history fixture seeds. */ const PARTIAL_HISTORY_TURN_COUNT = 18; -test('a bounded transcript range reaches its whole history without a control to ask', async ({ +async function frames(page: Page, count = 2): Promise { + await page.evaluate((remaining) => new Promise((resolve) => { + const step = (left: number): void => { + if (left === 0) resolve(); + else requestAnimationFrame(() => step(left - 1)); + }; + step(remaining); + }), count); +} + +/** The first Turn still on screen and its viewport top. */ +async function readingAnchor(page: Page): Promise<{ turnId: string; top: number }> { + return page.locator(SCROLLER).evaluate((scroller) => { + const rootTop = scroller.getBoundingClientRect().top; + const turn = [...scroller.querySelectorAll('[data-turn-id]')] + .find((candidate) => candidate.getBoundingClientRect().bottom > rootTop); + if (!turn?.dataset.turnId) throw new Error('no Turn is on screen'); + return { turnId: turn.dataset.turnId, top: turn.getBoundingClientRect().top }; + }); +} + +/** Wheel to the top as a reader would; a programmatic scroll does not release the tail pin. */ +async function wheelToTop(page: Page): Promise { + const scroller = page.locator(SCROLLER); + await expect(async () => { + // Re-read each attempt: the window may still be resizing. + const box = await scroller.boundingBox(); + if (!box) throw new Error('the chat scroll container has no box'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 4); + await page.mouse.wheel(0, -4_000); + await frames(page, 4); + expect(await scroller.evaluate((element) => element.scrollTop)).toBe(0); + }).toPass({ timeout: 30_000 }); + await frames(page, 4); +} + +async function turnTop(page: Page, turnId: string): Promise { + return page.locator(`[data-turn-id="${turnId}"]`).evaluate((turn) => turn.getBoundingClientRect().top); +} + +test('a transcript over the history budget loads earlier Turns only on request, holding the reader', async ({ partialHistoryWindow: page, }) => { + test.setTimeout(90_000); await page.setViewportSize({ width: 1_400, height: 800 }); + const loadEarlier = page.getByRole('button', { name: '载入更早的记录' }); + const ticks = page.locator(TICK); - await expect(page.locator(TURN).first()).toBeVisible(); - expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); + await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`)).toBeVisible(); + const opened = await ticks.count(); + expect(opened).toBeGreaterThan(0); + expect(opened).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); - const oldestPrompt = page.locator( - '.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]', - ); - await expect(oldestPrompt).toBeVisible(); - await oldestPrompt.click(); + // Reaching the top by scrolling loads nothing on its own. + await wheelToTop(page); + await page.waitForTimeout(500); + expect(await ticks.count()).toBe(opened); + await expect(loadEarlier).toHaveCount(1); - await expect(page.locator('[data-turn-id="turn-partial-history-1"]')).toBeVisible(); - // Where the jump landed, read from the reading position rather than from - // `data-search-highlight`: that highlight clears itself 2.2s after the - // command lands, so waiting for the Turn to mount and then asserting it - // fails whenever loading the page around it takes longer than the flash — - // measured here as a 3s pass turning into an 18s timeout under load. - await expect(oldestPrompt).toHaveAttribute('data-active', 'true'); - // A jump lands on its own page, not on the whole history. - expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); + let loads = 0; + while ((await loadEarlier.count()) > 0) { + await wheelToTop(page); + const before = await ticks.count(); + const anchor = await readingAnchor(page); + await loadEarlier.click(); + await expect.poll(() => ticks.count(), { timeout: 30_000 }).toBeGreaterThan(before); + await frames(page, 4); + const moved = Math.abs((await turnTop(page, anchor.turnId)) - anchor.top); + expect(moved, `load-earlier moved ${anchor.turnId} by ${moved}px`).toBeLessThanOrEqual(1); + loads += 1; + expect(loads).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); + } - // The newer side of the jump fills on its own as the reader moves into it. - await page.mouse.move(700, 400); - await expect(async () => { - await page.mouse.wheel(0, 400); - await expect(page.locator('[data-turn-id="turn-partial-history-3"]')).toBeVisible(); - }).toPass({ timeout: 30_000 }); + expect(loads).toBeGreaterThan(0); + await expect(ticks).toHaveCount(PARTIAL_HISTORY_TURN_COUNT); + await wheelToTop(page); + await expect(page.locator('[data-turn-id="turn-partial-history-1"]')).toBeVisible(); const returnToLatest = page.getByRole('button', { name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, }); await expect(returnToLatest).toBeVisible(); await returnToLatest.click(); - - // Reading the tail page and rebuilding the window around it is slower than - // the paging above, and measured past the suite's 10s expect timeout here. - await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`)) - .toBeVisible({ timeout: 30_000 }); - await expect(oldestPrompt).toBeVisible(); - expect(await page.locator(TURN).count()).toBeLessThan(PARTIAL_HISTORY_TURN_COUNT); + await expect(page.locator(`[data-turn-id="turn-partial-history-${PARTIAL_HISTORY_TURN_COUNT}"]`)).toBeVisible(); + await expect(ticks).toHaveCount(PARTIAL_HISTORY_TURN_COUNT); }); diff --git a/apps/desktop/e2e/transcript-scroll-cost.spec.ts b/apps/desktop/e2e/transcript-scroll-cost.spec.ts index 3d1eac57f2..99c590dad8 100644 --- a/apps/desktop/e2e/transcript-scroll-cost.spec.ts +++ b/apps/desktop/e2e/transcript-scroll-cost.spec.ts @@ -18,21 +18,8 @@ */ /** - * What one scroll through the transcript COSTS, asserted as counts. - * - * The suite this replaces asserted wall-clock frame timings, and a timing - * assertion on a shared runner either flakes or gets switched off — that one - * was switched off behind an env var nothing ever set, so it never ran at all - * and every regression it existed to catch shipped. These assertions are - * structural: a number that does not move between runs on the same code, and - * does move when the thing it guards regresses. They run in ordinary CI. - * - * Gestures are RELATIVE input — a real wheel through CDP, which is also what - * the product's own history paging listens for. The replaced suite drove - * scrolling by writing absolute `scrollTop` values per frame, which erases the - * scroll-anchoring correction the browser applied since the previous frame, so - * the probe fought the scroller and produced displacement that looked like a - * product bug. + * What one scroll through the transcript costs, asserted as per-frame geometry + * rather than timings. Gestures are real wheel input through CDP. */ import type { CDPSession, Page } from '@playwright/test'; @@ -40,334 +27,141 @@ import { PROMPT_RAIL_PROMPT_COUNT } from '../src/main/e2e-fixture/seed-helpers'; import { expect, test } from './fixtures'; const SCROLLER = '[data-chat-scroll-container="true"]'; -const TURN = '.maka-transcript-turn'; +const WHEEL_TICKS = 300; -/** - * Generous on purpose: the property worth guarding is that paging through the - * whole history stops adding Turns, and a range that kept everything it paged - * in would mount all of them. - */ +/** Generous: a list that mounted everything it loaded would mount all 120 Turns. */ const MOUNTED_TURNS_MAX = 40; /** - * How far a range boundary is allowed to move the reader, in CSS pixels. - * - * A boundary both installs a page and drops the far side of the band, and the - * two settle within the same quiet frame, so what is measurable is their sum. - * Not a tolerance for "close enough" motion: anchoring holds that sum to a - * fraction of a Turn, where a frame that lost the reader lands a Turn away or - * more. + * Per-frame backward motion of the thumb ratio allowed while the reader moves + * one way. Row measurement corrects virtua's size estimates by a few pixels; a + * range change swings the ratio by tenths. */ -const BOUNDARY_DISPLACEMENT_MAX_PX = 40; +const THUMB_RATIO_TOLERANCE = 0.02; + +/** Per-frame scrollHeight change allowed with no load-earlier and no streaming. */ +const SCROLL_HEIGHT_DRIFT_MAX = 0.05; + +interface Frame { + readonly scrollTop: number; + readonly scrollHeight: number; + readonly clientHeight: number; + readonly mounted: number; +} declare global { interface Window { - __makaTranscriptDisplacement?: { - boundaries: TranscriptBoundary[]; - isSettled(): boolean; - stop(): void; - }; + __makaScrollFrames?: { frames: Frame[]; stop(): void }; } } -/** - * One frame where the mounted range changed: a page installed, or the band - * trimmed, or both. - */ -interface TranscriptBoundary { - readonly firstBefore: string; - readonly firstAfter: string; - readonly mountedBefore: number; - readonly mountedAfter: number; - readonly grewPx: number; - readonly scrolledPx: number; - /** Turns present in both frames, so a reader position can be compared. */ - readonly carried: number; - readonly worstTurnId: string | null; - readonly worstPx: number; +async function frames(page: Page, count = 2): Promise { + await page.evaluate((remaining) => new Promise((resolve) => { + const step = (left: number): void => { + if (left === 0) resolve(); + else requestAnimationFrame(() => step(left - 1)); + }; + step(remaining); + }), count); } -/** - * Real wheel input at the centre of the scroller. Relative by construction: a - * wheel tick asks the compositor to move by a delta from wherever the scroller - * currently is, so an anchoring correction between ticks survives instead of - * being overwritten. - */ -async function wheel( - page: Page, - cdp: CDPSession, - options: { ticks: number; deltaY: number }, -): Promise { +async function wheel(page: Page, cdp: CDPSession, ticks: number, deltaY: number): Promise { const box = await page.locator(SCROLLER).boundingBox(); if (!box) throw new Error('the chat scroll container has no box'); - const x = box.x + box.width / 2; - const y = box.y + box.height / 2; - for (let tick = 0; tick < options.ticks; tick += 1) { + for (let tick = 0; tick < ticks; tick += 1) { await cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', - x, - y, + x: box.x + box.width / 2, + y: box.y + box.height / 2, deltaX: 0, - deltaY: options.deltaY, + deltaY, }); - await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + await frames(page, 1); } - await page.evaluate(() => new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - )); + await frames(page, 4); } -/** - * Watch every frame for a change in the mounted range, and measure what that - * change did to the reader. - * - * What must not move is where a Turn sits ON SCREEN, so the measurement is its - * viewport `top` and nothing else. Its position in the DOCUMENT is expected to - * move — installing a page above the reader is exactly what shifts it — and - * scroll anchoring answers that by adding the same amount to `scrollTop`, which - * is why the reader sees nothing. Measuring the document position instead would - * report every correctly absorbed page as a displacement the size of the page. - * - * Sampled per frame rather than per gesture: the frame that installs a page is - * the only one where the reader can be lost, and a per-gesture reading would - * subtract the reader's own scrolling back out and see nothing. - */ -async function observeDisplacement(page: Page): Promise { - await page.evaluate((scrollerSelector) => { - const scroller = document.querySelector(scrollerSelector); +async function recordFrames(page: Page): Promise { + await page.evaluate((selector) => { + const scroller = document.querySelector(selector); if (!scroller) throw new Error('the chat scroll container is missing'); - const read = () => { - const tops = new Map(); - for (const turn of document.querySelectorAll('[data-turn-id]')) { - const turnId = turn.dataset.turnId; - if (turnId) tops.set(turnId, turn.getBoundingClientRect().top); - } - return { - scrollTop: scroller.scrollTop, - scrollHeight: scroller.scrollHeight, - tops, - key: [...tops.keys()].join(','), - }; - }; - // Arm in the page's native event dispatch, before the authority's deferred - // publication. Arming from Playwright after wheel() returns races the same - // rendering frames that publish the range and can miss every boundary. - let recording = false; - const record = (on: boolean): void => { - if (recording === on) return; - recording = on; - previous = read(); - settled = null; - }; - const onWheel = (event: Event): void => { - const { deltaY } = event as WheelEvent; - const remaining = deltaY < 0 ? scroller.scrollTop - : scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop; - // Edge input cannot move the viewport and may never emit scrollend. - record(remaining <= 0); - }; - const onScrollEnd = (): void => record(true); - scroller.addEventListener('wheel', onWheel, { capture: true, passive: true }); - scroller.addEventListener('scrollend', onScrollEnd, { capture: true }); - const state: { - boundaries: unknown[]; - isSettled(): boolean; - stop(): void; - } = { - boundaries: [], - isSettled: () => recording && settled === null, - stop: () => { - running = false; - scroller.removeEventListener('wheel', onWheel, true); - scroller.removeEventListener('scrollend', onScrollEnd, true); - }, - }; + const state = { frames: [] as Frame[], stop: () => { running = false; } }; let running = true; - let previous = read(); - // The last frame before the range started changing. Held across a run of - // changing frames so the measurement spans settled state to settled state: - // scroll anchoring corrects after layout, so a reading taken inside the - // change would report a correction that never reached the screen. - let settled: ReturnType | null = null; const tick = (): void => { if (!running) return; - const current = read(); - if (!recording) { - settled = null; - previous = current; - requestAnimationFrame(tick); - return; - } - if (current.key !== previous.key) { - if (!settled) settled = previous; - } else if (settled) { - const before = settled; - settled = null; - const scrolled = current.scrollTop - before.scrollTop; - let carried = 0; - let worstPx = 0; - let worstTurnId: string | null = null; - for (const [turnId, top] of current.tops) { - const wasAt = before.tops.get(turnId); - if (wasAt === undefined) continue; - carried += 1; - const displaced = Math.abs(top - wasAt); - if (displaced > worstPx) { - worstPx = displaced; - worstTurnId = turnId; - } - } - state.boundaries.push({ - firstBefore: before.key.split(',')[0] ?? '', - firstAfter: current.key.split(',')[0] ?? '', - mountedBefore: before.tops.size, - mountedAfter: current.tops.size, - grewPx: current.scrollHeight - before.scrollHeight, - scrolledPx: scrolled, - carried, - worstTurnId, - worstPx, - }); - } - previous = current; + state.frames.push({ + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + mounted: scroller.querySelectorAll('[data-turn-id]').length, + }); requestAnimationFrame(tick); }; - window.__makaTranscriptDisplacement = state as never; + window.__makaScrollFrames = state; requestAnimationFrame(tick); }, SCROLLER); } -async function displacement(page: Page): Promise { +async function stopFrames(page: Page): Promise { return page.evaluate(() => { - const state = window.__makaTranscriptDisplacement; - if (!state) throw new Error('the transcript displacement probe is missing'); + const state = window.__makaScrollFrames; + if (!state) throw new Error('the frame probe is missing'); state.stop(); - return state.boundaries; - }); -} - -/** - * A transcript opened at its tail keeps fetching older history until two - * screens of it sit above the reader, and trims what falls outside the band it - * retains, so the mounted rows churn for as long as that runs. Wait for the - * window to stop moving before touching a row: a locator resolved mid-churn - * points at an element the Renderer has already unmounted. - * - * Timed out against that ramp rather than the suite's 10s default, which is - * sized for UI already on screen. - */ -async function settled(page: Page): Promise { - const mounted = async (): Promise => page.evaluate(() => { - const turns = document.querySelectorAll('[data-turn-id]'); - return `${turns.length}:${turns[0]?.getAttribute('data-turn-id')}`; + return state.frames; }); - let previous = await mounted(); - await expect - .poll(async () => { - await page.waitForTimeout(250); - const current = await mounted(); - const stable = current === previous; - previous = current; - return stable; - }, { timeout: 30_000 }) - .toBe(true); -} - -async function moveToTail(page: Page): Promise { - await settled(page); - // The bounded transcript can replace its last Turn between locator - // resolution and scrolling. The scroll container owns this movement. - await page.locator(SCROLLER).evaluate((element) => { element.scrollTop = element.scrollHeight; }); - await page.evaluate(() => new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - )); } -/** - * The affordance a reader who has paged away uses to come back. Waited for - * rather than probed: `isVisible()` answers about this instant, so a probe on - * a loaded runner falls through to whatever the else branch was before the - * button has rendered — which is how the suite this replaces carried an - * untested fallback through a prompt-rail tick that no run ever reached. - */ -async function returnToLatest(page: Page): Promise { - const returnLatest = page.getByRole('button', { - name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, - }); - await expect(returnLatest).toBeVisible(); - await returnLatest.click(); +function assertOneWay(recorded: readonly Frame[], direction: -1 | 1, label: string): void { + expect(recorded.length, label).toBeGreaterThan(WHEEL_TICKS); + const first = recorded[0]; + const last = recorded[recorded.length - 1]; + expect((last.scrollTop - first.scrollTop) * direction, `${label}: the reader has to travel`) + .toBeGreaterThan(first.clientHeight * 10); + const moving = recorded.filter((frame) => frame.scrollHeight > frame.clientHeight); + for (let index = 1; index < moving.length; index += 1) { + const before = moving[index - 1]; + const after = moving[index]; + const drift = Math.abs(after.scrollHeight - before.scrollHeight) / before.scrollHeight; + expect(drift, `${label}: scrollHeight ${before.scrollHeight} -> ${after.scrollHeight} at frame ${index}`) + .toBeLessThanOrEqual(SCROLL_HEIGHT_DRIFT_MAX); + const ratio = (frame: Frame): number => frame.scrollTop / (frame.scrollHeight - frame.clientHeight); + const backward = (ratio(before) - ratio(after)) * direction; + expect(backward, `${label}: thumb ${ratio(before)} -> ${ratio(after)} at frame ${index}`) + .toBeLessThanOrEqual(THUMB_RATIO_TOLERANCE); + expect(after.mounted, `${label}: mounted Turns at frame ${index}`).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); + } } -/** - * The scenario #5163 was reported from: quit Desktop, start it again, open a - * long Session, and scroll upward through history without stopping. The reader - * perceives stalls or jumps around range boundaries. - * - * A mounted-range bound alone does not say - * where the reader ended up while a page was installing, which is the whole of - * what that report is about. This one measures it: every frame the mounted - * range changes, whatever Turn the reader can still see must hold its viewport - * position. - * - * Each burst contains consecutive native wheel ticks. Publication boundaries - * are sampled after scrollend, separately from the reader's own movement. - * - * Displacement in pixels rather than frame timings on purpose — see this file's - * header for what happened to the timing assertions this suite replaced. A - * stall and a jump have the same cause here (a page boundary that moves - * content out from under the reader) and only one of them can be asserted - * without a clock. - */ -test('Host history paging stays bounded, preserves the reader and returns to latest', async ({ +test('a fully loaded transcript scrolls both ways with a stable document and bounded rows', async ({ promptRailWindow: page, }) => { - test.setTimeout(120_000); + test.setTimeout(180_000); await page.setViewportSize({ width: 1_000, height: 700 }); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1); + const tail = page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`); + await expect(tail).toHaveCount(1); + await expect(page.locator('.maka-prompt-rail-tick')).toHaveCount(Math.min(PROMPT_RAIL_PROMPT_COUNT, 64)); + await expect(page.getByRole('button', { name: '载入更早的记录' })).toHaveCount(0); const cdp = await page.context().newCDPSession(page); - const turns = page.locator('[data-turn-id]'); - await moveToTail(page); - await observeDisplacement(page); - let pages = 0; - let mountedMax = await turns.count(); - for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { - const firstBefore = await turns.first().getAttribute('data-turn-id'); - if (firstBefore === 'turn-prompt-rail-1') break; - await expect - .poll(async () => { - await wheel(page, cdp, { ticks: 12, deltaY: -120 }); - await page.waitForFunction(() => window.__makaTranscriptDisplacement?.isSettled()); - return turns.first().getAttribute('data-turn-id'); - }) - .not.toBe(firstBefore); - pages += 1; - mountedMax = Math.max(mountedMax, await turns.count()); - // Let the probe compare the changed range with its next rendered frame - // before another wheel closes the measurement interval. - await page.evaluate(() => new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(() => resolve())), - )); - } + await page.locator(SCROLLER).evaluate((scroller) => { scroller.scrollTop = scroller.scrollHeight; }); + await frames(page, 6); - expect(pages).toBeGreaterThan(0); - await expect(turns.first()).toHaveAttribute('data-turn-id', 'turn-prompt-rail-1'); - expect(mountedMax).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); + await recordFrames(page); + await wheel(page, cdp, WHEEL_TICKS, -120); + assertOneWay(await stopFrames(page), -1, 'scrolling up'); - const boundaries = await displacement(page); - // The probe has to have seen the thing it measures: a run that paged nothing, - // or one where every boundary replaced the range wholesale and carried no - // Turn across, proves nothing about the reader. - expect(boundaries.length).toBeGreaterThan(0); - expect(boundaries.filter((boundary) => boundary.carried > 0).length).toBeGreaterThan(0); + await recordFrames(page); + await wheel(page, cdp, WHEEL_TICKS, 120); + assertOneWay(await stopFrames(page), 1, 'scrolling down'); - const displaced = boundaries.filter((boundary) => boundary.worstPx > BOUNDARY_DISPLACEMENT_MAX_PX); - expect(displaced, `range boundaries moved the reader: ${JSON.stringify(displaced)}`) - .toEqual([]); - - await returnToLatest(page); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1, { timeout: 30_000 }); - expect(await turns.count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); + await wheel(page, cdp, 40, -120); + const returnLatest = page.getByRole('button', { + name: /^(?:滚动主对话到底部|Scroll main conversation to bottom)$/, + }); + await expect(returnLatest).toBeVisible(); + await returnLatest.click(); + await expect(tail).toBeVisible(); + await expect.poll(() => page.locator(SCROLLER).evaluate((scroller) => + scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop)).toBeLessThanOrEqual(4); + expect(await page.locator('[data-turn-id]').count()).toBeLessThanOrEqual(MOUNTED_TURNS_MAX); }); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 7248281a1e..1bf416012f 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -338,7 +338,7 @@ "@maka/ui": 1 }, "importSpecifiers": 10, - "nonTriviaTokens": 3616 + "nonTriviaTokens": 3612 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 4, @@ -725,7 +725,6 @@ "window.maka.sessions.compact": 1, "window.maka.sessions.getPlanState": 1, "window.maka.sessions.listActiveInteractions": 1, - "window.maka.sessions.listTurnLandmarks": 1, "window.maka.sessions.promoteQueueEntry": 1, "window.maka.sessions.reorderQueueEntries": 1, "window.maka.sessions.retractQueueEntry": 1, @@ -770,7 +769,7 @@ "useShellResume": 1, "useShellRunUpdates": 1, "useStableActions": 6, - "useState": 13, + "useState": 12, "useSystemUiLocale": 1, "useTaskSubmissionReadiness": 1, "useToast": 1, @@ -862,7 +861,7 @@ "react": 1 }, "importSpecifiers": 100, - "nonTriviaTokens": 13288 + "nonTriviaTokens": 13076 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index da339d70fe..5095764bac 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -14363,6 +14363,36 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: virtua@0.48.8 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/inokawa/virtua.git + +--- LICENSE --- +MIT License + +Copyright (c) 2022 inokawa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: which@2.0.2 Declared license: ISC Selected license: ISC diff --git a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts index 0e60dd6668..1679a872d1 100644 --- a/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts +++ b/apps/desktop/src/main/__tests__/app-shell-chat-actions-fixture.ts @@ -75,7 +75,7 @@ export function createTransientState() { export function createActionsDeps() { const activeIdRef = { current: undefined as string | undefined }; return { - onFollowLatest: async (_sessionId: string) => true, + onFollowLatest: (_sessionId: string) => true, uiLocale: 'en' as const, activeIdRef, captureComposerImportOwner: () => ({ diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index a9210208c7..618156edec 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -632,22 +632,9 @@ describe('composer first-send cleanup', () => { assert.equal(resolved, 0); }); - it('cancels restoration and accepts a message while latest history catches up in the background', async () => { - const latest = deferred(); + it('cancels restoration and follows latest before accepting a message', async () => { const order: string[] = []; const activeIdRef = { current: 'existing-session' as string | undefined }; - const transcript = { - store: { - sessionId: 'existing-session', - range: () => ({ sessionId: 'existing-session', hasNewer: false }), - snapshot: () => ({ messages: [] }), - }, - async loadLatest() { - order.push('latest'); - await latest.promise; - }, - } as unknown as DesktopTranscriptRangeController; - const transcriptRangeRef = { current: transcript as DesktopTranscriptRangeController | undefined }; const restoreWindow = installWindow({ sessions: { submitMessage: async () => { @@ -661,17 +648,14 @@ describe('composer first-send cleanup', () => { const sending = createAppShellChatActions({ ...createActionsDeps(), activeIdRef, - transcriptRangeRef, onFollowLatest: (sessionId) => prepareTranscriptForSend({ - sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef, - cancel: () => { order.push('cancel-restore'); }, followLatest: () => {}, + sessionId, currentSessionId: activeIdRef, + cancel: () => { order.push('cancel-restore'); }, + followLatest: () => { order.push('follow-latest'); }, }), }).send('hello'); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(order, ['cancel-restore', 'latest', 'send']); assert.equal(await sending, true); - latest.resolve(); - assert.deepEqual(order, ['cancel-restore', 'latest', 'send']); + assert.deepEqual(order, ['cancel-restore', 'follow-latest', 'send']); } finally { restoreWindow(); } @@ -746,49 +730,6 @@ describe('composer first-send cleanup', () => { } }); - for (const initialized of [false, true]) { - it(`does not navigate the previous Session controller (${initialized ? 'initialized' : 'opening'}) while sending`, async () => { - const submissions: string[] = []; - let latestReads = 0; - const transcript = { - store: { - sessionId: 'previous-session', - range: () => { - if (!initialized) throw new Error('Desktop transcript range is not initialized'); - return { sessionId: 'previous-session' }; - }, - }, - loadLatest: async () => { latestReads += 1; }, - } as unknown as DesktopTranscriptRangeController; - const restoreWindow = installWindow({ - sessions: { - submitMessage: async (sessionId: string) => { - submissions.push(sessionId); - return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; - }, - }, - }); - const activeIdRef = { current: 'selected-session' }; - const transcriptRangeRef = { current: transcript }; - try { - const result = await createAppShellChatActions({ - ...createActionsDeps(), - activeIdRef, - transcriptRangeRef, - onFollowLatest: (sessionId) => prepareTranscriptForSend({ - sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef, - cancel: () => {}, - followLatest: (sessionId) => { assert.equal(sessionId, 'selected-session'); }, - }), - }).send('hello'); - assert.equal(result, true); - assert.deepEqual(submissions, ['selected-session']); - assert.equal(latestReads, 0, 'the previous Session must not be navigated'); - } finally { - restoreWindow(); - } - }); - } }); /** * #1433 round 5: the failure feedback for a send is addressed to the surface diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index c0ac030ecd..96463e705c 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -36,7 +36,6 @@ import { } from '../../renderer/app-shell-session-ui-state.js'; import { createTranscriptRestoreLifecycle, - refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, } from '../../renderer/features/conversation/testing.js'; @@ -187,13 +186,12 @@ describe('app shell session UI state controller', () => { notifications += 1; }); - controller.setTranscriptReadingAnchor('drop', { turnId: 'turn-drop', sequence: 7 }); - controller.setTranscriptReadingAnchor('keep', { turnId: 'turn-keep', sequence: 11 }); controller.setTranscriptReadingAnchor('drop', { turnId: 'turn-drop' }); + controller.setTranscriptReadingAnchor('keep', { turnId: 'turn-keep' }); assert.deepEqual(controller.transcriptReadingAnchorBySessionRef.current, { - drop: { turnId: 'turn-drop', sequence: 7 }, - keep: { turnId: 'turn-keep', sequence: 11 }, + drop: { turnId: 'turn-drop' }, + keep: { turnId: 'turn-keep' }, }); assert.equal(notifications, 0, 'reading anchors have no live render subscriber'); @@ -224,169 +222,59 @@ describe('app shell session UI state controller', () => { assert.equal(notifications, 2); }); - it('clears Owner landmarks and ignores their late response when the active Session becomes a Guest', async () => { - let resolveOwner!: (value: { throughSequence: number; landmarks: string[] }) => void; - let index: { sessionId: string; throughSequence: number | null; turns: readonly string[] } | undefined = { - sessionId: 'owner-session', throughSequence: 0, turns: ['previous-owner-turn'], - }; - const dispose = refreshTranscriptTurnLandmarks({ - sessionId: 'owner-session', - newestDurablePromptSequence: 1, - list: () => new Promise<{ throughSequence: number; landmarks: string[] }>((resolve) => { - resolveOwner = resolve; - }), - isCurrent: () => true, - setIndex: (value) => { index = value; }, - }); - // The shell cleans up the Owner effect and passes no ownerActiveId for Guests. - dispose?.(); - refreshTranscriptTurnLandmarks({ - sessionId: undefined, - newestDurablePromptSequence: 1, - list: async () => assert.fail('Guests cannot query Owner turn landmarks'), - isCurrent: () => true, - setIndex: (value) => { index = value; }, - }); - resolveOwner({ throughSequence: 1, landmarks: ['owner-turn'] }); - await Promise.resolve(); - assert.equal(index, undefined); - }); - - it('enriches a Turn-only reading anchor when its range sequence arrives later', async () => { - let anchor: { turnId: string; sequence?: number } | undefined; - restoreSessionTranscriptRange({ - lifecycle: createTranscriptRestoreLifecycle(), - sessionId: 'session', - readingAnchor: { turnId: 'turn' }, - controller: { - store: { - sessionId: 'session', - range: () => ({ sessionId: 'session' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => 17, - newestDurableUserSequence: () => 17, - snapshot: () => ({ messages: [] }), - }, - loadAround: async () => assert.fail('the resident Turn must not load another range'), - }, - isCurrent: () => true, - setReadingAnchor: (_sessionId, next) => { - anchor = next; - }, - onError: (error) => assert.fail(String(error)), - }); - - assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 }); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 }); - }); - - it('does not enrich a reading anchor from another Session range', () => { - let sequenceReads = 0; - let anchor: { turnId: string; sequence?: number } | undefined; + it('does not restore a reading anchor from another Session range', () => { + let anchor: { turnId: string } | undefined = { turnId: 'turn' }; restoreSessionTranscriptRange({ lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'active', readingAnchor: { turnId: 'turn' }, controller: { store: { - sessionId: 'stale', - range: () => ({ sessionId: 'stale' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => { - sequenceReads += 1; - return 17; - }, - newestDurableUserSequence: () => 17, + range: () => ({ sessionId: 'stale', hasOlder: false, ready: true }), snapshot: () => ({ messages: [] }), }, - loadAround: async () => assert.fail('a stale range must not load'), + loadEarlier: async () => assert.fail('a stale range must not load'), }, isCurrent: () => true, setReadingAnchor: (_sessionId, next) => { anchor = next; }, + onRestoreUnavailable: () => assert.fail('a stale range cannot declare the anchor unavailable'), onError: (error) => assert.fail(String(error)), }); - assert.equal(sequenceReads, 0); - assert.equal(anchor, undefined); + assert.deepEqual(anchor, { turnId: 'turn' }); }); - it('abandons a Turn-only restore that remains absent after the range is ready', async () => { - let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'missing' }; + it('abandons a restore that remains absent once no earlier history is left', async () => { + let anchor: { turnId: string } | undefined = { turnId: 'missing' }; let unavailable: { sessionId: string; turnId: string } | undefined; - const options = { + restoreSessionTranscriptRange({ lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'session', readingAnchor: { turnId: 'missing' }, controller: { store: { - sessionId: 'session', - range: () => ({ sessionId: 'session' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => null, - snapshot: () => ({ messages: [] }), + range: () => ({ sessionId: 'session', hasOlder: false, ready: true }), + snapshot: () => ({ messages: [{ turnId: 'latest' }] }), }, - loadAround: async () => assert.fail('a Turn-only anchor has no load target'), + loadEarlier: async () => assert.fail('all history is already loaded'), }, isCurrent: () => true, - setReadingAnchor: (_sessionId: string, next: { turnId: string; sequence?: number } | undefined) => { + setReadingAnchor: (_sessionId, next) => { anchor = next; }, - onRestoreUnavailable: (sessionId: string, turnId: string) => { + onRestoreUnavailable: (sessionId, turnId) => { unavailable = { sessionId, turnId }; }, - onError: (error: unknown) => assert.fail(String(error)), - }; - - restoreSessionTranscriptRange(options); + onError: (error) => assert.fail(String(error)), + }); await new Promise((resolve) => setImmediate(resolve)); assert.equal(anchor, undefined); assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'missing' }); }); - it('abandons a known-sequence restore when loadAround cannot make the Turn resident', async () => { - let loadedSequence: number | undefined; - let unavailable: { sessionId: string; turnId: string } | undefined; - let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'removed', sequence: 23 }; - const options = { - lifecycle: createTranscriptRestoreLifecycle(), - sessionId: 'session', - readingAnchor: { turnId: 'removed', sequence: 23 }, - controller: { - store: { - sessionId: 'session', - range: () => ({ sessionId: 'session' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => 29, - snapshot: () => ({ messages: [{ id: 'latest' }] }), - }, - loadAround: async (sequence: number) => { - loadedSequence = sequence; - }, - }, - isCurrent: () => true, - setReadingAnchor: (_sessionId: string, next: { turnId: string; sequence?: number } | undefined) => { - anchor = next; - }, - onRestoreUnavailable: (sessionId: string, turnId: string) => { - unavailable = { sessionId, turnId }; - }, - onError: (error: unknown) => assert.fail(String(error)), - }; - - restoreSessionTranscriptRange(options); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(loadedSequence, 23); - assert.equal(anchor, undefined); - assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'removed' }); - }); - it('keeps the synchronous live-turn ref aligned with reducer updates', () => { const controller = createAppShellSessionUiStateController(); const projection = [armLiveTurn('turn-1')]; diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index e79a36a1d3..1a4a958ebe 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -20,25 +20,25 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; +import { deferred, waitFor } from '@maka/core/test-only/async-primitives'; import { SESSION_CONTINUITY_SCHEMA_VERSION } from '@maka/runtime-host/protocol'; import { + encodeDesktopTranscriptBatches, encodeDesktopTranscriptChange, - encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot, + type TranscriptBatchIdentity, } from '../desktop-transcript-ipc.js'; import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE, - DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES, DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, + type DesktopTranscriptHandle, } from '../../preload/transcript-contract.js'; import { createDesktopTranscriptReconnectRecovery, - createRecoveringDesktopTranscriptRangeController, createDesktopTranscriptRangeController, DesktopTranscriptRangeStore, } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { TranscriptReadSupersededError } from '../../renderer/features/conversation/index.js'; import { mergeSettledMessages } from '../../renderer/settled-message-merge.js'; import { readSettledMessages, @@ -108,130 +108,74 @@ test('cancels settlement while transcript open is pending', async () => { } }); -for (const paged of [false, true]) { - test(`reads one Host-owned Turn outside the bounded transcript tail${paged ? ' across multiple pages' : ''}`, async () => { - const sessionKey = JSON.stringify(['host-1', 'session-1']); - const turnB: StoredMessage[] = [ - userMessage('follow-up one', 'user-b'), - { ...assistantMessage('answer B', 'assistant-b'), turnId: 'turn-b', ts: 4 }, - { - type: 'turn_state', - id: 'complete-b', - turnId: 'turn-b', - ts: 5, - status: 'completed', - }, - ]; - const turnC: StoredMessage[] = [ - userMessage('follow-up two', 'user-c'), - { ...assistantMessage('answer C', 'assistant-c'), turnId: 'turn-c', ts: 7 }, - { - type: 'turn_state', - id: 'complete-c', - turnId: 'turn-c', - ts: 8, - status: 'completed', - }, - ]; - const navigations: number[] = []; - const extensions: number[] = []; - let deliverySequence = 0; +test('reads one Host-owned Turn outside the bounded transcript tail', async () => { + const sessionKey = JSON.stringify(['host-1', 'session-1']); + const turnB: StoredMessage[] = [ + userMessage('follow-up one', 'user-b'), + { ...assistantMessage('answer B', 'assistant-b'), turnId: 'turn-b', ts: 4 }, + { type: 'turn_state', id: 'complete-b', turnId: 'turn-b', ts: 5, status: 'completed' }, + ]; + const turnC: StoredMessage[] = [ + userMessage('follow-up two', 'user-c'), + { ...assistantMessage('answer C', 'assistant-c'), turnId: 'turn-c', ts: 7 }, + { type: 'turn_state', id: 'complete-c', turnId: 'turn-c', ts: 8, status: 'completed' }, + ]; + const turnReads: string[] = []; + let deliverySequence = 0; - const result = await readSettledMessagesFrom( - { - sessions: { - listTurns: async (sessionId) => { - assert.equal(sessionId, sessionKey); - return [{ turnId: 'turn-b', firstSequence: 3, status: 'completed' }]; - }, + const result = await readSettledMessagesFrom( + { + transcripts: { + async readTurn(sessionId, turnId) { + assert.equal(sessionId, sessionKey); + turnReads.push(turnId); + return turnB; }, - transcripts: { - open: async (_sessionId, handler) => { - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', - generation: 'generation-1', - hostEpoch: 'host-1', - durableThrough: 8, - durable: turnC.map((message, index) => ({ sequence: index + 6, message })), - overlay: [], - hasOlder: true, - hasNewer: false, - })) handler({ ...batch, deliverySequence: ++deliverySequence }); - return { - sessionId: sessionKey, - generation: 'generation-1', - hostEpoch: 'host-1', + open: async (_sessionId, handler, _registerCancellation, mode) => { + assert.equal(mode, 'tail'); + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 8, + durable: turnC.map((message, index) => ({ sequence: index + 6, message })), + overlay: [], + hasOlder: true, + })) handler({ ...batch, deliverySequence: ++deliverySequence }); + return transcriptHandle( + { sessionId: sessionKey, generation: 'generation-1', hostEpoch: 'host-1' }, + { readThroughMessageId: 'complete-c', async acknowledgeTail() { assert.fail('A recovery read must not mark the Session read'); }, - async loadBefore() {}, - async loadAfter(sequence, maxBytes, navigation) { - assert.equal(maxBytes, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - assert.equal(sequence, 4); - extensions.push(sequence); - for (const batch of encodeDesktopTranscriptPage({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigation, - }, { - durableThrough: 8, - durable: [{ sequence: 5, message: turnB[2]! }], - hasNewer: true, - }, { direction: 'newer', anchor: sequence })) { - handler({ ...batch, deliverySequence: ++deliverySequence }); - } - }, - async loadAround(sequence, maxBytes, navigation) { - assert.equal(maxBytes, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - navigations.push(sequence); - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', - generation: 'generation-1', - hostEpoch: 'host-1', - durableThrough: 8, - durable: (paged ? turnB.slice(0, 2) : turnB).map((message, index) => ({ sequence: index + 3, message })), - overlay: [], - hasOlder: true, - hasNewer: true, - }, navigation)) handler({ ...batch, deliverySequence: ++deliverySequence }); - }, - async loadLatest() {}, - async close() {}, - }; - }, + }, + ); }, }, - sessionKey, - { requiredTurnId: 'turn-b' }, - ); + }, + sessionKey, + { requiredTurnId: 'turn-b' }, + ); - assert.deepEqual(navigations, [3]); - assert.deepEqual(extensions, paged ? [4] : []); - assert.deepEqual(result, { messages: [...turnB, ...turnC], settled: true }); - }); -} + assert.deepEqual(turnReads, ['turn-b']); + assert.deepEqual(result, { messages: [...turnB, ...turnC], settled: true }); +}); -for (const hasSequence of [false, true]) { - test(`does not settle when a targeted Host-owned Turn ${hasSequence ? 'cannot be recovered' : 'has no indexed sequence'}`, async () => { +for (const failure of ['is empty', 'fails'] as const) { + test(`does not settle when the targeted Host-owned Turn read ${failure}`, async () => { const sessionKey = JSON.stringify(['host-1', 'session-1']); const tail: StoredMessage[] = [ { ...assistantMessage('answer C', 'assistant-c'), turnId: 'turn-c', ts: 7 }, - { - type: 'turn_state', - id: 'complete-c', - turnId: 'turn-c', - ts: 8, - status: 'completed', - }, + { type: 'turn_state', id: 'complete-c', turnId: 'turn-c', ts: 8, status: 'completed' }, ]; - let targetedRead = false; let deliverySequence = 0; const result = await readSettledMessagesFrom( { - sessions: { - listTurns: async () => [{ - turnId: 'missing-turn', status: 'completed', ...(hasSequence ? { firstSequence: 3 } : {}), - }], - }, transcripts: { + async readTurn() { + if (failure === 'fails') throw new Error('Turn read failed'); + return []; + }, open: async (_sessionId, handler) => { for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'session-1', @@ -241,22 +185,11 @@ for (const hasSequence of [false, true]) { durable: tail.map((message, index) => ({ sequence: index + 7, message })), overlay: [], hasOlder: true, - hasNewer: false, })) handler({ ...batch, deliverySequence: ++deliverySequence }); - return { - sessionId: sessionKey, - generation: 'generation-1', - hostEpoch: 'host-1', - readThroughMessageId: 'complete-c', - async acknowledgeTail() {}, - async loadBefore() {}, - async loadAfter() {}, - async loadAround() { - targetedRead = true; - }, - async loadLatest() {}, - async close() {}, - }; + return transcriptHandle( + { sessionId: sessionKey, generation: 'generation-1', hostEpoch: 'host-1' }, + { readThroughMessageId: 'complete-c' }, + ); }, }, }, @@ -264,7 +197,6 @@ for (const hasSequence of [false, true]) { { requiredTurnId: 'missing-turn' }, ); - assert.equal(targetedRead, hasSequence); assert.deepEqual(result, { messages: tail, settled: false }); }); } @@ -283,7 +215,6 @@ test('moves a fragmented overlay record to durable storage without duplicating i durable: [], overlay: [message], hasOlder: false, - hasNewer: false, })]; assert.ok(snapshot.length > 1); @@ -299,50 +230,13 @@ test('moves a fragmented overlay record to durable storage without duplicating i assert.deepEqual(store.snapshot().messages, [message]); assert.equal(store.hasDurableMessage(message.id), false); - const change = [...encodeDesktopTranscriptChange(identity, { + for (const batch of encodeDesktopTranscriptChange(identity, { coversFrom: null, durableThrough: 4, durableUpserts: [{ sequence: 4, message }], - })]; - for (const batch of change) store.accept(batch); + })) store.accept(batch); assert.deepEqual(store.snapshot().messages, [message]); assert.equal(store.hasDurableMessage(message.id), true); - - for (const batch of change) assert.equal(store.accept(batch), false); - assert.deepEqual(store.snapshot().messages, [message]); -}); - -test('tracks the newest resident durable prompt as the window changes', () => { - const identity = { - sessionId: 'session-1', - generation: 'generation-1', - hostEpoch: 'host-1', - }; - const store = transcriptStore(); - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, - durableThrough: 3, - durable: [ - { sequence: 1, message: userMessage('older', 'user-1') }, - { sequence: 2, message: assistantMessage('answer') }, - { sequence: 3, message: userMessage('newer', 'user-3') }, - ], - overlay: [], - hasOlder: false, - hasNewer: false, - })) store.accept(batch); - assert.equal(store.newestDurableUserSequence(), 3); - - for (const batch of encodeDesktopTranscriptChange(identity, { - coversFrom: 3, - durableThrough: 4, - durableUpserts: [{ sequence: 4, message: assistantMessage('latest') }], })) store.accept(batch); - assert.equal(store.newestDurableUserSequence(), 3); - - store.retain(2, null); - assert.equal(store.newestDurableUserSequence(), 3); - store.retain(4, null); - assert.equal(store.newestDurableUserSequence(), null); }); test('drops stale transcript batches after a generation reset', () => { @@ -355,7 +249,6 @@ test('drops stale transcript batches after a generation reset', () => { durable: [{ sequence: 1, message: assistantMessage('old') }], overlay: [], hasOlder: false, - hasNewer: false, })]; const nextMessage = assistantMessage('new'); const nextBatches = [...encodeDesktopTranscriptSnapshot({ @@ -366,7 +259,6 @@ test('drops stale transcript batches after a generation reset', () => { durable: [{ sequence: 2, message: nextMessage }], overlay: [], hasOlder: true, - hasNewer: false, })]; for (const batch of oldBatches) store.accept(batch); @@ -376,7 +268,8 @@ test('drops stale transcript batches after a generation reset', () => { { coversFrom: 2, durableThrough: 3, - durableUpserts: [{ sequence: 3, message: assistantMessage('stale') }], }, + durableUpserts: [{ sequence: 3, message: assistantMessage('stale') }], + }, )]; for (const batch of staleChange) assert.equal(store.accept(batch), false); assert.deepEqual(store.snapshot().messages, [nextMessage]); @@ -390,13 +283,14 @@ test('cached reload snapshots allow the same live transcript generation to resum hostEpoch: 'host-1', }; let opens = 0; + const errors: unknown[] = []; const deliveries: Array<{ generation: string; accepted: boolean }> = []; - const publish = (generation: string, text: string, navigation?: number) => { + const publish = (generation: string, text: string) => { for (const batch of encodeDesktopTranscriptSnapshot({ ...identity, generation, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage(text) }], - overlay: [], hasOlder: false, hasNewer: false, - }, navigation)) deliveries.push({ generation, accepted: store.accept(batch) }); + overlay: [], hasOlder: false, + })) deliveries.push({ generation, accepted: store.accept(batch) }); }; const controller = createDesktopTranscriptRangeController(store, async () => { opens += 1; @@ -406,20 +300,8 @@ test('cached reload snapshots allow the same live transcript generation to resum } // The event subscription keeps the main-process replica alive between opens. publish(identity.generation, `live-${opens}`); - return { - ...identity, readThroughMessageId: null, - async acknowledgeTail() {}, - async loadBefore() {}, - async loadAfter() {}, - async loadAround(_sequence, _maxBytes, navigation) { - publish(identity.generation, `live-${opens}`, navigation); - }, - async loadLatest(navigation) { - publish(identity.generation, `live-${opens}`, navigation); - }, - async close() {}, - }; - }); + return transcriptHandle(identity); + }, { onError: (error) => errors.push(error) }); try { await controller.ready(); @@ -435,8 +317,10 @@ test('cached reload snapshots allow the same live transcript generation to resum for (const batch of encodeDesktopTranscriptChange(identity, { coversFrom: 1, durableThrough: 2, - durableUpserts: [{ sequence: 2, message: updated }], })) assert.equal(store.accept(batch), true); + durableUpserts: [{ sequence: 2, message: updated }], + })) assert.equal(store.accept(batch), true); assert.deepEqual(store.snapshot().messages, [assistantMessage('live-3'), updated]); + assert.deepEqual(errors, []); } finally { await controller.close(); } @@ -448,7 +332,7 @@ test('a replacement live generation retires the previous replica through cached const snapshot = (generation: string) => [...encodeDesktopTranscriptSnapshot({ sessionId: 'session-1', generation, hostEpoch: 'host-1', durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage(generation) }], - overlay: [], hasOlder: false, hasNewer: false, + overlay: [], hasOlder: false, })]; const generations = ['previous-live', ...cachedGenerations, 'replacement-live']; for (const generation of generations) { @@ -462,7 +346,8 @@ test('a replacement live generation retires the previous replica through cached }, { coversFrom: 1, durableThrough: 2, - durableUpserts: [{ sequence: 2, message: assistantMessage('stale', 'stale') }], })) assert.equal(store.accept(batch), false); + durableUpserts: [{ sequence: 2, message: assistantMessage('stale', 'stale') }], + })) assert.equal(store.accept(batch), false); } assert.strictEqual(store.snapshot(), replacement); assert.deepEqual(store.snapshot().messages, [assistantMessage('replacement-live')]); @@ -484,7 +369,6 @@ test('keeps unchanged message references stable across immutable range snapshots durable: [{ sequence: 1, message: firstMessage }], overlay: [], hasOlder: false, - hasNewer: false, })) store.accept(batch); const first = store.snapshot(); @@ -496,7 +380,8 @@ test('keeps unchanged message references stable across immutable range snapshots for (const batch of encodeDesktopTranscriptChange(identity, { coversFrom: 1, durableThrough: 2, - durableUpserts: [{ sequence: 2, message: secondMessage }], })) store.accept(batch); + durableUpserts: [{ sequence: 2, message: secondMessage }], + })) store.accept(batch); const second = store.snapshot(); assert.notStrictEqual(second, first); @@ -504,6 +389,153 @@ test('keeps unchanged message references stable across immutable range snapshots assert.deepEqual(second.messages, [firstMessage, secondMessage]); }); +test('a reset spanning several batches publishes once, when it is ready', () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('old') }], + overlay: [], hasOlder: false, + })) store.accept(batch); + const installed = store.snapshot(); + let commits = 0; + store.subscribe(() => { commits += 1; }); + + const next = { ...identity, generation: 'generation-2' }; + const newest = assistantMessage('x'.repeat(300 * 1024), 'assistant-3'); + const older = assistantMessage('older', 'assistant-2'); + const overlay = assistantMessage('streaming', 'assistant-4'); + const batches = [ + ...encodeDesktopTranscriptBatches(next, { + durableThrough: 3, durable: [{ sequence: 3, message: newest }], overlay: [], + hasOlder: true, reset: true, ready: false, + }), + ...encodeDesktopTranscriptBatches(next, { + durableThrough: 3, durable: [{ sequence: 2, message: older }], overlay: [overlay], + hasOlder: false, reset: false, ready: true, + }), + ]; + assert.ok(batches.length > 2, 'the reset has to span several batches'); + for (const batch of batches.slice(0, -1)) { + assert.equal(store.accept(batch), false); + assert.strictEqual(store.snapshot(), installed); + } + assert.equal(commits, 0); + + assert.equal(store.accept(batches.at(-1)!), true); + assert.equal(commits, 1); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['assistant-2', 'assistant-3', 'assistant-4']); + assert.equal(store.range().generation, 'generation-2'); + assert.equal(store.range().hasOlder, false); +}); + +test('earlier history installs only below the oldest row it was read for', () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 6, overlay: [], hasOlder: true, + durable: [5, 6].map((sequence) => ({ sequence, message: assistantMessage(`${sequence}`, `assistant-${sequence}`) })), + })) store.accept(batch); + const ids = () => store.snapshot().messages.map(({ id }) => id); + + for (const batch of earlierBatches(identity, 6, [4], false)) assert.equal(store.accept(batch), false); + assert.deepEqual(ids(), ['assistant-5', 'assistant-6']); + + for (const batch of earlierBatches(identity, 5, [3, 4], true)) assert.equal(store.accept(batch), true); + assert.deepEqual(ids(), ['assistant-3', 'assistant-4', 'assistant-5', 'assistant-6']); + assert.equal(store.range().hasOlder, true); + assert.equal(store.range().durableThrough, 6); + + for (const batch of earlierBatches(identity, 5, [2], false)) assert.equal(store.accept(batch), false); + assert.deepEqual(ids(), ['assistant-3', 'assistant-4', 'assistant-5', 'assistant-6']); + assert.equal(store.range().hasOlder, true); + assert.equal(store.needsReload(), false); + + for (const batch of earlierBatches(identity, 3, [2], false)) assert.equal(store.accept(batch), true); + assert.deepEqual(ids(), ['assistant-2', 'assistant-3', 'assistant-4', 'assistant-5', 'assistant-6']); + assert.equal(store.range().hasOlder, false); +}); + +test('a tail change that does not continue the held rows reopens the transcript', async () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + const row = (sequence: number) => ({ sequence, message: assistantMessage(`${sequence}`, `assistant-${sequence}`) }); + const errors: unknown[] = []; + let opens = 0; + const controller = createDesktopTranscriptRangeController(store, async () => { + opens += 1; + const durable = opens === 1 ? [row(1)] : [row(1), row(2), row(9)]; + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: durable.at(-1)!.sequence, durable, overlay: [], hasOlder: false, + })) store.accept(batch); + return transcriptHandle(identity); + }, { onError: (error) => errors.push(error) }); + const ids = () => store.snapshot().messages.map(({ id }) => id); + try { + await controller.ready(); + + for (const batch of encodeDesktopTranscriptChange(identity, { + coversFrom: 1, durableThrough: 2, durableUpserts: [row(2)], + })) assert.equal(store.accept(batch), true); + assert.deepEqual(ids(), ['assistant-1', 'assistant-2']); + assert.equal(store.range().durableThrough, 2); + assert.equal(store.needsReload(), false); + + for (const batch of encodeDesktopTranscriptChange(identity, { + coversFrom: 7, durableThrough: 9, durableUpserts: [row(9)], + })) store.accept(batch); + assert.deepEqual(ids(), ['assistant-1', 'assistant-2'], 'nothing proves 9 adjacent to what is held'); + assert.equal(store.range().durableThrough, 2); + assert.equal(store.needsReload(), true); + + await waitFor(() => opens === 2 && !store.needsReload(), { timeoutMs: 5_000 }); + assert.deepEqual(ids(), ['assistant-1', 'assistant-2', 'assistant-9']); + assert.equal(store.range().durableThrough, 9); + assert.deepEqual(errors, []); + } finally { + await controller.close(); + } +}); + +test('loadEarlier shares one in-flight read and reports its failure', async () => { + const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; + const failure = new Error('earlier read failed'); + const gate = deferred(); + const errors: unknown[] = []; + let reads = 0; + const controller = createDesktopTranscriptRangeController(store, async () => { + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 5, overlay: [], hasOlder: true, + durable: [{ sequence: 5, message: assistantMessage('5', 'assistant-5') }], + })) store.accept(batch); + return transcriptHandle(identity, { + async loadEarlier() { + reads += 1; + await gate.promise; + throw failure; + }, + }); + }, { onError: (error) => errors.push(error) }); + try { + await controller.ready(); + const first = controller.loadEarlier(); + assert.strictEqual(controller.loadEarlier(), first); + gate.resolve(); + await first; + assert.equal(reads, 1); + assert.deepEqual(errors, [failure]); + + await controller.loadEarlier(); + assert.equal(reads, 2, 'a settled read does not block the next one'); + + for (const batch of earlierBatches(identity, 5, [4], false)) store.accept(batch); + await controller.loadEarlier(); + assert.equal(reads, 2, 'nothing is read once no earlier history remains'); + } finally { + await controller.close(); + } +}); + test('bounds the default active transcript range by Turn identities', async () => { const messages = Array.from({ length: 200 }, (_, sequence) => ({ identity: sequence, @@ -541,7 +573,6 @@ test('bounds the default active transcript range by Turn identities', async () = ); assert.equal(snapshot.durable.at(-1)?.sequence, 199); assert.equal(snapshot.hasOlder, true); - assert.equal(snapshot.hasNewer, false); }); test('bounds the default active transcript range by presentation bytes', async () => { @@ -569,10 +600,9 @@ test('bounds the default active transcript range by presentation bytes', async ( (total, { message }) => total + Buffer.byteLength(JSON.stringify(message), 'utf8'), 0, ); - assert.ok(bytes <= DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + assert.ok(bytes <= DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES); assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [12, 13, 14, 15]); assert.equal(snapshot.hasOlder, true); - assert.equal(snapshot.hasNewer, false); }); test('keeps an oversized latest Turn visible after bootstrap eviction', async () => { @@ -582,7 +612,7 @@ test('keeps an oversized latest Turn visible after bootstrap eviction', async () }; const latest = { identity: 1, - message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'), + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1), 'assistant-1'), }; const bootstrapPage = transcriptPage('older', null, latest.identity); const handle = runtimeHostSessionFixture({ @@ -609,7 +639,7 @@ test('keeps an oversized latest Turn visible after bootstrap eviction', async () test('keeps an oversized latest Turn visible before a trailing session note', async () => { const latest = { identity: 0, - message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-0'), + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1), 'assistant-0'), }; const trailingNote = { identity: 1, @@ -620,11 +650,7 @@ test('keeps an oversized latest Turn visible before a trailing session note', as kind: 'mode_change' as const, }, }; - const bootstrapPage = { - ...transcriptPage('older', null, trailingNote.identity), - rangeBoundarySequence: latest.identity, - protectedTurnSequence: latest.identity, - }; + const bootstrapPage = transcriptPage('older', null, trailingNote.identity); const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), @@ -633,12 +659,7 @@ test('keeps an oversized latest Turn visible before a trailing session note', as throughSequence: trailingNote.identity, overlayMessageCount: 0, durable: bootstrapPage, - overlay: { - ...bootstrapPage, - source: 'overlay', - rangeBoundarySequence: null, - protectedTurnSequence: null, - }, + overlay: { ...bootstrapPage, source: 'overlay' }, }, loadTranscriptOverlay: async () => [], decodeTranscriptPage: async () => ({ messages: [latest, trailingNote], nextCursor: null }), @@ -707,14 +728,10 @@ test('keeps an oversized streaming Turn visible when its overlay settles', async }; const latest = { identity: 1, - message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'), + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1), 'assistant-1'), }; const bootstrapPage = transcriptPage('older', null, older.identity); - const newerPage = { - ...transcriptPage('newer', null, latest.identity), - rangeBoundarySequence: latest.identity, - protectedTurnSequence: latest.identity, - }; + const newerPage = transcriptPage('newer', null, latest.identity); const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), @@ -749,7 +766,7 @@ test('keeps an oversized settled Turn visible before a trailing session note', a }; const latest = { identity: 1, - message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1), 'assistant-1'), + message: assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1), 'assistant-1'), }; const trailingNote = { identity: 2, @@ -761,11 +778,7 @@ test('keeps an oversized settled Turn visible before a trailing session note', a }, }; const bootstrapPage = transcriptPage('older', null, older.identity); - const newerPage = { - ...transcriptPage('newer', null, trailingNote.identity), - rangeBoundarySequence: trailingNote.identity, - protectedTurnSequence: latest.identity, - }; + const newerPage = transcriptPage('newer', null, trailingNote.identity); const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), @@ -792,113 +805,67 @@ test('keeps an oversized settled Turn visible before a trailing session note', a assert.deepEqual(snapshot.overlay, []); }); -for (const direction of ['older', 'newer'] as const) { - test(`does not resurrect a discarded replica when ${direction} history load is in flight`, async () => { - // A pending page must not repopulate or publish a reclaimed replica. - const messages = [0, 1, 2, 3, 4].map((sequence) => ({ - identity: sequence, - message: assistantMessage(String(sequence), `assistant-${sequence}`), - })); - const page = (nextCursor: string | null) => ({ - kind: 'page' as const, - sessionId: 'session-1', - source: 'durable' as const, - direction: 'older' as const, +test('does not resurrect a discarded replica when a history page read is in flight', async () => { + const messages = [0, 1, 2, 3, 4].map((sequence) => ({ + identity: sequence, + message: assistantMessage(String(sequence), `assistant-${sequence}`), + })); + const bootstrapPage = transcriptPage('older', 'older', 4); + const olderPage = transcriptPage('older', null, 4); + const pageGate = deferred(); + const pageEntered = deferred(); + const changes: DesktopTranscriptReplicaChange[] = []; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { throughSequence: 4, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page('older'); - const adjacentPage = { ...page(null), direction }; - let releasePage: () => void = () => {}; - const pageGate = new Promise((resolve) => { - releasePage = resolve; - }); - let signalEntered: () => void = () => {}; - const pageEntered = new Promise((resolve) => { - signalEntered = resolve; - }); - const changes: { durableUpserts: readonly { sequence: number }[] }[] = []; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 4, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...page(null), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => candidate === bootstrapPage - ? { messages: direction === 'older' ? messages.slice(4) : messages.slice(0, 1), nextCursor: 'older' } - : { messages: messages.slice(2, 4), nextCursor: null }, - loadTranscriptPage: async () => { - signalEntered(); - await pageGate; - return adjacentPage; - }, - async close() {}, - }); - const replica = await DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes: 1024 * 1024, - onChange: (_replica, change) => changes.push(change), - }); - - // Reclaim memory while an adjacent history page is pending. - const loading = direction === 'older' - ? replica.loadBefore(4, 128 * 1024) - : replica.loadAfter(1, 128 * 1024); - await pageEntered; - replica.discard(); - assert.equal(replica.resident, false); - releasePage(); - await loading; - - assert.equal(changes.length, 0, 'a discarded replica must not publish an in-flight history page'); - assert.equal(replica.resident, false); - assert.equal(replica.residentBytes, 0); + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, 4), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (candidate) => candidate === bootstrapPage + ? { messages: messages.slice(4), nextCursor: 'older' } + : { messages: messages.slice(2, 4), nextCursor: null }, + loadTranscriptPage: async () => { + pageEntered.resolve(); + await pageGate.promise; + return olderPage; + }, + async close() {}, }); -} + const replica = await DesktopTranscriptReplica.prepare(handle, { + maxResidentBytes: 1024 * 1024, + onChange: (_replica, change) => changes.push(change), + }); + + const reading = replica.readOlderPage(4, 'older'); + await pageEntered.promise; + replica.discard(); + pageGate.resolve(); + await assert.rejects(reading, /evicted/); + + assert.equal(changes.length, 0, 'a discarded replica must not publish an in-flight history page'); + assert.equal(replica.resident, false); + assert.equal(replica.residentBytes, 0); +}); test('does not drive a discarded replica terminal when a contiguous catch-up is in flight', async () => { - // Same post-await `#resident` invariant on the ordinary contiguous catch-up - // path: another observed session's LRU `discard()` reclaims this replica while - // a `direction: 'newer'` page is pending. The per-page callback already returns - // early, but without the post-loop guard the watermark check would throw - // `correlation_changed` and drive the session terminal. A discarded replica has - // no watermark to meet — catch-up must return cleanly, not reject. + // Another observed Session's LRU `discard()` reclaims this replica while a + // newer page is pending. A discarded replica has no watermark to meet, so + // catch-up must return cleanly rather than reject with `correlation_changed`. const messages = [0, 1, 2, 3, 4].map((sequence) => ({ identity: sequence, message: assistantMessage(String(sequence), `assistant-${sequence}`), })); const appended = { identity: 5, message: assistantMessage('5', 'assistant-5') }; - const page = (nextCursor: string | null, throughSequence: number) => ({ - kind: 'page' as const, - sessionId: 'session-1', - source: 'durable' as const, - direction: 'newer' as const, - throughSequence, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page(null, 4); - const newerPage = page(null, 5); - let releaseNewer: () => void = () => {}; - const newerGate = new Promise((resolve) => { - releaseNewer = resolve; - }); - let signalEntered: () => void = () => {}; - const newerEntered = new Promise((resolve) => { - signalEntered = resolve; - }); - const changes: { durableUpserts: readonly { sequence: number }[] }[] = []; + const bootstrapPage = transcriptPage('newer', null, 4); + const newerPage = transcriptPage('newer', null, 5); + const newerGate = deferred(); + const newerEntered = deferred(); + const changes: DesktopTranscriptReplicaChange[] = []; const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), @@ -907,17 +874,15 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is throughSequence: 4, overlayMessageCount: 0, durable: bootstrapPage, - overlay: { ...page(null, 4), source: 'overlay' }, + overlay: { ...transcriptPage('newer', null, 4), source: 'overlay' }, }, loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (candidate) => candidate === bootstrapPage ? { messages, nextCursor: null } : { messages: [appended], nextCursor: null }, loadTranscriptPage: async () => { - // Park catch-up inside the contiguous newer-page await so the test can - // reclaim memory at exactly that point. - signalEntered(); - await newerGate; + newerEntered.resolve(); + await newerGate.promise; return newerPage; }, async close() {}, @@ -926,17 +891,12 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is maxResidentBytes: 1024 * 1024, onChange: (_replica, change) => changes.push(change), }); - // A large budget keeps the whole bootstrap resident, so the tail is contiguous - // (`hasNewer` false) and `advance` takes the paged catch-up, not the re-anchor. - assert.equal(replica.snapshot().hasNewer, false); - // Advance the watermark contiguously; reclaim memory while the newer page is - // pending. Before the fix `advancing` rejects with `correlation_changed`. const advancing = replica.advance(5); - await newerEntered; + await newerEntered.promise; replica.discard(); assert.equal(replica.resident, false); - releaseNewer(); + newerGate.resolve(); await advancing; const upserts = changes.flatMap((change) => change.durableUpserts.map(({ sequence }) => sequence)); @@ -945,7 +905,7 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is assert.equal(replica.residentBytes, 0); }); -test('a window opened between catch-up pages can join the change that follows', async () => { +test('a transcript opened between catch-up pages can join the change that follows', async () => { const bootstrap = [0, 1, 2].map((sequence) => ({ identity: sequence, message: assistantMessage(String(sequence), `assistant-${sequence}`), @@ -955,25 +915,11 @@ test('a window opened between catch-up pages can join the change that follows', message: assistantMessage(String(sequence), `assistant-${sequence}`), })); const secondPage = [{ identity: 6, message: assistantMessage('6', 'assistant-6') }]; - const page = (nextCursor: string | null, throughSequence: number) => ({ - kind: 'page' as const, - sessionId: 'session-1', - source: 'durable' as const, - direction: 'newer' as const, - throughSequence, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrapPage = page(null, 2); - const first = page('more', 6); - const second = page(null, 6); - let releaseSecond: () => void = () => {}; - const secondGate = new Promise((resolve) => { releaseSecond = resolve; }); - let signalSecond: () => void = () => {}; - const secondEntered = new Promise((resolve) => { signalSecond = resolve; }); + const bootstrapPage = transcriptPage('newer', null, 2); + const first = transcriptPage('newer', 'more', 6); + const second = transcriptPage('newer', null, 6); + const secondGate = deferred(); + const secondEntered = deferred(); const changes: DesktopTranscriptReplicaChange[] = []; const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), @@ -983,7 +929,7 @@ test('a window opened between catch-up pages can join the change that follows', throughSequence: 2, overlayMessageCount: 0, durable: bootstrapPage, - overlay: { ...page(null, 2), source: 'overlay' }, + overlay: { ...transcriptPage('newer', null, 2), source: 'overlay' }, }, loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (candidate) => candidate === bootstrapPage @@ -993,8 +939,8 @@ test('a window opened between catch-up pages can join the change that follows', : { messages: secondPage, nextCursor: null }, loadTranscriptPage: async (request) => { if (request.cursor === null) return first; - signalSecond(); - await secondGate; + secondEntered.resolve(); + await secondGate.promise; return second; }, async close() {}, @@ -1005,13 +951,13 @@ test('a window opened between catch-up pages can join the change that follows', }); const advancing = replica.advance(6); - await secondEntered; - // The first page is installed; the second is pending. A window opening now - // must be told the watermark its rows actually reach. + await secondEntered.promise; + // The first page is installed; the second is pending. A transcript opening + // now must be told the watermark its rows actually reach. const opened = replica.snapshot(); assert.deepEqual(opened.durable.map(({ sequence }) => sequence), [0, 1, 2, 3, 4, 5]); assert.equal(opened.durableThrough, 5); - releaseSecond(); + secondGate.resolve(); await advancing; const store = transcriptStore(); @@ -1020,8 +966,11 @@ test('a window opened between catch-up pages can join the change that follows', for (const change of changes.slice(1)) { for (const batch of encodeDesktopTranscriptChange(identity, change)) store.accept(batch); } - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [0, 1, 2, 3, 4, 5, 6]); - assert.equal(store.range().hasNewer, false); + assert.deepEqual( + store.snapshot().messages.map(({ id }) => id), + [0, 1, 2, 3, 4, 5, 6].map((sequence) => `assistant-${sequence}`), + ); + assert.equal(store.needsReload(), false); }); test('rejects an overlay that exceeds its cache budget', async () => { @@ -1100,34 +1049,20 @@ test('does not release resident bytes when preparation accounting rejects them', test('reopens a failed transcript range with a fresh generation', async () => { const store = transcriptStore(); + const identity = { sessionId: 'session-1', generation: 'reloaded', hostEpoch: 'host-2' }; let attempts = 0; const controller = createDesktopTranscriptRangeController(store, async () => { attempts += 1; if (attempts === 1) throw new Error('open failed'); for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', - generation: 'reloaded', - hostEpoch: 'host-2', + ...identity, durableThrough: null, durable: [], overlay: [], hasOlder: false, - hasNewer: false, - })) - store.accept(batch); - return { - sessionId: 'session-1', - generation: 'reloaded', - hostEpoch: 'host-2', - readThroughMessageId: null, - async acknowledgeTail() {}, - async loadBefore() {}, - async loadAfter() {}, - async loadAround() {}, - async loadLatest() {}, - async close() {}, - }; - }); + })) store.accept(batch); + return transcriptHandle(identity); + }, { onError() {} }); await assert.rejects(() => controller.ready(), /open failed/); await controller.reload(); @@ -1173,58 +1108,6 @@ test('retries a failed transcript recovery after a newer observation becomes rea recovery.close(); }); -test('forwards a larger logical history range without changing batch size', async () => { - const store = transcriptStore(); - // Rows below an open newer edge only install as a command's answer, so this - // snapshot has to be one: it is the window a navigation asked for. - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', - generation: 'generation-1', - hostEpoch: 'host-1', - durableThrough: 4, - durable: [ - { sequence: 1, message: assistantMessage('earlier') }, - { - sequence: 2, - message: { ...assistantMessage('latest', 'assistant-2'), turnId: 'turn-2' }, - }, - { - sequence: 3, - message: { ...assistantMessage('more', 'assistant-3'), turnId: 'turn-2' }, - }, - ], - overlay: [], - hasOlder: true, - hasNewer: true, - }, store.navigate())) store.accept(batch); - let request: { anchorSequence: number | null; maxBytes?: number } | undefined; - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId: 'session-1', - generation: 'generation-1', - hostEpoch: 'host-1', - readThroughMessageId: 'assistant-1', - async acknowledgeTail() {}, - async loadBefore(anchorSequence, maxBytes) { - request = { anchorSequence, maxBytes }; - }, - async loadAfter(anchorSequence, maxBytes) { - request = { anchorSequence, maxBytes }; - }, - async loadAround() {}, - async loadLatest() {}, - async close() {}, - })); - - await controller.loadBefore(512 * 1024); - - assert.deepEqual(request, { anchorSequence: 1, maxBytes: 512 * 1024 }, - 'backward reads start at the oldest record the window holds'); - await controller.loadAfter(512 * 1024); - assert.deepEqual(request, { anchorSequence: 3, maxBytes: 512 * 1024 }, - 'forward reads start at the newest record the window holds'); - await controller.close(); -}); - test('waits for the required durable message on the current transcript generation', async () => { const store = transcriptStore(); const identity = { @@ -1238,23 +1121,23 @@ test('waits for the required durable message on the current transcript generatio durable: [], overlay: [], hasOlder: false, - hasNewer: false, })) store.accept(batch); const waiting = store.waitForDurableMessage('assistant-1', 100); for (const batch of encodeDesktopTranscriptChange(identity, { coversFrom: null, durableThrough: 0, - durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], })) store.accept(batch); + durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], + })) store.accept(batch); assert.equal(await waiting, true); }); -test('the window does not change while an answer is still being assembled', () => { +test('the transcript does not change while a tail change is still being assembled', () => { const store = transcriptStore(); const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; for (const batch of encodeDesktopTranscriptSnapshot({ ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], - overlay: [], hasOlder: false, hasNewer: false, + overlay: [], hasOlder: false, })) store.accept(batch); const installed = store.snapshot(); @@ -1265,137 +1148,40 @@ test('the window does not change while an answer is still being assembled', () = message: assistantMessage('x'.repeat(300 * 1024), 'assistant-2'), }], })]; - assert.ok(change.length > 1, 'the answer has to span more than one batch'); + assert.ok(change.length > 1, 'the change has to span more than one batch'); for (const batch of change.slice(0, -1)) assert.equal(store.accept(batch), false); - assert.strictEqual(store.snapshot(), installed, 'the screen is never a half-installed answer'); + assert.strictEqual(store.snapshot(), installed, 'the screen is never a half-installed change'); assert.equal(store.accept(change.at(-1)!), true); - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [1, 2]); -}); - -for (const coversFrom of [7, undefined]) { - test(`tail rows a window cannot join are dropped, and its ${coversFrom === undefined ? 'uncovered' : 'mismatched'} watermark still moves`, () => { - const store = transcriptStore(); - const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], - overlay: [], hasOlder: false, hasNewer: false, - })) store.accept(batch); - assert.equal(store.range().hasNewer, false); - - for (const batch of encodeDesktopTranscriptChange(identity, { - coversFrom, durableThrough: 9, - durableUpserts: [{ sequence: 9, message: assistantMessage('stranded', 'assistant-9') }], - })) store.accept(batch); - - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [1], - 'nothing proves 9 adjacent to what the window holds'); - assert.equal(store.range().durableThrough, 9); - assert.equal(store.range().hasNewer, true, 'so the window knows to read forward itself'); - }); -} - -test('a reset the reader has navigated past moves the watermark and nothing else', () => { - const store = transcriptStore(); - const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], - overlay: [], hasOlder: false, hasNewer: false, - })) store.accept(batch); - - const answer = [...encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 6, - durable: [ - { sequence: 5, message: assistantMessage('x'.repeat(300 * 1024), 'assistant-5') }, - { sequence: 6, message: assistantMessage('jumped', 'assistant-6') }, - ], - overlay: [], hasOlder: true, hasNewer: false, - }, store.navigate())]; - assert.ok(answer.length > 1); - store.accept(answer[0]!); - // The reader asked to be somewhere else before the first answer finished. - store.navigate(); - for (const batch of answer.slice(1)) store.accept(batch); - - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [1]); - assert.equal(store.range().durableThrough, 6); - assert.equal(store.pendingNavigation(), 2, 'the jump the reader is waiting for still stands'); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['assistant-1', 'assistant-2']); }); -test('a fill is issued once per window and again as soon as the window moves', async () => { - const store = transcriptStore(); - let reads = 0; - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - readThroughMessageId: null, - async acknowledgeTail() {}, - async loadBefore(_anchor, maxBytes) { - assert.equal(maxBytes, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - 'a background projection keeps its existing scan budget'); - reads += 1; - }, - async loadAfter() {}, async loadAround() {}, async loadLatest() {}, async close() {}, - })); - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: 2, hasOlder: true, hasNewer: false, overlay: [], - durable: [ - { sequence: 1, message: assistantMessage('first') }, - { sequence: 2, message: assistantMessage('second', 'assistant-2') }, - ], - })) store.accept(batch); - - assert.equal(await controller.loadBefore(), true); - assert.equal(await controller.loadBefore(), false, 'the same window answers the same way'); - assert.equal(reads, 1); - - assert.equal(store.retain(2, 2), true); - assert.equal(await controller.loadBefore(), true); - assert.equal(reads, 2); - await controller.close(); -}); - -test('reports each tail the window reaches once, and none while it is parked', async () => { +test('reports each tail watermark the reader reaches once', async () => { const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; const store = transcriptStore(); const acknowledged: number[] = []; - const readBudgets: number[] = []; - // The visible reader path: only a controller that acknowledges reports a tail. - const controller = createRecoveringDesktopTranscriptRangeController(store, async () => ({ - ...identity, readThroughMessageId: null, + const controller = createDesktopTranscriptRangeController(store, async () => transcriptHandle(identity, { async acknowledgeTail(through) { acknowledged.push(through); }, - async loadBefore(_anchor, maxBytes) { readBudgets.push(maxBytes); }, async loadAfter() {}, async loadAround() {}, - async loadLatest() {}, async close() {}, }), { onError() {} }); const settle = () => new Promise((resolve) => setImmediate(resolve)); - // Opening a Session at the tail: the read marker still moves on open. for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, hasOlder: true, hasNewer: false, overlay: [], + ...identity, durableThrough: 1, hasOlder: true, overlay: [], durable: [{ sequence: 1, message: assistantMessage('first') }], })) store.accept(batch); await settle(); assert.deepEqual(acknowledged, [1]); - await controller.loadBefore(); - assert.equal(readBudgets.length, 1); - assert.ok(readBudgets[0]! > 0 && readBudgets[0]! < DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - 'the visible reader uses an interactive page budget rather than the IPC ceiling'); for (const batch of encodeDesktopTranscriptChange(identity, { coversFrom: 1, durableThrough: 2, durableUpserts: [{ sequence: 2, message: assistantMessage('second', 'assistant-2') }], })) store.accept(batch); await settle(); - assert.deepEqual(acknowledged, [1, 2], 'a window that joined the tail reports it once'); + assert.deepEqual(acknowledged, [1, 2]); - // A trim reopens the newer edge, so the next change cannot join the window. - assert.equal(store.retain(1, 1), true); - for (const batch of encodeDesktopTranscriptChange(identity, { - coversFrom: 2, durableThrough: 3, - durableUpserts: [{ sequence: 3, message: assistantMessage('third', 'assistant-3') }], - })) store.accept(batch); + for (const batch of earlierBatches(identity, 1, [0], false)) assert.equal(store.accept(batch), true); await settle(); - assert.deepEqual(acknowledged, [1, 2], 'a parked window reports no tail'); + assert.deepEqual(acknowledged, [1, 2], 'earlier history moves no tail watermark'); await controller.close(); }); @@ -1409,12 +1195,69 @@ test('cancels a transcript open that is still waiting for a Host', async () => { openSignal = signal; signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }); }), + { onError() {} }, ); await controller.close(); assert.equal(openSignal?.aborted, true); }); +test('cached fallback remains readable and retries once per observation generation until live', async () => { + const store = transcriptStore(); + const errors: unknown[] = []; + let opens = 0; + let online = false; + let earlierReads = 0; + const controller = createDesktopTranscriptRangeController(store, async () => { + opens += 1; + const identity = { + sessionId: 'session-1', + generation: online ? 'live-generation' : 'cached:generation', + hostEpoch: 'host-1', + }; + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, + durable: [{ sequence: 1, message: assistantMessage(online ? 'live' : 'cached') }], + overlay: [], hasOlder: true, + })) store.accept(batch); + return transcriptHandle(identity, { async loadEarlier() { earlierReads += 1; } }); + }, { onError: (error) => errors.push(error) }); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + await controller.ready(); + await settle(); + assert.equal(opens, 1); + assert.equal(store.range().generation, 'cached:generation'); + await controller.loadEarlier(); + assert.equal(earlierReads, 0, 'a cached transcript has no Host to read earlier history from'); + controller.observationChanged('ready'); + await settle(); + assert.equal(opens, 2); + controller.observationChanged('ready'); + await settle(); + assert.equal(opens, 2); + online = true; + controller.observationChanged('pending'); + controller.observationChanged('ready'); + await settle(); + assert.equal(opens, 3); + assert.equal(store.range().generation, 'live-generation'); + assert.deepEqual(errors, []); + await controller.close(); +}); + +test('live transcript open failures without cache still report the original error', async () => { + const failure = new Error('no Host and no cache'); + const errors: unknown[] = []; + const controller = createDesktopTranscriptRangeController( + transcriptStore(), async () => { throw failure; }, + { onError: (error) => errors.push(error) }, + ); + await assert.rejects(controller.ready(), /no Host and no cache/); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(errors, [failure]); + await controller.close(); +}); + function assistantMessage( text: string, id = 'assistant-1', @@ -1433,6 +1276,40 @@ function transcriptStore(): DesktopTranscriptRangeStore { return new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); } +function transcriptHandle( + identity: TranscriptBatchIdentity, + overrides: Partial = {}, +): DesktopTranscriptHandle { + return { + ...identity, + readThroughMessageId: null, + async acknowledgeTail() {}, + async loadEarlier() {}, + async close() {}, + ...overrides, + }; +} + +function earlierBatches( + identity: TranscriptBatchIdentity, + earlierThan: number, + sequences: readonly number[], + hasOlder: boolean, +) { + return encodeDesktopTranscriptBatches(identity, { + durableThrough: null, + durable: sequences.map((sequence) => ({ + sequence, + message: assistantMessage(`${sequence}`, `assistant-${sequence}`), + })), + overlay: [], + earlierThan, + hasOlder, + reset: false, + ready: true, + }); +} + function userMessage( text: string, id: string, @@ -1459,8 +1336,6 @@ function transcriptPage( throughSequence, rawBytes: 1, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor, }; } @@ -1510,97 +1385,3 @@ function continuitySnapshot() { interactions: { pending: [] }, }; } - -test('cached fallback remains readable and retries once per observation generation until live', async () => { - const store = transcriptStore(); - const errors: unknown[] = []; - let opens = 0; - let online = false; - const controller = createRecoveringDesktopTranscriptRangeController(store, async () => { - opens += 1; - const identity = { - sessionId: 'session-1', - generation: online ? 'live-generation' : 'cached:generation', - hostEpoch: 'host-1', - }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, - durable: [{ sequence: 1, message: assistantMessage(online ? 'live' : 'cached') }], - overlay: [], hasOlder: false, hasNewer: false, - })) store.accept(batch); - return { - ...identity, readThroughMessageId: null, - acknowledgeTail: async () => {}, - loadBefore: async () => {}, loadAfter: async () => {}, loadAround: async () => {}, - loadLatest: async () => {}, close: async () => {}, - }; - }, { onError: (error) => errors.push(error) }); - const settle = () => new Promise((resolve) => setImmediate(resolve)); - await controller.ready(); - await settle(); - assert.equal(opens, 1); - assert.equal(store.range().generation, 'cached:generation'); - controller.observationChanged('ready'); - await settle(); - assert.equal(opens, 2); - controller.observationChanged('ready'); - await settle(); - assert.equal(opens, 2); - online = true; - controller.observationChanged('pending'); - controller.observationChanged('ready'); - await settle(); - assert.equal(opens, 3); - assert.equal(store.range().generation, 'live-generation'); - assert.deepEqual(errors, []); - await controller.close(); -}); - -test('a read refused for a Host epoch that moved is superseded, not failed', async () => { - const store = transcriptStore(); - const errors: unknown[] = []; - let opens = 0; - const otherFailure = new Error('the older page failed'); - const controller = createRecoveringDesktopTranscriptRangeController(store, async () => { - opens += 1; - const identity = { sessionId: 'session-1', generation: 'live-generation', hostEpoch: 'host-1' }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, - durable: [{ sequence: 1, message: assistantMessage('live') }], - overlay: [], hasOlder: true, hasNewer: false, - })) store.accept(batch); - return { - ...identity, readThroughMessageId: null, - acknowledgeTail: async () => {}, - loadBefore: async () => { throw otherFailure; }, - loadAfter: async () => {}, - loadAround: async () => { - throw new Error(`Error invoking remote method 'sessions:transcript:load-around': Error: ${DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE}: Desktop transcript host epoch changed; reopen the transcript`); - }, - loadLatest: async () => {}, close: async () => {}, - }; - }, { onError: (error) => errors.push(error) }); - try { - await controller.ready(); - await assert.rejects(controller.loadAround(1), TranscriptReadSupersededError); - await assert.rejects(controller.loadBefore(), (error) => error === otherFailure); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(errors, [], 'a superseded read must not reach the error surface'); - assert.equal(opens, 1, 'the replacement reset carries the new epoch, so nothing is reopened'); - } finally { - await controller.close(); - } -}); - -test('live transcript open failures without cache still report the original error', async () => { - const failure = new Error('no Host and no cache'); - const errors: unknown[] = []; - const controller = createRecoveringDesktopTranscriptRangeController( - transcriptStore(), async () => { throw failure; }, - { onError: (error) => errors.push(error) }, - ); - await assert.rejects(controller.ready(), /no Host and no cache/); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(errors, [failure]); - await controller.close(); -}); diff --git a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts index a77f4dbb35..301f8aeffa 100644 --- a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts +++ b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts @@ -40,7 +40,6 @@ test('reads the newest anchor on the active route', () => { { type: 'assistant' }, usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }), ], - { hasNewer: false }, MODEL, ROUTE, ); @@ -56,7 +55,6 @@ test('scans past an anchorless usage row, which is what manual compaction writes usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }), usage(), ], - { hasNewer: false }, MODEL, ROUTE, ); @@ -69,7 +67,6 @@ test('refuses an anchor from another model', () => { // request the user is not making. const tokens = selectLatestRequestUsage( [usage({ inputTokens: 100_000, modelId: 'model-b', connectionId: 'conn-a' })], - { hasNewer: false }, MODEL, ROUTE, ); @@ -79,7 +76,6 @@ test('refuses an anchor from another model', () => { test('refuses an anchor from another connection', () => { const tokens = selectLatestRequestUsage( [usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-b' })], - { hasNewer: false }, MODEL, ROUTE, ); @@ -89,18 +85,6 @@ test('refuses an anchor from another connection', () => { test('refuses an anchor written before anchors carried their route', () => { const tokens = selectLatestRequestUsage( [usage({ inputTokens: 100, outputTokens: 20 })], - { hasNewer: false }, - MODEL, - ROUTE, - ); - assert.equal(tokens, undefined); -}); - -test('refuses every anchor while the loaded range is not the session tail', () => { - // Browsing history must not report an older range's usage as current. - const tokens = selectLatestRequestUsage( - [usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' })], - { hasNewer: true }, MODEL, ROUTE, ); @@ -109,14 +93,13 @@ test('refuses every anchor while the loaded range is not the session tail', () = test('refuses when there is no active route yet', () => { const anchored = [usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-a' })]; - assert.equal(selectLatestRequestUsage(anchored, undefined, undefined, ROUTE), undefined); - assert.equal(selectLatestRequestUsage(anchored, undefined, MODEL, undefined), undefined); + assert.equal(selectLatestRequestUsage(anchored, undefined, ROUTE), undefined); + assert.equal(selectLatestRequestUsage(anchored, MODEL, undefined), undefined); }); test('refuses a non-positive input count', () => { const tokens = selectLatestRequestUsage( [usage({ inputTokens: 0, modelId: MODEL, connectionId: 'conn-a' })], - { hasNewer: false }, MODEL, ROUTE, ); diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts index 8bf452bdb4..7c86727227 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -23,7 +23,6 @@ import { afterEach, test } from 'node:test'; import { parseHTML } from 'linkedom'; import { act, createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { renderToStaticMarkup } from 'react-dom/server'; import { ChatSurfaceLayout, ChatView, LocaleProvider } from '@maka/ui'; import type { SessionEvent } from '@maka/core/events'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; @@ -46,6 +45,7 @@ import { type WorkbarIngestInput, type WorkbarServices, } from '../../renderer/features/workbar/testing.js'; +import { renderTranscriptMarkup } from './transcript-test-dom.js'; const originalGlobals = { document: globalThis.document, @@ -249,8 +249,8 @@ async function renderOwnershipProbe( stop: () => stop(), deleteQueuedEntry: (entryId: string) => deleteQueuedEntry(entryId), setPermissionMode: (mode: PermissionMode) => setPermissionMode(mode), - transcript() { - return parseHTML(`${renderToStaticMarkup( + async transcript() { + return parseHTML(`${await renderTranscriptMarkup( createElement(LocaleProvider, { locale: 'en', children: createElement(ChatSurfaceLayout, { composer: null, children: createElement(ChatView, { @@ -501,8 +501,8 @@ for (const proof of ['send reply', 'admission event'] as const) { h.emit({ type: 'text_complete', id: 'answer-event', messageId: 'answer', turnId: 'first-turn', ts: 2, text: 'answer to initial question' }); }); - const assertPromptBeforeReply = () => { - const transcript = h.transcript(); + const assertPromptBeforeReply = async () => { + const transcript = await h.transcript(); const turn = transcript.querySelector('[data-transcript-turn-id="first-turn"]'); assert.ok(turn); assert.ok(turn.querySelector('.maka-user-message')?.textContent.startsWith('initial question')); @@ -511,11 +511,11 @@ for (const proof of ['send reply', 'admission event'] as const) { assert.ok(text.indexOf('initial question') < text.indexOf('answer to initial question')); assert.equal(transcript.querySelectorAll('.maka-user-message').length, 1); }; - assertPromptBeforeReply(); + await assertPromptBeforeReply(); await act(async () => { h.hostTurn('first-turn', 'completed'); }); - assertPromptBeforeReply(); + await assertPromptBeforeReply(); await act(async () => { h.hostTurn('successor-turn'); }); - assertPromptBeforeReply(); + await assertPromptBeforeReply(); if (proof === 'admission event') { await act(async () => { receipt.resolve({ ok: true, turnId: 'first-turn' }); @@ -2190,7 +2190,7 @@ for (const proof of ['started receipt', 'admission event'] as const) { }); assert.equal(container.firstElementChild?.getAttribute('data-live-text'), 'new answer'); assert.match( - transcript().querySelector('[data-transcript-turn-id="new-turn"] .maka-user-message')?.textContent ?? '', + (await transcript()).querySelector('[data-transcript-turn-id="new-turn"] .maka-user-message')?.textContent ?? '', /start after settlement/, ); if (proof === 'admission event') { @@ -2243,7 +2243,7 @@ for (const proof of ['admission event', 'ownership recovery'] as const) { turnId: 'turn-b', ts: 3, text: 'reply to raced successor' }); }); await waitUntil(() => h.container.firstElementChild?.getAttribute('data-processing') === 'false'); - const turn = h.transcript().querySelector('[data-transcript-turn-id="turn-b"]'); + const turn = (await h.transcript()).querySelector('[data-transcript-turn-id="turn-b"]'); assert.ok(turn); assert.match(turn.querySelector('.maka-user-message')?.textContent ?? '', /raced successor prompt/); assert.ok(turn.textContent.includes('reply to raced successor')); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts index a1dc00a8e1..e6132be451 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts @@ -118,29 +118,6 @@ test('derives turn records from bounded contribution pages', async () => { await client.close(); }); -test('reads the bounded prompt rail index without paging every turn', async () => { - const connection = { - request: async (operation: string, input: unknown) => { - assert.equal(operation, 'session.turn_landmarks.query'); - assert.deepEqual(input, { sessionId: 'session-1', maxLandmarks: 64 }); - return { - sessionId: 'session-1', - throughSequence: 100, - landmarks: [{ turnId: 'turn-50', sequence: 50, label: 'middle' }], - }; - }, - close: async () => undefined, - } as unknown as RuntimeHostConnection; - const client = new DesktopRuntimeHostClient(connection); - - assert.deepEqual(await client.listSessionTurnLandmarks('session-1'), { - sessionId: 'session-1', - throughSequence: 100, - landmarks: [{ turnId: 'turn-50', sequence: 50, label: 'middle' }], - }); - await client.close(); -}); - function subscription( sessionId: string, lifecycle: string[], @@ -199,8 +176,6 @@ 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-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index b4301008f6..589d82fcde 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -97,33 +97,39 @@ for (const phase of ['connecting', 'seeding'] as const) { } } -test('window transcript reads are observation operations scoped to the renderer', async () => { +test('transcript history IPC forwards open mode, load-earlier, and read-turn to the registry', async () => { const ipc = ipcHarness(); const observations = new RuntimeHostSessionObservationRegistry(); const calls: unknown[] = []; - observations.loadTranscriptAfter = async (request, targetId) => { calls.push({ command: 'after', request, targetId }); }; - observations.loadTranscriptLatest = async (request, targetId) => { calls.push({ command: 'latest', request, targetId }); }; - registerRuntimeHostSessionObservationIpc({ observations, resolveSideConversation: async () => false }, ipc); - const request = { - consumerId: 'guest-consumer', sessionId: 'shared-session', hostEpoch: 'host-1', - anchorSequence: 42, maxBytes: 512 * 1024, navigation: 7, + observations.openTranscript = async (sessionId, consumerId, target, mode) => { + calls.push({ command: 'open', sessionId, consumerId, targetId: target.id, mode }); + return { sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null }; + }; + observations.loadEarlierTranscript = async (consumerId, targetId) => { + calls.push({ command: 'earlier', consumerId, targetId }); + }; + const turn = [{ type: 'user', id: 'message-1', turnId: 'turn-1', ts: 1, text: 'hello' }]; + observations.readTranscriptTurn = async (sessionId, turnId) => { + calls.push({ command: 'read-turn', sessionId, turnId }); + return turn as never; }; - await ipc.invoke('sessions:transcript:load-after', request); - await ipc.invoke('sessions:transcript:load-latest', { ...request, anchorSequence: null }); + registerRuntimeHostSessionObservationIpc({ observations, resolveSideConversation: async () => false }, ipc); + + await ipc.invoke('sessions:transcript:open', 'shared-session', 'guest-consumer', 'history'); + await ipc.invoke('sessions:transcript:load-earlier', 'guest-consumer'); + assert.deepEqual(await ipc.invoke('sessions:transcript:read-turn', 'shared-session', 'turn-1'), turn); + assert.equal(ipc.reconnectableChannels.has('sessions:transcript:read-turn'), true); assert.deepEqual(calls, [ - { command: 'after', request, targetId: 9 }, - { command: 'latest', request: { ...request, anchorSequence: null }, targetId: 9 }, + { command: 'open', sessionId: 'shared-session', consumerId: 'guest-consumer', targetId: 9, mode: 'history' }, + { command: 'earlier', consumerId: 'guest-consumer', targetId: 9 }, + { command: 'read-turn', sessionId: 'shared-session', turnId: 'turn-1' }, ]); await assert.rejects( - ipc.invoke('sessions:transcript:load-after', { ...request, anchorSequence: -1 }), - /Invalid Desktop transcript range anchor/, - ); - // The Renderer owns the window, so every read must name the version it reads for. - await assert.rejects( - ipc.invoke('sessions:transcript:load-after', { ...request, navigation: undefined }), - /Invalid Desktop transcript navigation/, + ipc.invoke('sessions:transcript:open', 'shared-session', 'other-consumer', 'window'), + /Invalid Desktop transcript open mode/, ); - assert.equal(calls.length, 2); + await assert.rejects(ipc.invoke('sessions:transcript:load-earlier', ''), /Transcript consumer/); + assert.equal(calls.length, 3); }); test('treats pending Session observation teardown as IPC cancellation', async () => { @@ -170,16 +176,14 @@ test('treats pending transcript teardown as IPC cancellation', async () => { readThroughMessageId: null, }; }, - async loadTranscriptBefore() {}, - async loadTranscriptAround() {}, - async loadTranscriptAfter() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const ipc = observationIpcHarness(observations); - const opening = ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'); + const opening = ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'); try { await started.promise; assert.deepEqual(observations.trackedSessionIds(), ['session-1']); @@ -215,16 +219,14 @@ for (const teardown of ['forgetSession', 'close'] as const) { readThroughMessageId: null, }; }, - async loadTranscriptBefore() {}, - async loadTranscriptAround() {}, - async loadTranscriptAfter() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const ipc = observationIpcHarness(observations); const observing = ipc.invoke('sessions:observe', 'session-1', 'observer-1'); - const opening = ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'); + const opening = ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'); // Attach rejection handlers before teardown. The late source completion // must not turn the lost observation into readiness or silent cancellation. const results = Promise.allSettled([observing, opening]); @@ -257,10 +259,8 @@ test('preserves genuine Session observation initialization failures', async () = async openTranscript() { throw transcriptFailure; }, - async loadTranscriptBefore() {}, - async loadTranscriptAround() {}, - async loadTranscriptAfter() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); @@ -271,7 +271,7 @@ test('preserves genuine Session observation initialization failures', async () = (error) => error === sessionFailure, ); await assert.rejects( - ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), + ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'), (error) => error === transcriptFailure, ); await observations.close(); @@ -286,14 +286,14 @@ test('releases a transcript registration whose source lacks the window contract' const ipc = observationIpcHarness(observations); await assert.rejects( - ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), + ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'), /transcript source is unavailable/, ); assert.deepEqual(observations.trackedSessionIds(), []); // Reusing the consumer id must reach the same missing-source failure rather // than the duplicate-identity guard, which only a leaked registration trips. await assert.rejects( - ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), + ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'), /transcript source is unavailable/, ); await observations.close(); @@ -313,10 +313,8 @@ test('returns explicit ready results for Session observation IPC', async () => { async openTranscript() { return transcript; }, - async loadTranscriptBefore() {}, - async loadTranscriptAround() {}, - async loadTranscriptAfter() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); @@ -326,7 +324,7 @@ test('returns explicit ready results for Session observation IPC', async () => { kind: 'ready', value: undefined, }); - assert.deepEqual(await ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1'), { + assert.deepEqual(await ipc.invoke('sessions:transcript:open', 'session-1', 'consumer-1', 'tail'), { kind: 'ready', value: transcript, }); @@ -2247,7 +2245,6 @@ function executionClient(overrides: Partial): ExecutionClient { getSession: unavailable, ingestAttachment: unavailable, interruptTurn: unavailable, - listSessionTurnLandmarks: unavailable, listSessionTurns: unavailable, queryMessageExecutions: unavailable, queryMessages: unavailable, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 3c657aafb9..7fab5ceaa3 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -467,16 +467,17 @@ test('restores transcript consumers across Host replacement', async () => { sessionId: string, consumerId: string, consumer: RuntimeHostTranscriptTarget, + mode?: string, ) { - opens.push(`${generation}:${sessionId}:${consumerId}`); + opens.push(`${generation}:${sessionId}:${consumerId}:${mode}`); consumer.send(`sessions:transcript:${consumerId}`, { deliverySequence: 1, sessionId, generation, hostEpoch: `host-${generation}`, durableThrough: null, - fragments: [], hasOlder: false, - hasNewer: false, + fragments: [], + hasOlder: false, reset: true, ready: true, }); @@ -487,16 +488,14 @@ test('restores transcript consumers across Host replacement', async () => { readThroughMessageId: null, }; }, - async loadTranscriptBefore() {}, - async loadTranscriptAfter() {}, - async loadTranscriptAround() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); const first = source('first'); await observations.attach(first, bind('first')); - await observations.openTranscript('session-1', 'consumer-1', target); + await observations.openTranscript('session-1', 'consumer-1', target, 'history'); observations.detach(first); assert.doesNotThrow(() => observations.acknowledgeTranscript('consumer-1', 'first', 1, 18)); const second = source('second'); @@ -514,9 +513,9 @@ test('restores transcript consumers across Host replacement', async () => { assert.equal((await pending).generation, 'third'); assert.deepEqual(opens, [ - 'first:session-1:consumer-1', - 'second:session-1:consumer-1', - 'third:session-1:consumer-2', + 'first:session-1:consumer-1:history', + 'second:session-1:consumer-1:history', + 'third:session-1:consumer-2:tail', ]); assert.deepEqual( batches.map((batch) => batch.generation), @@ -547,10 +546,8 @@ test('does not hold Host observation recovery on transcript replay', async () => async openTranscript() { return transcriptResult(generation); }, - async loadTranscriptBefore() {}, - async loadTranscriptAfter() {}, - async loadTranscriptAround() {}, - async loadTranscriptLatest() {}, + async loadEarlierTranscript() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); @@ -564,7 +561,7 @@ test('does not hold Host observation recovery on transcript replay', async () => const transcriptReplay = deferred(); let transcriptReplayStarted = false; let transcriptReplayCompleted = false; - let transcriptRangeStarted = false; + let transcriptEarlierStarted = false; let transcriptAcknowledged = false; const replacement = { async observe() { @@ -577,12 +574,10 @@ test('does not hold Host observation recovery on transcript replay', async () => transcriptReplayCompleted = true; return result; }, - async loadTranscriptBefore() { - transcriptRangeStarted = true; + async loadEarlierTranscript() { + transcriptEarlierStarted = true; }, - async loadTranscriptAfter() {}, - async loadTranscriptAround() {}, - async loadTranscriptLatest() {}, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, acknowledgeTranscript() { transcriptAcknowledged = true; @@ -601,30 +596,20 @@ test('does not hold Host observation recovery on transcript replay', async () => assert.deepEqual(await attaching, ['session-1']); assert.equal(attached, true); - const range = observations.loadTranscriptBefore( - { - consumerId: 'consumer-1', - sessionId: 'session-1', - hostEpoch: 'host-second', - anchorSequence: null, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }, - transcriptTarget.id, - ); + const earlier = observations.loadEarlierTranscript('consumer-1', transcriptTarget.id); observations.acknowledgeTranscript('consumer-1', 'second', 1, transcriptTarget.id); await Promise.resolve(); - assert.equal(transcriptRangeStarted, false); + assert.equal(transcriptEarlierStarted, false); assert.equal(transcriptAcknowledged, true); transcriptReplay.resolve(transcriptResult('second')); - await range; - assert.equal(transcriptRangeStarted, true); + await earlier; + assert.equal(transcriptEarlierStarted, true); await waitFor(() => transcriptReplayCompleted); await observations.close(); }); -test('fences transcript range failures to the current registration and Host source', async () => { +test('fences earlier transcript failures to the current registration and Host source', async () => { const observations = new RuntimeHostSessionObservationRegistry(); const target: RuntimeHostTranscriptTarget = { id: 19, @@ -634,7 +619,7 @@ test('fences transcript range failures to the current registration and Host sour }; const source = ( generation: string, - loadTranscriptBefore: () => Promise, + loadEarlierTranscript: () => Promise, ) => ({ async observe() {}, async unobserve() {}, @@ -646,119 +631,75 @@ test('fences transcript range failures to the current registration and Host sour readThroughMessageId: null, }; }, - loadTranscriptBefore, - async loadTranscriptAfter() {}, - async loadTranscriptAround() {}, - async loadTranscriptLatest() {}, + loadEarlierTranscript, + async readTranscriptTurn() { return []; }, acknowledgeTranscriptTail() {}, async closeTranscript() {}, }); - const request = (consumerId: string, generation: string) => ({ - consumerId, - sessionId: 'session-1', - hostEpoch: `host-${generation}`, - anchorSequence: null, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }); const closedFailure = deferred(); const first = source('first', () => closedFailure.promise); await observations.attach(first); - await observations.openTranscript('session-1', 'consumer-closed', target); - const closedRange = observations.loadTranscriptBefore( - request('consumer-closed', 'first'), - target.id, - ); + await observations.openTranscript('session-1', 'consumer-closed', target, 'history'); + const closedRead = observations.loadEarlierTranscript('consumer-closed', target.id); await observations.closeTranscript('consumer-closed', target.id); - closedFailure.reject(new Error('closed source rejected its range')); - await assert.doesNotReject(closedRange); + closedFailure.reject(new Error('closed source rejected its read')); + await assert.doesNotReject(closedRead); observations.detach(first); const replacedFailure = deferred(); const second = source('second', () => replacedFailure.promise); await observations.attach(second); - await observations.openTranscript('session-1', 'consumer-replaced', target); - const replacedRange = observations.loadTranscriptBefore( - request('consumer-replaced', 'second'), - target.id, - ); + await observations.openTranscript('session-1', 'consumer-replaced', target, 'history'); + const replacedRead = observations.loadEarlierTranscript('consumer-replaced', target.id); observations.detach(second); - const currentFailure = new Error('current source failed its range'); + const currentFailure = new Error('current source failed its read'); const third = source('third', async () => { throw currentFailure; }); await observations.attach(third); - replacedFailure.reject(new Error('replaced source rejected its range')); - await assert.doesNotReject(replacedRange); + replacedFailure.reject(new Error('replaced source rejected its read')); + await assert.doesNotReject(replacedRead); await assert.rejects( - observations.loadTranscriptBefore( - request('consumer-replaced', 'third'), - target.id, - ), + observations.loadEarlierTranscript('consumer-replaced', target.id), (error) => error === currentFailure, ); await observations.close(); }); -test('fences transcript range failures across same-source replica recovery', async () => { +test('fences earlier transcript failures across same-source replica recovery', async () => { const firstEvents = new AsyncFrameQueue(); const secondEvents = new AsyncFrameQueue(); - const staleRange = deferred(); - const currentFailure = new Error('current replica failed its range'); - const message: StoredMessage = { - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 1, - text: 'current', - modelId: 'test-model', - }; + const staleRead = deferred(); + const currentFailure = new Error('current replica failed its read'); let opens = 0; - let staleRangeStarted = false; - let currentRangeStarted = false; + let staleReadStarted = false; + let currentReadStarted = false; const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => { opens += 1; const first = opens === 1; const events = first ? firstEvents : secondEvents; - const bootstrap: SessionTranscriptPage = { - kind: 'page', - sessionId: 'session-1', - source: 'durable', - direction: 'older', - throughSequence: 1, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor: 'older', - }; + // The reset reads two one-row pages; the page at cursor '1' is read only by load earlier. + const host = historyHost([0, 1, 2].map((index) => turnRow(index, `turn-${index}`)), { pageRows: 1 }); return runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events, - transcriptBootstrap: { - throughSequence: 1, - overlayMessageCount: 0, - durable: bootstrap, - overlay: { ...bootstrap, source: 'overlay', nextCursor: null }, + transcriptBootstrap: host.bootstrap, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: host.decodeTranscriptPage, + loadTranscriptPage: async (input) => { + if (input.cursor !== '1') return host.loadTranscriptPage(input); + if (first) { + staleReadStarted = true; + return staleRead.promise; + } + currentReadStarted = true; + throw currentFailure; }, - decodeTranscriptPage: async (page) => ({ - messages: page === bootstrap ? [{ identity: 1, message }] : [], - nextCursor: page === bootstrap ? 'older' : null, - }), - loadTranscriptPage: first - ? () => { - staleRangeStarted = true; - return staleRange.promise; - } - : async () => { - currentRangeStarted = true; - throw currentFailure; - }, async close() { events.end(); }, @@ -766,18 +707,11 @@ test('fences transcript range failures across same-source replica recovery', asy }, }, emitSessionsChanged() {}, + transcriptHistoryBytes: 1, }); const observations = new RuntimeHostSessionObservationRegistry(); const batches: DesktopTranscriptBatch[] = []; const consumerId = 'consumer-replica-recovery'; - const request = (hostEpoch: string) => ({ - consumerId, - sessionId: 'session-1', - hostEpoch, - anchorSequence: 1, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }); const target: RuntimeHostTranscriptTarget = { id: 20, send(_channel, batch) { @@ -795,9 +729,9 @@ test('fences transcript range failures across same-source replica recovery', asy off() {}, }; await observations.attach(observer); - const opened = await observations.openTranscript('session-1', consumerId, target); - const staleLoad = observations.loadTranscriptBefore(request(opened.hostEpoch), target.id); - await waitFor(() => staleRangeStarted); + const opened = await observations.openTranscript('session-1', consumerId, target, 'history'); + const staleLoad = observations.loadEarlierTranscript(consumerId, target.id); + await waitFor(() => staleReadStarted); firstEvents.push({ kind: 'subscription.closed', @@ -806,18 +740,18 @@ test('fences transcript range failures across same-source replica recovery', asy sequence: 1, reason: 'slow_consumer', }); - // Recovery installs a replacement replica and resets every consumer onto its - // tail; no page read is replayed on its behalf. - await waitFor(() => batches.some((batch) => batch.reset && batch.generation !== opened.generation)); - assert.equal(currentRangeStarted, false); - staleRange.reject(new Error('stale replica rejected its range')); + await waitFor(() => opens === 2); + staleRead.reject(new Error('stale replica rejected its read')); await assert.doesNotReject(staleLoad); + // Recovery resets the consumer onto the replacement replica; the abandoned read is not replayed. + await waitFor(() => batches.some((batch) => batch.ready && batch.generation !== opened.generation)); + assert.equal(currentReadStarted, false); await assert.rejects( - observations.loadTranscriptBefore(request(opened.hostEpoch), target.id), + observations.loadEarlierTranscript(consumerId, target.id), (error) => error === currentFailure, ); - assert.equal(currentRangeStarted, true); + assert.equal(currentReadStarted, true); await observations.close(); await observer.close(); }); @@ -878,8 +812,6 @@ test('broadcasts durable admission and transcript changes from the same message' throughSequence: 0, rawBytes: 1, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, }), decodeTranscriptPage: async () => ({ @@ -1041,8 +973,6 @@ test('moves the read marker only as far as the Renderer window reports reaching' throughSequence: input.throughSequence ?? null, rawBytes: 1, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, }), // One durable row per catch-up target; the bootstrap page carries none. @@ -1188,7 +1118,7 @@ test('keeps a bounded transcript batch window in flight until the renderer ackno await observer.close(); }); -test('finishes transcript open and replays a stale range request after replacement', async () => { +test('finishes transcript open on the replacement replica after recovery', async () => { const firstEvents = new AsyncFrameQueue(); const secondEvents = new AsyncFrameQueue(); const message: StoredMessage = { @@ -1200,8 +1130,6 @@ test('finishes transcript open and replays a stale range request after replaceme modelId: 'test-model', }; let opens = 0; - let rangeLoads = 0; - const requestedAnchors: Array = []; const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => { @@ -1222,8 +1150,6 @@ test('finishes transcript open and replays a stale range request after replaceme throughSequence: 0, rawBytes: 1, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: 'older', }, overlay: { @@ -1234,8 +1160,6 @@ test('finishes transcript open and replays a stale range request after replaceme throughSequence: null, rawBytes: 0, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, }, }, @@ -1244,22 +1168,6 @@ test('finishes transcript open and replays a stale range request after replaceme messages: page.rawBytes === 1 ? [{ identity: 0, message }] : [], nextCursor: page.nextCursor, }), - loadTranscriptPage: async (input) => { - rangeLoads += 1; - requestedAnchors.push(input.anchorSequence); - return { - kind: 'page', - sessionId: 'session-1', - source: input.source, - direction: input.direction, - throughSequence: input.throughSequence, - rawBytes: 0, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor: null, - }; - }, async close() { events.end(); }, @@ -1269,21 +1177,10 @@ test('finishes transcript open and replays a stale range request after replaceme emitSessionsChanged() {}, }); const batches: DesktopTranscriptBatch[] = []; - let autoAcknowledge = false; const opening = observer.openTranscript('session-1', 'consumer-recovery', { id: 22, send(_channel, batch) { batches.push(batch); - if (autoAcknowledge) { - queueMicrotask(() => - observer.acknowledgeTranscript( - 'consumer-recovery', - batch.generation, - batch.deliverySequence, - 22, - ), - ); - } }, once() {}, off() {}, @@ -1328,60 +1225,6 @@ test('finishes transcript open and replays a stale range request after replaceme assert.equal(opened.error, undefined); assert.equal(opened.value?.generation, batches.at(-1)?.generation); assert.notEqual(opened.value?.generation, staleGeneration); - rangeLoads = 0; - requestedAnchors.length = 0; - autoAcknowledge = true; - // The renderer dispatched this range request before the replacement replica - // was installed; the same Session and Host epoch continue the read against - // the current replica, and the requested slice is what the replica loads. - await assert.doesNotReject(() => - observer.loadTranscriptBefore( - { - consumerId: 'consumer-recovery', - sessionId: 'session-1', - hostEpoch: 'host-1', - anchorSequence: 0, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }, - 22, - ), - ); - assert.equal(rangeLoads, 1); - assert.deepEqual(requestedAnchors, [0]); - // Durable sequence identity is Session- and Host-epoch-scoped: a request - // for a different Session or Host epoch must reject instead of silently - // reading a different slice of the transcript. - await assert.rejects( - () => - observer.loadTranscriptBefore( - { - consumerId: 'consumer-recovery', - sessionId: 'session-1', - hostEpoch: 'other-host', - anchorSequence: 0, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }, - 22, - ), - /Desktop transcript host epoch changed/, - ); - await assert.rejects( - () => - observer.loadTranscriptBefore( - { - consumerId: 'consumer-recovery', - sessionId: 'other-session', - hostEpoch: 'host-1', - anchorSequence: 0, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }, - 22, - ), - /Desktop transcript consumer belongs to another session/, - ); await observer.close(); }); @@ -1403,8 +1246,6 @@ 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) => { @@ -1478,9 +1319,7 @@ test('coalesces transcript changes into one bounded delta while renderer deliver assert.equal(batches[1]!.reset, false); assert.equal(batches[1]!.durableThrough, 4); assert.equal(batches[1]!.fragments.length, 4); - assert.equal(batches[1]!.navigation, undefined, 'tail growth is a broadcast, not an answer'); assert.equal(batches[1]!.hasOlder, undefined); - assert.equal(batches[1]!.hasNewer, undefined); observer.acknowledgeTranscript( consumerId, batches[1]!.generation, @@ -1490,36 +1329,95 @@ test('coalesces transcript changes into one bounded delta while renderer deliver await observer.close(); }); -test('answers a window page read on its own navigation version and drops a stale one', async () => { +test('delivers history in whole Turns within the budget and continues exactly on load earlier', async () => { const events = new AsyncFrameQueue(); - const record = (sequence: number) => ({ - identity: sequence, - message: { - type: 'assistant' as const, - id: `a-${sequence}`, - turnId: `turn-${sequence}`, - ts: sequence, - text: String(sequence), - modelId: 'test-model', + // Oldest first: a (3 rows), b (1 huge row), c (2 rows), d (1 huge row). The budget is one and a half + // small rows and pages hold two rows, so Turns cross both the budget and the Host page edges. + const huge = 'x'.repeat(4096); + const rows = [ + turnRow(10, 'turn-a'), turnRow(20, 'turn-a'), turnRow(30, 'turn-a'), + turnRow(40, 'turn-b', huge), + turnRow(50, 'turn-c'), turnRow(60, 'turn-c'), + turnRow(70, 'turn-d', huge), + ]; + const smallRowBytes = Buffer.byteLength(JSON.stringify(rows[0]!.message), 'utf8'); + const host = historyHost(rows, { pageRows: 2 }); + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + transcriptBootstrap: host.bootstrap, + loadTranscriptOverlay: async () => [], + loadTranscriptPage: host.loadTranscriptPage, + decodeTranscriptPage: host.decodeTranscriptPage, + async close() { + events.end(); + }, + }), }, + emitSessionsChanged() {}, + transcriptHistoryBytes: Math.floor(smallRowBytes * 1.5), }); - const durablePage = (nextCursor: string | null): SessionTranscriptPage => ({ - kind: 'page', - sessionId: 'session-1', - source: 'durable', - direction: 'older', - throughSequence: 2, - rawBytes: 1, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor, - }); - const bootstrap = durablePage(null); - const decoded = new Map>; - nextCursor: string | null; - }>([[bootstrap, { messages: [record(2)], nextCursor: null }]]); + const batches: DesktopTranscriptBatch[] = []; + const consumerId = 'consumer-history'; + await observer.openTranscript('session-1', consumerId, ackingTranscriptTarget(observer, consumerId, 26, batches), 'history'); + + const answer = () => { + const taken = batches.splice(0); + assert.ok(taken.at(-1)?.ready, 'an answer ends with its ready batch'); + assert.equal(taken.filter((batch) => batch.ready).length, 1); + return { + taken, + sequences: taken.flatMap((batch) => durableSequences(batch)).sort((left, right) => left - right), + hasOlder: taken.at(-1)!.hasOlder, + }; + }; + + // The newest Turn alone exceeds the budget and is still delivered whole, and nothing older is. + const reset = answer(); + assert.equal(reset.taken[0]!.reset, true); + assert.equal(reset.taken.some((batch) => batch.earlierThan !== undefined), false); + assert.equal(reset.taken.at(-1)!.durableThrough, 70); + assert.deepEqual(reset.sequences, [70]); + assert.equal(reset.hasOlder, true); + + // Turn c crosses the budget, so the read stops after all of c although b shares its Host page. + await observer.loadEarlierTranscript(consumerId, 26); + const second = answer(); + assert.equal(second.taken.some((batch) => batch.reset), false); + assert.equal(second.taken[0]!.earlierThan, 70); + assert.deepEqual(second.sequences, [50, 60]); + assert.equal(second.hasOlder, true); + + await observer.loadEarlierTranscript(consumerId, 26); + const third = answer(); + assert.equal(third.taken[0]!.earlierThan, 50); + assert.deepEqual(third.sequences, [40]); + assert.equal(third.hasOlder, true); + + await observer.loadEarlierTranscript(consumerId, 26); + const fourth = answer(); + assert.equal(fourth.taken[0]!.earlierThan, 40); + assert.deepEqual(fourth.sequences, [10, 20, 30]); + assert.equal(fourth.hasOlder, false); + + assert.deepEqual( + [...fourth.sequences, ...third.sequences, ...second.sequences, ...reset.sequences], + rows.map((row) => row.identity), + ); + await observer.loadEarlierTranscript(consumerId, 26); + assert.equal(batches.length, 0, 'nothing older remains to deliver'); + await observer.close(); +}); + +test('a tail transcript consumer still receives the replica snapshot', async () => { + const events = new AsyncFrameQueue(); + const rows = [turnRow(10, 'turn-a'), turnRow(20, 'turn-b')]; + const host = historyHost(rows, { pageRows: 1 }); + let pageReads = 0; const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => @@ -1527,81 +1425,73 @@ test('answers a window page read on its own navigation version and drops a stale snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events, - transcriptBootstrap: { - throughSequence: 2, - overlayMessageCount: 0, - durable: bootstrap, - overlay: { ...bootstrap, source: 'overlay' }, - }, + transcriptBootstrap: host.bootstrap, loadTranscriptOverlay: async () => [], - loadTranscriptPage: async (request) => { - // `loadAround` probes one row older than its anchor to learn - // whether history precedes it; that probe stays empty here. - const answer = request.direction === 'older' - ? request.maxBytes === 1 - ? { messages: [], nextCursor: null } - : { messages: [record(1)], nextCursor: 'older' } - : request.anchorSequence === 0 - ? { messages: [record(1)], nextCursor: 'newer' } - : { messages: [record(2)], nextCursor: null }; - const page = durablePage(answer.nextCursor); - decoded.set(page, answer); - return page; + loadTranscriptPage: async (input) => { + pageReads += 1; + return host.loadTranscriptPage(input); }, - decodeTranscriptPage: async (page) => decoded.get(page)!, + decodeTranscriptPage: host.decodeTranscriptPage, async close() { events.end(); }, }), }, emitSessionsChanged() {}, + transcriptHistoryBytes: 1, }); const batches: DesktopTranscriptBatch[] = []; - const consumerId = 'consumer-window'; - const request = (navigation: number, anchorSequence: number | null) => ({ - consumerId, - sessionId: 'session-1', - hostEpoch: 'host-1', - anchorSequence, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation, - }); - await observer.openTranscript('session-1', consumerId, { - id: 26, - send(_channel, batch) { - batches.push(batch); - queueMicrotask(() => - observer.acknowledgeTranscript(consumerId, batch.generation, batch.deliverySequence, 26), - ); + await observer.openTranscript('session-1', 'consumer-tail', ackingTranscriptTarget(observer, 'consumer-tail', 27, batches)); + + assert.equal(pageReads, 0, 'a tail consumer is answered from the replica, not history reads'); + assert.equal(batches[0]!.reset, true); + assert.equal(batches.at(-1)!.ready, true); + assert.equal(batches.at(-1)!.durableThrough, 20); + assert.equal(batches.at(-1)!.hasOlder, false); + assert.deepEqual(batches.flatMap((batch) => durableSequences(batch)), [10, 20]); + await assert.rejects( + observer.loadEarlierTranscript('consumer-tail', 27), + /history consumer does not exist/, + ); + await observer.close(); +}); + +test('reads every row of one Turn through the Host Turn index', async () => { + const events = new AsyncFrameQueue(); + const rows = [ + turnRow(10, 'turn-a'), + turnRow(20, 'turn-b'), turnRow(30, 'turn-b'), turnRow(40, 'turn-b'), + turnRow(50, 'turn-c'), + ]; + const host = historyHost(rows, { pageRows: 2 }); + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + transcriptBootstrap: host.bootstrap, + loadTranscriptOverlay: async () => [], + loadTranscriptPage: host.loadTranscriptPage, + decodeTranscriptPage: host.decodeTranscriptPage, + async close() { + events.end(); + }, + }), + listSessionTurns: async () => [ + { turnId: 'turn-a', firstSequence: 10 }, + { turnId: 'turn-b', firstSequence: 20 }, + { turnId: 'turn-c', firstSequence: 50 }, + ] as never, }, - once() {}, - off() {}, + emitSessionsChanged() {}, }); - batches.splice(0); - - await observer.loadTranscriptBefore(request(1, 2), 26); - assert.equal(batches.length, 1); - assert.equal(batches[0]!.navigation, 1); - assert.equal(batches[0]!.reset, false, 'extending the window does not replace it'); - assert.equal(batches[0]!.hasOlder, true); - assert.equal(batches[0]!.hasNewer, undefined, 'an older page establishes only its older edge'); - - // The same version extends the window the Renderer already holds. - await observer.loadTranscriptAfter(request(1, 1), 26); - assert.equal(batches.length, 2); - assert.equal(batches[1]!.navigation, 1); - assert.equal(batches[1]!.reset, false); - assert.equal(batches[1]!.hasNewer, false); - await observer.loadTranscriptAround(request(2, 1), 26); - assert.equal(batches.length, 3); - assert.equal(batches[2]!.navigation, 2); - assert.equal(batches[2]!.reset, true, 'a window-replacing command resets the Renderer'); - assert.equal(batches[2]!.hasOlder, false); - assert.equal(batches[2]!.hasNewer, true); - - await observer.loadTranscriptBefore(request(1, 2), 26); - assert.equal(batches.length, 3, 'a version the Renderer already abandoned is dropped silently'); + assert.deepEqual( + (await observer.readTranscriptTurn('session-1', 'turn-b')).map((message) => message.id), + ['row-20', 'row-30', 'row-40'], + ); await observer.close(); }); @@ -1623,8 +1513,6 @@ 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) => { @@ -1730,8 +1618,6 @@ 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) => ({ @@ -1792,20 +1678,16 @@ test('keeps a transcript consumer available after a delivery fails', async () => await waitFor(() => failedDeliveries === 1); failDelivery = false; - await assert.doesNotReject( - observer.loadTranscriptAround( - { - consumerId, - sessionId: opened.sessionId, - hostEpoch: opened.hostEpoch, - anchorSequence: 0, - maxBytes: DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - navigation: 1, - }, - 25, - ), - ); - assert.ok(successfulDeliveries > 1); + const delivered = successfulDeliveries; + events.push({ + kind: 'subscription.transcript_advanced', + hostEpoch: opened.hostEpoch, + subscriptionId: 'subscription-1', + sessionId: 'session-1', + sequence: 2, + throughSequence: 1, + }); + await waitFor(() => successfulDeliveries > delivered); assert.equal(closeCount, 0); await observer.closeTranscript(consumerId, 25); await waitFor(() => closeCount === 1); @@ -2247,8 +2129,6 @@ test("recovers when transcript paging loses the active subscription", async () = throughSequence: 0, rawBytes: 0, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, }; }, @@ -3440,6 +3320,93 @@ async function waitFor(predicate: () => boolean): Promise { await pollFor(predicate, { attempts: 100, message: 'Timed out waiting for observer state' }); } +interface TranscriptRow { + readonly identity: number; + readonly message: StoredMessage; +} + +function turnRow(identity: number, turnId: string, text = `row ${identity}`): TranscriptRow { + return { + identity, + message: { type: 'assistant', id: `row-${identity}`, turnId, ts: identity, text, modelId: 'test-model' }, + }; +} + +/** + * A durable Host transcript cut into pages of `pageRows` rows. Older cursors + * name the index a page starts at; newer cursors are prefixed with `n`. + */ +function historyHost(rows: readonly TranscriptRow[], options: { readonly pageRows: number }) { + const decoded = new Map(); + const throughSequence = rows.at(-1)?.identity ?? null; + const page = (messages: TranscriptRow[], nextCursor: string | null, source: 'durable' | 'overlay' = 'durable') => { + const value: SessionTranscriptPage = { + kind: 'page', + sessionId: 'session-1', + source, + direction: 'older', + throughSequence, + rawBytes: 1, + fragments: [], + nextCursor, + }; + decoded.set(value, { messages, nextCursor }); + return value; + }; + return { + bootstrap: { + throughSequence, + overlayMessageCount: 0, + durable: page([...rows], null), + overlay: page([], null, 'overlay'), + }, + loadTranscriptPage: async (input: { + readonly direction: 'older' | 'newer'; + readonly cursor: string | null; + readonly anchorSequence: number | null; + }): Promise => { + if (input.direction === 'older') { + const end = input.cursor === null ? rows.length : Number(input.cursor); + const start = Math.max(0, end - options.pageRows); + return page(rows.slice(start, end), start > 0 ? String(start) : null); + } + const start = input.cursor !== null + ? Number(input.cursor.slice(1)) + : rows.findIndex((row) => input.anchorSequence === null || row.identity > input.anchorSequence); + const end = Math.min(rows.length, start + options.pageRows); + return page(rows.slice(start, end), end < rows.length ? `n${end}` : null); + }, + decodeTranscriptPage: async (value: SessionTranscriptPage) => { + const answer = decoded.get(value); + assert.ok(answer, 'decoded a page this Host did not serve'); + return answer; + }, + }; +} + +function ackingTranscriptTarget( + observer: RuntimeHostSessionObserver, + consumerId: string, + id: number, + batches: DesktopTranscriptBatch[], +): RuntimeHostTranscriptTarget { + return { + id, + send(_channel, batch) { + batches.push(batch); + queueMicrotask(() => observer.acknowledgeTranscript(consumerId, batch.generation, batch.deliverySequence, id)); + }, + once() {}, + off() {}, + }; +} + +function durableSequences(batch: DesktopTranscriptBatch): number[] { + return batch.fragments + .filter((fragment) => fragment.source === 'durable' && fragment.byteOffset === 0) + .map((fragment) => fragment.identity as number); +} + test('a later observer in the same renderer receives the accumulated active stream', async () => { const events = new AsyncFrameQueue(); let opens = 0; 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 108d47bfb9..c213a0ee45 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 @@ -73,8 +73,6 @@ 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__/session-local.test.ts b/apps/desktop/src/main/__tests__/session-local.test.ts index 7e05626ee1..136686ede0 100644 --- a/apps/desktop/src/main/__tests__/session-local.test.ts +++ b/apps/desktop/src/main/__tests__/session-local.test.ts @@ -499,7 +499,6 @@ test('cache restoration never includes live overlay and expires independently of ], overlay: [{ type: 'user', id: 'live-1', turnId: 'turn', ts: 2, text: 'in flight' }], hasOlder: false, - hasNewer: false, }); assert.deepEqual(store.transcript('authority', 'session-1')?.snapshot.overlay, []); assert.equal(store.transcript('different-authority', 'session-1'), undefined); @@ -553,7 +552,6 @@ test('durable Host evidence retires delivery independently of cache admission an ], overlay: [], hasOlder: false, - hasNewer: false, }; service.cacheTranscript(target.scope, snapshot); if (cacheLoss === 'revision') db.store.enqueue('other-authority', intent('other-message')); diff --git a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts index e867490437..a0a86cb276 100644 --- a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts +++ b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts @@ -24,7 +24,7 @@ import { LocaleProvider } from '@maka/ui'; import type { StoredMessage } from '@maka/core/session'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { useAppShellSessionWorkspace } from '../../renderer/use-app-shell-session-workspace.js'; -import { createRecoveringDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; +import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; /** @@ -78,7 +78,7 @@ describe('session workspace action identity', () => { const row = (id: string): StoredMessage => ({ id, type: 'user', text: id, turnId: id, ts: 1 }); const a = [row('a-message')]; const c = [row('c-message')]; - const reader = (id: string) => createRecoveringDesktopTranscriptRangeController( + const reader = (id: string) => createDesktopTranscriptRangeController( new DesktopTranscriptRangeStore(id), async () => { throw new Error('unexpected read'); }, { onError() {} }, ); const readerA = reader(sessionA); @@ -137,28 +137,19 @@ describe('session workspace action identity', () => { act(() => workspace.setActiveId(sessionA)); assert.deepEqual(workspace.retiredSessionIds([{ id: sessionB }]), [sessionA]); - // Publication is scheduled outside the current React lifecycle. - // A retired queued source may not replace the displayed - // Session or publish its reader. + // A retired source may not replace the displayed Session or publish its reader. act(() => workspace.setActiveId(sessionC)); for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'c', generation: 'publication', hostEpoch: 'host', - durableThrough: null, durable: [], overlay: c, hasOlder: false, hasNewer: false, + durableThrough: null, durable: [], overlay: c, hasOlder: false, })) readerC.store.accept(batch); - let blocked = true; - let idle!: () => void; - const detach = workspace.sessionUiController.transcriptViewportNavigation.attachCommitScheduler(sessionC, { - subscribeToReaderScroll: () => () => {}, - commitRange: (commit) => { if (blocked) idle = commit; else commit(); }, - }); let publications = 0; + const retiredSelection = workspace.captureSelection(); + act(() => workspace.clearOwnedSessionState(sessionC)); await act(async () => workspace.publishTranscript( - sessionC, readerC, workspace.captureSelection(), () => { publications += 1; }, + sessionC, readerC, retiredSelection, () => { publications += 1; }, )); - assert.equal(workspace.activeId, sessionB); - act(() => workspace.clearOwnedSessionState(sessionC)); - await act(async () => { blocked = false; idle(); }); - assert.equal(publications, 0, 'retirement revokes queued publication'); + assert.equal(publications, 0, 'retirement revokes publication'); assert.equal(workspace.activeId, sessionB); assert.equal(workspace.transcriptRangeRef.current, undefined); await act(async () => { @@ -170,7 +161,6 @@ describe('session workspace action identity', () => { assert.equal((workspace.messages as StoredMessage[])[0]?.id, 'c-message'); assert.equal(workspace.publishedTranscriptRange?.sessionId, sessionC); assert.equal(workspace.transcriptRangeRef.current, readerC); - await act(async () => detach()); }); it('keeps every action identity fixed across re-renders', () => { diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 5b869d8239..67391480ec 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -20,7 +20,6 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { createElement, type ReactNode } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { parseHTML } from 'linkedom'; import type { SessionEvent } from '@maka/core/events'; import { @@ -36,9 +35,10 @@ import { createAppShellSessionEventHandlers, } from '../../renderer/app-shell-session-events.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { renderTranscriptMarkup } from './transcript-test-dom.js'; -function renderWithLocale(child: ReactNode): string { - return renderToStaticMarkup( +function renderWithLocale(child: ReactNode): Promise { + return renderTranscriptMarkup( createElement(LocaleProvider, { locale: 'zh-CN', children: createElement(ChatSurfaceLayout, { composer: null, children: child }), @@ -63,7 +63,7 @@ async function waitFor(predicate: () => boolean, message: string): Promise await pollFor(predicate, { timeoutMs: 3_000, pollMs: 10, message }); } -function renderLiveTurn(liveTurn: LiveTurnProjection): string { +function renderLiveTurn(liveTurn: LiveTurnProjection): Promise { return renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', @@ -88,14 +88,14 @@ function renderLiveTurn(liveTurn: LiveTurnProjection): string { } describe('single live-turn handoff', () => { - it('keeps activity in the process row before the session or Turn arrives', () => { + it('keeps activity in the process row before the session or Turn arrives', async () => { const session: NonNullable[0]['activeSession']> = { id: 'session-1', name: 'pending', status: 'running' as const, backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask' as const, }; for (const activeSession of [undefined, session]) { - const markup = renderWithLocale(createElement(ChatView, { + const markup = await renderWithLocale(createElement(ChatView, { activeSession, messages: [], transientMessages: [{ @@ -114,8 +114,8 @@ describe('single live-turn handoff', () => { } }); - it('renders a transient user message without manufacturing a Turn', () => { - const markup = renderWithLocale(createElement(ChatView, { + it('renders a transient user message without manufacturing a Turn', async () => { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'active', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, @@ -139,8 +139,8 @@ describe('single live-turn handoff', () => { assert.match(markup, />send now { - const markup = renderWithLocale(createElement(ChatView, { + it('does not flash the empty-chat Maka hero before a first transient message', async () => { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'pending', status: 'active', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, @@ -162,8 +162,8 @@ describe('single live-turn handoff', () => { assert.doesNotMatch(markup, /maka-hero-empty-chat/); }); - it('shows a loading transient before its real live Turn answer', () => { - const markup = renderWithLocale(createElement(ChatView, { + it('shows a loading transient before its real live Turn answer', async () => { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'running', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, @@ -199,8 +199,8 @@ describe('single live-turn handoff', () => { assert.equal((markup.match(/data-transcript-turn-id="turn-1"/g) ?? []).length, 1); }); - it('keeps an unresolved Message independent of a Turn without an admission binding', () => { - const markup = renderWithLocale(createElement(ChatView, { + it('keeps an unresolved Message independent of a Turn without an admission binding', async () => { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'pending', lastMessageAt: 1, status: 'running', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, @@ -236,8 +236,8 @@ describe('single live-turn handoff', () => { assert.equal((markup.match(/data-transient-message-id=/g) ?? []).length, 1); }); - it('renders one ordered timeline: thinking before its tool and answer', () => { - const markup = renderLiveTurn({ + it('renders one ordered timeline: thinking before its tool and answer', async () => { + const markup = await renderLiveTurn({ turnId: 'turn-1', steps: [{ stepId: 'assistant-1', @@ -262,9 +262,9 @@ describe('single live-turn handoff', () => { assert.equal((markup.match(/data-turn-id=/g) ?? []).length, 1); }); - it('keeps a completed live answer as the only visible owner until settle', () => { + it('keeps a completed live answer as the only visible owner until settle', async () => { const finalText = 'one visible answer'; - const markup = renderWithLocale(createElement(ChatView, { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'streaming', lastMessageAt: 1, status: 'active', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, @@ -291,9 +291,9 @@ describe('single live-turn handoff', () => { assert.equal(markup.split(finalText).length - 1, 1); }); - it('keeps an incomplete live answer as the only owner after early persistence', () => { + it('keeps an incomplete live answer as the only owner after early persistence', async () => { const text = 'persisted before a slow tool finishes'; - const markup = renderWithLocale(createElement(ChatView, { + const markup = await renderWithLocale(createElement(ChatView, { activeSession: { id: 'session-1', name: 'streaming', lastMessageAt: 1, status: 'running', backend: 'ai-sdk', labels: [], isFlagged: false, isArchived: false, hasUnread: false, diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts b/apps/desktop/src/main/__tests__/transcript-history-read.test.ts similarity index 80% rename from apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts rename to apps/desktop/src/main/__tests__/transcript-history-read.test.ts index 859efc6285..d90e866abc 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-history-read.test.ts @@ -29,20 +29,20 @@ import { readSessionTranscriptPage, updateSubscriberTranscriptHighWater, } from '../../../../../packages/runtime-host/dist/server/session-transcript-pager.js'; -import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../../preload/transcript-contract.js'; +import { DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES } from '../../preload/transcript-contract.js'; import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; -import { openTranscriptNavigationLedger } from './transcript-navigation-test-fixture.js'; +import { openTranscriptLedger } from './transcript-ledger-test-fixture.js'; const PAGE_BYTES = 128 * 1024; -const HOST_EPOCH = 'transcript-navigation-host'; -const SUBSCRIPTION_ID = 'transcript-navigation-subscription'; +const HOST_EPOCH = 'transcript-history-host'; +const SUBSCRIPTION_ID = 'transcript-history-subscription'; test('keeps both Turns reachable when an oversized ledger Turn is followed by a new durable tail', async () => { const source = transcriptFixture(); assert.ok(source.first.reduce((bytes, message) => bytes + Buffer.byteLength(JSON.stringify(message)), 0) - > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - const ledger = await openTranscriptNavigationLedger([...source.first, ...source.second]); + > DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES); + const ledger = await openTranscriptLedger([...source.first, ...source.second]); let opened: Awaited> | undefined; try { const firstThrough = await ledger.appendThrough('completed-a'); @@ -50,9 +50,7 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a assertSourcePayloads(first, source.first); opened = await openReplica(ledger, firstThrough); const { replica, subscription, state } = opened; - assertRecords(replica, first); - assert.equal(replica.snapshot().hasOlder, false, 'sparse first-row sequence does not imply older history'); - assert.equal(replica.snapshot().hasNewer, false, 'unused low watermark bits do not imply a newer row'); + assertTailOf(replica, first); // RuntimeEvent transcripts publish a Turn durably only after it ends. The // running checkpoints below stay in the overlay, then this terminal event @@ -74,30 +72,27 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a await replica.advance(completeThrough); assertRecords(replica, second); - // A window read answers the Renderer without touching Main's tail cache. - const older = await replica.loadBefore(second[0]!.sequence, PAGE_BYTES); - assert.ok(older); - assert.deepEqual(older.durable, first, - 'older paging returns the complete oversized Turn adjacent to the anchor'); - assert.equal(older.hasOlder, false); - assert.equal(older.hasNewer, undefined, 'an older page establishes only its older edge'); + // A history read walks older pages through the watermark without touching + // Main's tail cache, and returns every row of the oversized Turn. + const history: { sequence: number; message: StoredMessage }[] = []; + let cursor: string | null = null; + do { + const page = await replica.readOlderPage(completeThrough, cursor); + history.unshift(...page.durable); + cursor = page.nextCursor; + } while (cursor !== null); + assert.deepEqual(history, complete); assertRecords(replica, second); - for (let attempt = 0; attempt < 2; attempt += 1) { - // An oversized Turn fills a client range on its own, so a reset anchored - // on the oldest row ends at that range boundary rather than at the tail. - // Reachability is carried by the newer edge and the page behind it. - const around = await replica.loadAround(first[0]!.sequence, PAGE_BYTES); - assert.ok(around); - assert.deepEqual(around.durable, first); - assert.equal(around.hasOlder, false); - assert.equal(around.hasNewer, true); - const newer = await replica.loadAfter(first.at(-1)!.sequence, PAGE_BYTES); - assert.ok(newer); - assert.deepEqual(newer.durable, second, 'the page past the boundary reaches the current tail'); - assert.equal(newer.hasNewer, false); - assertRecords(replica, second); + for (const [turnId, records] of [['a', first], ['b', second]] as const) { + assert.deepEqual( + await replica.readTurn(turnId, records[0]!.sequence, 16 * 1024 * 1024), + records.map(({ message }) => message), + 'a Turn read stops at the next Turn and keeps the oversized payload whole', + ); } + await assert.rejects(replica.readTurn('a', first[0]!.sequence, PAGE_BYTES), RangeError); + assertRecords(replica, second); } finally { opened?.replica.close(); await opened?.subscription.close(); @@ -106,9 +101,9 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a }); for (const checkpoint of ['running-b', 'result-b'] as const) { - test(`retains the full oversized durable Turn while the running second Turn reaches ${checkpoint}`, async () => { + test(`keeps the running second Turn reachable at ${checkpoint} behind an oversized durable Turn`, async () => { const source = transcriptFixture(); - const ledger = await openTranscriptNavigationLedger([...source.first, ...source.second]); + const ledger = await openTranscriptLedger([...source.first, ...source.second]); let opened: Awaited> | undefined; try { const firstThrough = await ledger.appendThrough('completed-a'); @@ -121,7 +116,7 @@ for (const checkpoint of ['running-b', 'result-b'] as const) { }; opened = await openReplica(ledger, firstThrough, rootTurn); const { replica } = opened; - assertRecords(replica, first); + assertTailOf(replica, first); const expected = source.second.slice(0, source.second.findIndex(({ id }) => id === checkpoint) + 1) .filter((message) => message.type !== 'turn_state').map(({ id }) => id); assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), expected); @@ -140,7 +135,6 @@ for (const checkpoint of ['running-b', 'result-b'] as const) { assert.deepEqual(replica.snapshot().overlay, []); const second = (await ledger.durableRecords()).filter(({ message }) => message.turnId === rootTurn.turnId); assertRecords(replica, second); - assert.equal(replica.snapshot().hasNewer, false); } finally { opened?.replica.close(); await opened?.subscription.close(); @@ -149,7 +143,7 @@ for (const checkpoint of ['running-b', 'result-b'] as const) { }); } -type Ledger = Awaited>; +type Ledger = Awaited>; async function openReplica( ledger: Ledger, throughSequence: number | null, @@ -183,6 +177,14 @@ async function openReplica( return { replica, subscription, state: opened.state }; } +/** The bootstrap page is byte-bounded, so the tail cache may start inside the oversized Turn. */ +function assertTailOf(replica: DesktopTranscriptReplica, records: readonly { sequence: number; message: StoredMessage }[]) { + const { durable, hasOlder } = replica.snapshot(); + assert.ok(durable.length > 0); + assert.deepEqual(durable, records.slice(records.length - durable.length)); + assert.equal(hasOlder, durable.length < records.length, 'older history is reported exactly when rows are missing'); +} + function assertRecords(replica: DesktopTranscriptReplica, records: readonly { sequence: number; message: StoredMessage }[]) { assert.deepEqual(replica.snapshot().durable, records, 'every selected Turn row and its full payload survives paging'); } @@ -232,5 +234,5 @@ function transcriptFixture() { ]; // One complete tool payload crosses both page and resident-range budgets. // The record count is incidental; the next Turn starts live and ends durably. - return { first: turn('a', DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + PAGE_BYTES), second: turn('b', 256) }; + return { first: turn('a', DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + PAGE_BYTES), second: turn('b', 256) }; } diff --git a/apps/desktop/src/main/__tests__/transcript-identity.test.ts b/apps/desktop/src/main/__tests__/transcript-identity.test.ts index 4c43ae0398..696d212eb7 100644 --- a/apps/desktop/src/main/__tests__/transcript-identity.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-identity.test.ts @@ -31,8 +31,8 @@ function batch(overrides: Partial = {}): DesktopTranscri generation: 'generation-1', hostEpoch: 'host-1', durableThrough: null, - fragments: [], hasOlder: false, - hasNewer: false, + fragments: [], + hasOlder: false, reset: false, ready: true, deliverySequence: 1, diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts b/apps/desktop/src/main/__tests__/transcript-ledger-test-fixture.ts similarity index 98% rename from apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts rename to apps/desktop/src/main/__tests__/transcript-ledger-test-fixture.ts index 34b46ea5fd..c2bd30b58c 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts +++ b/apps/desktop/src/main/__tests__/transcript-ledger-test-fixture.ts @@ -37,7 +37,7 @@ const FIXTURE_EPOCH = Date.UTC(2026, 0, 2, 3, 4, 5); * projected by the production RuntimeEvent reader. Running Turns live only in * the active overlay; their rows acquire sparse durable sequences on ending. */ -export async function openTranscriptNavigationLedger(messages: readonly StoredMessage[]) { +export async function openTranscriptLedger(messages: readonly StoredMessage[]) { const base = await mkdtemp(join(tmpdir(), 'maka-transcript-navigation-')); const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); const owner = await tryAcquireInteractiveRootOwner(capability); diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts deleted file mode 100644 index 896320173b..0000000000 --- a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts +++ /dev/null @@ -1,559 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { deferred } from '@maka/core/test-only/async-primitives'; -import type { StoredMessage } from '@maka/core/session'; -import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol'; -import type { DesktopTranscriptBatch, DesktopTranscriptHandle, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; -import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; -import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; -import { RuntimeHostSessionObserver } from '../runtime-host-session-observer.js'; -import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; - -const PAGE_BYTES = 128 * 1024; - -for (const kind of ['before', 'around'] as const) { - test(`an invalidated ${kind} page neither answers its window nor touches the tail`, async () => { - const entered = deferred(); - const release = deferred(); - const installed: number[][] = []; - const bootstrap = page(1); - const pending = page(1); - const older = record(0); - const latest = record(1); - const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => ({ - messages: candidate === bootstrap ? [latest] : [older], - nextCursor: candidate === bootstrap ? 'older' : null, - }), - loadTranscriptPage: async () => { - entered.resolve(); - await release.promise; - return pending; - }, - async close() {}, - }), { onChange: (_replica, change) => installed.push(change.durableUpserts.map(({ sequence }) => sequence)) }); - let current = true; - const isCurrent = () => current; - const loading = kind === 'before' - ? replica.loadBefore(1, PAGE_BYTES, isCurrent) - : replica.loadAround(0, PAGE_BYTES, isCurrent); - await entered.promise; - // The Renderer replaced its window while this page was in flight. - current = false; - release.resolve(); - assert.equal(await loading, undefined); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [1]); - assert.deepEqual(installed, []); - replica.close(); - }); -} - -test('a global cache trim empties the tail without publishing or reading history', async () => { - const bootstrap = page(1); - const decoded = new Map>([[bootstrap, record(1)]]); - const requests: number[] = []; - const changes: number[] = []; - const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => ({ messages: [decoded.get(candidate)!], nextCursor: null }), - loadTranscriptPage: async (request) => { - assert.ok(request.throughSequence !== null); - requests.push(request.throughSequence); - const candidate = page(request.throughSequence); - decoded.set(candidate, record(request.throughSequence)); - return candidate; - }, - async close() {}, - }), { maxResidentBytes: 64, onChange: (_replica, change) => changes.push(change.durableUpserts.length) }); - try { - await replica.advance(2); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2], - 'catch-up keeps the newest Turn and drops the oldest'); - const published = changes.length; - requests.length = 0; - - replica.trimDurable(0); - - assert.deepEqual(replica.snapshot().durable, []); - assert.equal(changes.length, published, 'a cache trim is not a transcript change'); - assert.deepEqual(requests, [], 'a cache trim reads nothing back'); - } finally { - replica.close(); - } -}); - -test('a tail the global cache trim emptied is read back before it answers follow latest', async () => { - const bootstrap = page(1); - const tail = page(1); - const reads: Array<{ direction: string; anchorSequence: number | null }> = []; - const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => ({ - messages: [record(1)], nextCursor: candidate === bootstrap ? 'older' : null, - }), - loadTranscriptPage: async (request) => { - reads.push({ direction: request.direction, anchorSequence: request.anchorSequence }); - return tail; - }, - async close() {}, - })); - try { - replica.trimDurable(0); - assert.deepEqual(replica.snapshot().durable, [], 'global memory pressure empties the tail'); - - await replica.refillTail(PAGE_BYTES); - - assert.deepEqual(reads, [{ direction: 'older', anchorSequence: 2 }], - 'the refill reads the newest page, not history'); - const snapshot = replica.snapshot(); - assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [1]); - assert.equal(snapshot.durableThrough, replica.durableThrough); - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - for (const batch of encodeDesktopTranscriptSnapshot(snapshot)) store.accept(batch); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - assert.equal(store.range().hasNewer, false, 'the reader is at the tail, not short of it'); - - reads.length = 0; - await replica.refillTail(PAGE_BYTES); - assert.deepEqual(reads, [], 'a cache holding the whole transcript answers on its own'); - } finally { - replica.close(); - } -}); - -test('return to latest answers with a tail after reclaim emptied the cache', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const eventsClosed = deferred(); - const bootstrap = page(1); - const tail = page(1); - const reads: Array<{ direction: string; anchorSequence: number | null }> = []; - const observer = new RuntimeHostSessionObserver({ - client: { openSession: async () => runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, - transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - // What global reclaim leaves behind: the watermark stands, the rows are gone. - decodeTranscriptPage: async (candidate) => candidate === bootstrap - ? { messages: [], nextCursor: 'older' } - : { messages: [record(1)], nextCursor: null }, - loadTranscriptPage: async (request) => { - reads.push({ direction: request.direction, anchorSequence: request.anchorSequence }); - return tail; - }, - async close() { eventsClosed.resolve(); }, - }) }, - emitSessionsChanged() {}, - }); - await observer.openTranscript('session-1', 'consumer-1', { - id: 1, once() {}, off() {}, - send(_channel, batch) { - store.accept(batch); - queueMicrotask(() => observer.acknowledgeTranscript('consumer-1', batch.generation, batch.deliverySequence, 1)); - }, - }); - assert.deepEqual(store.snapshot().messages, [], 'the window opens on the emptied cache'); - - const navigation = store.navigate(); - await observer.loadTranscriptLatest({ - consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', - anchorSequence: null, maxBytes: PAGE_BYTES, navigation, - }, 1); - - assert.deepEqual(reads, [{ direction: 'older', anchorSequence: 2 }]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - assert.equal(store.range().hasNewer, false, 'the return-to-latest affordance is gone because the rows arrived'); - await observer.close(); -}); - -test('a superseded fragmented reset cannot clear or complete the next navigation', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - acceptSnapshot(store, undefined, 'generation-1', [record(1)]); - store.navigate(); - const stale = [...encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, - durable: [{ sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }], - overlay: [], hasOlder: false, hasNewer: true, - }, 1)]; - assert.equal(store.accept(stale[0]!), false); - store.navigate(); - acceptSnapshot(store, 2, 'generation-2', [record(1)]); - const committed = store.snapshot(); - for (const batch of stale) assert.equal(store.accept(batch), false); - assert.strictEqual(store.snapshot(), committed); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - // Even a reset carrying the current navigation cannot resurrect a retired replica. - acceptSnapshot(store, 2, 'generation-1', [record(0)]); - assert.strictEqual(store.snapshot(), committed); -}); - -test('a replica replacement is admitted whole, however far the window has navigated', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - acceptSnapshot(store, undefined, 'generation-1', [record(1)]); - store.navigate(); - const replacement = [...encodeDesktopTranscriptSnapshot({ - ...identity, generation: 'generation-2', durableThrough: 1, - durable: [ - { sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }, - { sequence: 1, message: record(1).message }, - ], - overlay: [], hasOlder: false, hasNewer: false, - })]; - assert.ok(replacement.length > 1, 'the replacement has to span more than its reset batch'); - for (const batch of replacement) store.accept(batch); - assert.equal(store.range().ready, true); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-0', 'message-1']); -}); - -test('a fill landing under a pending jump joins the window it was anchored on', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const arrive = deferred(); - const handle: DesktopTranscriptHandle = { - ...identity, readThroughMessageId: null, acknowledgeTail: async () => {}, - async loadBefore(anchor) { - for (const batch of encodeDesktopTranscriptPage(identity, { - durableThrough: 1, durable: [{ sequence: 0, message: record(0).message }], hasOlder: false, - }, { direction: 'older', anchor })) store.accept(batch); - }, - async loadAfter() { assert.fail('unexpected'); }, - async loadAround(_anchor, _bytes, navigation) { - await arrive.promise; - acceptSnapshot(store, navigation, 'generation-1', [record(9)]); - }, - async loadLatest() { assert.fail('unexpected'); }, - async close() {}, - }; - const controller = createDesktopTranscriptRangeController(store, async () => handle); - acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2)]); - const navigation = controller.loadAround(9); - await Promise.resolve(); - - assert.equal(await controller.loadBefore(), true); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), - ['message-0', 'message-1', 'message-2'], - 'the fill is anchored on an edge of the window still on screen, and reaches it'); - - arrive.resolve(); - await navigation; - // The jump replaces the window whole, so the fill leaves no trace in it. - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-9']); - await controller.close(); -}); - -test('a fill that left an edge where it found it is not asked again', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - let reads = 0; - const handle: DesktopTranscriptHandle = { - ...identity, readThroughMessageId: null, acknowledgeTail: async () => {}, - // The window still has history, but this answer reaches none of it: the - // Host read past a retired generation, or the page came back refused. - async loadBefore() { reads += 1; }, - async loadAfter() { assert.fail('unexpected'); }, - async loadAround() { assert.fail('unexpected'); }, - async loadLatest() { assert.fail('unexpected'); }, - async close() {}, - }; - const controller = createDesktopTranscriptRangeController(store, async () => handle); - acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2)]); - assert.equal(store.range().hasOlder, true); - - await controller.loadBefore(); - await controller.loadBefore(); - await controller.loadBefore(); - assert.equal(reads, 1, 'the same edge is not read twice'); - - // Anything that moves the edge makes it worth asking again. - assert.equal(store.retain(2, 2), true); - await controller.loadBefore(); - assert.equal(reads, 2); - await controller.close(); -}); - -test('a fill anchored on the window a navigation replaced cannot splice onto it', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - acceptSnapshot(store, undefined, 'generation-1', [record(8), record(9)]); - const navigating = store.navigate(); - acceptSnapshot(store, navigating, 'generation-1', [record(1), record(2)]); - for (const batch of encodeDesktopTranscriptPage(identity, { - durableThrough: 9, durable: [{ sequence: 9, message: record(9).message }], hasNewer: false, - }, { direction: 'newer', anchor: 9 })) store.accept(batch); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1', 'message-2']); -}); - -test('a navigation outlives the band trimming the window it was issued under', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2)]); - const navigating = store.navigate(); - assert.equal(store.retain(2, 2), true); - const replacement = [...encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, - durable: [ - { sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }, - { sequence: 1, message: record(1).message }, - ], - overlay: [], hasOlder: false, hasNewer: false, - }, navigating)]; - assert.ok(replacement.length > 1, 'the replacement has to span more than its reset batch'); - for (const batch of replacement) store.accept(batch); - assert.equal(store.range().ready, true); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-0', 'message-1']); - // The fill the band left in flight is anchored on an edge nothing here has. - for (const batch of encodeDesktopTranscriptPage(identity, { - durableThrough: 3, durable: [{ sequence: 3, message: record(3).message }], hasNewer: false, - }, { direction: 'newer', anchor: 2 })) store.accept(batch); - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [0, 1]); - assert.equal(store.range().hasNewer, true, 'the watermark it carried still moved'); -}); - -test('a page anchored on an edge the band has since dropped is refused', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - acceptSnapshot(store, undefined, 'generation-1', [record(1), record(2), record(3)]); - assert.equal(store.retain(3, 3), true); - for (const batch of encodeDesktopTranscriptPage(identity, { - durableThrough: 3, durable: [{ sequence: 0, message: record(0).message }], - hasOlder: false, - }, { direction: 'older', anchor: 1 })) store.accept(batch); - // Installing it would leave 1..2 missing between the answer and the window, - // and no edge cursor can name a hole in the middle. - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-3']); - assert.equal(store.range().hasOlder, true); -}); - -test('follow latest invalidates an in-flight history navigation before open resolves', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const opening = deferred(); - const requests: Array<{ command: 'around' | 'latest'; anchor: number | null; navigation: number }> = []; - const handle = (generation: string): DesktopTranscriptHandle => ({ - ...identity, generation, readThroughMessageId: null, acknowledgeTail: async () => {}, - async loadBefore() { assert.fail('an obsolete history request was replayed'); }, - async loadAfter() { assert.fail('an obsolete newer request was replayed'); }, - async loadAround(anchor, _bytes, navigation) { - requests.push({ command: 'around', anchor, navigation }); - acceptSnapshot(store, navigation, generation, [record(0)]); - }, - async loadLatest(navigation) { - requests.push({ command: 'latest', anchor: null, navigation }); - acceptSnapshot(store, navigation, generation, [record(1)]); - }, - async close() {}, - }); - const controller = createDesktopTranscriptRangeController(store, async () => opening.promise); - const history = controller.loadAround(0); - const latest = controller.loadLatest(); - opening.resolve(handle('generation-1')); - await Promise.all([history, latest]); - assert.deepEqual(requests, [{ command: 'latest', anchor: null, navigation: 2 }]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - await controller.close(); -}); - -test('a rejected older navigation cannot fail the newer latest command', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const historyEntered = deferred(); - let rejectHistory!: (error: Error) => void; - const historyResult = new Promise((_resolve, reject) => { rejectHistory = reject; }); - const controller = createDesktopTranscriptRangeController(store, async () => ({ - ...identity, readThroughMessageId: null, acknowledgeTail: async () => {}, - async loadBefore() {}, async loadAfter() {}, - async loadAround(anchor) { - assert.equal(anchor, 0); - historyEntered.resolve(); - await historyResult; - }, - async loadLatest(navigation) { - acceptSnapshot(store, navigation, identity.generation, [record(1)]); - }, - async close() {}, - })); - const history = controller.loadAround(0); - await historyEntered.promise; - await controller.loadLatest(); - rejectHistory(new Error('the obsolete range failed')); - await assert.doesNotReject(history); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - await controller.close(); -}); - -test('superseded batches remain ACKable and cannot reset the latest window while delivery drains', { timeout: 10_000 }, async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const firstOldBatch = deferred(); - const eventsClosed = deferred(); - const bootstrap = page(1); - const historyPage = page(1); - const latestPage = page(1); - const old = record(0); - const largeOld = { ...old, message: { ...old.message, text: 'A'.repeat(700 * 1024) } as StoredMessage }; - const latest = record(1); - const blocked: DesktopTranscriptBatch[] = []; - let releaseAcks = false; - const observer = new RuntimeHostSessionObserver({ - client: { openSession: async () => runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, - transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => ({ - messages: candidate === historyPage ? [largeOld] : [latest], - nextCursor: candidate === historyPage ? 'newer' : 'older', - }), - loadTranscriptPage: async (request) => request.direction === 'newer' ? historyPage : latestPage, - async close() { eventsClosed.resolve(); }, - }) }, - emitSessionsChanged() {}, - }); - const ack = (batch: DesktopTranscriptBatch) => observer.acknowledgeTranscript('consumer-1', batch.generation, batch.deliverySequence, 1); - await observer.openTranscript('session-1', 'consumer-1', { - id: 1, once() {}, off() {}, - send(_channel, batch) { - store.accept(batch); - if (batch.navigation === 1 && !releaseAcks) { - blocked.push(batch); - firstOldBatch.resolve(); - } else queueMicrotask(() => ack(batch)); - }, - }); - const request: DesktopTranscriptRangeRequest = { - consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', - anchorSequence: 0, maxBytes: PAGE_BYTES, navigation: 1, - }; - store.navigate(); - const history = observer.loadTranscriptAround(request, 1); - await firstOldBatch.promise; - store.navigate(); - const following = observer.loadTranscriptLatest({ ...request, navigation: 2, anchorSequence: null }, 1); - releaseAcks = true; - for (const batch of blocked) ack(batch); - await Promise.all([history, following]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); - const snapshot = store.snapshot(); - // Replaying the reset of the answer the reader navigated away from: it names - // a navigation that is over, so it cannot reinstall the window it was read for. - for (const batch of blocked.filter(({ reset }) => reset)) { - assert.equal(store.accept(batch), false); - } - assert.strictEqual(store.snapshot(), snapshot); - await observer.close(); -}); - -test('a fill in flight does not discard the replacement it was issued under', async () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const entered = deferred(); - const release = deferred(); - const eventsClosed = deferred(); - const bootstrap = page(1); - const historyPage = page(1); - let gated = true; - const observer = new RuntimeHostSessionObserver({ - client: { openSession: async () => runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, - transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => ({ - messages: [candidate === bootstrap ? record(1) : record(0)], nextCursor: null, - }), - loadTranscriptPage: async () => { - if (gated) { - gated = false; - entered.resolve(); - await release.promise; - } - return historyPage; - }, - async close() { eventsClosed.resolve(); }, - }) }, - emitSessionsChanged() {}, - }); - await observer.openTranscript('session-1', 'consumer-1', { - id: 1, once() {}, off() {}, - send(_channel, batch) { - store.accept(batch); - queueMicrotask(() => observer.acknowledgeTranscript('consumer-1', batch.generation, batch.deliverySequence, 1)); - }, - }); - const request: DesktopTranscriptRangeRequest = { - consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', - anchorSequence: 0, maxBytes: PAGE_BYTES, navigation: 1, - }; - store.navigate(); - const navigation = observer.loadTranscriptAround(request, 1); - await entered.promise; - // A fill issued while the jump is still reading names the same navigation: - // it extends the window, and nothing about it abandons the jump. - const filling = observer.loadTranscriptBefore({ ...request, anchorSequence: 1 }, 1); - release.resolve(); - await Promise.all([navigation, filling]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-0']); - await observer.close(); -}); - -const identity = { sessionId: 'session-1', hostEpoch: 'host-1', generation: 'generation-1' }; -function acceptSnapshot(store: DesktopTranscriptRangeStore, navigation: number | undefined, generation: string, records: Array>) { - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, generation, durableThrough: 1, - durable: records.map(({ identity: sequence, message }) => ({ sequence, message })), - overlay: [], hasOlder: true, hasNewer: false, - }, navigation)) store.accept(batch); -} -function record(identity: number) { - const message: StoredMessage = { type: 'assistant', id: `message-${identity}`, turnId: `turn-${identity}`, ts: 1, text: String(identity), modelId: 'test' }; - return { identity, message }; -} -function page(throughSequence: number): SessionTranscriptPage { - return { kind: 'page', sessionId: 'session-1', source: 'durable', direction: 'older', throughSequence, - rawBytes: 1, fragments: [], rangeBoundarySequence: null, protectedTurnSequence: null, nextCursor: null }; -} -function continuitySnapshot() { - return { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, - session: { sessionId: 'session-1', metadataRevision: 1, status: 'running' as const, createdAt: 1, isArchived: false }, - projectionRevision: 1, rootTurn: null, goal: null, - queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] } }; -} diff --git a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts index ba7289008f..f6bf9718a4 100644 --- a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts @@ -33,18 +33,13 @@ import { updateSubscriberTranscriptHighWater, } from '../../../../../packages/runtime-host/dist/server/session-transcript-pager.js'; import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { - encodeDesktopTranscriptChange, - encodeDesktopTranscriptPage, - encodeDesktopTranscriptSnapshot, -} from '../desktop-transcript-ipc.js'; +import { encodeDesktopTranscriptChange, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { DesktopTranscriptReplica, type DesktopTranscriptReplicaChange } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; -import { openTranscriptNavigationLedger } from './transcript-navigation-test-fixture.js'; +import { openTranscriptLedger } from './transcript-ledger-test-fixture.js'; const HOST_EPOCH = 'host-1'; const SUBSCRIPTION_ID = 'overlay-settlement-subscription'; -const PAGE_BYTES = 128 * 1024; const BOOTSTRAP_THROUGH = 'running-b'; const B_STEERING_THROUGH = 'steering-b'; const B_COMPLETED_THROUGH = 'completed-b'; @@ -78,55 +73,6 @@ for (const coalesced of [false, true]) { }); } -test('a window parked off the tail reads the completed Turn back through its own edge', async () => { - const fixture = await openFixture(); - try { - const { replica, renderer } = fixture; - // Reading history: the window dropped the newest rows to meet its budget, - // so its newer edge is a gap and tail growth is no longer its business. - const oldest = renderer.range().oldestSequence; - assert.ok(oldest !== null); - renderer.retain(oldest, oldest); - assert.equal(renderer.range().hasNewer, true); - assert.equal( - renderer.snapshot().messages.some(({ id }) => id === 'answer-b'), false, - 'the overlay is a fact about the tail, and this window no longer reaches it', - ); - - await fixture.advance(B_COMPLETED_THROUGH); - await fixture.advance(C_COMPLETED_THROUGH); - - assert.deepEqual( - renderer.durableEntries().map(({ sequence }) => sequence), [oldest], - 'tail growth has nothing to join onto, so the window stays the range it was trimmed to', - ); - assert.equal(renderer.range().hasNewer, true); - - // Paging back: each read is anchored on the edge the last one left, which - // is the only thing that makes the rows spliceable. - for (let read = 0; read < 8 && renderer.range().hasNewer; read += 1) { - const anchor = renderer.range().newestSequence; - const page = await replica.loadAfter(anchor, PAGE_BYTES); - assert.ok(page); - for (const batch of encodeDesktopTranscriptPage({ - sessionId: replica.sessionId, - generation: replica.generation, - hostEpoch: replica.hostEpoch, - }, page, { direction: 'newer', anchor })) renderer.accept(batch); - } - - assert.deepEqual( - renderer.snapshot().messages.flatMap((message) => - message.type === 'assistant' && message.turnId === 'b' ? [message.text] : []), - ['B partial and completed answer'], - 'reading forward from the edge brings the completed body back', - ); - assert.equal(replica.snapshot().overlay.length, 0); - } finally { - await fixture.close(); - } -}); - test('a completed live answer remains unique after a fresh transcript subscription', async () => { const fixture = await openFixture(); let reopened: Awaited> | undefined; @@ -197,7 +143,7 @@ test('catch-up retires the completed overlay in one notification', async () => { } }); -test('a window page read retires the tail overlay copy without notifying other windows', async () => { +test('a history page read retires the tail overlay copy without notifying other consumers', async () => { const older: StoredMessage = { type: 'assistant', id: 'answer-older', turnId: 'older', ts: 1, text: 'Older answer', modelId: 'fixture-model', @@ -208,8 +154,7 @@ test('a window page read retires the tail overlay copy without notifying other w }; const durablePage = (): SessionTranscriptPage => ({ kind: 'page', sessionId: 'session-1', source: 'durable', direction: 'older', - throughSequence: 2, rawBytes: 1, fragments: [], rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, + throughSequence: 2, rawBytes: 1, fragments: [], nextCursor: null, }); const bootstrap = durablePage(); const decoded = new Map id), ['answer-older']); - const page = await replica.loadBefore(2, PAGE_BYTES); + const page = await replica.readOlderPage(2, null); - assert.ok(page); assert.deepEqual(page.durable.map(({ message }) => message.id), ['answer-older']); assert.deepEqual(replica.snapshot().overlay, [], 'the durable row retires the tail overlay copy'); - assert.deepEqual(changes, [], 'a window page changes nothing another window holds'); + assert.deepEqual(changes, [], 'a history page changes nothing another consumer holds'); assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2]); } finally { replica.close(); @@ -265,7 +209,7 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => assistant('b', 'B partial and completed answer'), turnState('b', 'completed'), user('c'), turnState('c', 'running'), assistant('c', 'C'.repeat(600 * 1024)), turnState('c', 'completed'), ]; - const ledger = await openTranscriptNavigationLedger(messages); + const ledger = await openTranscriptLedger(messages); const { reader, sessionId } = ledger; const bootstrapThrough = await ledger.appendThrough(BOOTSTRAP_THROUGH); assert.ok(bootstrapThrough !== null); @@ -358,7 +302,7 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => }; } -async function openSettledReplica(ledger: Awaited>) { +async function openSettledReplica(ledger: Awaited>) { const { sessionId, reader } = ledger; const opened = await createSessionTranscriptBootstrap({ reader, sessionId, subscriptionId: `${SUBSCRIPTION_ID}-reopened`, diff --git a/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts b/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts deleted file mode 100644 index 413fd09ff8..0000000000 --- a/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import type { StoredMessage } from '@maka/core/session'; -import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { - encodeDesktopTranscriptChange, - encodeDesktopTranscriptPage, - encodeDesktopTranscriptSnapshot, -} from '../desktop-transcript-ipc.js'; - -const identity = { sessionId: 'session-1', hostEpoch: 'host-1', generation: 'generation-1' }; -const message = (sequence: number, text = String(sequence)): StoredMessage => - ({ type: 'assistant', id: `message-${sequence}`, turnId: `turn-${sequence}`, ts: 1, text, modelId: 'test' }); - -test('a Turn completing at the tail does not splice into a window parked far from it', () => { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - // A jump to 5 during a run: loadAround's snapshot carries the live overlay (21). - const navigation = store.navigate(); - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 20, - durable: [{ sequence: 5, message: message(5) }, { sequence: 6, message: message(6) }], - overlay: [message(21, 'partial')], hasOlder: true, hasNewer: true, - }, navigation)) store.accept(batch); - // 21 completes; the tail broadcast carries its durable row. - for (const batch of encodeDesktopTranscriptChange(identity, { - coversFrom: 20, durableThrough: 21, - durableUpserts: [{ sequence: 21, message: message(21, 'partial and completed') }], - })) store.accept(batch); - // 7..20 are not in the window, so 21 cannot join its durable range contiguously. - assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [5, 6]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-5', 'message-6']); - - // Reading forward to the tail is what brings 21 in. It arrives once, as the - // completed durable row: seeing that row retired the overlay the jump - // installed, even though the window could not keep it at the time. - for (const batch of encodeDesktopTranscriptPage(identity, { - durableThrough: 21, hasOlder: true, hasNewer: false, - durable: Array.from({ length: 15 }, (_, index) => ({ - sequence: index + 7, - message: message(index + 7, index === 14 ? 'partial and completed' : undefined), - })), - }, { direction: 'newer', anchor: 6 })) store.accept(batch); - const ids = store.snapshot().messages.map(({ id }) => id); - assert.deepEqual(ids.slice(-2), ['message-20', 'message-21']); - assert.equal(ids.length, 17, 'the settled overlay is gone, so 21 is shown once'); -}); diff --git a/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts b/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts deleted file mode 100644 index 935ec21439..0000000000 --- a/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { deferred } from '@maka/core/test-only/async-primitives'; -import type { StoredMessage } from '@maka/core/session'; -import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol'; -import type { DesktopTranscriptBatch, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; -import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { RuntimeHostSessionObserver } from '../runtime-host-session-observer.js'; -import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; - -// Drives the real observer, replica and Renderer store with a fake Host whose -// jump read (loadAround) is held until a fill has been queued behind it. -const PAGE_BYTES = 128 * 1024; -const THROUGH = 20; - -async function harness() { - const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - const eventsClosed = deferred(); - const aroundEntered = deferred(); - const releaseAround = deferred(); - const bootstrap = page(); - const decoded = new Map>; nextCursor: string | null }>([ - [bootstrap, { messages: [record(18), record(19), record(20)], nextCursor: 'older' }], - ]); - const observer = new RuntimeHostSessionObserver({ - client: { openSession: async () => runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, - transcriptBootstrap: { throughSequence: THROUGH, overlayMessageCount: 0, durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' } }, - loadTranscriptOverlay: async () => [], - decodeTranscriptPage: async (candidate) => decoded.get(candidate)!, - loadTranscriptPage: async (request) => { - const candidate = page(); - if (request.direction === 'newer') { - aroundEntered.resolve(); - await releaseAround.promise; - decoded.set(candidate, { messages: [record(5), record(6)], nextCursor: 'newer' }); - } else if (request.maxBytes === 1) { - decoded.set(candidate, { messages: [record(4)], nextCursor: 'older' }); - } else { - decoded.set(candidate, { messages: [record(request.anchorSequence! - 1)], nextCursor: 'older' }); - } - return candidate; - }, - async close() { eventsClosed.resolve(); }, - }) }, - emitSessionsChanged() {}, - }); - const ack = (batch: DesktopTranscriptBatch) => observer.acknowledgeTranscript('consumer-1', batch.generation, batch.deliverySequence, 1); - await observer.openTranscript('session-1', 'consumer-1', { - id: 1, once() {}, off() {}, - send(_channel, batch) { store.accept(batch); queueMicrotask(() => ack(batch)); }, - }); - const request = (navigation: number, anchorSequence: number): DesktopTranscriptRangeRequest => ({ - consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', anchorSequence, maxBytes: PAGE_BYTES, navigation, - }); - const sequences = () => store.durableEntries().map(({ sequence }) => sequence); - return { store, observer, request, sequences, aroundEntered, releaseAround }; -} - -test('a fill issued while a jump is pending does not splice the old edge onto the new window', async () => { - const h = await harness(); - try { - assert.deepEqual(h.sequences(), [18, 19, 20]); - const navigation = h.store.navigate(); - const jump = h.observer.loadTranscriptAround(h.request(navigation, 5), 1); - await h.aroundEntered.promise; - // Anchored on the window still on screen (18), under the jump's navigation. - const fill = h.observer.loadTranscriptBefore(h.request(navigation, h.store.range().oldestSequence!), 1); - h.releaseAround.resolve(); - await Promise.all([jump, fill]); - assert.deepEqual(h.sequences(), [5, 6]); - } finally { - await h.observer.close(); - } -}); - -test('a trim and a fill while a jump is pending do not make Main drop the jump', async () => { - const h = await harness(); - try { - const navigation = h.store.navigate(); - const jump = h.observer.loadTranscriptAround(h.request(navigation, 5), 1); - await h.aroundEntered.promise; - assert.equal(h.store.retain(19, 20), true); - const fill = h.observer.loadTranscriptBefore(h.request(navigation, h.store.range().oldestSequence!), 1); - h.releaseAround.resolve(); - await Promise.all([jump, fill]); - assert.ok(h.sequences().includes(5), `the jump target never arrived: ${JSON.stringify(h.sequences())}`); - } finally { - await h.observer.close(); - } -}); - -function record(identity: number) { - const message: StoredMessage = { type: 'assistant', id: `message-${identity}`, turnId: `turn-${identity}`, ts: 1, text: String(identity), modelId: 'test' }; - return { identity, message }; -} -function page(): SessionTranscriptPage { - return { kind: 'page', sessionId: 'session-1', source: 'durable', direction: 'older', throughSequence: THROUGH, - rawBytes: 1, fragments: [], rangeBoundarySequence: null, protectedTurnSequence: null, nextCursor: null }; -} -function continuitySnapshot() { - return { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, - session: { sessionId: 'session-1', metadataRevision: 1, status: 'running' as const, createdAt: 1, isArchived: false }, - projectionRevision: 1, rootTurn: null, goal: null, - queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] } }; -} diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index 4451617294..c032e44588 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -23,13 +23,12 @@ import { act, createElement, createRef, type ComponentProps } from 'react'; import { deferred } from '@maka/core/test-only/async-primitives'; import type { StoredMessage } from '@maka/core/session'; import type { DesktopTranscriptHandle } from '../../preload/transcript-contract.js'; -import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { encodeDesktopTranscriptBatches, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { createAppShellSessionUiStateController, TranscriptReadingPositionController, type TranscriptReadingPositionCommands, - TranscriptReadSupersededError, } from '../../renderer/features/conversation/index.js'; import { createTranscriptRestoreLifecycle, @@ -40,149 +39,126 @@ import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; afterEach(cleanupFakeDom); -test('a resident navigation supersedes a pending history replacement without rereading an idle window', async () => { - const sessionId = JSON.stringify(['host-1', 'session-1']); - const store = new DesktopTranscriptRangeStore(sessionId); - const pending = deferred(); - const reads: number[] = []; - const publish = (sequence: number, navigation?: number) => { - const turnId = sequence === 10 ? 'a' : 'b'; - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: 20, durable: [{ sequence, message: { - type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - } }], overlay: [], hasOlder: true, hasNewer: true, - }, navigation)) store.accept(batch); +const SESSION_ID = JSON.stringify(['host-1', 'session-1']); +const IDENTITY = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +function answer(turnId: string): StoredMessage { + return { type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture' }; +} + +function handle(overrides: Partial = {}): DesktopTranscriptHandle { + return { + sessionId: SESSION_ID, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, + acknowledgeTail: async () => {}, + loadEarlier: async () => assert.fail('unexpected earlier history read'), + close: async () => {}, + ...overrides, }; - publish(20); - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, - acknowledgeTail: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, - close: async () => {}, loadLatest: async () => assert.fail('unexpected tail navigation'), - async loadAround(sequence, _bytes, navigation) { - reads.push(sequence); - if (sequence === 10) await pending.promise; - publish(sequence, navigation); +} + +test('a bookmark older than the loaded history is restored by loading earlier history through the store', async () => { + const store = new DesktopTranscriptRangeStore(SESSION_ID); + for (const batch of encodeDesktopTranscriptSnapshot({ + ...IDENTITY, durableThrough: 30, durable: [{ sequence: 30, message: answer('c') }], overlay: [], hasOlder: true, + })) store.accept(batch); + const earlier = [ + { sequence: 20, turnId: 'b', hasOlder: true }, + { sequence: 10, turnId: 'a', hasOlder: false }, + ]; + let reads = 0; + const controller = createDesktopTranscriptRangeController(store, async () => handle({ + async loadEarlier() { + const page = earlier[reads++]!; + for (const batch of encodeDesktopTranscriptBatches(IDENTITY, { + durableThrough: 30, durable: [{ sequence: page.sequence, message: answer(page.turnId) }], overlay: [], + hasOlder: page.hasOlder, earlierThan: store.snapshot().messages.length === 1 ? 30 : 20, + reset: false, ready: true, + })) store.accept(batch); }, - })); + }), { onError: (error) => assert.fail(String(error)) }); + let anchor: { turnId: string } | undefined = { turnId: 'a' }; const lifecycle = createTranscriptRestoreLifecycle(); - const navigate = (turnId: string, sequence: number, nonce: number) => restoreSessionTranscriptRange({ - lifecycle, sessionId, controller, searchTarget: { sessionId, turnId, sequence, nonce }, - isCurrent: () => true, setReadingAnchor() {}, onError: (error) => assert.fail(String(error)), + const restore = () => restoreSessionTranscriptRange({ + lifecycle, sessionId: SESSION_ID, controller, readingAnchor: { turnId: 'a' }, + isCurrent: () => true, + setReadingAnchor: (_sessionId, next) => { anchor = next; }, + onRestoreUnavailable: () => assert.fail('the bookmark is in earlier history'), + onError: (error) => assert.fail(String(error)), }); - const settle = () => new Promise((resolve) => setImmediate(resolve)); try { - navigate('b', 20, 1); - await settle(); - assert.deepEqual(reads, [], 'an idle resident target needs no read'); - navigate('a', 10, 2); - await settle(); - navigate('b', 20, 3); - await settle(); - pending.resolve(); + await controller.ready(); + restore(); + for (let tick = 0; tick < 4; tick += 1) await settle(); + assert.equal(reads, 2); + assert.deepEqual(store.snapshot().messages.map(({ turnId }) => turnId), ['a', 'b', 'c']); + assert.deepEqual(anchor, { turnId: 'a' }); + restore(); await settle(); - assert.deepEqual(store.snapshot().messages.map((message) => message.turnId), ['b'], - 'the late A response must not replace the newer B navigation'); + assert.equal(reads, 2, 'a restored bookmark reads nothing more'); } finally { - pending.resolve(); await controller.close(); } }); -test('sending before transcript open completes supersedes the queued bookmark without delaying admission', { timeout: 5_000 }, async () => { - const sessionId = JSON.stringify(['host-1', 'session-1']); - const store = new DesktopTranscriptRangeStore(sessionId); +test('sending before transcript open completes cancels the queued bookmark without delaying admission', { timeout: 5_000 }, async () => { + const store = new DesktopTranscriptRangeStore(SESSION_ID); const opening = deferred(); - const controller = createDesktopTranscriptRangeController(store, () => opening.promise); + const controller = createDesktopTranscriptRangeController(store, () => opening.promise, { + onError: (error) => assert.fail(String(error)), + }); const lifecycle = createTranscriptRestoreLifecycle(); - const requests: Array<{ sequence: number | null; navigation: number }> = []; - const publish = (sequence: number | null, navigation: number) => { - requests.push({ sequence, navigation }); - const turnId = sequence === null ? 'b' : 'a'; - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: 20, - durable: [{ sequence: sequence ?? 20, message: { - type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - } }], overlay: [], hasOlder: true, hasNewer: sequence !== null, - }, navigation)) store.accept(batch); - }; - const handle: DesktopTranscriptHandle = { - sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, - acknowledgeTail: async () => {}, - loadBefore: async () => {}, loadAfter: async () => {}, close: async () => {}, - async loadAround(sequence, _maxBytes, navigation) { publish(sequence, navigation); }, - async loadLatest(navigation) { publish(null, navigation); }, - }; + let reads = 0; const restore = () => restoreSessionTranscriptRange({ - lifecycle, sessionId, controller, readingAnchor: { turnId: 'a', sequence: 10 }, + lifecycle, sessionId: SESSION_ID, controller, readingAnchor: { turnId: 'a' }, isCurrent: () => true, - setReadingAnchor: () => assert.fail('the cancelled bookmark must not be restored'), + setReadingAnchor: () => assert.fail('the cancelled bookmark must not be settled'), onError: (error) => assert.fail(String(error)), }); try { restore(); assert.throws(() => store.range(), /not initialized/); let pins = 0; - assert.equal(await prepareTranscriptForSend({ - sessionId, currentSessionId: { current: sessionId }, controller: { current: controller }, + assert.equal(prepareTranscriptForSend({ + sessionId: SESSION_ID, currentSessionId: { current: SESSION_ID }, cancel: (target) => lifecycle.cancel(target), followLatest: () => { pins += 1; }, }), true, 'local admission must finish while transcript open is still pending'); assert.equal(pins, 1); - assert.equal(requests.length, 0); - opening.resolve(handle); - await new Promise((resolve) => setImmediate(resolve)); - restore(); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(requests.map(({ sequence, navigation }) => - [sequence, navigation]), [[null, 2]]); - assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['answer-b']); - const latest = store.snapshot(); + opening.resolve(handle({ loadEarlier: async () => { reads += 1; } })); for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: 20, - durable: [{ sequence: 10, message: { - type: 'assistant', id: 'answer-a', turnId: 'a', text: 'a', ts: 1, modelId: 'fixture', - } }], overlay: [], hasOlder: false, hasNewer: true, - }, 1)) assert.equal(store.accept(batch), false); - assert.strictEqual(store.snapshot(), latest, 'a late history response must not replace the latest range'); + ...IDENTITY, durableThrough: 20, durable: [{ sequence: 20, message: answer('b') }], overlay: [], hasOlder: true, + })) store.accept(batch); + await settle(); + restore(); + await settle(); + assert.equal(reads, 0, 'the cancelled bookmark must not load earlier history'); } finally { - opening.resolve(handle); await controller.close(); } }); -test('an overlay-only bookmark stays available without loading another range', async () => { - const sessionId = JSON.stringify(['host-1', 'session-1']); - const store = new DesktopTranscriptRangeStore(sessionId); - const overlay: StoredMessage = { - type: 'assistant', id: 'answer-b', turnId: 'b', text: 'partial B', ts: 1, modelId: 'fixture', - }; +test('an overlay-only bookmark stays available without loading earlier history', async () => { + const store = new DesktopTranscriptRangeStore(SESSION_ID); for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: null, durable: [], overlay: [overlay], hasOlder: false, hasNewer: false, + ...IDENTITY, durableThrough: null, durable: [], overlay: [answer('b')], hasOlder: true, })) store.accept(batch); - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, - acknowledgeTail: async () => {}, - loadBefore: async () => {}, loadAfter: async () => {}, close: async () => {}, - loadAround: async () => assert.fail('an overlay-only bookmark has no page to load'), - loadLatest: async () => assert.fail('an overlay-only bookmark has no page to load'), - })); + const controller = createDesktopTranscriptRangeController(store, async () => handle(), { + onError: (error) => assert.fail(String(error)), + }); const lifecycle = createTranscriptRestoreLifecycle(); let unavailable = 0; let cleared = 0; const restore = () => restoreSessionTranscriptRange({ - lifecycle, sessionId, controller, readingAnchor: { turnId: 'b' }, + lifecycle, sessionId: SESSION_ID, controller, readingAnchor: { turnId: 'b' }, isCurrent: () => true, setReadingAnchor: (_sessionId, anchor) => { if (!anchor) cleared += 1; }, onRestoreUnavailable: () => { unavailable += 1; }, onError: (error) => assert.fail(String(error)), }); try { + await controller.ready(); restore(); - await new Promise((resolve) => setImmediate(resolve)); + await settle(); restore(); - assert.equal(store.sequenceForTurn('b'), null); assert.equal(unavailable, 0); assert.equal(cleared, 0); } finally { @@ -190,138 +166,59 @@ test('an overlay-only bookmark stays available without loading another range', a } }); -test('a failed return to the tail reports to its own Session', async () => { +test('loading earlier history only reaches the current Session controller', async () => { const fixture = controllerFixture(); - const errors: string[] = []; - fixture.props.onNavigationError = (error) => { errors.push(String(error)); }; - fixture.controller.loadLatest = async () => { throw new Error('tail read failed'); }; + let first = 0; + let second = 0; + fixture.controller.loadEarlier = async () => { first += 1; }; await fixture.render(); - - await fixture.commands.current!.returnToLatest(); - assert.deepEqual(errors, ['Error: tail read failed']); -}); - -test('an old Session return to the tail cannot report against the new Session', async () => { - const fixture = controllerFixture(); - const first = deferred(); - fixture.props.onNavigationError = () => assert.fail('a superseded Session must not report'); - fixture.controller.loadLatest = () => first.promise; - await fixture.render(); - const returningFirst = fixture.commands.current!.returnToLatest(); + await fixture.commands.current!.loadEarlier(); + assert.equal(first, 1); fixture.props.currentSessionId.current = 'session-2'; + await fixture.commands.current!.loadEarlier(); + assert.equal(first, 1, 'a superseded Session cannot load history'); + fixture.props.sessionId = 'session-2'; fixture.props.rangeController.current = { ...fixture.controller, - store: { ...fixture.controller.store, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, - loadLatest: async () => {}, + store: { ...fixture.controller.store, range: () => ({ sessionId: 'session-2', hasOlder: true, ready: true }) }, + loadEarlier: async () => { second += 1; }, }; await fixture.render(); - await fixture.commands.current!.returnToLatest(); - - first.reject(new Error('superseded tail request failed')); - await returningFirst; -}); - -test('filling an edge leaves an outstanding jump alone', async () => { - const fixture = controllerFixture(); - let cleared = 0; - fixture.props.searchTarget = { sessionId: 'session-1', nonce: 1, turnId: 'turn-1' } as never; - fixture.props.clearSearchTarget = () => { cleared += 1; }; - const failure = new Error('the older read failed'); - fixture.controller.loadBefore = async () => { throw failure; }; - await fixture.render(); - - // The jump is still in flight; the band asking for the edge it is scrolling - // towards decides nothing, so it must not answer for the reader. - await assert.rejects(fixture.commands.current!.prefetchHistory('older'), failure); - assert.equal(cleared, 0); + await fixture.commands.current!.loadEarlier(); + assert.deepEqual([first, second], [1, 1]); }); -for (const known of [true, false]) { -test(`a bookmark ${known ? 'survives' : 'cannot outlive'} a Host epoch change`, async () => { +test('captured reading anchors belong to the current Session and preparing a send clears them', async () => { const fixture = controllerFixture(); - const landmarks = known ? [{ turnId: 'turn-t', sequence: 77, label: 'T' }] : []; - let range = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; - fixture.controller.store.range = () => range; - fixture.props.listTurnLandmarks = async () => ({ throughSequence: 80, landmarks }); - const loaded: number[] = []; - fixture.controller.loadAround = async (sequence: number) => { loaded.push(sequence); }; - await fixture.render(); - fixture.props.sessionUi.setTranscriptReadingAnchor('session-1', { turnId: 'turn-t', sequence: 10 }); - - range = { sessionId: 'session-1', generation: 'generation-2', hostEpoch: 'host-2' }; - fixture.props.messages = []; + let followed: string[] = []; + fixture.props.sessionUi.transcriptViewportNavigation.subscribe((sessionId) => { followed = [...followed, sessionId]; }); await fixture.render(); - await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); - - // The old epoch's sequence names a different row, so only the Turn resolved - // in the new epoch may be navigated to. - assert.deepEqual(loaded, known ? [77] : []); - assert.deepEqual( - fixture.props.sessionUi.transcriptReadingAnchorBySessionRef.current['session-1'], - { turnId: 'turn-t', sequence: known ? 77 : 10 }, - ); -}); -} + const anchors = fixture.props.sessionUi.transcriptReadingAnchorBySessionRef; -test('a read superseded by a Host epoch change leaves the bookmark alone', async () => { - const sessionId = 'session-1'; - const lifecycle = createTranscriptRestoreLifecycle(); - const controller = { - store: { - sessionId, - range: () => ({ sessionId }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => null, - snapshot: () => ({ messages: [] }), - }, - loadAround: async () => { - throw new TranscriptReadSupersededError('Desktop transcript host epoch changed; reopen the transcript'); - }, - }; - restoreSessionTranscriptRange({ - lifecycle, sessionId, controller, readingAnchor: { turnId: 'turn-t', sequence: 10 }, - isCurrent: () => true, - setReadingAnchor: () => assert.fail('a superseded read must not clear the bookmark'), - onRestoreUnavailable: () => assert.fail('a superseded read decides nothing about the bookmark'), - onError: (error) => assert.fail(String(error)), - }); - await new Promise((resolve) => setImmediate(resolve)); -}); + fixture.commands.current!.captureAnchor('turn-1'); + assert.deepEqual(anchors.current['session-1'], { turnId: 'turn-1' }); -test('retaining the reader window trims the store to the visible Turns', async () => { - const fixture = controllerFixture(); - const retained: Array<[number | null, number | null]> = []; - fixture.controller.store.sequenceForTurn = (turnId: string, edge?: 'first' | 'last') => - turnId === 'first' ? 10 : turnId === 'last' ? (edge === 'last' ? 21 : 20) : null; - fixture.controller.store.retain = (oldest, newest) => { - retained.push([oldest, newest]); - return true; - }; - await fixture.render(); + assert.equal(fixture.commands.current!.prepareSend('session-2'), false); + assert.deepEqual(followed, []); + assert.equal(fixture.commands.current!.prepareSend('session-1'), true); + assert.deepEqual(followed, ['session-1']); + assert.equal(anchors.current['session-1'], undefined); - fixture.commands.current!.retainWindow({ firstTurnId: 'first', lastTurnId: 'last' }); - assert.deepEqual(retained, [[10, 21]]); + fixture.props.currentSessionId.current = 'session-2'; + fixture.commands.current!.captureAnchor('turn-2'); + assert.equal(anchors.current['session-1'], undefined, 'a superseded Session cannot capture an anchor'); }); function controllerFixture() { const { root } = installReactRenderer(); const commands = createRef(); const controller = { - loadAround: async (_sequence: number) => {}, - loadBefore: async () => true, - loadAfter: async () => true, - loadLatest: async () => {}, + loadEarlier: async () => {}, store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - retain: (_oldest: number | null, _newest: number | null) => false, - pendingNavigation: () => undefined, - sequenceForTurn: (_turnId: string, _edge?: 'first' | 'last'): number | null => null, - newestDurableUserSequence: () => null, - snapshot: () => ({ messages: [] }), + range: () => ({ sessionId: 'session-1', hasOlder: true, ready: true }), + snapshot: () => ({ messages: [] as StoredMessage[] }), }, }; const props: ComponentProps = { @@ -333,11 +230,7 @@ function controllerFixture() { searchTarget: undefined, clearSearchTarget: () => {}, sessionUi: createAppShellSessionUiStateController(), - turnIndex: undefined, - setTurnIndex: () => {}, - listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), onRestoreError: (error) => assert.fail(String(error)), - onNavigationError: (error) => assert.fail(String(error)), }; return { commands, controller, props, diff --git a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts index 32a04d154a..5d6257a7e8 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -23,7 +23,6 @@ import { act, createElement, createRef, Fragment, useRef, useState, type Compone import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; import type { StoredMessage } from '@maka/core/session'; -import { deferred } from '@maka/core/test-only/async-primitives'; import { TranscriptScrollAuthorityProvider, TranscriptScrollButton, @@ -37,6 +36,8 @@ import { type TranscriptReadingPositionCommands, } from '../../renderer/features/conversation/index.js'; +type VirtualizerHandle = NonNullable[0]['virtualizerRef']['current']>; + const cleanups: Array<() => Promise> = []; afterEach(async () => { while (cleanups.length) await cleanups.pop()!(); }); @@ -48,9 +49,8 @@ test('preparing a send follows the new prompt and streaming growth, then lets th assert.equal(fixture.sessionUi.transcriptReadingAnchorBySessionRef.current['session-a']?.turnId, 'history'); let prepared: boolean | undefined; - await act(async () => { prepared = await fixture.commands.current!.prepareSend('session-a'); }); + await act(() => { prepared = fixture.commands.current!.prepareSend('session-a'); }); assert.equal(prepared, true); - assert.equal(fixture.latestReads(), 1); assert.equal(fixture.pinned(), true); assert.equal(fixture.scroller.scrollTop, 2400); @@ -68,10 +68,8 @@ test('preparing a send follows the new prompt and streaming growth, then lets th assert.equal(fixture.scroller.scrollTop, 500); }); -test('reading a live Turn without a durable sequence bookmarks its Turn identity', async () => { +test('reading a live Turn bookmarks its Turn identity', async () => { const fixture = viewportFixture(); - const sequenceForTurn = fixture.controller.store.sequenceForTurn; - fixture.controller.store.sequenceForTurn = (turnId) => turnId === 'latest' ? null : sequenceForTurn(turnId); await fixture.render(); await fixture.readAt(1900); @@ -79,18 +77,12 @@ test('reading a live Turn without a durable sequence bookmarks its Turn identity assert.deepEqual(fixture.sessionUi.transcriptReadingAnchorBySessionRef.current['session-a'], { turnId: 'latest' }); }); -test('a send is accepted before latest history loads and its old completion cannot move a new Session viewport', async () => { +test('a send prepared for an old Session cannot move the new Session viewport', async () => { const fixture = viewportFixture(); - const latest = deferred(); - fixture.controller.loadLatest = () => latest.promise; await fixture.render(); - await fixture.readAt(1000); - await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - assert.equal(fixture.pinned(), true, 'local admission must not wait for the latest range'); - await fixture.switchSession('session-b'); await fixture.readAt(900); - await act(async () => { latest.resolve(); }); + await act(() => { assert.equal(fixture.commands.current!.prepareSend('session-a'), false); }); assert.equal(fixture.pinned(), false); assert.equal(fixture.scroller.scrollTop, 900); @@ -98,30 +90,13 @@ test('a send is accepted before latest history loads and its old completion cann assert.equal(fixture.scroller.scrollTop, 900); }); -for (const direction of ['older', 'newer'] as const) { - test(`filling the ${direction} edge supersedes the background range load of an accepted send`, async () => { - const fixture = viewportFixture(); - const latest = deferred(); - fixture.controller.loadLatest = () => latest.promise; - await fixture.render(); - await fixture.readAt(1000); - await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - await fixture.fillEdge(direction); - const readerTop = fixture.scroller.scrollTop; - await act(async () => { latest.resolve(); }); - - assert.equal(fixture.pinned(), false); - assert.equal(fixture.scroller.scrollTop, readerTop); - }); -} - test('a prepared send cancels an outstanding bookmark frame before it can scroll to history', async () => { const fixture = viewportFixture(); - fixture.sessionUi.setTranscriptReadingAnchor('session-a', { turnId: 'history', sequence: 0 }); + fixture.sessionUi.setTranscriptReadingAnchor('session-a', { turnId: 'history' }); await fixture.render(); assert.equal(fixture.pinned(), false); - await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); + await act(() => { assert.equal(fixture.commands.current!.prepareSend('session-a'), true); }); await fixture.flushFrames(); await fixture.append('new-question', 200); @@ -131,10 +106,7 @@ test('a prepared send cancels an outstanding bookmark frame before it can scroll test('the return-to-latest button consumes a pending bookmark frame and permits subsequent reader navigation', async () => { const fixture = viewportFixture({ returnButton: true }); - const latest = deferred(); - fixture.controller.loadLatest = () => latest.promise; - fixture.controller.store.range = () => ({ sessionId: 'session-a', hasNewer: true }); - fixture.sessionUi.setTranscriptReadingAnchor('session-a', { turnId: 'history', sequence: 0 }); + fixture.sessionUi.setTranscriptReadingAnchor('session-a', { turnId: 'history' }); await fixture.render(); assert.equal(fixture.pinned(), false); @@ -144,41 +116,8 @@ test('the return-to-latest button consumes a pending bookmark frame and permits assert.equal(fixture.scroller.scrollTop, 2400); await fixture.readAt(1000); - await act(async () => { latest.resolve(); }); await fixture.append('later-content', 200); - assert.equal(fixture.pinned(), false, 'the reader can leave while the latest range is pending'); - assert.equal(fixture.scroller.scrollTop, 1000); -}); - -test('geometry changes from the latest range do not cancel the background load of an accepted send', async () => { - const fixture = viewportFixture(); - const latest = deferred(); - fixture.controller.loadLatest = () => latest.promise; - await fixture.render(); - await fixture.readAt(1000); - await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - - await fixture.replaceRangeFromHost(); - await act(async () => { latest.resolve(); }); - - assert.deepEqual(fixture.visibleTurns(), ['latest-b']); - assert.equal(fixture.pinned(), true); - await fixture.append('new-question', 200); - assert.equal(fixture.scroller.scrollTop, 400); -}); - -test('the reader can leave an accepted send while its latest range is still loading', async () => { - const fixture = viewportFixture(); - const latest = deferred(); - fixture.controller.loadLatest = () => latest.promise; - await fixture.render(); - await fixture.readAt(1900); - await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - - await fixture.readAt(1000); - await act(async () => { latest.resolve(); }); - - assert.equal(fixture.pinned(), false); + assert.equal(fixture.pinned(), false, 'the reader can leave the tail again'); assert.equal(fixture.scroller.scrollTop, 1000); }); @@ -230,30 +169,25 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { IS_REACT_ACT_ENVIRONMENT: true, }); const messages: StoredMessage[] = []; + const turns: Array<{ id: string; start: number }> = []; const addTurn = (id: string, start: number, size: number) => { const article = document.createElement('article'); article.dataset.turnId = id; article.getBoundingClientRect = () => rectangle(start - top, size); - article.scrollIntoView = () => { scroller.scrollTop = start; }; scroller.append(article); + turns.push({ id, start }); messages.push({ id, type: 'user', turnId: id, ts: 1, text: id }); }; addTurn('history', 0, 1800); addTurn('latest', 1800, 1200); - let reads = 0; + const virtualizer = { + findItemIndex: (offset: number) => Math.max(0, turns.filter((turn) => turn.start <= offset).length - 1), + scrollToIndex: (index: number) => { scroller.scrollTop = turns[index]?.start ?? 0; }, + } as unknown as VirtualizerHandle; const controller = { - loadAround: async () => {}, loadBefore: async () => true, loadAfter: async () => true, - loadLatest: async () => { reads += 1; }, + loadEarlier: async () => {}, store: { - sessionId: 'session-a', - range: () => ({ sessionId: 'session-a' }), - retain: () => false, - pendingNavigation: () => undefined, - sequenceForTurn: (turnId: string) => { - const sequence = messages.findIndex((message) => message.turnId === turnId); - return sequence < 0 ? null : sequence; - }, - newestDurableUserSequence: () => 1, + range: () => ({ sessionId: 'session-a', hasOlder: false, ready: true }), snapshot: () => ({ messages }), }, }; @@ -263,25 +197,24 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { commands, sessionId: 'session-a', currentSessionId: { current: 'session-a' }, rangeController: { current: controller }, messages, sessionUi, searchTarget: undefined, clearSearchTarget: () => {}, - turnIndex: undefined, setTurnIndex: () => {}, - listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), - onRestoreError: (error) => assert.fail(String(error)), onNavigationError: (error) => assert.fail(String(error)), + onRestoreError: (error) => assert.fail(String(error)), }; let authority: TranscriptScrollAuthority | undefined; function Harness() { const scrollRef = useRef(scroller); + const virtualizerRef = useRef(virtualizer); authority = useTranscriptScrollAuthority(); const anchor = sessionUi.transcriptReadingAnchorBySessionRef.current[props.sessionId!]; useChatScroll({ - scrollRef, sessionId: props.sessionId, messages: props.messages, + scrollRef, virtualizerRef, sessionId: props.sessionId, + turnIds: props.messages.map((message) => message.turnId!), + measureStartMargin: () => 0, restoreTarget: anchor, viewportNavigation: sessionUi.transcriptViewportNavigation, onReadingAnchorChange: (turnId) => commands.current?.captureAnchor(turnId), behavior: 'auto', }); return createElement(Fragment, null, createElement(TranscriptReadingPositionController, props), - options.returnButton ? createElement(TranscriptScrollButton, { - onActivate: () => commands.current?.returnToLatest(), - }) : null, + options.returnButton ? createElement(TranscriptScrollButton) : null, ); } const root = createRoot(mount); @@ -289,18 +222,12 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { const render = () => act(() => root.render(createElement(TranscriptScrollAuthorityProvider, null, createElement(Harness)))); return { scroller, controller, sessionUi, commands, render, - pinned: () => authority!.getSnapshot().pinned, latestReads: () => reads, - visibleTurns: () => props.messages.map((message) => message.turnId), + pinned: () => authority!.getSnapshot().pinned, async clickReturnToLatest() { const button = mount.querySelector('button'); assert.ok(button); await act(() => { button.dispatchEvent(new window.Event('click', { bubbles: true })); }); }, - async fillEdge(edge: 'older' | 'newer') { - // A reader who scrolls to an edge releases the pin, and the band fills - // that edge behind them. - await act(async () => { authority!.releasePin(); await commands.current!.prefetchHistory(edge); }); - }, async readAt(offset: number) { await act(() => { const input = new window.Event('wheel'); @@ -318,22 +245,12 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { await render(); await act(() => { for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); }); }, - async replaceRangeFromHost() { - // The browser clamps the old offset when a much shorter range arrives. - // ResizeObserver changes awayFromTail, without a reader scroll gesture. - height = 800; - messages.splice(0); - scroller.replaceChildren(); - addTurn('latest-b', 0, 800); - scroller.scrollTop = scroller.scrollTop; - props.messages = [...messages]; - await render(); - await act(() => { for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); }); - }, async switchSession(sessionId: string) { props.sessionId = sessionId; props.currentSessionId.current = sessionId; - props.rangeController.current = { ...controller, store: { ...controller.store, sessionId, range: () => ({ sessionId }) } }; + props.rangeController.current = { + ...controller, store: { ...controller.store, range: () => ({ sessionId, hasOlder: false, ready: true }) }, + }; await render(); }, async flushFrames() { diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts b/apps/desktop/src/main/__tests__/transcript-tail-restore.test.ts similarity index 58% rename from apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts rename to apps/desktop/src/main/__tests__/transcript-tail-restore.test.ts index ae626e671d..77928fbac1 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-tail-restore.test.ts @@ -24,7 +24,7 @@ import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage, } from '@maka/runtime-host/protocol'; -import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../../preload/transcript-contract.js'; +import { DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES } from '../../preload/transcript-contract.js'; import { createTranscriptRestoreLifecycle, restoreSessionTranscriptRange, @@ -32,24 +32,21 @@ import { import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; -const PAGE_BYTES = 128 * 1024; - test('a history page reaches an oversized earlier Turn without disturbing the tail', async () => { const fixture = await oversizedHistoryFixture(); try { assert.deepEqual(sequences(fixture.replica), [2, 3]); - const page = await fixture.replica.loadBefore(2, PAGE_BYTES); + const page = await fixture.replica.readOlderPage(3, 'more'); - assert.ok(page); assert.deepEqual(page.durable.map(({ sequence }) => sequence), [0, 1]); assert.equal(page.durable[1]?.message.id, 'assistant-a', 'the oversized earlier answer reaches the Renderer whole'); - assert.equal(page.hasOlder, false); + assert.equal(page.nextCursor, null); assert.deepEqual( sequences(fixture.replica), [2, 3], - 'a window read answers the Renderer and leaves the Main tail alone', + 'a history read answers its consumer and leaves the Main tail alone', ); } finally { fixture.replica.close(); @@ -77,118 +74,65 @@ test('tail catch-up evicts only the oldest Turns and always keeps the newest com } }); -test('a completed resident bookmark does not reload after streaming settlement evicts its Turn', async () => { - const fixture = await oversizedHistoryFixture({ live: true }); +test('a completed loaded bookmark does not load earlier history after later notifications', async () => { const lifecycle = createTranscriptRestoreLifecycle(); - let loaded = 0; - const controller = { - loadAround: async (sequence: number) => { - loaded += 1; - await fixture.replica.loadAround(sequence, PAGE_BYTES); - }, - store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - pendingNavigation: () => undefined, - sequenceForTurn: (turnId: string) => fixture.replica.snapshot().durable - .find(({ message }) => message.turnId === turnId)?.sequence ?? null, - newestDurableUserSequence: () => 2, - snapshot: () => ({ messages: fixture.replica.messages() }), - }, - }; + const history = restoreHistory([{ turnId: 'turn-a' }]); const restore = () => restoreSessionTranscriptRange({ lifecycle, sessionId: 'session-1', - readingAnchor: { turnId: 'turn-a', sequence: 0 }, - controller, + readingAnchor: { turnId: 'turn-a' }, + controller: history.controller, isCurrent: () => true, setReadingAnchor: () => {}, onError: (error) => assert.fail(String(error)), }); - try { - restore(); - await settleRestore(); - assert.equal(loaded, 0, 'an already resident bookmark completes without a range read'); - - await fixture.replica.advance(3); - restore(); - await settleRestore(); - await fixture.replica.advance(4); - restore(); - await settleRestore(); - - assert.equal(loaded, 0, 'message notifications cannot revive the completed bookmark command'); - assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); - assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-b-later'); - } finally { - fixture.replica.close(); - } + restore(); + await settleRestore(); + history.messages = [{ turnId: 'turn-b' }]; + restore(); + await settleRestore(); + assert.equal(history.loads, 0, 'a loaded bookmark completes without reading history, and stays completed'); }); -test('reopening a bookmark at the current Turn retains content persisted later in that same Turn', async () => { - const fixture = await oversizedHistoryFixture(); - try { - assert.deepEqual(sequences(fixture.replica), [2, 3]); - await fixture.replica.advance(4); - assert.equal(fixture.replica.durableThrough, 4, 'the Host has persisted the final answer segment'); - - // Reopening an observed Session reuses its resident replica. The renderer - // starts a fresh restore lifecycle, and the bookmark is already resident. - restoreSessionTranscriptRange({ - lifecycle: createTranscriptRestoreLifecycle(), - sessionId: 'session-1', - readingAnchor: { turnId: 'turn-b', sequence: 2 }, - controller: { - loadAround: async (sequence) => { await fixture.replica.loadAround(sequence, PAGE_BYTES); }, - store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - pendingNavigation: () => undefined, - sequenceForTurn: (turnId) => fixture.replica.snapshot().durable - .find(({ message }) => message.turnId === turnId)?.sequence ?? null, - newestDurableUserSequence: () => 2, - snapshot: () => ({ messages: fixture.replica.messages() }), - }, - }, - isCurrent: () => true, - setReadingAnchor: () => {}, - onError: (error) => assert.fail(String(error)), - }); - await settleRestore(); - - assert.equal( - fixture.replica.messages().some(({ id }) => id === 'assistant-b-later'), - true, - 'restoring the visible Turn must not silently omit its later persisted answer segment', - ); - } finally { - fixture.replica.close(); - } +test('a bookmark older than the loaded transcript loads earlier history until its Turn arrives', async () => { + let anchor: { turnId: string } | undefined = { turnId: 'turn-a' }; + const history = restoreHistory([{ turnId: 'turn-c' }], async () => { + history.messages = history.loads === 1 + ? [{ turnId: 'turn-b' }, { turnId: 'turn-c' }] + : [{ turnId: 'turn-a' }, { turnId: 'turn-b' }, { turnId: 'turn-c' }]; + }); + restoreSessionTranscriptRange({ + lifecycle: createTranscriptRestoreLifecycle(), + sessionId: 'session-1', + readingAnchor: { turnId: 'turn-a' }, + controller: history.controller, + isCurrent: () => true, + setReadingAnchor: (_sessionId, next) => { anchor = next; }, + onRestoreUnavailable: () => assert.fail('the bookmark was reachable'), + onError: (error) => assert.fail(String(error)), + }); + await settleRestore(); + await settleRestore(); + assert.equal(history.loads, 2); + assert.deepEqual(anchor, { turnId: 'turn-a' }); }); test('repeated message notifications share one pending restore and cancellation preserves the newer bookmark', async () => { const lifecycle = createTranscriptRestoreLifecycle(); let finishLoad!: () => void; const loading = new Promise((resolve) => { finishLoad = resolve; }); - let reads = 0; - let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'turn-a', sequence: 0 }; + let anchor: { turnId: string } | undefined = { turnId: 'turn-a' }; let unavailable: string | undefined; + const history = restoreHistory([], async () => { + await loading; + history.hasOlder = false; + history.messages = []; + }); const options = { lifecycle, sessionId: 'session-1', - readingAnchor: { turnId: 'turn-a', sequence: 0 }, - controller: { - setReadingAnchor: async () => {}, - loadAround: async () => { reads += 1; await loading; }, - store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => 2, - snapshot: () => ({ messages: ['old restored range'] }), - }, - }, + readingAnchor: { turnId: 'turn-a' }, + controller: history.controller, isCurrent: () => true, setReadingAnchor: (_sessionId: string, next: typeof anchor) => { anchor = next; }, onRestoreUnavailable: (_sessionId: string, turnId: string) => { unavailable = turnId; }, @@ -197,99 +141,82 @@ test('repeated message notifications share one pending restore and cancellation restoreSessionTranscriptRange(options); restoreSessionTranscriptRange(options); await settleRestore(); - assert.equal(reads, 1); + assert.equal(history.loads, 1); lifecycle.cancel('session-1'); - anchor = { turnId: 'turn-b', sequence: 2 }; + anchor = { turnId: 'turn-b' }; finishLoad(); await settleRestore(); restoreSessionTranscriptRange(options); await settleRestore(); - assert.equal(reads, 1, 'cancellation must not recapture the bookmark in the same activation'); - assert.deepEqual(anchor, { turnId: 'turn-b', sequence: 2 }); + assert.equal(history.loads, 1, 'cancellation must not recapture the bookmark in the same activation'); + assert.deepEqual(anchor, { turnId: 'turn-b' }); assert.equal(unavailable, undefined, 'a cancelled restore cannot declare the newer bookmark unavailable'); }); test('switching away and back creates a fresh restore while clearing search does not replay a bookmark', async () => { const lifecycle = createTranscriptRestoreLifecycle(); - const reads: number[] = []; + const history = restoreHistory([]); const options = { lifecycle, sessionId: 'session-1', profileId: 'profile-1', - readingAnchor: { turnId: 'turn-a', sequence: 0 }, - controller: { - setReadingAnchor: async () => {}, - loadAround: async (sequence: number) => { reads.push(sequence); }, - store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => 2, - snapshot: () => ({ messages: [] as string[] }), - }, - }, + readingAnchor: { turnId: 'turn-a' }, + controller: history.controller, isCurrent: () => true, setReadingAnchor: () => {}, onError: (error: unknown) => assert.fail(String(error)), }; restoreSessionTranscriptRange(options); await settleRestore(); - restoreSessionTranscriptRange({ ...options, searchTarget: { - sessionId: 'session-1', turnId: 'turn-b', sequence: 2, nonce: 1, - } }); + restoreSessionTranscriptRange({ ...options, searchTarget: { sessionId: 'session-1', turnId: 'turn-b', nonce: 1 } }); await settleRestore(); restoreSessionTranscriptRange(options); await settleRestore(); - assert.deepEqual(reads, [0, 2]); + assert.equal(history.loads, 2); restoreSessionTranscriptRange({ ...options, sessionId: 'other-session', controller: undefined }); restoreSessionTranscriptRange(options); await settleRestore(); - assert.deepEqual(reads, [0, 2, 0], 'a later session activation may restore the saved bookmark again'); + assert.equal(history.loads, 3, 'a later session activation may restore the saved bookmark again'); restoreSessionTranscriptRange({ ...options, profileId: 'profile-2' }); await settleRestore(); - assert.deepEqual(reads, [0, 2, 0, 0], 'changing Hosts also creates a fresh activation'); + assert.equal(history.loads, 4, 'changing Hosts also creates a fresh activation'); }); test('effect teardown followed by setup lets only the replacement restore settle its bookmark', async () => { const lifecycle = createTranscriptRestoreLifecycle(); const loads: Array<() => void> = []; - let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'turn-a', sequence: 0 }; + let anchor: { turnId: string } | undefined = { turnId: 'turn-a' }; let unavailable: string | undefined; + const history = restoreHistory([], () => new Promise((resolve) => { + loads.push(() => { + history.hasOlder = false; + history.messages = []; + resolve(); + }); + })); const options = { lifecycle, sessionId: 'session-1', - readingAnchor: { turnId: 'turn-a', sequence: 0 }, - controller: { - setReadingAnchor: async () => {}, - loadAround: () => new Promise((resolve) => { loads.push(resolve); }), - store: { - sessionId: 'session-1', - range: () => ({ sessionId: 'session-1' }), - pendingNavigation: () => undefined, - sequenceForTurn: () => null, - newestDurableUserSequence: () => 2, - snapshot: () => ({ messages: ['replacement range'] }), - }, - }, + readingAnchor: { turnId: 'turn-a' }, + controller: history.controller, isCurrent: () => true, setReadingAnchor: (_sessionId: string, next: typeof anchor) => { anchor = next; }, onRestoreUnavailable: (_sessionId: string, turnId: string) => { unavailable = turnId; }, onError: (error: unknown) => assert.fail(String(error)), }; restoreSessionTranscriptRange(options); - assert.equal(loads.length, 1, 'the first command admits its navigation synchronously'); + assert.equal(loads.length, 1, 'the first command loads earlier history synchronously'); lifecycle.deactivate(); restoreSessionTranscriptRange(options); - assert.equal(loads.length, 2, 'StrictMode replay must admit a replacement navigation'); + assert.equal(loads.length, 2, 'StrictMode replay must start a replacement load'); loads[0]!(); await settleRestore(); - assert.deepEqual(anchor, { turnId: 'turn-a', sequence: 0 }); + assert.deepEqual(anchor, { turnId: 'turn-a' }); assert.equal(unavailable, undefined, 'the deactivated command cannot settle after replacement'); loads[1]!(); await settleRestore(); @@ -297,10 +224,6 @@ test('effect teardown followed by setup lets only the replacement restore settle assert.equal(unavailable, 'turn-a', 'only the replacement restore settles its unavailable target'); }); -async function settleRestore(): Promise { - await new Promise((resolve) => setImmediate(resolve)); -} - test('a live second Turn remains reachable after persistence evicts the oversized first Turn', async () => { const fixture = await oversizedHistoryFixture({ live: true }); try { @@ -316,12 +239,37 @@ test('a live second Turn remains reachable after persistence evicts the oversize assert.equal(answer?.type, 'assistant'); assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'Second answer, persisted completely.'); assert.equal(fixture.replica.snapshot().hasOlder, true); - assert.equal(fixture.replica.snapshot().hasNewer, false); } finally { fixture.replica.close(); } }); +async function settleRestore(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +/** A loaded transcript whose earlier history is read by `load`; every read changes the snapshot. */ +function restoreHistory(initial: Array<{ turnId: string }>, load: () => Promise = async () => {}) { + let snapshot = { messages: initial }; + const history = { + loads: 0, + hasOlder: true, + get messages() { return snapshot.messages; }, + set messages(messages: Array<{ turnId: string }>) { snapshot = { messages }; }, + controller: { + store: { + range: () => ({ sessionId: 'session-1', hasOlder: history.hasOlder, ready: true }), + snapshot: () => snapshot, + }, + loadEarlier: async () => { + history.loads += 1; + await load(); + }, + }, + }; + return history; +} + function sequences(replica: DesktopTranscriptReplica): number[] { return replica.snapshot().durable.map(({ sequence }) => sequence); } @@ -329,12 +277,12 @@ function sequences(replica: DesktopTranscriptReplica): number[] { async function oversizedHistoryFixture(options: { live?: boolean } = {}) { const records = [ message('user', 'user-a', 'turn-a', 'First question.'), - message('assistant', 'assistant-a', 'turn-a', 'A'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1)), + message('assistant', 'assistant-a', 'turn-a', 'A'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1)), message('user', 'user-b', 'turn-b', 'Second question.'), message('assistant', 'assistant-b', 'turn-b', 'Second answer, persisted completely.'), message('assistant', 'assistant-b-later', 'turn-b', 'A later durable answer segment.'), message('user', 'user-c', 'turn-c', 'Third question while the reader stays in the second Turn.'), - message('assistant', 'assistant-c', 'turn-c', 'C'.repeat(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + 1)), + message('assistant', 'assistant-c', 'turn-c', 'C'.repeat(DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES + 1)), ].map((message, identity) => ({ identity, message })); const decodedPages = new Map(); const page = (input: { @@ -342,7 +290,6 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) { through: number; records: typeof records; hasMore: boolean; - protectedSequence: number | null; }): SessionTranscriptPage => { const result: SessionTranscriptPage = { kind: 'page', @@ -352,10 +299,6 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) { throughSequence: input.through, rawBytes: input.records.reduce((bytes, record) => bytes + Buffer.byteLength(JSON.stringify(record.message)), 0), fragments: [], - rangeBoundarySequence: input.direction === 'older' - ? input.records[0]?.identity ?? null - : input.records.at(-1)?.identity ?? null, - protectedTurnSequence: input.protectedSequence, nextCursor: input.hasMore ? 'more' : null, }; decodedPages.set(result, { messages: input.records, nextCursor: result.nextCursor }); @@ -367,9 +310,7 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) { through, records: options.live ? records.slice(0, 2) : records.slice(2, 4), hasMore: !options.live, - protectedSequence: options.live ? 0 : 2, }); - const requests: Array<{ direction: string; anchorSequence: number | null; throughSequence: number | null }> = []; const handle = runtimeHostSessionFixture({ snapshot: { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, @@ -398,22 +339,17 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) { }, loadTranscriptPage: async (request) => { const through = request.throughSequence ?? 4; - const anchor = request.anchorSequence ?? null; - requests.push({ direction: request.direction, anchorSequence: anchor, throughSequence: through }); - const history = request.direction === 'older' ? anchor === 2 : anchor === null; - return page({ - direction: request.direction, - through, - records: history ? records.slice(0, 2) - : request.direction === 'older' ? records.slice(2, through + 1) - : records.slice((anchor ?? -1) + 1, through + 1), - hasMore: history ? request.direction === 'newer' : request.direction === 'older', - protectedSequence: history ? 0 : through >= 5 ? 5 : 2, - }); + if (request.direction === 'older') { + return request.cursor === null + ? page({ direction: 'older', through, records: records.slice(2, through + 1), hasMore: true }) + : page({ direction: 'older', through, records: records.slice(0, 2), hasMore: false }); + } + const anchor = request.anchorSequence ?? -1; + return page({ direction: 'newer', through, records: records.slice(anchor + 1, through + 1), hasMore: false }); }, async close() {}, }); - return { replica: await DesktopTranscriptReplica.prepare(handle), requests }; + return { replica: await DesktopTranscriptReplica.prepare(handle) }; } function message( diff --git a/apps/desktop/src/main/__tests__/transcript-test-dom.ts b/apps/desktop/src/main/__tests__/transcript-test-dom.ts new file mode 100644 index 0000000000..ea67a01957 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-test-dom.ts @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The virtualized transcript mounts Turns only after its scroller reports a + * size, so static server markup contains none. This client-renders on LinkeDOM + * with a ResizeObserver that gives the scroller a tall viewport, so every Turn + * mounts. Globals and the shared LinkeDOM prototype are restored afterwards. + */ + +import { act, type ReactElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; + +const GLOBAL_KEYS = [ + 'CSS', + 'Element', + 'HTMLElement', + 'IS_REACT_ACT_ENVIRONMENT', + 'IntersectionObserver', + 'MutationObserver', + 'Node', + 'ResizeObserver', + 'cancelAnimationFrame', + 'document', + 'getComputedStyle', + 'matchMedia', + 'requestAnimationFrame', + 'window', +] as const; + +const GEOMETRY_KEYS = ['offsetParent', 'scrollTop', 'scrollHeight', 'clientHeight'] as const; + +/** Client-rendered markup of `element`, with every Turn of a transcript mounted. */ +export async function renderTranscriptMarkup(element: ReactElement): Promise { + const originals = new Map( + GLOBAL_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ); + const { document, window } = parseHTML('
'); + const prototype = window.HTMLElement.prototype; + const prototypeOriginals = new Map( + GEOMETRY_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(prototype, key)]), + ); + const scrollTops = new WeakMap(); + Object.defineProperties(prototype, { + offsetParent: { configurable: true, get(this: HTMLElement) { return this.parentElement; } }, + scrollTop: { + configurable: true, + get(this: HTMLElement) { return scrollTops.get(this) ?? 0; }, + set(this: HTMLElement, value: number) { scrollTops.set(this, Math.max(0, value)); }, + }, + scrollHeight: { configurable: true, get: () => 0 }, + clientHeight: { configurable: true, get: () => 0 }, + }); + class MeasuringResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + queueMicrotask(() => { + const height = target.hasAttribute('data-chat-scroll-container') ? 100_000 : 100; + this.callback( + [{ target, contentRect: { height, width: 800 } } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + }); + } + unobserve(): void {} + disconnect(): void {} + } + class InertObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + takeRecords(): [] { return []; } + } + Object.assign(window, { ResizeObserver: MeasuringResizeObserver }); + Object.assign(globalThis, { + CSS: { escape: String, supports: () => false }, + Element: window.Element, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + IntersectionObserver: InertObserver, + MutationObserver: InertObserver, + Node: window.Node, + ResizeObserver: MeasuringResizeObserver, + cancelAnimationFrame: () => undefined, + document, + getComputedStyle: () => ({ overflowY: 'visible' }), + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 0, + window, + }); + const container = document.querySelector('#root')!; + const root = createRoot(container); + try { + await act(async () => { root.render(element); }); + // Viewport measurement mounts the Turns; their own measurement settles them. + await act(async () => {}); + await act(async () => {}); + return container.innerHTML; + } finally { + await act(async () => { root.unmount(); }); + for (const [key, descriptor] of prototypeOriginals) { + if (descriptor) Object.defineProperty(prototype, key, descriptor); + else delete (prototype as unknown as Record)[key]; + } + for (const [key, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + } +} diff --git a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts index fb5cb24477..d64678990a 100644 --- a/apps/desktop/src/main/__tests__/transient-message-projection.test.ts +++ b/apps/desktop/src/main/__tests__/transient-message-projection.test.ts @@ -103,21 +103,6 @@ test('keeps transient messages ordered independently from a sparse durable tail' assert.deepEqual(projected.map((message) => message.id), ['message-1']); }); -test('keeps a transient message out of a sparse historical range', () => { - const live = { ...transient, id: 'message-live', text: 'latest prompt' }; - const pending = new Map([[live.id, live]]); - const historical: StoredMessage[] = [ - { type: 'user', id: 'message-old', turnId: 'turn-old', ts: 1, text: 'old prompt' }, - ]; - - const projected = reconcileTransientMessages(pending, historical, { - includeTransient: false, - }); - - assert.deepEqual(projected, []); - assert.equal(pending.has('message-live'), true); -}); - test('uses the Host queue snapshot order for already-present transient messages', () => { const localSecond = { ...transient, diff --git a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts index 7ba1ebae65..90f653fef3 100644 --- a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts @@ -33,6 +33,7 @@ import { import { ChatSurfaceLayout, LocaleProvider } from '@maka/ui'; import { WorkHubConversation, WorkHubDelegationStatus } from '../../renderer/features/workhub/testing.js'; import { getWorkHubRailCopy } from "../../renderer/locales/workhub-copy.js"; +import { renderTranscriptMarkup } from './transcript-test-dom.js'; import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session'; test('durable task results restore Host-scoped work links without treating failed or unrelated tools as delegations', () => { @@ -187,8 +188,8 @@ test("focus display is derived from the selected Session ID, not delegation prio }); -test('a shared coordination turn keeps every Work label without assigning one Work color to the whole turn', () => { - const markup = renderToStaticMarkup(createElement(LocaleProvider, { locale: 'en', children: null }, +test('a shared coordination turn keeps every Work label without assigning one Work color to the whole turn', async () => { + const markup = await renderTranscriptMarkup(createElement(LocaleProvider, { locale: 'en', children: null }, createElement(ChatSurfaceLayout, { composer: null, children: null }, createElement(WorkHubConversation, { activeSession: { id: 'coordination', name: 'WorkHub', status: 'active', labels: [], isFlagged: false, isArchived: false, hasUnread: false, backend: 'ai-sdk', llmConnectionSlug: 'test', connectionLocked: false, model: 'test', permissionMode: 'ask' }, messages: [{ type: 'user', id: 'user', turnId: 'shared', text: 'Do both tasks', ts: 1 }], diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index 2f6559df9c..146127d9cb 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -27,12 +27,11 @@ import { build } from 'esbuild'; import { deferred, waitFor } from '@maka/core/test-only/async-primitives'; import type { StoredMessage } from '@maka/core/session'; import type { MakaBridge } from '../../preload/bridge-contract.js'; -import type { DesktopTranscriptBatch, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; +import type { DesktopTranscriptBatch } from '../../preload/transcript-contract.js'; import { createDesktopWorkHubServices } from '../../renderer/platform/desktop/create-workhub-services.js'; -import type { WorkHubTranscriptSnapshot } from '../../renderer/features/workhub/index.js'; import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; import type { WorkHubPrepareAttachmentsResult } from '../../shared/workhub-conversation.js'; -import { encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { encodeDesktopTranscriptBatches, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { AttachmentIngestBlockedError } from '@maka/core/attachments'; import type { AttachmentRef } from '@maka/core/events'; import { MESSAGE_QUEUE_MAX_ENTRIES } from '@maka/runtime-host/protocol'; @@ -125,125 +124,7 @@ test('WorkHub upload references round-trip through idle answers, both queue mode await assert.rejects(services.enqueueMessage(sessionId, 'foreign', 'read this', foreign, 'next_turn'), /another Host or Session/); }); -test('WorkHub projects the exact delegated Turn status and bounded assistant result', async (t) => { - const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); - Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); - t.after(() => { - if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); - else Reflect.deleteProperty(globalThis, 'window'); - }); - const sessionId = desktopSessionKey({ hostId: 'owner-host', sessionId: 'target-session' }); - const result: StoredMessage = { - type: 'assistant', id: 'answer', turnId: 'owned-turn', ts: 3, - modelId: 'model', text: 'The delegated task finished with this exact result.', - }; - const services = createDesktopWorkHubServices({ - attachments: {}, - sessions: { - async list() { - return [{ - id: sessionId, name: 'Target task', isFlagged: false, isArchived: false, - labels: [], hasUnread: false, status: 'active', runningTurnIds: [], revision: 1, - }]; - }, - async listTurns() { - return [{ turnId: 'owned-turn', firstSequence: 1, status: 'completed', statusSource: 'recorded' }]; - }, - async queryMessageExecutions() { - return { resolutions: [{ messageId: 'delegated-message', state: 'owned', turnId: 'owned-turn', runId: 'run' }] }; - }, - }, - transcripts: { - async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { - const snapshot = { - sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 1, overlay: [], hasOlder: false, hasNewer: false, - }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, durable: [{ sequence: 1, message: result }], - })) onBatch({ ...batch, deliverySequence: 1 }); - return { - ...snapshot, readThroughMessageId: result.id, - loadBefore: async () => undefined, loadAfter: async () => undefined, - loadAround: async () => undefined, close: async () => undefined, - }; - }, - }, - } as unknown as Parameters[0]); - - assert.deepEqual(await services.delegationFeedback([{ - id: 'delegation-record', targetSessionId: sessionId, - targetMessageId: 'delegated-message', targetTurnId: 'initial-turn', - }]), [{ - id: 'delegation-record', state: 'completed', - resultPreview: 'The delegated task finished with this exact result.', - }]); -}); - -test('delegation feedback does not advance the target Session read marker', async (t) => { - const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); - Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); - t.after(() => { - if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); - else Reflect.deleteProperty(globalThis, 'window'); - }); - const sessionId = desktopSessionKey({ hostId: 'owner-host', sessionId: 'target-session' }); - const result: StoredMessage = { - type: 'assistant', id: 'answer', turnId: 'owned-turn', ts: 3, - modelId: 'model', text: 'The delegated task finished with this exact result.', - }; - const later: StoredMessage = { - type: 'user', id: 'later', turnId: 'next-turn', ts: 4, text: 'A later turn nobody has read.', - }; - const acknowledged: number[] = []; - const services = createDesktopWorkHubServices({ - attachments: {}, - sessions: { - async list() { - return [{ - id: sessionId, name: 'Target task', isFlagged: false, isArchived: false, - labels: [], hasUnread: true, status: 'active', runningTurnIds: [], revision: 1, - }]; - }, - async listTurns() { - return [{ turnId: 'owned-turn', firstSequence: 1, status: 'completed', statusSource: 'recorded' }]; - }, - async queryMessageExecutions() { - return { resolutions: [{ messageId: 'delegated-message', state: 'owned', turnId: 'owned-turn', runId: 'run' }] }; - }, - }, - transcripts: { - async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { - // The real open answers over IPC, so its first batches reach a consumer - // that is already listening. - await Promise.resolve(); - const snapshot = { - sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 2, overlay: [], hasOlder: false, hasNewer: false, - }; - for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, - durable: [{ sequence: 1, message: result }, { sequence: 2, message: later }], - })) onBatch({ ...batch, deliverySequence: 1 }); - return { - ...snapshot, readThroughMessageId: later.id, - async acknowledgeTail(through: number) { acknowledged.push(through); }, - loadBefore: async () => undefined, loadAfter: async () => undefined, - loadAround: async () => undefined, close: async () => undefined, - }; - }, - }, - } as unknown as Parameters[0]); - - assert.equal((await services.delegationFeedback([{ - id: 'delegation-record', targetSessionId: sessionId, - targetMessageId: 'delegated-message', targetTurnId: 'initial-turn', - }]))[0]?.resultPreview, result.text); - await new Promise((resolve) => { setImmediate(resolve); }); - assert.deepEqual(acknowledged, [], 'a result projection is not a reader of the target Session'); -}); - -test('WorkHub proves a long historical Turn tail before caching its final result', async (t) => { +test('WorkHub projects a delegated Turn result from one Turn read without opening the target transcript', async (t) => { const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); t.after(() => { @@ -257,21 +138,16 @@ test('WorkHub proves a long historical Turn tail before caching its final result }; const final: StoredMessage = { type: 'assistant', id: 'final', turnId: 'owned-turn', ts: 2, - modelId: 'model', text: 'Final answer after the historical Turn boundary.', - }; - const next: StoredMessage = { - type: 'user', id: 'next', turnId: 'next-turn', ts: 3, text: 'Later turn', + modelId: 'model', text: 'The delegated task finished with this exact result.', }; - let deliverySequence = 0; - let opens = 0; - let loadAfters = 0; + const turnReads: string[] = []; const services = createDesktopWorkHubServices({ attachments: {}, sessions: { async list() { return [{ id: sessionId, name: 'Target task', isFlagged: false, isArchived: false, - labels: [], hasUnread: false, status: 'active', runningTurnIds: [], revision: 1, + labels: [], hasUnread: true, status: 'active', runningTurnIds: [], revision: 1, }]; }, async listTurns() { @@ -282,44 +158,13 @@ test('WorkHub proves a long historical Turn tail before caching its final result }, }, transcripts: { - async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { - opens += 1; - const emit = ( - navigation: number | undefined, - durable: Array<{ sequence: number; message: StoredMessage }>, - hasOlder: boolean, - hasNewer: boolean, - ) => { - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 4, overlay: [], hasOlder, hasNewer, durable, - }, navigation)) onBatch({ ...batch, deliverySequence: ++deliverySequence }); - }; - emit(undefined, [{ sequence: 4, message: { ...next, id: 'tail', ts: 4 } }], true, false); - return { - sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 4, hasOlder: true, hasNewer: false, readThroughMessageId: 'tail', - loadBefore: async () => undefined, - async loadAround(_sequence: number | null, _maxBytes: number | undefined, navigation: number) { - emit(navigation, [{ sequence: 1, message: intermediate }], false, true); - }, - async loadAfter(anchor: number | null, _maxBytes: number | undefined, navigation: number) { - loadAfters += 1; - assert.equal(anchor, 1); - // An extension splices onto the window; only a navigation replaces it. - for (const batch of encodeDesktopTranscriptPage({ - sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - navigation, - }, { - durableThrough: 4, hasNewer: true, - durable: [ - { sequence: 2, message: final }, - { sequence: 3, message: next }, - ], - }, { direction: 'newer', anchor })) onBatch({ ...batch, deliverySequence: ++deliverySequence }); - }, - close: async () => undefined, - }; + async readTurn(requestedSessionId: string, turnId: string) { + assert.equal(requestedSessionId, sessionId); + turnReads.push(turnId); + return [intermediate, final]; + }, + async open() { + assert.fail('a result projection is not a reader of the target Session'); }, }, } as unknown as Parameters[0]); @@ -328,10 +173,11 @@ test('WorkHub proves a long historical Turn tail before caching its final result targetMessageId: 'delegated-message', targetTurnId: 'initial-turn', }; + assert.deepEqual(await services.delegationFeedback([reference]), [{ + id: 'delegation-record', state: 'completed', resultPreview: final.text, + }]); assert.equal((await services.delegationFeedback([reference]))[0]?.resultPreview, final.text); - assert.equal((await services.delegationFeedback([reference]))[0]?.resultPreview, final.text); - assert.equal(opens, 1); - assert.equal(loadAfters, 1); + assert.deepEqual(turnReads, ['owned-turn'], 'the final result is cached'); }); test('WorkHub batches more than the message execution query limit per target Session', async (t) => { @@ -398,31 +244,30 @@ test('WorkHub does not infer live running when the Session catalog is unavailabl }); // Keep the real preload in this consumer regression; the IPC stub models the -// observer's authoritative reset reply to a latest command. -test('WorkHub tail navigation converges through the preload with a fragmented sparse tail', { timeout: 5_000 }, async (t) => { +// observer's earlier-history answer to a load-earlier command. +test('WorkHub loads earlier history through the preload with a fragmented answer', { timeout: 5_000 }, async (t) => { const owner = { hostId: 'owner-host', targetEpoch: 'owner-epoch', profileId: 'local', profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready', }; const sessionId = desktopSessionKey({ hostId: owner.hostId, sessionId: 'coordination' }); - const snapshot = { - sessionId: 'coordination', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 8, overlay: [], hasOlder: true, hasNewer: false, + const identity = { sessionId: 'coordination', generation: 'generation-1', hostEpoch: 'epoch-1' }; + const tail: StoredMessage = { + type: 'user', id: 'tail-message', turnId: 'tail-turn', ts: 8, text: 'Tail coordination record', }; - const message: StoredMessage = { - type: 'user', id: 'latest-message', turnId: 'latest-turn', ts: 7, - text: 'Latest coordination record '.repeat(8_000), + const earlier: StoredMessage = { + type: 'user', id: 'earlier-message', turnId: 'earlier-turn', ts: 7, + text: 'Earlier coordination record '.repeat(8_000), }; - const requests: DesktopTranscriptRangeRequest[] = []; - const projections: string[][] = []; + const earlierReads: unknown[] = []; + const projections: Array<{ ids: string[]; hasOlder: boolean }> = []; const partialProjectionCounts: number[] = []; let bridge: MakaBridge | undefined; let consumerId: string; let deliverySequence = 0; let deliverDirect: ((batch: DesktopTranscriptBatch) => void) | undefined; const listeners = new Map void>(); - let finishResponse!: () => void; - const responseDelivered = new Promise((resolve) => { finishResponse = resolve; }); + const responseDelivered = deferred(); const deliver = (batch: Omit) => { listeners.get(`sessions:transcript:${consumerId}`)?.({}, owner, { ...batch, deliverySequence: ++deliverySequence, @@ -438,40 +283,46 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp if (channel === 'session-local:transcript') return null; if (channel === 'sessions:transcript:open') { consumerId = args[2] as string; - for (const batch of encodeDesktopTranscriptSnapshot({ ...snapshot, durable: [] })) { - deliver(batch); - } - return { kind: 'ready', value: { ...snapshot, readThroughMessageId: null } }; + assert.equal(args[3], 'history'); + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 8, overlay: [], hasOlder: true, + durable: [{ sequence: 8, message: tail }], + })) deliver(batch); + return { kind: 'ready', value: { ...identity, readThroughMessageId: null } }; } - if (channel === 'sessions:transcript:load-latest') { - const request = args[1] as DesktopTranscriptRangeRequest; - requests.push(request); + if (channel === 'sessions:transcript:load-earlier') { + earlierReads.push(args[1]); + assert.equal(args[1], consumerId); // Bound a regressed request loop so the test reports its cause. - if (requests.length >= 3) return new Promise(() => {}); + if (earlierReads.length >= 2) return new Promise(() => {}); await new Promise((resolve) => setImmediate(resolve)); try { - for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, - durable: [{ sequence: 7, message }], - }, request.navigation)) { + for (const batch of encodeDesktopTranscriptBatches(identity, { + durableThrough: 8, durable: [{ sequence: 7, message: earlier }], overlay: [], + earlierThan: 8, hasOlder: false, reset: false, ready: true, + })) { deliver(batch); if (!batch.ready) { partialProjectionCounts.push(projections.length); // A batch from another replica generation must not publish a - // partial valid snapshot or clear the load guard. + // partial answer or complete it. deliverDirect?.({ - ...batch, generation: 'unrelated-generation', reset: false, fragments: [], ready: true, + ...batch, generation: 'unrelated-generation', fragments: [], ready: true, deliverySequence: ++deliverySequence, }); partialProjectionCounts.push(projections.length); } } } finally { - finishResponse(); + responseDelivered.resolve(); } return; } - if (channel === 'sessions:transcript:ack' || channel === 'sessions:transcript:close') return; + if ( + channel === 'sessions:transcript:ack' || + channel === 'sessions:transcript:acknowledge-tail' || + channel === 'sessions:transcript:close' + ) return; throw new Error(`Unexpected channel: ${channel}`); }, }; @@ -501,28 +352,32 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp ...bridge, transcripts: { ...bridge.transcripts, - open(requestedSessionId, handler, registerCancellation) { + open(requestedSessionId, handler, registerCancellation, mode) { deliverDirect = handler; - return bridge!.transcripts.open(requestedSessionId, handler, registerCancellation); + return bridge!.transcripts.open(requestedSessionId, handler, registerCancellation, mode); }, }, }); const handle = await services.openTranscript( sessionId, - (snapshot) => projections.push(snapshot.messages.map((message) => message.id)), + (snapshot) => projections.push({ ids: snapshot.messages.map((message) => message.id), hasOlder: snapshot.hasOlder }), new AbortController().signal, (error) => { throw error; }, ); try { await waitFor(() => projections.length === 1, { timeoutMs: 5_000 }); - await handle.loadLatest(); - await responseDelivered; + await handle.loadEarlier(); + await responseDelivered.promise; await new Promise((resolve) => setImmediate(resolve)); - assert.equal(requests.length, 1); - assert.equal(requests[0]!.navigation, 1); - assert.equal(requests[0]!.anchorSequence, null); - assert.deepEqual(partialProjectionCounts, [1, 1]); - assert.deepEqual(projections, [[], ['latest-message']]); + assert.equal(earlierReads.length, 1); + assert.ok(partialProjectionCounts.length > 0, 'the answer has to span more than one batch'); + assert.ok(partialProjectionCounts.every((count) => count === 1)); + assert.deepEqual(projections, [ + { ids: ['tail-message'], hasOlder: true }, + { ids: ['earlier-message', 'tail-message'], hasOlder: false }, + ]); + await handle.loadEarlier(); + assert.equal(earlierReads.length, 1, 'nothing is read once no earlier history remains'); } finally { await handle.close(); } @@ -564,23 +419,19 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] throw new Error('transient initial open failure'); } const cached = attempt === 1; - const snapshot = { + const identity = { sessionId: 'coordination', generation: cached ? 'cached:epoch-1' : `live-${attempt}`, - hostEpoch: 'epoch-1', durableThrough: 1, overlay: [], hasOlder: false, hasNewer: false, + hostEpoch: 'epoch-1', }; - const deliver = (navigation?: number) => { - for (const batch of encodeDesktopTranscriptSnapshot({ - ...snapshot, - durable: [{ sequence: 1, message: { type: 'user', id: cached ? 'cached-message' : 'live-message', turnId: 'turn-1', ts: 1, text: cached ? 'Cached history' : 'Live history' } }], - }, navigation)) onBatch({ ...batch, deliverySequence: 1 }); - }; - deliver(); + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, durableThrough: 1, overlay: [], hasOlder: false, + durable: [{ sequence: 1, message: { type: 'user', id: cached ? 'cached-message' : 'live-message', turnId: 'turn-1', ts: 1, text: cached ? 'Cached history' : 'Live history' } }], + })) onBatch({ ...batch, deliverySequence: 1 }); const unavailable = async () => { throw new Error('Reconnect the Host to load uncached history'); }; return { - ...snapshot, readThroughMessageId: null, + ...identity, readThroughMessageId: null, acknowledgeTail: async () => {}, - loadBefore: unavailable, loadAfter: unavailable, loadLatest: unavailable, - loadAround: cached ? unavailable : async (_sequence, _maxBytes, navigation) => deliver(navigation), + loadEarlier: unavailable, close: async () => { closedCount++; }, }; }, @@ -605,77 +456,3 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] } }); } - -test('WorkHub fills and trims its transcript window through the reader band', async (t) => { - const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); - Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); - t.after(() => { - if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); - else Reflect.deleteProperty(globalThis, 'window'); - }); - const sessionId = desktopSessionKey({ hostId: 'owner-host', sessionId: 'coordination' }); - const identity = { sessionId: 'coordination', generation: 'generation-1', hostEpoch: 'epoch-1' }; - const row = (sequence: number, turnId: string): { sequence: number; message: StoredMessage } => ({ - sequence, message: { type: 'user', id: `message-${sequence}`, turnId, ts: sequence, text: `Record ${sequence}` }, - }); - let deliverySequence = 0; - let newerReads = 0; - let olderReads = 0; - let snapshots: WorkHubTranscriptSnapshot[] = []; - const services = createDesktopWorkHubServices({ - attachments: {}, - transcripts: { - async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { - for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 4, overlay: [], hasOlder: true, hasNewer: true, - durable: [row(2, 'turn-a'), row(3, 'turn-b')], - })) onBatch({ ...batch, deliverySequence: ++deliverySequence }); - return { - ...identity, durableThrough: 4, hasOlder: true, hasNewer: true, readThroughMessageId: 'message-3', - acknowledgeTail: async () => {}, - loadBefore: async () => { olderReads += 1; }, - loadAround: async () => {}, - loadLatest: async () => {}, - async loadAfter(anchor: number | null, _maxBytes: number | undefined, navigation: number) { - newerReads += 1; - assert.equal(anchor, 3); - for (const batch of encodeDesktopTranscriptPage( - { ...identity, navigation }, - { durableThrough: 4, hasNewer: false, durable: [row(4, 'turn-c')] }, - { direction: 'newer', anchor }, - )) onBatch({ ...batch, deliverySequence: ++deliverySequence }); - }, - close: async () => undefined, - }; - }, - } satisfies Pick, - } as unknown as Parameters[0]); - const handle = await services.openTranscript( - sessionId, - (snapshot) => { snapshots.push(snapshot); }, - new AbortController().signal, - (error) => { throw error; }, - ); - const latest = () => snapshots.at(-1)!; - try { - await waitFor(() => latest()?.ready === true, { timeoutMs: 5_000 }); - assert.deepEqual(latest().messages.map(({ turnId }) => turnId), ['turn-a', 'turn-b']); - assert.equal(await handle.prefetchHistory('newer'), true); - assert.deepEqual(latest().messages.map(({ turnId }) => turnId), ['turn-a', 'turn-b', 'turn-c']); - assert.equal(latest().hasNewer, false); - assert.equal(await handle.prefetchHistory('newer'), false, 'a window at the tail has no newer edge to read'); - assert.equal(newerReads, 1); - assert.equal(await handle.prefetchHistory('older'), true); - assert.equal(await handle.prefetchHistory('older'), false, 'the same window answers an older read the same way'); - assert.equal(olderReads, 1); - snapshots = []; - handle.retain({ firstTurnId: 'turn-b', lastTurnId: 'turn-c' }); - assert.deepEqual(latest().messages.map(({ turnId }) => turnId), ['turn-b', 'turn-c']); - assert.equal(latest().hasOlder, true, 'a trimmed edge becomes history again'); - // A trim moves the window, so the edge it re-opened is worth asking again. - assert.equal(await handle.prefetchHistory('older'), true); - assert.equal(olderReads, 2); - } finally { - await handle.close(); - } -}); diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts index 885afa2c35..5afc30bbbb 100644 --- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -44,11 +44,8 @@ async function mountController(failFirstRead = false, overrides: Partial void; let onExecution: Parameters[4]; let observe!: Parameters[1]; - let loadLatestCount = 0; - const prefetched: Array<'older' | 'newer'> = []; - const retained: Array<{ firstTurnId: string; lastTurnId: string }> = []; + let earlierLoads = 0; let admission = deferred<{ turnId: string }>(); - const latestRead = deferred(); const requests: Array[1]> = []; let rootTurn: { turnId: string; runId: string; status: 'running' | 'cancelled' | 'completed' } | undefined; const queueMutations: unknown[][] = []; @@ -102,12 +99,10 @@ async function mountController(failFirstRead = false, overrides: Partial {}, - prefetchHistory: async (edge: 'older' | 'newer') => { prefetched.push(edge); return true; }, - retain: (window: { firstTurnId: string; lastTurnId: string }) => { retained.push(window); }, - loadLatest: () => { loadLatestCount += 1; return latestRead.promise; }, + loadEarlier: async () => { earlierLoads += 1; }, close: async () => {}, }; }, @@ -143,14 +138,13 @@ async function mountController(failFirstRead = false, overrides: Partial(); }, admit(turnId: string) { rootTurn = { turnId, runId: `run:${turnId}`, status: 'running' }; projectExecution(); }, loseObservation() { projectExecution(false); }, - get loadLatestCount() { return loadLatestCount; }, - prefetched, retained, + get earlierLoads() { return earlierLoads; }, emit(event: Parameters[0]) { observe(event); }, - publish(messages: StoredMessage[]) { publish({ messages, ready: true, hasOlder: false, hasNewer: false }); }, + publish(messages: StoredMessage[]) { publish({ messages, ready: true, hasOlder: false }); }, }; } @@ -253,8 +247,7 @@ test('WorkHub shows the submitted prompt before admission and keeps it until its await act(async () => { sent = h.controller.send(text, attachments); }); assert.deepEqual(h.controller.transientMessages.map((message) => message.text), [text]); assert.deepEqual(h.controller.transientMessages[0]!.attachments, attachments); - assert.equal(h.requests.length, 1, 'the pending history read must not delay admission'); - assert.equal(h.loadLatestCount, 1); + assert.equal(h.requests.length, 1); assert.deepEqual(followed, [h.sessionId]); const turnId = h.requests[0]!.turnId; assert.equal(h.controller.liveTurn?.turnId, turnId, 'waiting feedback starts before admission'); @@ -271,38 +264,25 @@ test('WorkHub shows the submitted prompt before admission and keeps it until its assert.equal(h.controller.transientMessages.length, 0); assert.deepEqual(h.controller.transcript.messages.map((message) => message.id), ['canonical-user-id']); unsubscribe(); - h.latestRead.resolve(); }); -test('WorkHub holds transcript and live handoff together until publication is admitted', async () => { +test('WorkHub hands transcript and live content off together when publication commits', async () => { const h = await mountController(); let sent!: Promise; - await act(() => { sent = h.controller.send('held prompt', []); }); + await act(() => { sent = h.controller.send('handoff prompt', []); }); const turnId = h.requests[0]!.turnId; await act(async () => { h.admission.resolve({ turnId }); await sent; }); await act(() => h.emit({ type: 'text_delta', id: 'delta', turnId, messageId: 'answer', ts: 1, text: 'Answer' })); - let held = true; - let idle!: () => void; - const detach = h.controller.viewportNavigation.attachCommitScheduler(h.sessionId, { - subscribeToReaderScroll: () => () => {}, - commitRange(commit) { if (held) idle = commit; else commit(); }, - }); const messages: StoredMessage[] = [ - { type: 'user', id: 'user', turnId, text: 'held prompt', ts: 1 }, + { type: 'user', id: 'user', turnId, text: 'handoff prompt', ts: 1 }, { type: 'assistant', id: 'answer', turnId, text: 'Answer', ts: 2, modelId: 'fixture' }, ]; await act(() => h.publish(messages)); await act(() => h.emit({ type: 'complete', id: 'done', turnId, ts: 3, stopReason: 'end_turn' })); await act(() => h.controller.streamingSettled('answer')); - assert.equal(h.controller.transcript.messages.length, 0); - assert.equal(h.controller.transientMessages.length, 1); - assert.ok(h.controller.liveTurn?.steps.some((step) => step.stepId === 'answer')); - await act(() => { held = false; idle(); }); assert.deepEqual(h.controller.transcript.messages, messages); assert.equal(h.controller.transientMessages.length, 0); assert.ok(!h.controller.liveTurn?.steps.some((step) => step.stepId === 'answer')); - detach(); - h.latestRead.resolve(); }); test('WorkHub marks a failed submission and preserves its retry identity', async () => { @@ -319,7 +299,6 @@ test('WorkHub marks a failed submission and preserves its retry identity', async await act(async () => { assert.equal(await h.controller.send('retry this prompt', []), false); }); assert.equal(h.requests[1]!.turnId, turnId); assert.equal(h.controller.transientMessages.length, 1); - h.latestRead.resolve(); }); test('a lost admission response cannot erase confirmed WorkHub activity', async () => { @@ -332,7 +311,6 @@ test('a lost admission response cannot erase confirmed WorkHub activity', async assert.equal(h.controller.liveTurn?.turnId, turnId); assert.equal(h.controller.liveTurn?.unconfirmed, undefined); assert.equal(h.controller.busy, true); - h.latestRead.resolve(); }); @@ -406,7 +384,6 @@ test('WorkHub carries Stop through deferred or uncertain admission for the origi await act(async () => h.emit({ type: 'text_delta', id: 'later', turnId: 'later-turn', messageId: 'later-answer', ts: 3, text: 'Later work' })); assert.equal(h.interrupts.length, 1, 'the intent cannot transfer to a later Turn'); } - h.latestRead.resolve(); cleanupFakeDom(); } }); @@ -474,7 +451,6 @@ test('an unknown WorkHub submission converges through the original Host admissio await act(async () => { h.admit(nextTurn); h.admission.resolve({ turnId: nextTurn }); await next; }); assert.equal(h.interrupts.length, 0, 'an unadmitted attempt cannot leave Stop on a later Turn'); } - h.latestRead.resolve(); cleanupFakeDom(); } }); @@ -495,7 +471,6 @@ test('Retry reopens a failed initial WorkHub read after Session resolution', asy assert.equal(h.controller.transcript.ready, true); assert.equal(h.controller.error, undefined); await act(async () => { h.admission.resolve({ turnId }); await sent; }); - h.latestRead.resolve(); }); @@ -522,7 +497,6 @@ test('WorkHub steering keeps the current Turn and Stop authority and reconciles assert.deepEqual(h.controller.transientMessages, []); await act(async () => { await h.controller.stop(); }); assert.equal(h.interrupts[0]?.turnId, turnId); - h.latestRead.resolve(); }); test('uncertain steering retains its identity across Turn completion and rejection preserves the active Turn', async () => { @@ -553,7 +527,6 @@ test('uncertain steering retains its identity across Turn completion and rejecti await act(async () => { assert.equal(await h.controller.send('change direction', [], 'steer'), true); }); assert.equal(h.steers[2]![1], messageId); assert.equal(h.requests.length, 0, 'retry must recover the steering receipt even after its Turn ends'); - h.latestRead.resolve(); }); @@ -568,7 +541,6 @@ test('Host retraction resolves an uncertain WorkHub attempt before the next draf h.setSteerResult('admitted'); await act(async () => { assert.equal(await h.controller.send('new direction', [], 'steer'), true); }); assert.notEqual(h.steers[1]![1], messageId); - h.latestRead.resolve(); }); test('steering observed before its admission response renders once and outranks an uncertain receipt', async () => { @@ -580,7 +552,6 @@ test('steering observed before its admission response renders once and outranks assert.deepEqual(h.controller.transientMessages, []); assert.equal(h.controller.error, undefined); assert.equal(h.controller.liveTurn?.turnId, 'active-turn'); - h.latestRead.resolve(); }); @@ -604,7 +575,6 @@ test('WorkHub Host queue owns restored, consumed and retracted rows without tran await act(() => h.emit({ type: 'queue_update', id: 'removed', turnId: 'active-turn', ts: 4, steering: [], followup: [], steeringEntries: [] })); assert.deepEqual(h.controller.messageQueue.entries, []); assert.deepEqual(h.controller.transientMessages, [], 'withdrawal needs no separate admission event'); - h.latestRead.resolve(); }); test('WorkHub sends queue edits, withdrawal and both queue orders to the Host and waits for its projection', async () => { @@ -628,7 +598,6 @@ test('WorkHub sends queue edits, withdrawal and both queue orders to the Host an queueRevision: 10, steering: ['edited second'], followup: [], steeringEntries: [{ ...entries[1]!, content: { text: 'edited second' } }] })); assert.deepEqual(h.controller.messageQueue.entries.map((entry) => entry.content.text), ['edited second']); assert.deepEqual(h.controller.transientMessages, []); - h.latestRead.resolve(); }); @@ -661,33 +630,25 @@ test('WorkHub defaults to follow-up and moves each message into its admitted suc await act(() => h.publish([{ type: 'user', id: first, turnId: 'successor', text: 'first follow-up', attachments, ts: 2 }])); assert.deepEqual(h.controller.transientMessages, []); assert.deepEqual(h.controller.messageQueue.entries.map((entry) => entry.messageId), [second]); - h.latestRead.resolve(); }); -test('a queued follow-up returns the window to the tail so its own retry guard can clear', async () => { +test('an uncertain queued follow-up blocks the next one until its row is observed', async () => { const h = await mountController(); await act(() => { h.admit('active-turn'); h.emit({ type: 'text_delta', id: 'live', turnId: 'active-turn', messageId: 'answer', ts: 1, text: 'Working' }); }); h.setSteerResult('unknown'); await act(async () => { assert.equal(await h.controller.send('queued while parked in history', []), false); }); - const messageId = h.steers[0]![1]; - assert.equal(h.loadLatestCount, 1, 'an uncertain enqueue still has to reach the tail to be observed'); - await act(async () => { assert.equal(await h.controller.send('a different follow-up', []), false); }); + const messageId = h.steers[0]![1]; await act(async () => { assert.equal(await h.controller.send('a different follow-up', []), false); }); assert.equal(h.steers.length, 1, 'an unobserved attempt refuses the next follow-up'); await act(() => h.publish([{ type: 'user', id: messageId, turnId: 'successor', text: 'queued while parked in history', ts: 2 }])); h.setSteerResult('admitted'); await act(async () => { assert.equal(await h.controller.send('a different follow-up', []), true); }); assert.equal(h.steers.length, 2, 'the observed row releases the guard'); - h.latestRead.resolve(); }); -test('the transcript band reaches WorkHub’s window', async () => { +test('loading earlier history reaches WorkHub’s transcript', async () => { const h = await mountController(); - assert.equal(await h.controller.prefetchHistory('older'), true); - assert.equal(await h.controller.prefetchHistory('newer'), true); - assert.deepEqual(h.prefetched, ['older', 'newer']); - h.controller.retainWindow({ firstTurnId: 'turn-b', lastTurnId: 'turn-c' }); - assert.deepEqual(h.retained, [{ firstTurnId: 'turn-b', lastTurnId: 'turn-c' }]); - h.latestRead.resolve(); + await h.controller.loadEarlier(); + assert.equal(h.earlierLoads, 1); }); test('follow-up admission before an uncertain response keeps its successor placement', async () => { @@ -699,7 +660,6 @@ test('follow-up admission before an uncertain response keeps its successor place assert.equal(h.controller.transientMessages[0]?.transientPlacement, 'current_turn'); assert.equal(h.controller.transientMessages[0]?.hostTurnId, 'successor'); assert.equal(h.controller.error, undefined); - h.latestRead.resolve(); }); @@ -730,7 +690,6 @@ test('Stop retires only Host-confirmed queued messages even without retraction e assert.deepEqual(h.controller.messageQueue.entries.map((entry) => entry.messageId), ['retained']); assert.deepEqual(h.controller.transientMessages.filter((message) => message.id !== turnId), []); assert.equal(h.controller.stopPending, false); - h.latestRead.resolve(); cleanupFakeDom(); } }); diff --git a/apps/desktop/src/main/desktop-transcript-ipc.ts b/apps/desktop/src/main/desktop-transcript-ipc.ts index 1559c7abf5..d15b3d7376 100644 --- a/apps/desktop/src/main/desktop-transcript-ipc.ts +++ b/apps/desktop/src/main/desktop-transcript-ipc.ts @@ -21,69 +21,48 @@ import type { StoredMessage } from '@maka/core/session'; import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, type DesktopTranscriptBatchPayload, - type DesktopTranscriptExtension, type DesktopTranscriptFragment, } from '../preload/transcript-contract.js'; import type { DesktopSequencedTranscriptMessage, DesktopTranscriptReplicaChange, - DesktopTranscriptReplicaPage, DesktopTranscriptReplicaSnapshot, } from './desktop-transcript-replica.js'; -interface TranscriptBatchIdentity { - readonly navigation?: number; +export interface TranscriptBatchIdentity { readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; } -interface TranscriptBatchContent { +/** One part of an answer; `ready` marks its last part. */ +export interface TranscriptBatchContent { readonly durableThrough: number | null; readonly durable: readonly DesktopSequencedTranscriptMessage[]; readonly overlay: readonly StoredMessage[]; readonly hasOlder?: boolean; - readonly hasNewer?: boolean; - readonly extends?: DesktopTranscriptExtension; + readonly earlierThan?: number; readonly coversFrom?: number | null; readonly reset: boolean; + readonly ready: boolean; } export function encodeDesktopTranscriptSnapshot( snapshot: DesktopTranscriptReplicaSnapshot, - navigation?: number, ): Iterable { - return encodeDesktopTranscriptBatches({ ...snapshot, navigation }, { + return encodeDesktopTranscriptBatches(snapshot, { durableThrough: snapshot.durableThrough, durable: snapshot.durable, overlay: snapshot.overlay, hasOlder: snapshot.hasOlder, - hasNewer: snapshot.hasNewer, reset: true, - }); -} - -export function encodeDesktopTranscriptPage( - identity: TranscriptBatchIdentity, - page: DesktopTranscriptReplicaPage, - extension: DesktopTranscriptExtension, -): Iterable { - return encodeDesktopTranscriptBatches(identity, { - durableThrough: page.durableThrough, - durable: page.durable, - overlay: [], - hasOlder: page.hasOlder, - hasNewer: page.hasNewer, - extends: extension, - reset: false, + ready: true, }); } export function encodeDesktopTranscriptChange( identity: TranscriptBatchIdentity, - // A merge that had to drop rows carries no `coversFrom` at all: it claims - // nothing about adjacency and only moves the watermark. - change: Omit & { readonly coversFrom?: number | null }, + change: DesktopTranscriptReplicaChange, ): Iterable { return encodeDesktopTranscriptBatches(identity, { durableThrough: change.durableThrough, @@ -91,10 +70,11 @@ export function encodeDesktopTranscriptChange( overlay: [], coversFrom: change.coversFrom, reset: false, + ready: true, }); } -function* encodeDesktopTranscriptBatches( +export function* encodeDesktopTranscriptBatches( identity: TranscriptBatchIdentity, content: TranscriptBatchContent, ): Iterable { @@ -113,19 +93,18 @@ function* encodeDesktopTranscriptBatches( rawBytes += bytes; fragment = fragments.next(); } + const last = fragment.done === true; yield { - ...(identity.navigation === undefined ? {} : { navigation: identity.navigation }), - ...(content.extends === undefined ? {} : { extends: content.extends }), + ...(content.earlierThan === undefined ? {} : { earlierThan: content.earlierThan }), ...(content.coversFrom === undefined ? {} : { coversFrom: content.coversFrom }), sessionId: identity.sessionId, generation: identity.generation, hostEpoch: identity.hostEpoch, durableThrough: content.durableThrough, fragments: batchFragments, - ...(content.hasOlder === undefined ? {} : { hasOlder: content.hasOlder }), - ...(content.hasNewer === undefined ? {} : { hasNewer: content.hasNewer }), + ...(content.hasOlder === undefined || !(last && content.ready) ? {} : { hasOlder: content.hasOlder }), reset: content.reset && first, - ready: fragment.done === true, + ready: last && content.ready, }; first = false; } diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index 09513df657..d4a0a62e89 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -25,12 +25,13 @@ import { } from '@maka/runtime-host/adapter'; import { RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; import { - SESSION_TRANSCRIPT_RANGE_MAX_BYTES, + SESSION_TRANSCRIPT_PAGE_MAX_BYTES, type SessionTranscriptPage, } from '@maka/runtime-host/protocol'; import { + DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES, - DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES, DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, } from '../preload/transcript-contract.js'; import type { DesktopRuntimeHostSession } from './runtime-host-client.js'; @@ -61,15 +62,12 @@ export interface DesktopTranscriptReplicaSnapshot { readonly durable: readonly DesktopSequencedTranscriptMessage[]; readonly overlay: readonly StoredMessage[]; readonly hasOlder: boolean; - readonly hasNewer: boolean; } -/** A durable page read on behalf of one Renderer window; never installed here. */ -export interface DesktopTranscriptReplicaPage { - readonly durableThrough: number; +/** One durable page read for a history consumer; never installed here. Rows ascend. */ +export interface DesktopTranscriptHistoryPage { readonly durable: readonly DesktopSequencedTranscriptMessage[]; - readonly hasOlder?: boolean; - readonly hasNewer?: boolean; + readonly nextCursor: string | null; } /** @@ -89,9 +87,8 @@ interface ResidentMessage extends DesktopSequencedTranscriptMessage { /** * Main's view of one Session transcript: the durable tail the projector needs, - * the overlay of not-yet-durable messages, and a pass-through pager for the - * Renderer's own window. The Renderer decides what it holds; this class only - * keeps the tail current and answers page reads. + * the overlay of not-yet-durable messages, and pass-through reads of older + * history. This class only keeps the tail current and answers those reads. */ export class DesktopTranscriptReplica { readonly sessionId: string; @@ -129,12 +126,12 @@ export class DesktopTranscriptReplica { this.generation = options.generation ?? randomUUID(); this.hostEpoch = handle.hostEpoch; this.#maxResidentBytes = - options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES; + options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES; this.#maxResidentTurns = options.maxResidentTurns ?? DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS; this.#maxOverlayBytes = options.maxOverlayBytes ?? DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES; - this.#maxMessageBytes = options.maxMessageBytes ?? SESSION_TRANSCRIPT_RANGE_MAX_BYTES; + this.#maxMessageBytes = options.maxMessageBytes ?? DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES; this.#accountPreparationBytes = options.accountPreparationBytes ?? (() => undefined); this.#onChange = options.onChange ?? (() => undefined); this.#durableThrough = handle.transcriptBootstrap.throughSequence; @@ -157,12 +154,7 @@ export class DesktopTranscriptReplica { replica.#installDurable(durable.messages); replica.#hasOlder = durable.nextCursor !== null; }); - replica.#evictToBudget( - undefined, - handle.transcriptBootstrap.durable.protectedTurnSequence ?? - replica.#durableThrough ?? - undefined, - ); + replica.#evictToBudget(); if (replica.#overlayBytes > replica.#maxOverlayBytes) { throw new RangeError('Desktop transcript overlay exceeds the session cache limit'); } @@ -207,7 +199,6 @@ export class DesktopTranscriptReplica { durable: this.#orderedDurable(false), overlay: [...this.#overlay.values()], hasOlder: this.#hasOlder, - hasNewer: false, }; } @@ -238,160 +229,77 @@ export class DesktopTranscriptReplica { return latest?.message.id ?? null; } - loadBefore( - anchorSequence: number | null, - maxBytes: number, - isCurrent: () => boolean = () => true, - ): Promise { - return this.#enqueue(() => this.#loadPage('older', anchorSequence, maxBytes, isCurrent)); - } - - loadAfter( - anchorSequence: number | null, - maxBytes: number, - isCurrent: () => boolean = () => true, - ): Promise { - return this.#enqueue(() => this.#loadPage('newer', anchorSequence, maxBytes, isCurrent)); - } - - async #loadPage( - direction: 'older' | 'newer', - anchorSequence: number | null, - maxBytes: number, - isCurrent: () => boolean, - ): Promise { - if (!this.#isLive() || !isCurrent()) return undefined; - const throughSequence = this.#durableThrough; - if (throughSequence === null) return undefined; + /** One page older than `cursor`, or the newest page through `throughSequence` when it is null. */ + async readOlderPage( + throughSequence: number, + cursor: string | null, + ): Promise { + this.#assertLive(); const page = await this.#handle.loadTranscriptPage({ source: 'durable', - direction, + direction: 'older', throughSequence, - cursor: null, - anchorSequence, - maxBytes, + cursor, + anchorSequence: null, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, }); return this.#withDecodedPage(page, (decoded) => { - if (!this.#isLive() || !isCurrent()) return undefined; + this.#assertLive(); this.#acceptRange(decoded.messages); - if ( - anchorSequence !== null && - decoded.messages.length > 0 && - !(direction === 'older' - ? this.#matchesCoverageStep(anchorSequence, decoded.messages.at(-1)!.identity + 1) - : this.#matchesCoverageStep(decoded.messages[0]!.identity, anchorSequence + 1)) - ) { - throw correlationError(`Desktop transcript ${direction} page did not meet its anchor`); - } this.#completeOverlay(decoded.messages); return { - durableThrough: throughSequence, durable: decoded.messages.map((entry) => ({ sequence: entry.identity, message: entry.message, })), - ...(direction === 'older' - ? { hasOlder: decoded.nextCursor !== null } - : { hasNewer: decoded.nextCursor !== null }), + nextCursor: decoded.nextCursor, }; }); } /** - * Reads the newest page back into the tail cache when global reclaim has - * trimmed it below a tail. Follow-tail is answered from this cache, so - * without the refill a reader returning to latest is shown whatever reclaim - * happened to leave — down to nothing. - */ - refillTail(maxBytes: number, isCurrent: () => boolean = () => true): Promise { - return this.#enqueue(async () => { - if (!this.#isLive() || !isCurrent() || !this.#tailIsShort()) return; - const throughSequence = this.#durableThrough; - if (throughSequence === null) return; - const page = await this.#handle.loadTranscriptPage({ - source: 'durable', - direction: 'older', - throughSequence, - cursor: null, - anchorSequence: throughSequence + 1, - maxBytes, - }); - await this.#withDecodedPage(page, (decoded) => { - if (!this.#isLive() || !isCurrent()) return; - this.#acceptRange(decoded.messages); - this.#installDurable(decoded.messages); - this.#hasOlder = decoded.nextCursor !== null; - this.#evictToBudget( - undefined, - page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, - ); - }); - }); - } - - /** - * Whether the cache holds less than the tail it is meant to hold. `#hasOlder` - * settles the case a Turn count cannot: a short Session whose whole durable - * transcript is resident is never short, however few Turns that is. + * Every durable row of one Turn, read forward from its first sequence until + * a row of another Turn, plus its overlay. Only the Turn's own rows count + * toward `maxBytes`. */ - #tailIsShort(): boolean { - if (!this.#hasOlder) return false; - const turns = new Set(); - for (const entry of this.#durable.values()) turns.add(residentTurnKey(entry)); - return turns.size < this.#maxResidentTurns; - } - - loadAround( - sequence: number, - maxBytes: number, - isCurrent: () => boolean = () => true, - ): Promise { - return this.#enqueue(async () => { - if (!this.#isLive() || !isCurrent()) return undefined; - const throughSequence = this.#durableThrough; - if (throughSequence === null || sequence > throughSequence) return undefined; - const page = await this.#handle.loadTranscriptPage({ - source: 'durable', - direction: 'newer', - throughSequence, - cursor: null, - anchorSequence: sequence === 0 ? null : sequence - 1, - maxBytes, - }); - if (!this.#isLive() || !isCurrent()) return undefined; - // A durable sequence is an event ordinal times its stride, so the oldest - // row of a Session is at no fixed number and `sequence > 0` cannot answer - // whether anything precedes the anchor. Ask for one row older instead. - const older = await this.#handle.loadTranscriptPage({ - source: 'durable', - direction: 'older', - throughSequence, - cursor: null, - anchorSequence: sequence, - maxBytes: 1, - }); - return this.#withDecodedPage(page, (decoded) => { - if (!this.#isLive() || !isCurrent()) return undefined; - this.#acceptRange(decoded.messages); - if (decoded.messages.length > 0 && decoded.messages[0]!.identity !== sequence) { - throw correlationError('Desktop transcript range did not meet its anchor'); - } - this.#completeOverlay(decoded.messages); - return { - sessionId: this.sessionId, - generation: this.generation, - hostEpoch: this.hostEpoch, - durableThrough: throughSequence, - durable: decoded.messages.map((entry) => ({ - sequence: entry.identity, - message: entry.message, - })), - overlay: [...this.#overlay.values()], - hasOlder: older.fragments.length > 0, - hasNewer: decoded.nextCursor !== null, - }; - }); - }); + async readTurn(turnId: string, firstSequence: number, maxBytes: number): Promise { + this.#assertLive(); + const throughSequence = this.#durableThrough; + const durable: StoredMessage[] = []; + let bytes = 0; + let cursor: string | null = null; + if (throughSequence !== null && firstSequence <= throughSequence) { + do { + const page: SessionTranscriptPage = await this.#handle.loadTranscriptPage({ + source: 'durable', + direction: 'newer', + throughSequence, + cursor, + anchorSequence: cursor === null && firstSequence > 0 ? firstSequence - 1 : null, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + }); + const ended = await this.#withDecodedPage(page, (decoded) => { + this.#assertLive(); + for (const { message } of decoded.messages) { + const owner = messageTurnId(message); + if (owner !== undefined && owner !== turnId) return true; + if (owner !== turnId) continue; + bytes += encodedMessageBytes(message); + if (bytes > maxBytes) throw new RangeError('Desktop transcript Turn exceeds its read limit'); + durable.push(message); + } + cursor = decoded.nextCursor; + return false; + }); + if (ended) break; + } while (cursor !== null); + } + const durableIds = new Set(durable.map((message) => message.id)); + return durable.concat( + [...this.#overlay.values()] + .filter((message) => messageTurnId(message) === turnId && !durableIds.has(message.id)) + .map((message) => structuredClone(message)), + ); } advance(throughSequence: number): Promise { @@ -465,7 +373,7 @@ export class DesktopTranscriptReplica { throughSequence: target, cursor, anchorSequence: cursor === null ? anchorSequence : null, - maxBytes: 512 * 1024, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, }); await this.#withDecodedPage(page, (decoded) => { // A concurrent `discard()` (LRU reclaim for another observed session) @@ -486,10 +394,7 @@ export class DesktopTranscriptReplica { nextSequence = decoded.messages.at(-1)!.identity + 1; } this.#installDurable(decoded.messages); - this.#evictToBudget( - undefined, - page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, - ); + this.#evictToBudget(); // The watermark moves with every page, not only at the end: a window // opening mid-catch-up takes a snapshot whose rows must agree with the // `durableThrough` it names, or the next change cannot join it. @@ -539,11 +444,7 @@ export class DesktopTranscriptReplica { this.#completeOverlay(messages); } - /** - * The durable row settles the overlay it replaces, so the tail cache stops - * carrying both. Each window retires its own overlay when it installs the - * row; a window that never installs it keeps showing what it has. - */ + /** The durable row settles the overlay it replaces, so the tail cache stops carrying both. */ #completeOverlay(messages: readonly { readonly message: StoredMessage }[]): void { for (const { message } of messages) { const overlay = this.#overlay.get(message.id); @@ -594,27 +495,25 @@ export class DesktopTranscriptReplica { } /** - * Evicts whole Turns from the oldest edge until the tail fits. The protected - * Turn and everything newer stay even when they alone exceed the budget: the - * projector needs the newest Turn complete. Global pressure calls with no - * protection and may empty the tail. + * Evicts whole Turns from the oldest edge until the tail fits. The newest + * Turn stays even when it alone exceeds the budget: the projector needs it + * complete. Global pressure passes a budget and may empty the tail. */ - #evictToBudget( - budget: number | undefined = undefined, - protectedSequence?: number, - ): void { + #evictToBudget(budget?: number): void { const residentBudget = budget ?? this.#maxResidentBytes + this.#overlayBytes; const turns = new Map(); - for (const sequence of [...this.#durable.keys()].sort((left, right) => left - right)) { + const sequences = [...this.#durable.keys()].sort((left, right) => left - right); + for (const sequence of sequences) { const key = residentTurnKey(this.#durable.get(sequence)!); const group = turns.get(key); if (group) group.push(sequence); else turns.set(key, [sequence]); } - const protectedEntry = protectedSequence === undefined - ? undefined - : this.#durable.get(protectedSequence); - const protectedKey = protectedEntry === undefined ? undefined : residentTurnKey(protectedEntry); + // A trailing Session note is not the Turn the projector needs to keep whole. + const keys = [...turns.keys()]; + const protectedKey = budget === undefined + ? [...keys].reverse().find((key) => key.startsWith('turn:')) ?? keys.at(-1) + : undefined; let residentTurns = turns.size; for (const [key, sequences] of turns) { if (this.#residentBytes <= residentBudget && residentTurns <= this.#maxResidentTurns) return; @@ -701,6 +600,11 @@ export class DesktopTranscriptReplica { if (this.#closed) throw new Error('Desktop transcript replica is closed'); } + #assertLive(): void { + this.#assertOpen(); + this.#assertResident(); + } + #assertResident(): void { if (!this.#resident) { throw new Error('Desktop transcript replica was evicted'); diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index 763b91ada0..4be51c3136 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -125,6 +125,10 @@ export function promptRailSession(now: number): SessionHeader { * A plain multi-prompt conversation: no tools, no thinking, no usage rows. * The transcript perf suite measures against this, so every turn is just a * prompt and a reply long enough to push the transcript past the scrollport. + * + * Reply length has to vary the way a real transcript's does. A uniform reply + * lets the virtualizer estimate every unmounted row correctly, which is the one + * case where mounting a row above the reader cannot move them. */ export function promptRailMessages(now: number): StoredMessage[] { const messages: StoredMessage[] = []; @@ -143,12 +147,22 @@ export function promptRailMessages(now: number): StoredMessage[] { id: `msg-prompt-rail-assistant-${index}`, turnId, ts: ts + 1_000, - text: `第 ${index} 段回答。`.repeat(40), + text: promptRailReply(index), modelId: 'glm-5.1', }); } return messages; } + +function promptRailReply(index: number): string { + const prose = `第 ${index} 段回答。`.repeat(4 + ((index * 7) % 60)); + if (index % 5 !== 0) return prose; + const code = Array.from( + { length: 6 + ((index * 3) % 40) }, + (_, line) => ` const step${line} = await pipeline.run(${index}, ${line});`, + ).join('\n'); + return `${prose}\n\n\`\`\`ts\n${code}\n\`\`\`\n\n收尾说明。`; +} export function partialHistorySession(now: number): SessionHeader { return header({ id: PARTIAL_HISTORY_SESSION_ID, diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 1e1cd6a535..9c1b7d6d8d 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -35,8 +35,9 @@ export const E2E_FIXTURE_NOW = Date.UTC(2026, 4, 22, 3, 0, 0); export const TURN_SESSION_ID = 'e2e-fixture-turn'; export const PROMPT_RAIL_SESSION_ID = 'e2e-fixture-prompt-rail'; export const PARTIAL_HISTORY_SESSION_ID = 'e2e-fixture-partial-history'; -/** Exceeds both the 64-tick rail and the bounded active transcript range. */ -export const PROMPT_RAIL_PROMPT_COUNT = 120; +/** The transcript history budget the partial-history fixture runs with, so its Session loads in chunks. */ +export const PARTIAL_HISTORY_TRANSCRIPT_BYTES = 1024 * 1024; +export const PROMPT_RAIL_PROMPT_COUNT = 500; export const LONG_SIDEBAR_SESSION_PREFIX = 'e2e-fixture-sidebar-long-'; export const LONG_SIDEBAR_SESSION_COUNT = 60; export const LONG_SIDEBAR_PROJECT_ID = 'e2e-fixture-project'; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index fe49fdff85..d2d092b88a 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -126,6 +126,7 @@ import { resolveE2eFixture, seedE2eFixture, } from "./e2e-fixture.js"; +import { PARTIAL_HISTORY_TRANSCRIPT_BYTES } from "./e2e-fixture/seed-helpers.js"; import { createKeepSystemAwakeController } from "./keep-system-awake.js"; import { isDarkAppearance } from "./theme-source.js"; import { @@ -1261,6 +1262,9 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( }, emitSessionsChanged, cacheTranscript: (scope, snapshot) => sessionLocal.cacheTranscript(scope, snapshot), + ...(e2eFixture?.scenario === "chat-partial-history" + ? { transcriptHistoryBytes: PARTIAL_HISTORY_TRANSCRIPT_BYTES } + : {}), completeDesktopInteractionTurn, createSessionCopyCleanup: ({ removeSession, resumeSessionCopy }) => createSessionCopyCleanupAuthority({ diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ab21f66cd7..f57ea63bc8 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1700,16 +1700,6 @@ export class DesktopRuntimeHostClient { .flatMap((contribution) => projectSessionTurnContribution(contribution) ?? []); } - async listSessionTurnLandmarks( - sessionId: string, - ): Promise> { - this.#assertOpen(); - return this.request('session.turn_landmarks.query', { - sessionId, - maxLandmarks: 64, - }); - } - close(): Promise { this.#closeTask ??= this.#close(); return this.#closeTask; diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 041300e768..5f5a56a286 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -159,6 +159,7 @@ export interface DesktopRuntimeHostCandidateDeps { sessionId: string, ) => void | Promise; readonly e2eInteractions?: RuntimeHostSessionExecutionIpcDeps["e2eInteractions"]; + readonly transcriptHistoryBytes?: number; readonly renderer?: { send(channel: string, scope: DesktopTargetScope, payload: unknown): void; }; @@ -657,6 +658,7 @@ export async function createDesktopRuntimeHostCandidate( }; const sessionObserver = new RuntimeHostSessionObserver({ client, + transcriptHistoryBytes: deps.transcriptHistoryBytes, cacheTranscript: (snapshot) => { if (target.access === 'owner') deps.cacheTranscript?.(scope, snapshot); }, diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index 5b156b094c..196d64f662 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -73,7 +73,7 @@ import { type RuntimeHostTranscriptTarget, } from "./runtime-host-session-observer.js"; import type { - DesktopTranscriptRangeRequest, + DesktopTranscriptOpenMode, DesktopTranscriptTailAcknowledgement, } from '../preload/transcript-contract.js'; import type { DesktopSessionStopResult } from '../preload/bridge-contract.js'; @@ -111,7 +111,6 @@ type RuntimeHostSessionExecutionClient = Pick< | "ingestAttachment" | "interruptTurn" | 'listSessionTurns' - | 'listSessionTurnLandmarks' | 'queryMessageExecutions' | 'queryMessages' | "queryTurnResume" @@ -229,12 +228,10 @@ export interface RuntimeHostSessionObservationIpcDeps { observations: Pick< RuntimeHostSessionObservationRegistry, | 'acknowledgeTranscriptTail' - | 'loadTranscriptAround' - | 'loadTranscriptBefore' - | 'loadTranscriptAfter' - | 'loadTranscriptLatest' + | 'loadEarlierTranscript' | 'observe' | 'openTranscript' + | 'readTranscriptTurn' >; resolveSideConversation(sessionId: string): Promise; } @@ -261,39 +258,28 @@ export function registerRuntimeHostSessionObservationIpc( ); ipcMain.handle( 'sessions:transcript:open', - async (event, sessionId: unknown, consumerId: unknown) => + async (event, sessionId: unknown, consumerId: unknown, mode: unknown) => observationIpcResult( deps.observations.openTranscript( requiredId(sessionId, 'Session'), requiredId(consumerId, 'Transcript consumer'), event.sender as RuntimeHostTranscriptTarget, + normalizeTranscriptOpenMode(mode), ), ), ); - ipcMain.handle('sessions:transcript:load-before', async (event, input: unknown) => { - await deps.observations.loadTranscriptBefore( - normalizeTranscriptRangeRequest(input), - event.sender.id, - ); - }); - ipcMain.handle('sessions:transcript:load-around', async (event, input: unknown) => { - await deps.observations.loadTranscriptAround( - normalizeTranscriptRangeRequest(input), - event.sender.id, - ); - }); - ipcMain.handle('sessions:transcript:load-after', async (event, input: unknown) => { - await deps.observations.loadTranscriptAfter( - normalizeTranscriptRangeRequest(input), - event.sender.id, - ); - }); - ipcMain.handle('sessions:transcript:load-latest', async (event, input: unknown) => { - await deps.observations.loadTranscriptLatest( - normalizeTranscriptRangeRequest(input), + ipcMain.handle('sessions:transcript:load-earlier', async (event, consumerId: unknown) => { + await deps.observations.loadEarlierTranscript( + requiredId(consumerId, 'Transcript consumer'), event.sender.id, ); }); + handleReconnectableRead( + ipcMain, + 'sessions:transcript:read-turn', + (_event, sessionId: unknown, turnId: unknown) => + deps.observations.readTranscriptTurn(requiredId(sessionId, 'Session'), requiredId(turnId, 'Turn')), + ); ipcMain.handle('sessions:transcript:acknowledge-tail', async (event, input: unknown) => { await deps.observations.acknowledgeTranscriptTail( normalizeTranscriptTailAcknowledgement(input), @@ -391,12 +377,6 @@ export function registerRuntimeHostSessionExecutionIpc( handleReconnectableRead(ipcMain, 'sessions:listTurns', async (_event, sessionId: unknown) => deps.client.listSessionTurns(requiredId(sessionId, 'Session')), ); - handleReconnectableRead( - ipcMain, - 'sessions:listTurnLandmarks', - async (_event, sessionId: unknown) => - deps.client.listSessionTurnLandmarks(requiredId(sessionId, 'Session')), - ); handleReconnectableRead( ipcMain, "sessions:readExecutionBoundary", @@ -883,35 +863,9 @@ export function registerRuntimeHostSessionExecutionIpc( }; } -function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRangeRequest { - if (!input || typeof input !== 'object' || Array.isArray(input)) { - throw new Error('Invalid Desktop transcript range request'); - } - const value = input as Record; - const anchorSequence = value.anchorSequence; - const maxBytes = value.maxBytes; - if ( - anchorSequence !== null && - (!Number.isSafeInteger(anchorSequence) || (anchorSequence as number) < 0) - ) { - throw new Error('Invalid Desktop transcript range anchor'); - } - if (!Number.isSafeInteger(maxBytes)) { - throw new Error('Invalid Desktop transcript range byte limit'); - } - if ( - !Number.isSafeInteger(value.navigation) || (value.navigation as number) < 0 - ) { - throw new Error('Invalid Desktop transcript navigation'); - } - return { - consumerId: requiredId(value.consumerId, 'Transcript consumer'), - sessionId: requiredId(value.sessionId, 'Session'), - hostEpoch: requiredId(value.hostEpoch, 'Host epoch'), - anchorSequence: anchorSequence as number | null, - maxBytes: maxBytes as number, - navigation: value.navigation as number, - }; +function normalizeTranscriptOpenMode(mode: unknown): DesktopTranscriptOpenMode { + if (mode === 'tail' || mode === 'history') return mode; + throw new Error('Invalid Desktop transcript open mode'); } function normalizeTranscriptTailAcknowledgement( diff --git a/apps/desktop/src/main/runtime-host-session-observation-registry.ts b/apps/desktop/src/main/runtime-host-session-observation-registry.ts index 354e774905..c5c09740ea 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { StoredMessage } from '@maka/core/session'; import { RuntimeHostOperationError } from "@maka/runtime-host/client"; import type { RuntimeHostSessionObserver, @@ -25,8 +26,8 @@ import type { RuntimeHostTranscriptTarget, } from "./runtime-host-session-observer.js"; import type { + DesktopTranscriptOpenMode, DesktopTranscriptOpenResult, - DesktopTranscriptRangeRequest, DesktopTranscriptTailAcknowledgement, } from '../preload/transcript-contract.js'; @@ -37,11 +38,9 @@ type SessionObservationSource = Pick >; @@ -50,11 +49,9 @@ type TranscriptSource = Required< RuntimeHostSessionObserver, | 'acknowledgeTranscriptTail' | 'closeTranscript' - | 'loadTranscriptAround' - | 'loadTranscriptBefore' - | 'loadTranscriptAfter' - | 'loadTranscriptLatest' + | 'loadEarlierTranscript' | 'openTranscript' + | 'readTranscriptTurn' > >; @@ -78,10 +75,8 @@ function requireTranscriptSource( if ( !source?.openTranscript || !source.acknowledgeTranscriptTail || - !source.loadTranscriptBefore || - !source.loadTranscriptAfter || - !source.loadTranscriptAround || - !source.loadTranscriptLatest || + !source.loadEarlierTranscript || + !source.readTranscriptTurn || !source.closeTranscript ) { throw new Error('Runtime Host transcript source is unavailable'); @@ -114,6 +109,7 @@ interface SessionObservationRegistration { interface TranscriptRegistration { readonly sessionId: string; + readonly mode: DesktopTranscriptOpenMode; readonly target: RuntimeHostTranscriptTarget; readonly destroyedListener: () => void; readonly ready: TranscriptReadiness; @@ -345,6 +341,7 @@ export class RuntimeHostSessionObservationRegistry { sessionId: string, consumerId: string, target: RuntimeHostTranscriptTarget, + mode: DesktopTranscriptOpenMode = 'tail', ): Promise { this.#assertOpen(); if (this.#transcripts.has(consumerId)) { @@ -357,6 +354,7 @@ export class RuntimeHostSessionObservationRegistry { void ready.promise.catch(() => undefined); const registration: TranscriptRegistration = { sessionId, + mode, target, destroyedListener, ready, @@ -374,6 +372,7 @@ export class RuntimeHostSessionObservationRegistry { sessionId, consumerId, this.#bindTarget(target), + mode, ); if (this.#source === source && this.#transcripts.get(consumerId) === registration) { registration.lifecycle = 'active'; @@ -391,40 +390,15 @@ export class RuntimeHostSessionObservationRegistry { return registration.ready.promise; } - async loadTranscriptBefore( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - await this.#runTranscriptOperation(request, (source) => - source.loadTranscriptBefore(request, targetId), - ); - } - - async loadTranscriptAround( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - await this.#runTranscriptOperation(request, (source) => - source.loadTranscriptAround(request, targetId), - ); - } - - async loadTranscriptAfter( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - await this.#runTranscriptOperation(request, (source) => - source.loadTranscriptAfter(request, targetId), + async loadEarlierTranscript(consumerId: string, targetId?: number): Promise { + await this.#runTranscriptOperation({ consumerId }, (source) => + source.loadEarlierTranscript(consumerId, targetId), ); } - async loadTranscriptLatest( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - await this.#runTranscriptOperation(request, (source) => - source.loadTranscriptLatest(request, targetId), - ); + readTranscriptTurn(sessionId: string, turnId: string): Promise { + this.#assertOpen(); + return requireTranscriptSource(this.#source).readTranscriptTurn(sessionId, turnId); } async acknowledgeTranscriptTail( @@ -573,6 +547,7 @@ export class RuntimeHostSessionObservationRegistry { registration.sessionId, consumerId, this.#bindTarget(registration.target), + registration.mode, ); if ( this.#source === source && diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 1ca5390129..6558c8ff73 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -27,25 +27,24 @@ import { isRuntimeHostTerminalTurn as isTerminalTurn, projectRuntimeHostInteractionRequest, } from "@maka/runtime-host/adapter"; -import { - SESSION_TRANSCRIPT_RANGE_MAX_BYTES, - type InteractionAnsweredSnapshot, - type InteractionPendingSnapshot, - type SessionDomainChange, - type SessionContinuitySnapshot, - type SubscriptionFrame, +import type { + InteractionAnsweredSnapshot, + InteractionPendingSnapshot, + SessionDomainChange, + SessionContinuitySnapshot, + SubscriptionFrame, } from "@maka/runtime-host/protocol"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES, - DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE, - DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES, + DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, type DesktopTranscriptBatch, type DesktopTranscriptBatchPayload, + type DesktopTranscriptOpenMode, type DesktopTranscriptOpenResult, - type DesktopTranscriptRangeRequest, type DesktopTranscriptTailAcknowledgement, } from '../preload/transcript-contract.js'; import { @@ -60,8 +59,8 @@ import { type DesktopTranscriptReplicaSnapshot, } from './desktop-transcript-replica.js'; import { + encodeDesktopTranscriptBatches, encodeDesktopTranscriptChange, - encodeDesktopTranscriptPage, encodeDesktopTranscriptSnapshot, } from './desktop-transcript-ipc.js'; @@ -103,6 +102,7 @@ export interface RuntimeHostSessionObserverDeps { ) => void; emitSubscriptionRecovered?: (sessionId: string) => void; recoverConnectionClosed?: boolean; + transcriptHistoryBytes?: number; now?: () => number; } @@ -131,21 +131,12 @@ interface TranscriptConsumer { readonly consumerId: string; readonly target: RuntimeHostTranscriptTarget; generation: string; - /** The standing replacement; a read naming an older one has been abandoned. */ - navigation: number; deliverySequence: number; deliveryBytes: number; deliveryTask?: Promise; resetRequested: boolean; - /** - * Set only when the reset answers a navigation command, and stamped on that - * snapshot so the window can tell its own answer from a replacement it did - * not ask for. A reset from recovery or from an error carries no version: - * the window applies it to whatever it holds, under any navigation. - */ - resetNavigation?: number; - /** Page answers queued behind the delivery loop so they never interleave with a change. */ - readonly pendingPages: PendingTranscriptPage[]; + earlierRequested: boolean; + readonly history?: TranscriptHistory; pendingChange?: PendingTranscriptChange; readonly pendingDeliveries: Map; } +/** + * Where a history consumer's delivered history ends. Host cursors cut pages by + * bytes, not Turns, so rows read past a Turn boundary wait in `carry` for the + * next earlier read. + */ +interface TranscriptHistory { + /** A reset reads again as many budgets as the consumer has been delivered. */ + budgets: number; + throughSequence: number | null; + started: boolean; + cursor: string | null; + carry: DesktopSequencedTranscriptMessage[]; + carryBytes: number; + oldestSequence: number | null; +} + interface PendingTranscriptChange { - coversFrom: number | null | undefined; + readonly coversFrom: number | null; durableThrough: number | null; readonly durableUpserts: Map; encodedBytes: number; } -interface PendingTranscriptPage { - readonly navigation: number; - readonly generation: string; - readonly batches: Iterable; - readonly encodedBytes: number; -} - interface PendingTranscriptUpsert { readonly entry: DesktopSequencedTranscriptMessage; readonly encodedBytes: number; @@ -225,6 +225,7 @@ export class RuntimeHostSessionObserver { ) => void; readonly #emitSubscriptionRecovered: (sessionId: string) => void; readonly #recoverConnectionClosed: boolean; + readonly #transcriptHistoryBytes: number; readonly #now: () => number; #closed = false; #transcriptAccessClock = 0; @@ -248,6 +249,7 @@ export class RuntimeHostSessionObserver { this.#emitSubscriptionRecovered = deps.emitSubscriptionRecovered ?? (() => undefined); this.#recoverConnectionClosed = deps.recoverConnectionClosed ?? false; + this.#transcriptHistoryBytes = deps.transcriptHistoryBytes ?? DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES; this.#now = deps.now ?? Date.now; } @@ -255,6 +257,7 @@ export class RuntimeHostSessionObserver { sessionId: string, consumerId: string, target: RuntimeHostTranscriptTarget, + mode: DesktopTranscriptOpenMode = 'tail', ): Promise { this.#assertOpen(); if ( @@ -306,11 +309,23 @@ export class RuntimeHostSessionObserver { consumerId, target, generation: replica.generation, - navigation: 0, deliverySequence: 0, deliveryBytes: 0, resetRequested: false, - pendingPages: [], + earlierRequested: false, + ...(mode === 'history' + ? { + history: { + budgets: 1, + throughSequence: null, + started: false, + cursor: null, + carry: [], + carryBytes: 0, + oldestSequence: null, + }, + } + : {}), pendingDeliveries: new Map(), }; state.transcriptConsumers.set(consumerId, consumer); @@ -342,176 +357,42 @@ export class RuntimeHostSessionObserver { } } - async loadTranscriptBefore( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, false, async (replica, isCurrent) => { - const page = await replica.loadBefore( - request.anchorSequence, - requireTranscriptRangeBytes(request.maxBytes), - isCurrent, - ); - return page && { - batches: encodeDesktopTranscriptPage(this.#pageIdentity(replica, request), page, { - direction: 'older', anchor: request.anchorSequence, - }), - bytes: page.durable, - }; - }); - } - - async loadTranscriptAfter( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, false, async (replica, isCurrent) => { - const page = await replica.loadAfter( - request.anchorSequence, - requireTranscriptRangeBytes(request.maxBytes), - isCurrent, - ); - return page && { - batches: encodeDesktopTranscriptPage(this.#pageIdentity(replica, request), page, { - direction: 'newer', anchor: request.anchorSequence, - }), - bytes: page.durable, - }; - }); - } - - async loadTranscriptAround( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - if (request.anchorSequence === null) { - throw new Error('Desktop transcript around request requires an anchor'); - } - const sequence = request.anchorSequence; - await this.#runTranscriptRangeOperation(request, targetId, true, async (replica, isCurrent) => { - const snapshot = await replica.loadAround( - sequence, - requireTranscriptRangeBytes(request.maxBytes), - isCurrent, - ); - return snapshot && { - batches: encodeDesktopTranscriptSnapshot(snapshot, request.navigation), - bytes: [...snapshot.durable, ...snapshot.overlay.map((message) => ({ message }))], - }; - }); - } - - async loadTranscriptLatest( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): Promise { - const { state, replica, consumer } = this.#admitTranscriptNavigation(request, targetId, true); - if (!consumer) return; - // This answer is the tail cache, and global reclaim trims that cache even - // while the Session is open (`#touchReplica`). Refill it first or a reader - // returning to latest is answered with less than a tail. - await replica.refillTail( - requireTranscriptRangeBytes(request.maxBytes), - this.#transcriptReadIsCurrent(state, replica, consumer, request), - ); - if (!this.#transcriptReadIsCurrent(state, replica, consumer, request)()) return; - consumer.resetRequested = true; - consumer.resetNavigation = request.navigation; + /** Delivers the next history budget older than what the consumer holds. */ + async loadEarlierTranscript(consumerId: string, targetId?: number): Promise { + const state = this.#transcriptConsumers.get(consumerId); + const consumer = state?.transcriptConsumers.get(consumerId); + if (!state || !consumer?.history) { + throw new Error('Desktop transcript history consumer does not exist'); + } + if (targetId !== undefined && consumer.target.id !== targetId) { + throw new Error('Desktop transcript consumer belongs to another renderer'); + } + consumer.earlierRequested = true; await this.#scheduleTranscriptDelivery(state, consumer); + // The loop may have been finishing when the request arrived. + if (consumer.earlierRequested) await this.#scheduleTranscriptDelivery(state, consumer); this.#touchReplica(state); } - #pageIdentity(replica: DesktopTranscriptReplica, request: DesktopTranscriptRangeRequest) { - return { - sessionId: replica.sessionId, - generation: replica.generation, - hostEpoch: replica.hostEpoch, - navigation: request.navigation, - }; - } - - /** - * Main's navigation number is a cancellation hint and nothing more: dropping - * it would leave the system correct, because the Renderer decides what its - * window can splice from the anchors the answers carry. What it buys is not - * reading and shipping pages for a window the reader has already left. - */ - #admitTranscriptNavigation( - request: DesktopTranscriptRangeRequest, - targetId: number | undefined, - replaces: boolean, - ): { state: ObservedSessionState; replica: DesktopTranscriptReplica; consumer?: TranscriptConsumer } { - const { state, replica, consumer } = this.#requireTranscriptConsumer(request, targetId); - const navigation = request.navigation; - if (!Number.isSafeInteger(navigation) || navigation < 0) throw new Error('Invalid transcript navigation version'); - if (navigation < consumer.navigation) return { state, replica }; - if (replaces && navigation > consumer.navigation) { - consumer.navigation = navigation; - // Every queued answer was read for a window this replacement discards. - consumer.pendingPages.splice(0).forEach((page) => - this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes), - ); - } - return { state, replica, consumer }; - } - - /** Asked before a read is started and again before its answer is sent. */ - #deliversTranscriptPage(consumer: TranscriptConsumer, navigation: number): boolean { - return navigation >= consumer.navigation; - } - - /** Whether a Host read still belongs to the window that asked for it. */ - #transcriptReadIsCurrent( - state: ObservedSessionState, - replica: DesktopTranscriptReplica, - consumer: TranscriptConsumer, - request: DesktopTranscriptRangeRequest, - ): () => boolean { - return () => - state.replica === replica && - state.transcriptConsumers.get(request.consumerId) === consumer && - this.#deliversTranscriptPage(consumer, request.navigation); - } - - async #runTranscriptRangeOperation( - request: DesktopTranscriptRangeRequest, - targetId: number | undefined, - replaces: boolean, - operation: ( - replica: DesktopTranscriptReplica, - isCurrent: () => boolean, - ) => Promise< - | { batches: Iterable; bytes: readonly { readonly message: StoredMessage }[] } - | undefined - >, - ): Promise { - const { state, replica, consumer } = this.#admitTranscriptNavigation(request, targetId, replaces); - if (!consumer) return; - const isCurrent = this.#transcriptReadIsCurrent(state, replica, consumer, request); - let answer: Awaited>; + /** Every message of one Turn, whether or not any consumer holds it. */ + async readTranscriptTurn(sessionId: string, turnId: string): Promise { + this.#assertOpen(); + const state = this.#state(sessionId); + state.pendingTranscriptConsumers += 1; try { - answer = await operation(replica, isCurrent); - } catch (error) { - // A replaced replica's failure is not a failure of the current window. - if (!isCurrent()) return; - throw error; - } - if (!answer || !isCurrent()) return; - const encodedBytes = answer.bytes.reduce( - (total, { message }) => total + encodedTranscriptMessageBytes(message), - 0, - ); - if (!this.#adjustTranscriptDeliveryBytes(consumer, encodedBytes)) { - throw new Error('Desktop transcript delivery capacity was reached'); + await state.subscriptionOwner.waitUntilReady(); + if (!state.replica?.resident) await state.subscriptionOwner.refresh(); + const replica = state.replica; + if (!replica?.resident) throw new Error('Desktop transcript replica is unavailable'); + const turns = await this.#client.listSessionTurns?.(sessionId); + const firstSequence = turns?.find((turn) => turn.turnId === turnId)?.firstSequence; + if (firstSequence === undefined) return replica.messagesForTurn(turnId); + return await replica.readTurn(turnId, firstSequence, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES); + } finally { + state.pendingTranscriptConsumers -= 1; + this.#touchReplica(state); + void this.#closeIfIdle(state); } - consumer.pendingPages.push({ - navigation: request.navigation, - generation: replica.generation, - batches: answer.batches, - encodedBytes, - }); - await this.#scheduleTranscriptDelivery(state, consumer); - if (isCurrent()) this.#touchReplica(state); } async closeTranscript(consumerId: string, targetId?: number): Promise { @@ -1371,38 +1252,30 @@ export class RuntimeHostSessionObserver { while (state.transcriptConsumers.get(consumer.consumerId) === consumer) { if (consumer.resetRequested) { consumer.resetRequested = false; - const resetNavigation = consumer.resetNavigation; - consumer.resetNavigation = undefined; this.#clearPendingTranscriptChange(consumer); const replica = state.replica; if (!replica?.resident || state.closing) return; + consumer.generation = replica.generation; + if (consumer.history) { + await this.#sendTranscriptHistory(state, consumer, consumer.history, replica, true); + continue; + } const deliveryBytes = resetDeliveryWorkingSetBytes(replica.residentBytes); if (!this.#adjustTranscriptDeliveryBytes(consumer, deliveryBytes)) { throw new Error('Desktop transcript delivery capacity was reached'); } - consumer.generation = replica.generation; try { - await this.#sendTranscriptBatches( - consumer, - encodeDesktopTranscriptSnapshot(replica.snapshot(), resetNavigation), - ); + await this.#sendTranscriptBatches(consumer, encodeDesktopTranscriptSnapshot(replica.snapshot())); } finally { this.#adjustTranscriptDeliveryBytes(consumer, -deliveryBytes); } continue; } - const page = consumer.pendingPages.shift(); - if (page) { - try { - if ( - this.#deliversTranscriptPage(consumer, page.navigation) && - page.generation === consumer.generation && - state.replica?.generation === consumer.generation - ) { - await this.#sendTranscriptBatches(consumer, page.batches); - } - } finally { - this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes); + if (consumer.earlierRequested) { + consumer.earlierRequested = false; + const replica = state.replica; + if (consumer.history && replica?.resident && replica.generation === consumer.generation) { + await this.#sendTranscriptHistory(state, consumer, consumer.history, replica, false); } continue; } @@ -1455,17 +1328,124 @@ export class RuntimeHostSessionObserver { } /** - * Coalesces tail growth for one consumer. A merged change can only keep rows - * while each change starts where the last one ended; where it does not, the - * rows go and the merge carries nothing but the watermark, which is enough - * for the window to learn it has fallen behind and read forward itself. + * One history answer: a reset reads the newest whole Turns through the tail + * watermark and ends with the overlay; an earlier read continues below what + * was delivered. Each answer stops at a Turn boundary once it reaches its + * byte budget. */ + async #sendTranscriptHistory( + state: ObservedSessionState, + consumer: TranscriptConsumer, + history: TranscriptHistory, + replica: DesktopTranscriptReplica, + reset: boolean, + ): Promise { + let overlay: readonly StoredMessage[] = []; + let earlierThan: number | undefined; + if (reset) { + const snapshot = replica.snapshot(); + overlay = snapshot.overlay; + this.#adjustTranscriptDeliveryBytes(consumer, -history.carryBytes); + Object.assign(history, { + throughSequence: snapshot.durableThrough, + started: false, + cursor: null, + carry: [], + carryBytes: 0, + oldestSequence: null, + }); + } else { + if (history.oldestSequence === null || !historyHasOlder(history)) return; + earlierThan = history.oldestSequence; + history.budgets += 1; + } + const budget = this.#transcriptHistoryBytes * (reset ? history.budgets : 1); + const identity = { sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch }; + const isCurrent = () => + state.replica === replica && + state.transcriptConsumers.get(consumer.consumerId) === consumer && + !consumer.resetRequested; + let first = true; + const send = async (durable: readonly DesktopSequencedTranscriptMessage[], ready: boolean) => { + if (!ready && durable.length === 0) return; + await this.#sendTranscriptBatches( + consumer, + encodeDesktopTranscriptBatches(identity, { + durableThrough: history.throughSequence, + durable, + overlay: ready ? overlay : [], + hasOlder: historyHasOlder(history), + ...(earlierThan === undefined ? {} : { earlierThan }), + reset: reset && first, + ready, + }), + ); + first = false; + }; + let bytes = 0; + let boundary: string | undefined; + while (history.throughSequence !== null) { + let rows: DesktopSequencedTranscriptMessage[]; + let rowsBytes: number; + if (history.carry.length > 0) { + rows = history.carry; + rowsBytes = history.carryBytes; + history.carry = []; + history.carryBytes = 0; + } else if (!history.started || history.cursor !== null) { + let page: Awaited>; + try { + page = await replica.readOlderPage(history.throughSequence, history.cursor); + } catch (error) { + if (!isCurrent()) return; + throw error; + } + if (!isCurrent()) return; + history.started = true; + history.cursor = page.nextCursor; + rows = [...page.durable]; + rowsBytes = rows.reduce((total, entry) => total + encodedTranscriptMessageBytes(entry.message), 0); + if (!this.#adjustTranscriptDeliveryBytes(consumer, rowsBytes)) { + throw new Error('Desktop transcript delivery capacity was reached'); + } + } else { + break; + } + let cut = 0; + for (let index = rows.length - 1; index >= 0; index -= 1) { + const key = transcriptTurnKey(rows[index]!); + if (boundary !== undefined && key !== boundary) { + cut = index + 1; + break; + } + bytes += encodedTranscriptMessageBytes(rows[index]!.message); + if (boundary === undefined && bytes >= budget) boundary = key; + } + history.carry = rows.slice(0, cut); + history.carryBytes = history.carry.reduce( + (total, entry) => total + encodedTranscriptMessageBytes(entry.message), + 0, + ); + const delivered = rows.slice(cut); + if (delivered.length > 0) history.oldestSequence = delivered[0]!.sequence; + try { + await send(delivered, false); + } finally { + this.#adjustTranscriptDeliveryBytes(consumer, history.carryBytes - rowsBytes); + } + if (!isCurrent() || cut > 0) break; + } + if (isCurrent()) await send([], true); + } + + /** Coalesces tail growth for one consumer; a change that does not join the pending one needs a reset. */ #mergeTranscriptChange( consumer: TranscriptConsumer, change: DesktopTranscriptReplicaChange, ): boolean { if (consumer.resetRequested) return true; const existing = consumer.pendingChange; + if (existing && existing.durableThrough !== change.coversFrom) return false; const pending = existing ?? { coversFrom: change.coversFrom, durableThrough: change.durableThrough, @@ -1473,15 +1453,8 @@ export class RuntimeHostSessionObserver { encodedBytes: 0, }; let byteDelta = 0; - const joins = !existing || - (pending.coversFrom !== undefined && pending.durableThrough === change.coversFrom); - if (!joins) { - for (const { encodedBytes } of pending.durableUpserts.values()) byteDelta -= encodedBytes; - pending.durableUpserts.clear(); - pending.coversFrom = undefined; - } pending.durableThrough = change.durableThrough; - for (const entry of joins ? change.durableUpserts : []) { + for (const entry of change.durableUpserts) { const previous = pending.durableUpserts.get(entry.sequence); if (previous) byteDelta -= previous.encodedBytes; const encodedBytes = encodedTranscriptMessageBytes(entry.message); @@ -1597,42 +1570,6 @@ export class RuntimeHostSessionObserver { await Promise.all(deliveries); } - #requireTranscriptConsumer( - request: DesktopTranscriptRangeRequest, - targetId?: number, - ): { - state: ObservedSessionState; - replica: DesktopTranscriptReplica; - consumer: TranscriptConsumer; - } { - const state = this.#transcriptConsumers.get(request.consumerId); - const consumer = state?.transcriptConsumers.get(request.consumerId); - const replica = state?.replica; - if (!state || !consumer || !replica) { - throw new Error('Desktop transcript consumer does not exist'); - } - if (targetId !== undefined && consumer.target.id !== targetId) { - throw new Error('Desktop transcript consumer belongs to another renderer'); - } - // Durable transcript sequence identities belong to the Session and the - // Runtime Host epoch, not to a Desktop replica generation. Recovery may - // install a replacement replica (new generation, same session and host - // epoch) after the renderer dispatches a range request; continue that - // read against the current replica so navigation completes across - // reconnect. When the Host itself is replaced, sequence identity is not - // preserved, so reject the stale request instead of silently reading a - // different slice. - if (state.sessionId !== request.sessionId) { - throw new Error('Desktop transcript consumer belongs to another session'); - } - if (replica.hostEpoch !== request.hostEpoch) { - throw new Error( - `${DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE}: Desktop transcript host epoch changed; reopen the transcript`, - ); - } - return { state, replica, consumer }; - } - #detachTranscriptConsumer( state: ObservedSessionState, consumer: TranscriptConsumer, @@ -1641,10 +1578,8 @@ export class RuntimeHostSessionObserver { state.transcriptConsumers.delete(consumer.consumerId); this.#transcriptConsumers.delete(consumer.consumerId); consumer.resetRequested = false; + consumer.earlierRequested = false; this.#clearPendingTranscriptChange(consumer); - consumer.pendingPages.splice(0).forEach((page) => - this.#adjustTranscriptDeliveryBytes(consumer, -page.encodedBytes), - ); for (const pending of consumer.pendingDeliveries.values()) { pending.reject(new Error('Desktop transcript consumer was closed')); } @@ -1713,20 +1648,18 @@ function encodedTranscriptMessageBytes(message: StoredMessage): number { return Buffer.byteLength(JSON.stringify(message), 'utf8'); } -function requireTranscriptRangeBytes(value: number): number { - if ( - !Number.isSafeInteger(value) || - value < 1 || - value > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES - ) { - throw new Error('Invalid Desktop transcript range byte limit'); - } - return value; +function historyHasOlder(history: TranscriptHistory): boolean { + return history.carry.length > 0 || (history.started ? history.cursor !== null : history.throughSequence !== null); +} + +function transcriptTurnKey(entry: DesktopSequencedTranscriptMessage): string { + const turnId = 'turnId' in entry.message ? entry.message.turnId : undefined; + return typeof turnId === 'string' ? `turn:${turnId}` : `sequence:${entry.sequence}`; } function resetDeliveryWorkingSetBytes(residentBytes: number): number { return ( - Math.min(residentBytes, SESSION_TRANSCRIPT_RANGE_MAX_BYTES) + + Math.min(residentBytes, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES) + Math.min( residentBytes, (TRANSCRIPT_DELIVERY_WINDOW * 2 + 1) * DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index f8d15013a3..4d640fce00 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -73,7 +73,7 @@ import type { } from '@maka/core/runtime-inputs'; import type { PlanSessionState } from '@maka/core/plan'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; -import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; +import type { SessionChangedEvent, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { E2eFixtureState } from '@maka/core/e2e-fixture'; import type { @@ -113,6 +113,7 @@ import type { DeepResearchChangedEvent, DeepResearchClientProgress } from '@maka import type { DesktopTranscriptBatch, DesktopTranscriptHandle, + DesktopTranscriptOpenMode, } from './transcript-contract.js'; import type { PetPackManifestV1 } from '@maka/core/pet'; import type { @@ -1224,7 +1225,6 @@ export interface MakaBridge { }) => void, ): () => void; listTurns(sessionId: string): Promise; - listTurnLandmarks(sessionId: string): Promise>; compact(sessionId: string): Promise>; resumeLatest(sessionId: string): Promise< | { disposition: 'started'; runId: string; turnId: string } @@ -1319,10 +1319,14 @@ export interface MakaBridge { abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; }; transcripts: { + /** Every message of one Turn, read from the Host rather than from any open transcript. */ + readTurn(sessionId: string, turnId: string): Promise; + /** Opens the whole transcript unless a consumer asks for the tail alone. */ open( sessionId: string, handler: (batch: DesktopTranscriptBatch) => void, registerCancellation?: (cancel: () => void) => void, + mode?: DesktopTranscriptOpenMode, ): Promise; }; externalSessions: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 677e6f7dff..153373f04f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -94,10 +94,10 @@ import { type DesktopHostExternalSessionCatalogItem, } from './external-session-catalog.js'; import { - DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, assertDesktopTranscriptBatch, type DesktopTranscriptBatch, type DesktopTranscriptHandle, + type DesktopTranscriptOpenMode, type DesktopTranscriptOpenResult, } from './transcript-contract.js'; import { @@ -167,6 +167,7 @@ import type { SessionCatalogSummary, SessionChangedEvent, SessionSummary, + StoredMessage, TurnRecord, } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; @@ -266,6 +267,7 @@ import { projectDesktopDailyReviewSummary, projectDesktopSessionEvent, projectDesktopSessionSummary, + projectDesktopStoredMessage, projectDesktopTurnRecord, projectDesktopUsageStats, type DesktopSessionSummary, @@ -2269,9 +2271,6 @@ const makaBridge = { ) as TurnRecord[]; return turns.map((turn) => projectDesktopTurnRecord(session.scope, turn)); }, - listTurnLandmarks(sessionId) { - return invokeProjectedSessionRuntimeHost('sessions:listTurnLandmarks', sessionId); - }, regenerateTurn(sessionId: string, input: RegenerateTurnInput): Promise { return invokeSessionRuntimeHost('sessions:regenerateTurn', sessionId, input); }, @@ -2531,10 +2530,21 @@ const makaBridge = { }, }, transcripts: { + async readTurn(sessionId: string, turnId: string): Promise { + const session = await runtimeHostSessionRef(sessionId); + const messages = await ipcRenderer.invoke( + 'sessions:transcript:read-turn', + session.scope, + session.sessionId, + turnId, + ) as StoredMessage[]; + return messages.map((message) => projectDesktopStoredMessage(session.scope, message)); + }, async open( sessionId: string, handler: (batch: DesktopTranscriptBatch) => void, registerCancellation?: (cancel: () => void) => void, + mode: DesktopTranscriptOpenMode = 'history', ): Promise { const consumerId = crypto.randomUUID(); const channel = `sessions:transcript:${consumerId}`; @@ -2600,6 +2610,7 @@ const makaBridge = { session.scope, session.sessionId, consumerId, + mode, ) as Promise>, }; }); @@ -2626,8 +2637,7 @@ const makaBridge = { return { ...cachedIdentity, sessionId, readThroughMessageId: null, acknowledgeTail: unavailable, - loadBefore: unavailable, loadAfter: unavailable, loadAround: unavailable, - loadLatest: unavailable, + loadEarlier: unavailable, close: async () => {}, }; } @@ -2641,29 +2651,6 @@ const makaBridge = { const opened = openResult.value; if (closed) throw new Error('Desktop transcript open was cancelled'); identity ??= { generation: opened.generation, hostEpoch: opened.hostEpoch }; - const range = ( - operation: - | 'sessions:transcript:load-before' - | 'sessions:transcript:load-after' - | 'sessions:transcript:load-around' - | 'sessions:transcript:load-latest', - anchorSequence: number | null, - maxBytes: number, - navigation: number, - ): Promise => { - const currentIdentity = identity; - if (!currentIdentity) { - throw new Error('Desktop transcript identity is unavailable'); - } - return ipcRenderer.invoke(operation, consumerScope, { - consumerId, - sessionId: opened.sessionId, - hostEpoch: currentIdentity.hostEpoch, - anchorSequence, - maxBytes, - navigation, - }) as Promise; - }; return { ...opened, sessionId, @@ -2679,14 +2666,8 @@ const makaBridge = { through, }) as Promise; }, - loadBefore: (anchorSequence, maxBytes, navigation) => - range('sessions:transcript:load-before', anchorSequence, maxBytes, navigation), - loadAfter: (anchorSequence, maxBytes, navigation) => - range('sessions:transcript:load-after', anchorSequence, maxBytes, navigation), - loadAround: (sequence, maxBytes, navigation) => - range('sessions:transcript:load-around', sequence, maxBytes, navigation), - loadLatest: (navigation) => - range('sessions:transcript:load-latest', null, DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, navigation), + loadEarlier: () => + ipcRenderer.invoke('sessions:transcript:load-earlier', consumerScope, consumerId) as Promise, async close() { if (closed) return; requestClose(); diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts index 515d3f0b51..6ea52d2ec8 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -18,17 +18,14 @@ */ export const DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES = 128 * 1024; -export const DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES = 512 * 1024; -/** Turns the Main tail cache keeps for the projector and for the tail the Renderer opens with. */ +export const DESKTOP_TRANSCRIPT_TAIL_MAX_BYTES = 512 * 1024; +/** Turns the Main tail cache keeps for the projector and for tail-only consumers. */ export const DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS = 10; export const DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES = 16 * 1024 * 1024; -/** - * Main rejects a read whose Host epoch moved under it. `ipcRenderer.invoke` - * carries nothing across but the Error's message, so both sides name the - * rejection by this code rather than by matching prose. - */ -export const DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE = 'DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED'; +export const DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES = 16 * 1024 * 1024; export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; +/** Projected message bytes of whole Turns one history read delivers: the first read and each "load earlier". */ +export const DESKTOP_TRANSCRIPT_HISTORY_MAX_BYTES = 64 * 1024 * 1024; export interface DesktopTranscriptFragment { readonly source: 'durable' | 'overlay'; @@ -44,15 +41,14 @@ export interface DesktopTranscriptFragment { * cannot be read off durable sequence numbers: they advance by a stride, so * only the Host read that produced a row proves what it is contiguous with. * - * - `extends` names the edge a page read started from. - * - `coversFrom` names the watermark a tail change read forward from; absent - * means the batch claims no contiguity and only moves the watermark. - * - `navigation` appears on the reset answering `loadAround` / `loadLatest`, - * which replaces the window outright instead of splicing onto it. + * - `reset` starts a replacement of everything the consumer holds. + * - `earlierThan` starts earlier history that ends just before that sequence. + * - `coversFrom` names the watermark a tail change read forward from. + * + * One answer spans batches until `ready`; `hasOlder` is final only there. */ export interface DesktopTranscriptBatchPayload { - readonly navigation?: number; - readonly extends?: DesktopTranscriptExtension; + readonly earlierThan?: number; readonly coversFrom?: number | null; readonly sessionId: string; readonly generation: string; @@ -60,16 +56,10 @@ export interface DesktopTranscriptBatchPayload { readonly durableThrough: number | null; readonly fragments: readonly DesktopTranscriptFragment[]; readonly hasOlder?: boolean; - readonly hasNewer?: boolean; readonly reset: boolean; readonly ready: boolean; } -export interface DesktopTranscriptExtension { - readonly direction: 'older' | 'newer'; - readonly anchor: number | null; -} - export interface DesktopTranscriptBatch extends DesktopTranscriptBatchPayload { readonly deliverySequence: number; } @@ -81,19 +71,16 @@ export interface DesktopTranscriptOpenResult { readonly readThroughMessageId: string | null; } -export interface DesktopTranscriptRangeRequest { - readonly navigation: number; - readonly consumerId: string; - readonly sessionId: string; - readonly hostEpoch: string; - readonly anchorSequence: number | null; - readonly maxBytes: number; -} +/** + * Tail-only consumers get the Main tail cache; history consumers get the + * newest whole Turns up to the history budget and may ask for earlier ones. + */ +export type DesktopTranscriptOpenMode = 'tail' | 'history'; /** - * The Renderer reporting that its window now holds every durable row through + * The Renderer reporting that it now holds every durable row through * `through`. Main cannot derive this: a consumer only proves the Session is - * open, and a tail change a parked window refuses moves no window. + * open, and a change still assembling in the Renderer moves no reader. */ export interface DesktopTranscriptTailAcknowledgement { readonly consumerId: string; @@ -104,10 +91,7 @@ export interface DesktopTranscriptTailAcknowledgement { export interface DesktopTranscriptHandle extends DesktopTranscriptOpenResult { acknowledgeTail(through: number): Promise; - loadBefore(anchorSequence: number | null, maxBytes: number, navigation: number): Promise; - loadAfter(anchorSequence: number | null, maxBytes: number, navigation: number): Promise; - loadAround(sequence: number, maxBytes: number, navigation: number): Promise; - loadLatest(navigation: number): Promise; + loadEarlier(): Promise; close(): Promise; } @@ -118,8 +102,7 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB const batch = value as Record; if ( typeof batch.sessionId !== 'string' || - (batch.navigation !== undefined && !isSequence(batch.navigation)) || - !isExtension(batch.extends) || + (batch.earlierThan !== undefined && !isSequence(batch.earlierThan)) || (batch.coversFrom !== undefined && batch.coversFrom !== null && !isSequence(batch.coversFrom)) || !isSequence(batch.deliverySequence) || typeof batch.generation !== 'string' || @@ -127,7 +110,6 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB (batch.durableThrough !== null && !isSequence(batch.durableThrough)) || !Array.isArray(batch.fragments) || (batch.hasOlder !== undefined && typeof batch.hasOlder !== 'boolean') || - (batch.hasNewer !== undefined && typeof batch.hasNewer !== 'boolean') || typeof batch.reset !== 'boolean' || typeof batch.ready !== 'boolean' ) { @@ -173,11 +155,3 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB function isSequence(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) >= 0; } - -function isExtension(value: unknown): boolean { - if (value === undefined) return true; - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const extension = value as Record; - return (extension.direction === 'older' || extension.direction === 'newer') && - (extension.anchor === null || isSequence(extension.anchor)); -} diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index ad4225ffa8..4e34674ac5 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -168,7 +168,7 @@ export function createAppShellChatActions(deps: { removeTransientMessage: (sessionId: string, messageId: string) => void; transcriptRangeRef: RefBox; isMessagePublished: (message: StoredMessage) => boolean; - onFollowLatest: (sessionId: string) => Promise; + onFollowLatest: (sessionId: string) => boolean; /** #646: arm the "正在处理…" indicator locally at send() — the model-wait * window opens before any SessionEvent arrives (turn_started is not one). */ setInteractionBySession: InteractionQueueUpdater; @@ -436,7 +436,7 @@ export function createAppShellChatActions(deps: { void refreshSessions().catch(() => undefined); return true; } - if (!await onFollowLatest(initialSessionId)) return false; + if (!onFollowLatest(initialSessionId)) return false; optimisticSessionId = initialSessionId; optimisticMessageId = messageId; publishTransientUserMessage(initialSessionId, { diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index c3d13c4f99..13d3dcb7f1 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -418,7 +418,7 @@ export function useActiveSessionEvents(options: { else signal.addEventListener('abort', cancel, { once: true }); }, ); - const controller = desktopTranscript.createRecoveringDesktopTranscriptRangeController( + const controller = desktopTranscript.createDesktopTranscriptRangeController( transcript, openTranscript, { diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index f68acb2764..90a8808529 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -426,11 +426,6 @@ function AppShellContent({ const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const transcriptReadingCommands = useRef(null); - const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ - sessionId: string; - throughSequence: number | null; - turns: readonly { turnId: string; sequence: number; label: string }[]; - }>(); const [petCompletionNonce, setPetCompletionNonce] = useState(0); const [navigationState, setNavigationState] = useState(() => readNavigationState()); const navSelection = navigationState.selection; @@ -1457,7 +1452,7 @@ function AppShellContent({ updateTransientMessage, removeTransientMessage, transcriptRangeRef, - onFollowLatest: (sessionId) => transcriptReadingCommands.current?.prepareSend(sessionId) ?? Promise.resolve(true), + onFollowLatest: (sessionId) => transcriptReadingCommands.current?.prepareSend(sessionId) ?? true, isMessagePublished, setInteractionBySession: sessionUiController.setInteractionBySession, onInteractionChanged: markInteractionChanged, @@ -2266,19 +2261,12 @@ function AppShellContent({ rangeController={transcriptRangeRef} messages={messages} searchTarget={searchScrollTarget} - landmarkSessionId={ownerActiveId ?? null} clearSearchTarget={() => setSearchScrollTarget(null)} sessionUi={sessionUiController} - turnIndex={transcriptTurnIndex} - setTurnIndex={setTranscriptTurnIndex} - listTurnLandmarks={(sessionId) => window.maka.sessions.listTurnLandmarks(sessionId)} onRestoreError={(error, sessionId) => sessionUiController.setMessageLoadErrorBySession((current) => ({ ...current, [sessionId]: localizedShellErrorMessage(error, desktopConversationCopy.actions.operationFailedFallback, uiLocale), }))} - onNavigationError={(error, sessionId) => showSessionError(sessionId, - desktopConversationCopy.actions.messageReadFailedTitle, - localizedShellErrorMessage(error, desktopConversationCopy.actions.operationFailedFallback, uiLocale))} /> transcriptReadingCommands.current?.returnToLatest() - : undefined} hidden={workHubActive || !sessionsSelected} composer={ <> @@ -2529,7 +2514,7 @@ function AppShellContent({ activeModel={activeModel} activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} - latestRequestUsageTokens={selectLatestRequestUsage(messages, activeTranscriptRange, activeModel, activeSessionForModelControls)} + latestRequestUsageTokens={selectLatestRequestUsage(messages, activeModel, activeSessionForModelControls)} onOpenContextUsage={() => commands.openTool('inspector')} LiveContextUsageProbe={LiveContextUsageProbe} contextUsageSessionId={ownerActiveId} @@ -2613,11 +2598,8 @@ function AppShellContent({ sessionUiController={sessionUiController} activeSessionId={activeId} activeTurn={Conversation.chatTurnActivity(activeExecution)} - hasOlderHistory={activeTranscriptRange?.hasOlder} - hasNewerHistory={activeTranscriptRange?.hasNewer} - onPrefetchHistory={(edge) => - transcriptReadingCommands.current?.prefetchHistory(edge) ?? Promise.resolve(false)} - onRetainWindow={(band) => transcriptReadingCommands.current?.retainWindow(band)} + hasEarlierHistory={activeTranscriptRange?.hasOlder} + onLoadEarlierHistory={() => transcriptReadingCommands.current?.loadEarlier()} liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} transientMessages={transientMessages} @@ -2667,14 +2649,6 @@ function AppShellContent({ onReadingAnchorChange={activeId ? (turnId) => transcriptReadingCommands.current?.captureAnchor(turnId) : undefined} - transcriptTurnIndex={ - transcriptTurnIndex && transcriptTurnIndex.sessionId === activeId - ? transcriptTurnIndex.turns - : undefined - } - onLoadTranscriptTurn={activeId - ? (target) => openSessionInChat(activeId, target.turnId, target.sequence) - : undefined} scrollBehavior={readScrollMotionBehavior()} branchBanner={branchBanner} onBranchBannerClick={openSessionInChat} diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts index 62635a5cee..ce32b827fe 100644 --- a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts +++ b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts @@ -47,13 +47,11 @@ export interface LatestRequestUsageAnchor { export function selectLatestRequestUsage( messages: readonly { type: string; lastRequestAnchor?: LatestRequestUsageAnchor }[], - /** `hasNewer` means the loaded range is not the session tail. */ - range: { hasNewer?: boolean } | undefined, model: string | undefined, route: { llmConnectionId?: string } | undefined, ): number | undefined { const connectionId = route?.llmConnectionId; - if (range?.hasNewer || model === undefined || connectionId === undefined) return undefined; + if (model === undefined || connectionId === undefined) return undefined; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (message?.type !== 'token_usage') continue; diff --git a/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts b/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts index b06bea0c6e..4b89d962ed 100644 --- a/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts +++ b/apps/desktop/src/renderer/application/contracts/transient-message-projection.ts @@ -70,9 +70,7 @@ export function mergeTransientMessageProjection( export function reconcileTransientMessages( transient: Map, durable: readonly StoredMessage[], - options: { includeTransient?: boolean } = {}, ): TransientUserMessage[] { for (const message of durable) transient.delete(message.id); - if (transient.size === 0 || options.includeTransient === false) return []; return [...transient.values()]; } diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index 9cef7a1e5d..11299b5c55 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -62,9 +62,7 @@ interface ChatMessageSurfaceProps extends Omit< | 'liveTurns' | 'shellRunUpdates' | 'goalIndicator' - | 'onPrefetchHistory' - | 'onRetainWindow' ->, Required, 'onPrefetchHistory' | 'onRetainWindow'>> { +> { /** * #1985: the live projection and the shell-run records are the only session * UI state that changes per streamed token, and this surface is their only diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx index f8391b0f7f..da7bdafbe9 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx @@ -17,42 +17,21 @@ * under the License. */ -import { useEffect, useImperativeHandle, useRef, useState, type Dispatch, type Ref, type SetStateAction } from 'react'; +import { useEffect, useImperativeHandle, useState, type Ref } from 'react'; import type { StoredMessage } from '@maka/core/session'; import type { AppShellSessionUiStateController } from '../model/session-ui-state.js'; import { - captureTranscriptReadingAnchor, createTranscriptRestoreLifecycle, - currentTranscriptRange, - newestDurablePromptSequence, prepareTranscriptForSend, - refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, - TranscriptReadSupersededError, } from './transcript-reading-position.js'; -type RangeController = NonNullable>[0]['controller']> & { - readonly store: { - retain(oldestSequence: number | null, newestSequence: number | null): boolean; - snapshot(): object; - }; - loadBefore(maxBytes?: number): Promise; - loadAfter(maxBytes?: number): Promise; - loadLatest(): Promise; -}; - -interface TurnIndex { - sessionId: string; - throughSequence: number | null; - turns: readonly { turnId: string; sequence: number; label: string }[]; -} +type RangeController = NonNullable>[0]['controller']>; export interface TranscriptReadingPositionCommands { - prepareSend(sessionId: string): Promise; + prepareSend(sessionId: string): boolean; captureAnchor(turnId?: string): void; - returnToLatest(): Promise; - prefetchHistory(edge: 'older' | 'newer'): Promise; - retainWindow(window: { firstTurnId: string; lastTurnId: string }): void; + loadEarlier(): Promise; } /** The conversation owns restoration lifetime; the shell supplies explicit ports. */ @@ -60,29 +39,17 @@ export function TranscriptReadingPositionController(props: { commands: Ref; sessionId?: string; profileId?: string; - landmarkSessionId?: string | null; currentSessionId: { current: string | undefined }; rangeController: { current: RangeController | undefined }; messages: readonly StoredMessage[]; searchTarget: Parameters[0]['searchTarget']; clearSearchTarget(): void; sessionUi: AppShellSessionUiStateController; - turnIndex: TurnIndex | undefined; - setTurnIndex: Dispatch>; - listTurnLandmarks: Parameters>[0]['list']; onRestoreError(error: unknown, sessionId: string): void; - onNavigationError(error: unknown, sessionId: string): void; }) { const [lifecycle] = useState(createTranscriptRestoreLifecycle); - const lastLiveGeneration = useRef< - { sessionId: string; generation: string; hostEpoch: string } | undefined - >(undefined); const isCurrent = (sessionId: string, controller: object) => props.currentSessionId.current === sessionId && props.rangeController.current === controller; - const reportNavigationError = (error: unknown, sessionId: string, controller: object) => { - if (error instanceof TranscriptReadSupersededError) return; - if (isCurrent(sessionId, controller)) props.onNavigationError(error, sessionId); - }; const cancel = (sessionId: string, clearAnchor = false) => { lifecycle.cancel(sessionId); if (props.searchTarget?.sessionId === sessionId) props.clearSearchTarget(); @@ -94,8 +61,7 @@ export function TranscriptReadingPositionController(props: { useImperativeHandle(props.commands, () => ({ prepareSend(sessionId) { return prepareTranscriptForSend({ - sessionId, currentSessionId: props.currentSessionId, - controller: props.rangeController, cancel, + sessionId, currentSessionId: props.currentSessionId, cancel, followLatest: props.sessionUi.transcriptViewportNavigation.followLatest, }); }, @@ -103,68 +69,16 @@ export function TranscriptReadingPositionController(props: { const { sessionId } = props; if (!sessionId || props.currentSessionId.current !== sessionId) return; props.sessionUi.setTranscriptRestoreUnavailable(sessionId, undefined); - captureTranscriptReadingAnchor({ - sessionId, currentSessionId: props.currentSessionId.current, turnId, - controller: props.rangeController.current, - setAnchor: props.sessionUi.setTranscriptReadingAnchor, - }); - }, - retainWindow(window) { - const controller = props.rangeController.current; - const { sessionId } = props; - if (!controller || !sessionId || !isCurrent(sessionId, controller)) return; - if (currentTranscriptRange(controller, sessionId) === undefined) return; - try { - controller.store.retain( - controller.store.sequenceForTurn(window.firstTurnId, 'first'), - controller.store.sequenceForTurn(window.lastTurnId, 'last'), - ); - } catch { - // A stale range has no window to trim. - } + props.sessionUi.setTranscriptReadingAnchor(sessionId, turnId ? { turnId } : undefined); }, - /** - * Deliberately not `returnToLatest`: that one cancels restoration and - * clears the search target, because a reader who asks to go somewhere has - * decided where to be. Filling decides nothing, so it must leave an - * outstanding jump alone — the page it is waiting for can still be in - * flight. - * - * Safe to ask on every frame the geometry wants it: the range controller - * refuses a read against a window it has already read, and answers whether - * it issued one. - */ - async prefetchHistory(edge) { - const controller = props.rangeController.current; - const { sessionId } = props; - if (!controller || !sessionId || !isCurrent(sessionId, controller)) return false; - return edge === 'older' ? controller.loadBefore() : controller.loadAfter(); - }, - async returnToLatest() { + async loadEarlier() { const controller = props.rangeController.current; const { sessionId } = props; if (!controller || !sessionId || !isCurrent(sessionId, controller)) return; - cancel(sessionId, true); - try { - await controller.loadLatest(); - } catch (error) { - reportNavigationError(error, sessionId, controller); - } + await controller.loadEarlier(); }, })); - const newestPrompt = newestDurablePromptSequence(props.rangeController.current, props.sessionId); - const landmarkSessionId = props.landmarkSessionId === null - ? undefined - : props.landmarkSessionId ?? props.sessionId; - useEffect(() => refreshTranscriptTurnLandmarks({ - sessionId: landmarkSessionId, - newestDurablePromptSequence: newestPrompt, - current: props.turnIndex, - list: props.listTurnLandmarks, - isCurrent: (sessionId) => props.currentSessionId.current === sessionId, - setIndex: props.setTurnIndex, - }), [props.sessionId, landmarkSessionId, newestPrompt, props.turnIndex]); useEffect(() => () => { lifecycle.deactivate(); }, [props.sessionId, props.profileId, lifecycle]); @@ -182,44 +96,5 @@ export function TranscriptReadingPositionController(props: { onRestoreUnavailable: props.sessionUi.setTranscriptRestoreUnavailable, onError: props.onRestoreError, }), [props.sessionId, props.profileId, props.messages, props.searchTarget?.nonce]); - useEffect(() => { - const controller = props.rangeController.current; - const { sessionId } = props; - const range = currentTranscriptRange(controller, sessionId); - if (!controller || !sessionId || !range?.generation || !range.hostEpoch) return; - if (range.generation.startsWith('cached:')) return; - const previous = lastLiveGeneration.current; - lastLiveGeneration.current = { sessionId, generation: range.generation, hostEpoch: range.hostEpoch }; - if (!previous || previous.sessionId !== sessionId || previous.generation === range.generation) return; - const anchor = props.sessionUi.transcriptReadingAnchorBySessionRef.current[sessionId]; - if (!anchor || controller.store.sequenceForTurn(anchor.turnId) !== null) return; - const navigate = (sequence: number) => { - void controller.loadAround(sequence).catch((error) => { - reportNavigationError(error, sessionId, controller); - }); - }; - if (previous.hostEpoch === range.hostEpoch) { - if (anchor.sequence !== undefined) navigate(anchor.sequence); - return; - } - // Sequences only name the same rows within one Host epoch, so a bookmark - // carried across one has to be found again by Turn. The landmark index in - // hand still names the epoch that is gone, hence the refresh first. - if (landmarkSessionId !== sessionId) return; - const { turnId } = anchor; - let disposed = false; - void props.listTurnLandmarks(sessionId).then((snapshot) => { - if (disposed || !isCurrent(sessionId, controller)) return; - props.setTurnIndex({ sessionId, throughSequence: snapshot.throughSequence, turns: snapshot.landmarks }); - // A reader who has gone somewhere else since owns the position now. - if (props.sessionUi.transcriptReadingAnchorBySessionRef.current[sessionId]?.turnId !== turnId) return; - const landmark = snapshot.landmarks.find((turn) => turn.turnId === turnId); - // A Turn the new epoch does not name leaves the reader where the reset put them. - if (!landmark) return; - props.sessionUi.setTranscriptReadingAnchor(sessionId, { turnId, sequence: landmark.sequence }); - navigate(landmark.sequence); - }, () => undefined); - return () => { disposed = true; }; - }, [props.sessionId, props.messages]); return null; } diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts index 09ed6eddcb..51c3e9b533 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts @@ -19,53 +19,25 @@ import type { TranscriptReadingAnchor } from '../model/session-ui-state.js'; -interface TranscriptRangeStore { - readonly sessionId: string; - range(): { - readonly sessionId: string; - readonly hasNewer?: boolean; - readonly generation?: string; - readonly hostEpoch?: string; - }; - sequenceForTurn(turnId: string, edge?: 'first' | 'last'): number | null; - pendingNavigation(): number | undefined; - newestDurableUserSequence(): number | null; - snapshot(): { readonly messages: readonly Message[] }; -} - interface TranscriptRangeController { - readonly store: TranscriptRangeStore; - loadAround(sequence: number): Promise; -} - -/** - * A read the Host refused because the window it was stamped for belongs to a - * Runtime Host epoch that is gone. Nothing the reader asked for failed: the - * replacement reset carries the new epoch, and the cross-epoch re-anchor finds - * the bookmarked Turn in it. The platform adapter that speaks to the Host - * raises this; every reading-position path treats it as a read that was - * superseded rather than one that went wrong. - */ -export class TranscriptReadSupersededError extends Error { - constructor(message: string, options?: { cause?: unknown }) { - super(message, options); - this.name = 'TranscriptReadSupersededError'; - } + readonly store: { + range(): { readonly sessionId: string; readonly hasOlder: boolean; readonly ready: boolean }; + snapshot(): { readonly messages: readonly Message[] }; + }; + loadEarlier(): Promise; } interface SearchTarget { readonly sessionId: string; readonly turnId: string; - readonly sequence?: number; readonly nonce?: number; } interface TranscriptRestoreCommand { - target: TranscriptReadingAnchor; + readonly target: TranscriptReadingAnchor; readonly fromSearch: boolean; completed: boolean; - controller?: object; - attempt?: object; + loading?: object; } /** A bookmark survives navigation; a command to restore it does not. */ @@ -99,19 +71,12 @@ export function createTranscriptRestoreLifecycle() { profileId: input.profileId, searchKey, command: input.sessionId && target - ? { target: { turnId: target.turnId, sequence: target.sequence }, fromSearch: Boolean(search), completed: false } + ? { target: { turnId: target.turnId }, fromSearch: Boolean(search), completed: false } : undefined, }; } const command = activation?.command; - if (!command || command.completed) return; - if (command.target.sequence === undefined) { - const target = search ?? input.readingAnchor; - if (target?.turnId === command.target.turnId && target.sequence !== undefined) { - command.target = { ...command.target, sequence: target.sequence }; - } - } - return command; + return command && !command.completed ? command : undefined; }, isCurrent(command: TranscriptRestoreCommand): boolean { return activation?.command === command && !command.completed; @@ -131,29 +96,16 @@ export function createTranscriptRestoreLifecycle() { export type TranscriptRestoreLifecycle = ReturnType; -export async function prepareTranscriptForSend(options: { +export function prepareTranscriptForSend(options: { sessionId: string; currentSessionId: { current: string | undefined }; - controller: { current: (TranscriptRangeController & { loadLatest(): Promise }) | undefined }; cancel(sessionId: string, clearAnchor: boolean): void; followLatest(sessionId: string): void; -}): Promise { +}): boolean { const { sessionId } = options; if (options.currentSessionId.current !== sessionId) return false; options.cancel(sessionId, true); - const controller = options.controller.current; options.followLatest(sessionId); - if (!controller || controller.store.sessionId !== sessionId) return true; - // Invalidate pending history immediately, but keep local Message admission - // independent of an unopened, slow or offline transcript. The explicit pin - // happens once; a late page must not reclaim the viewport from the reader. - void (async () => { - try { - await controller.loadLatest(); - } catch { - // Catch-up failure must not prevent the Message from being saved locally. - } - })(); return true; } @@ -169,19 +121,6 @@ export function currentTranscriptRange( - controller: TranscriptRangeController | undefined, - sessionId: string | undefined, -): number | null { - try { - return controller && controller.store.range().sessionId === sessionId - ? controller.store.newestDurableUserSequence() - : null; - } catch { - return null; - } -} - export function transcriptRestoreTarget( anchor: TranscriptReadingAnchor | undefined, unavailableTurnId: string | undefined, @@ -197,42 +136,10 @@ export function transcriptRestoreTarget( : undefined; } -export function refreshTranscriptTurnLandmarks(options: { - readonly sessionId?: string; - readonly newestDurablePromptSequence: number | null; - readonly current?: { readonly sessionId: string; readonly throughSequence: number | null }; - readonly list: (sessionId: string) => Promise<{ readonly throughSequence: number | null; readonly landmarks: readonly T[] }>; - readonly isCurrent: (sessionId: string) => boolean; - readonly setIndex: (index: { sessionId: string; throughSequence: number | null; turns: readonly T[] } | undefined) => void; -}): (() => void) | undefined { - const { sessionId } = options; - if (!sessionId) { - options.setIndex(undefined); - return; - } - if ( - options.current?.sessionId === sessionId && - (options.newestDurablePromptSequence === null || - (options.current.throughSequence !== null && - options.newestDurablePromptSequence <= options.current.throughSequence)) - ) return; - let disposed = false; - void options.list(sessionId).then( - (snapshot) => { - if (disposed || !options.isCurrent(sessionId)) return; - options.setIndex({ - sessionId, - throughSequence: snapshot.throughSequence, - turns: snapshot.landmarks, - }); - }, - () => undefined, - ); - return () => { - disposed = true; - }; -} - +/** + * Finds the target Turn in the loaded transcript, loading earlier history + * until it appears or none is left. Runs again on every transcript change. + */ export function restoreSessionTranscriptRange(options: { readonly lifecycle: TranscriptRestoreLifecycle; readonly sessionId?: string; @@ -250,109 +157,36 @@ export function restoreSessionTranscriptRange(options: { }): void { const { controller, sessionId } = options; const command = options.lifecycle.request(options); - if (!command || !controller || !sessionId || !options.isCurrent(sessionId, controller)) return; - if (command.controller === controller && command.attempt) return; - if (!command.fromSearch && command.target.sequence === undefined) { - const { turnId } = command.target; - try { - const sequence = controller.store.range().sessionId === sessionId - ? controller.store.sequenceForTurn(turnId) - : null; - if (sequence !== null) { - command.target = { turnId, sequence }; - options.setReadingAnchor(sessionId, command.target); - } - } catch { - // A stale range cannot enrich the anchor, but also cannot invalidate it. - } - } - const target = command.target; - if (command.fromSearch && target.sequence === undefined) return; - const restoringReadingAnchor = !command.fromSearch; - const attempt = {}; - command.controller = controller; - command.attempt = attempt; - const current = (): boolean => options.lifecycle.isCurrent(command) - && command.attempt === attempt && options.isCurrent(sessionId, controller); - if (!current()) { - command.attempt = undefined; + if (!command || command.loading || !controller || !sessionId || !options.isCurrent(sessionId, controller)) return; + const range = currentTranscriptRange(controller, sessionId); + if (!range?.ready) return; + const { turnId } = command.target; + if (controller.store.snapshot().messages.some((message) => + message !== null && typeof message === 'object' && 'turnId' in message && message.turnId === turnId, + )) { + command.completed = true; return; } - let admitted: Promise; - try { - const residentSequence = currentTranscriptRange(controller, sessionId) - ? controller.store.sequenceForTurn(target.turnId) - : null; - // A resident target needs no page unless an older replacement is still - // pending. In that case this navigation must supersede the old read too. - admitted = (residentSequence !== null && controller.store.pendingNavigation() === undefined) - || target.sequence === undefined - ? Promise.resolve() - : controller.loadAround(target.sequence); - } catch (error) { - admitted = Promise.reject(error); - } - void admitted - .then(() => { - if (!current() || controller.store.range().sessionId !== sessionId) return false; - const residentSequence = controller.store.sequenceForTurn(target.turnId); - if (residentSequence !== null) { - if (restoringReadingAnchor && target.sequence === undefined) { - options.setReadingAnchor(sessionId, { turnId: target.turnId, sequence: residentSequence }); - } - return false; - } - if (controller.store.snapshot().messages.some((message) => - message !== null && typeof message === 'object' && - 'turnId' in message && message.turnId === target.turnId, - )) { - // Active Turns are overlay-only in the RuntimeEvent projection. Their - // bookmark is already visible even though no durable sequence exists. - return false; - } - return restoringReadingAnchor; - }) - .then((unavailable) => { - if (!current()) return; - command.completed = true; - if (unavailable) { - options.setReadingAnchor(sessionId, undefined); - options.onRestoreUnavailable?.(sessionId, target.turnId); - } - }) - .catch((error) => { - if (error instanceof TranscriptReadSupersededError) { - // The sequence this command carries names a row of the epoch that is - // gone, so retrying it would land somewhere else entirely. Consume the - // command and leave the position to the cross-epoch re-anchor. - command.completed = true; - return; - } - if (current()) options.onError(error, sessionId); - }) - .finally(() => { - if (command.attempt === attempt) command.attempt = undefined; - }); -} - -export function captureTranscriptReadingAnchor(options: { - readonly sessionId?: string; - readonly currentSessionId?: string; - readonly turnId?: string; - readonly controller?: TranscriptRangeController; - readonly setAnchor: (sessionId: string, anchor: TranscriptReadingAnchor | undefined) => void; -}): void { - const { sessionId, turnId } = options; - if (!sessionId || options.currentSessionId !== sessionId) return; - if (!turnId) { - options.setAnchor(sessionId, undefined); + if (range.hasOlder) { + const loading = {}; + command.loading = loading; + const current = () => options.lifecycle.isCurrent(command) && options.isCurrent(sessionId, controller); + const before = controller.store.snapshot(); + void controller.loadEarlier().then( + () => { + if (command.loading === loading) command.loading = undefined; + if (current() && controller.store.snapshot() !== before) restoreSessionTranscriptRange(options); + }, + (error: unknown) => { + if (command.loading === loading) command.loading = undefined; + if (current()) options.onError(error, sessionId); + }, + ); return; } - try { - if (options.controller?.store.range().sessionId !== sessionId) return; - const sequence = options.controller.store.sequenceForTurn(turnId) ?? undefined; - options.setAnchor(sessionId, sequence === undefined ? { turnId } : { turnId, sequence }); - } catch { - // A stale range says nothing new about the reader's current intent. + command.completed = true; + if (!command.fromSearch) { + options.setReadingAnchor(sessionId, undefined); + options.onRestoreUnavailable?.(sessionId, turnId); } } diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts index 80ce52fab1..cb92ff9bcc 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-app-shell-session-ui-state.ts @@ -24,7 +24,7 @@ import { currentTranscriptRange } from './transcript-reading-position.js'; import { createAppShellSessionUiStateController, type AppShellSessionUiStateController } from '../model/session-ui-state.js'; interface TranscriptSource { - range(): { readonly sessionId: string; readonly hasOlder: boolean; readonly hasNewer: boolean }; + range(): { readonly sessionId: string; readonly hasOlder: boolean }; snapshot(): { readonly messages: readonly StoredMessage[]; readonly ready: boolean }; } @@ -35,8 +35,7 @@ export type TranscriptPublisher = ( onReady: () => void, ) => void; -/** The rendered messages and gap flags are a single publication. The source - * may advance during reader input, but only the scroll authority admits it. */ +/** The rendered messages and the earlier-history flag are a single publication. */ export function useAppShellSessionUiState< Controller extends { readonly store: TranscriptSource }, Session extends SessionSummary & { localState?: string; shared?: boolean }, @@ -84,12 +83,10 @@ export function useAppShellSessionUiState< isCurrent: () => boolean, onReady: () => void, ) { - controller.transcriptViewportNavigation.commitRange(sessionId, () => { - if (!isCurrent()) return; - const snapshot = rangeController.store.snapshot(); - if (!snapshot.ready || !commitTranscript(sessionId, [...snapshot.messages], rangeController)) return; - onReady(); - }); + if (!isCurrent()) return; + const snapshot = rangeController.store.snapshot(); + if (!snapshot.ready || !commitTranscript(sessionId, [...snapshot.messages], rangeController)) return; + onReady(); }, })); diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index 37a6c454f4..f7f26c4cde 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -22,8 +22,6 @@ import { transcriptRestoreTarget, } from './controller/transcript-reading-position.js'; -export { TranscriptReadSupersededError } from './controller/transcript-reading-position.js'; - export const transcriptReadingPosition = { currentRange: currentTranscriptRange, restoreTarget: transcriptRestoreTarget, diff --git a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts index 24dbad1852..e25f0cf865 100644 --- a/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts +++ b/apps/desktop/src/renderer/features/conversation/model/session-ui-state.ts @@ -61,7 +61,6 @@ export interface SessionPendingClaim { export interface TranscriptReadingAnchor { readonly turnId: string; - readonly sequence?: number; } const SESSION_UI_MAP_KEYS = [ @@ -253,12 +252,8 @@ function createTranscriptReadingAnchorRegistry() { registry.clear(sessionId); return; } - const next = previous?.turnId === anchor.turnId && - previous.sequence !== undefined && anchor.sequence === undefined - ? previous - : anchor; - if (next === previous) return; - ref.current = { ...ref.current, [sessionId]: next }; + if (previous?.turnId === anchor.turnId) return; + ref.current = { ...ref.current, [sessionId]: anchor }; }, }; } diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts index fa076070f5..bd9c0549c9 100644 --- a/apps/desktop/src/renderer/features/conversation/testing.ts +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -20,6 +20,5 @@ export { createTranscriptRestoreLifecycle, prepareTranscriptForSend, - refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, } from './controller/transcript-reading-position.js'; diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts index 195700e263..a97c8f4391 100644 --- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts +++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts @@ -44,7 +44,6 @@ import type { WorkHubServices, WorkHubTranscript, WorkHubTranscriptSnapshot } fr const emptyTranscript: WorkHubTranscriptSnapshot = { messages: [], hasOlder: false, - hasNewer: false, ready: false, }; interface SendAttempt { @@ -376,22 +375,19 @@ export function useWorkHubController(onSubmit?: () => void) { const queued = pendingQueued.current; if (queued?.sessionId === sessionId && snapshot.messages.some((message) => message.type === 'user' && message.id === queued.messageId)) queued.observed = true; - viewportNavigation.commitRange(sessionId, () => { - if (disposed) return; - transcriptRef.current = snapshot; - setTranscript(snapshot); - if (snapshot.ready && observationPhase === 'ready') setReadError(undefined); - setTransientMessages((previous) => previous.filter((pending) => - !snapshot.messages.some((message) => message.type === 'user' && - (message.id === pending.id || (pending.id === pending.hostTurnId && message.turnId === pending.hostTurnId))), - )); - const settled = snapshot.messages.filter((message) => - message.type === 'assistant' && settledBeforePublication.current.delete(message.id)); - setLiveTurns((previous) => { - let next = previous; - for (const message of settled) if (next) next = settleLiveTurnBufferStep(next, message.id); - return next ? reconcileLiveTurnBuffer(next, snapshot.messages) : next; - }); + transcriptRef.current = snapshot; + setTranscript(snapshot); + if (snapshot.ready && observationPhase === 'ready') setReadError(undefined); + setTransientMessages((previous) => previous.filter((pending) => + !snapshot.messages.some((message) => message.type === 'user' && + (message.id === pending.id || (pending.id === pending.hostTurnId && message.turnId === pending.hostTurnId))), + )); + const settled = snapshot.messages.filter((message) => + message.type === 'assistant' && settledBeforePublication.current.delete(message.id)); + setLiveTurns((previous) => { + let next = previous; + for (const message of settled) if (next) next = settleLiveTurnBufferStep(next, message.id); + return next ? reconcileLiveTurnBuffer(next, snapshot.messages) : next; }); }, transcriptAbort.signal, readFailed); void opening @@ -446,12 +442,6 @@ export function useWorkHubController(onSubmit?: () => void) { ts: Date.now(), transientPlacement: attempt.placement, pendingSteering: attempt.placement === 'current_turn', }]); viewportNavigation.followLatest(target); - // A queued message becomes visible only where the tail is, and its own - // retry guard waits on seeing it. Issue the read before admission so an - // uncertain enqueue — the case that arms the guard — is covered too. - void range.current?.loadLatest().catch((reason: unknown) => { - if (currentSessionId.current === target) report(reason); - }); const result = await services.enqueueMessage(target, attempt.messageId, text, attachments, attempt.placement); if (result === 'rejected' && pendingQueued.current === attempt) { pendingQueued.current = undefined; @@ -477,10 +467,6 @@ export function useWorkHubController(onSubmit?: () => void) { attachments: [...attachments], transientPlacement: 'current_turn', }]); viewportNavigation.followLatest(target); - // Return a historical range to the tail without delaying message admission. - void range.current?.loadLatest().catch((reason: unknown) => { - if (currentSessionId.current === target) report(reason); - }); const result = await services.answer(attempt.sessionId, attempt.input); return acceptAnswer(attempt, result); } catch (reason) { @@ -621,11 +607,7 @@ export function useWorkHubController(onSubmit?: () => void) { void send(attempt.input.text, attempt.input.attachments ?? []); } else retryResolution.current(); }, - prefetchHistory: (edge: 'older' | 'newer') => - range.current?.prefetchHistory(edge) ?? Promise.resolve(false), - retainWindow: (window: { firstTurnId: string; lastTurnId: string }) => - range.current?.retain(window), - loadLatest: () => range.current?.loadLatest(), + loadEarlier: () => range.current?.loadEarlier(), report, streamingSettled(messageId?: string) { if (!messageId || currentSessionId.current !== sessionId) return; diff --git a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts index 3659db9256..f10e572547 100644 --- a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts +++ b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts @@ -19,7 +19,7 @@ import type { UiCatalog } from '@maka/core/ui-locale'; export const workHubLiveCopy = { - en: { filterConversation: 'Filter conversation by Work', clearConversationFilter: 'Show all conversations', noWorkConversation: 'No conversations for this Work in this part of history.', olderConversations: 'Earlier history', newerConversations: 'Later history', navigationGesture: 'Click to locate conversations; click again to filter; click once more to show all', attachmentLimit: 'Attachment count or size exceeds the limit', attachmentUploadFailed: 'Attachment upload did not return a reference', reviewAttachments: 'Please review the attachments.', sendFailed: 'Could not send', + en: { filterConversation: 'Filter conversation by Work', clearConversationFilter: 'Show all conversations', noWorkConversation: 'No conversations for this Work in this part of history.', navigationGesture: 'Click to locate conversations; click again to filter; click once more to show all', attachmentLimit: 'Attachment count or size exceeds the limit', attachmentUploadFailed: 'Attachment upload did not return a reference', reviewAttachments: 'Please review the attachments.', sendFailed: 'Could not send', retrySteering: 'Retry the original text and attachments with Shift+Enter to resolve the previous submission first.', retryFollowup: 'Retry the original text and attachments with Enter to resolve the previous submission first.', sendUnknown: 'The Host has not confirmed this message. Retry checks the same submission.', @@ -46,7 +46,7 @@ export const workHubLiveCopy = { delegationCompleted: 'Completed', delegationFailed: 'Failed', delegationAborted: 'Aborted', delegationRecovering: 'Recovering', openWork: 'Open task', openResult: 'Open result', }, - 'zh-CN': { filterConversation: '筛选此 Work 的对话', clearConversationFilter: '显示全部对话', noWorkConversation: '这段历史中没有此 Work 的对话。', olderConversations: '更早的历史', newerConversations: '更新的历史', navigationGesture: '点击定位对话;再点筛选;再次点击显示全部', attachmentLimit: '附件数量或大小超过限制', attachmentUploadFailed: '附件上传失败', reviewAttachments: '请查看附件。', sendFailed: '发送失败', + 'zh-CN': { filterConversation: '筛选此 Work 的对话', clearConversationFilter: '显示全部对话', noWorkConversation: '这段历史中没有此 Work 的对话。', navigationGesture: '点击定位对话;再点筛选;再次点击显示全部', attachmentLimit: '附件数量或大小超过限制', attachmentUploadFailed: '附件上传失败', reviewAttachments: '请查看附件。', sendFailed: '发送失败', retrySteering: '请先保留原文和附件,用 Shift+Enter 重试并确认上次提交结果。', retryFollowup: '请先保留原文和附件,用 Enter 重试并确认上次提交结果。', sendUnknown: 'Host 尚未确认这条消息。重试会核对原提交。', @@ -73,7 +73,7 @@ export const workHubLiveCopy = { delegationCompleted: '已完成', delegationFailed: '失败', delegationAborted: '已中止', delegationRecovering: '正在恢复', openWork: '打开任务', openResult: '打开结果', }, - 'zh-TW': { filterConversation: '篩選此 Work 的對話', clearConversationFilter: '顯示全部對話', noWorkConversation: '這段歷史中沒有此 Work 的對話。', olderConversations: '更早的歷史', newerConversations: '更新的歷史', navigationGesture: '點擊定位對話;再點篩選;再次點擊顯示全部', attachmentLimit: '附件數量或大小超過限制', attachmentUploadFailed: '附件上傳失敗', reviewAttachments: '請查看附件。', sendFailed: '傳送失敗', + 'zh-TW': { filterConversation: '篩選此 Work 的對話', clearConversationFilter: '顯示全部對話', noWorkConversation: '這段歷史中沒有此 Work 的對話。', navigationGesture: '點擊定位對話;再點篩選;再次點擊顯示全部', attachmentLimit: '附件數量或大小超過限制', attachmentUploadFailed: '附件上傳失敗', reviewAttachments: '請查看附件。', sendFailed: '傳送失敗', retrySteering: '請先保留原文和附件,用 Shift+Enter 重試並確認上次提交結果。', retryFollowup: '請先保留原文和附件,用 Enter 重試並確認上次提交結果。', sendUnknown: 'Host 尚未確認這則訊息。重試會核對原提交。', diff --git a/apps/desktop/src/renderer/features/workhub/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts index 453791b3d2..84d193168e 100644 --- a/apps/desktop/src/renderer/features/workhub/ports.ts +++ b/apps/desktop/src/renderer/features/workhub/ports.ts @@ -36,16 +36,11 @@ import type { export interface WorkHubTranscriptSnapshot { readonly messages: readonly StoredMessage[]; readonly hasOlder: boolean; - readonly hasNewer: boolean; readonly ready: boolean; } export interface WorkHubTranscript { observationChanged(phase: 'pending' | 'ready'): void; - /** Fills the window at an edge the reader approaches; resolves to whether a read was issued. */ - prefetchHistory(edge: 'older' | 'newer'): Promise; - /** Trims the window to the Turns the reader's band still covers. */ - retain(window: { firstTurnId: string; lastTurnId: string }): void; - loadLatest(): Promise; + loadEarlier(): Promise; close(): Promise; } export interface WorkHubServices { diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx index b036bf1d3c..e0bf5b5fc1 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useContext, useMemo, useState, type ComponentProps, type CSSProperties } from 'react'; +import { useContext, useMemo, type ComponentProps, type CSSProperties } from 'react'; import { ChatView, useUiLocale } from '@maka/ui'; import type { UiLocale } from '@maka/core/ui-locale'; import { Button, Link, Text } from '@astryxdesign/core'; @@ -53,16 +53,6 @@ export function WorkHubConversation(props: ComponentProps & { w const workHubIdentityHue = useWorkHubIdentityHue(assignments.map((work) => work.targetSessionId)); const locale = useUiLocale(); const copy = workHubLiveCopy[locale]; - const [loadingHistory, setLoadingHistory] = useState(false); - const [historyError, setHistoryError] = useState(false); - async function loadHistory(edge: 'older' | 'newer') { - if (loadingHistory) return; - setLoadingHistory(true); - setHistoryError(false); - try { await chat.onPrefetchHistory?.(edge); } - catch { setHistoryError(true); } - finally { setLoadingHistory(false); } - } // A coordination turn can delegate to several Works. Keep every label and // leave its shared bar neutral rather than attributing the entire turn to one. const worksByTurn = useMemo(() => { @@ -132,9 +122,6 @@ export function WorkHubConversation(props: ComponentProps & { w {selected &&
{selected.name}
} & { w transientMessages={selected ? chat.transientMessages?.filter((message) => message.hostTurnId && matchingTurns.has(message.hostTurnId)) : chat.transientMessages} activeTurn={activeTurn} emptyOverride={selected ?

{copy.noWorkConversation}

: chat.emptyOverride} - onRetainWindow={selected ? undefined : chat.onRetainWindow} - onPrefetchHistory={selected ? undefined : chat.onPrefetchHistory} turnDecorations={turnDecorations} promptRailDecorations={promptRailDecorations} onPromptRailHighlight={(turnId) => highlight.highlight(turnId ? workByTurn.get(turnId) : undefined)} diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx index 02711ef094..5b1c498173 100644 --- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx +++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx @@ -290,7 +290,6 @@ export function WorkHubRoot() { {(controller.error || control?.error) && ( @@ -341,7 +340,7 @@ export function WorkHubRoot() { onModelChange={controller.changeModel} modelSwitchAvailability={controller.configuringModel ? { available: false, pending: true, reason: 'pending' } : undefined} contextUsage={session ? { - usageTokens: liveContextUsage?.usageTokens ?? selectLatestRequestUsage(transcript.messages, transcript, session.model, session), + usageTokens: liveContextUsage?.usageTokens ?? selectLatestRequestUsage(transcript.messages, session.model, session), declaredContextWindow: modelChoice?.declaredContextWindow, meteredContextWindow: liveContextUsage?.contextWindow, metadataContextWindow: modelChoice?.contextWindow, @@ -384,10 +383,8 @@ export function WorkHubRoot() { scrollBehavior="auto" onNew={() => composer.current?.focus()} messages={[...transcript.messages]} - hasOlderHistory={transcript.hasOlder} - hasNewerHistory={transcript.hasNewer} - onPrefetchHistory={controller.prefetchHistory} - onRetainWindow={controller.retainWindow} + hasEarlierHistory={transcript.hasOlder} + onLoadEarlierHistory={controller.loadEarlier} transientMessages={controller.transientMessages} viewportNavigation={controller.viewportNavigation} liveTurns={controller.liveTurns} diff --git a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts index c90014325e..55843476ec 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts @@ -32,72 +32,12 @@ import { import { DesktopTranscriptRangeStore, createDesktopTranscriptRangeController, - createRecoveringDesktopTranscriptRangeController, } from './desktop-transcript-range-store.js'; -import type { TurnRecord } from '@maka/core/session'; import { MESSAGE_QUEUE_MAX_ENTRIES, type TurnMessageExecutionResolution, } from '@maka/runtime-host/protocol'; -const WORKHUB_RESULT_SCAN_MAX_PAGES = 16; - -async function readDelegatedTurnResult( - bridge: Pick, - sessionId: string, - turn: TurnRecord, -): Promise { - const store = new DesktopTranscriptRangeStore(sessionId); - const controller = createDesktopTranscriptRangeController(store, (signal) => - bridge.transcripts.open( - sessionId, - (batch) => { - if (signal.aborted) return; - store.accept(batch); - }, - (cancel) => { - if (signal.aborted) cancel(); - else signal.addEventListener('abort', cancel, { once: true }); - }, - ), - ); - try { - await controller.ready(); - let preview: string | undefined; - let lastTargetSequence: number | undefined; - const boundaryEstablished = () => { - const entries = store.durableEntries(); - const currentPreview = workHubTurnResultPreview( - entries.map(({ message }) => message), - turn.turnId, - ); - if (currentPreview) preview = currentPreview; - for (const entry of entries) { - if (entry.message.turnId === turn.turnId) { - lastTargetSequence = Math.max(lastTargetSequence ?? entry.sequence, entry.sequence); - } - } - const crossedIntoLaterTurn = lastTargetSequence !== undefined && entries.some( - (entry) => entry.sequence > lastTargetSequence! && entry.message.turnId !== turn.turnId, - ); - return lastTargetSequence !== undefined && ( - crossedIntoLaterTurn || !store.range().hasNewer - ); - }; - if (!boundaryEstablished() && lastTargetSequence === undefined && turn.firstSequence !== undefined) { - await controller.loadAround(turn.firstSequence); - } - for (let page = 0; page <= WORKHUB_RESULT_SCAN_MAX_PAGES; page += 1) { - if (boundaryEstablished()) return preview; - if (!store.range().hasNewer || page === WORKHUB_RESULT_SCAN_MAX_PAGES) return undefined; - await controller.loadAfter(); - } - return undefined; - } finally { - await controller.close(); - } -} - export function createDesktopWorkHubServices( bridge: Pick< MakaBridge, @@ -202,7 +142,10 @@ export function createDesktopWorkHubServices( resultPreview = delegatedResultCache.get(cacheKey); if (!resultPreview) { try { - resultPreview = await readDelegatedTurnResult(bridge, sessionId, turn); + resultPreview = workHubTurnResultPreview( + await bridge.transcripts.readTurn(sessionId, turn.turnId), + turn.turnId, + ); if (resultPreview) { delegatedResultCache.set(cacheKey, resultPreview); if (delegatedResultCache.size > 100) { @@ -258,10 +201,8 @@ export function createDesktopWorkHubServices( }, async openTranscript(sessionId, handler, cancellation, onError) { const store = new DesktopTranscriptRangeStore(sessionId); - // Every window change commits through the store, a trim included, so - // this is the whole of what the surface hears. const unsubscribe = store.subscribe(() => handler(store.snapshot())); - const controller = createRecoveringDesktopTranscriptRangeController(store, (signal) => + const controller = createDesktopTranscriptRangeController(store, (signal) => bridge.transcripts.open( sessionId, (batch) => { @@ -280,17 +221,7 @@ export function createDesktopWorkHubServices( if (cancellation.aborted) cancel(); return { observationChanged: controller.observationChanged, - prefetchHistory: (edge) => - edge === 'older' ? controller.loadBefore() : controller.loadAfter(), - retain: ({ firstTurnId, lastTurnId }) => { - // A Turn the band named but the window no longer holds yields null, - // which leaves that side of the window unbounded rather than empty. - controller.store.retain( - controller.store.sequenceForTurn(firstTurnId, 'first'), - controller.store.sequenceForTurn(lastTurnId, 'last'), - ); - }, - loadLatest: () => controller.loadLatest(), + loadEarlier: () => controller.loadEarlier(), close: () => { cancellation.removeEventListener('abort', cancel); unsubscribe(); diff --git a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts index 4ff6bb44de..cdeb0f91c5 100644 --- a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts @@ -19,199 +19,28 @@ import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; import { markPersisted } from '@maka/core/persisted-value'; -import { - DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, - DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE, - type DesktopTranscriptBatchPayload, - type DesktopTranscriptExtension, - type DesktopTranscriptFragment, - type DesktopTranscriptHandle, +import type { + DesktopTranscriptBatchPayload, + DesktopTranscriptFragment, + DesktopTranscriptHandle, } from '../../../preload/transcript-contract.js'; -import { TranscriptReadSupersededError } from '../../features/conversation/index.js'; import { projectDesktopStoredMessage } from '../../../shared/desktop-session-projection.js'; import { parseDesktopSessionKey } from '../../../shared/runtime-host-identity.js'; -// Interactive reads render real rows. Keep a page smaller than the IPC fragment -// ceiling so short replies cannot turn one read into dozens of mounted Turns. -// The Host still completes a boundary Turn that exceeds this page budget. -const TRANSCRIPT_READING_PAGE_BYTES = 16 * 1024; - /** - * The Renderer's window onto one Session transcript. `loadAround` and - * `loadLatest` replace the window and mint a navigation number; `loadBefore` - * and `loadAfter` extend it from an edge. Main answers the request and - * otherwise only broadcasts tail growth. + * The Renderer's copy of one Session transcript, opened in history mode: it + * only grows, by tail changes and by explicit `loadEarlier` reads. */ export interface DesktopTranscriptRangeController { readonly store: DesktopTranscriptRangeStore; ready(): Promise; waitForDurableMessage(messageId: string, timeoutMs: number): Promise; - /** Resolves to whether a read was actually issued. */ - loadBefore(maxBytes?: number): Promise; - loadAfter(maxBytes?: number): Promise; - loadAround(sequence: number, maxBytes?: number): Promise; - loadLatest(): Promise; + loadEarlier(): Promise; reload(): Promise; + observationChanged(phase: 'pending' | 'ready'): void; close(): Promise; } -/** - * `acknowledgesTail` is what a reader that renders the transcript says about - * itself. A consumer opened only to project rows reaches the tail just as a - * reader does, and acknowledging from there would mark the Session read on - * behalf of nobody, so the default is to stay silent. - */ -export function createDesktopTranscriptRangeController( - store: DesktopTranscriptRangeStore, - open: (signal: AbortSignal) => Promise, - options: { readonly acknowledgesTail?: boolean } = {}, -): DesktopTranscriptRangeController { - let closed = false; - let openController = new AbortController(); - let handle = open(openController.signal); - const extending: { - older?: { anchor: number | null; task: Promise }; - newer?: { anchor: number | null; task: Promise }; - } = {}; - /** The window a read was issued against; the same window answers the same way. */ - const spent: { older?: object; newer?: object } = {}; - const current = async () => { - if (closed) throw new Error('Desktop transcript range is closed'); - return handle; - }; - const command = async ( - replace: boolean, - run: (value: DesktopTranscriptHandle, navigation: number) => Promise, - ) => { - // Mint before awaiting an open handle or any in-flight page. Main uses the - // number only to cancel work a newer navigation has made pointless, so an - // extension issued while a navigation is still in flight names that - // navigation: it is the one Main is already reading for. - const navigation = replace ? store.navigate() : store.pendingNavigation() ?? store.navigation(); - const opening = handle; - const isCurrent = () => !closed && opening === handle && - (!replace || store.pendingNavigation() === navigation); - try { - const value = await current(); - if (!isCurrent()) return; - await run(value, navigation); - } catch (error) { - if (isCurrent()) throw error; - } - }; - /** The row a read at this edge would anchor on, or undefined with no edge to read. */ - const edgeAt = (edge: 'older' | 'newer'): { anchor: number | null } | undefined => { - let range: DesktopTranscriptRangeState; - try { - range = store.range(); - } catch { - return undefined; - } - if (edge === 'older' ? !range.hasOlder : !range.hasNewer) return undefined; - return { anchor: edge === 'older' ? range.oldestSequence : range.newestSequence }; - }; - const extend = async (edge: 'older' | 'newer', maxBytes: number): Promise => { - const at = edgeAt(edge); - if (!at) return false; - // Sharing a read only holds while the edge it was anchored on does. - const pending = extending[edge]; - if (pending && pending.anchor === at.anchor) { - await pending.task; - return false; - } - // A read issued against this exact window answers it the same way again. - // Every commit mints a fresh snapshot object, so a window that moved at all - // — by a page, a trim, a navigation or tail growth — is worth asking again. - const window = store.snapshot(); - if (spent[edge] === window) return false; - spent[edge] = window; - const task = command(false, (value, navigation) => - edge === 'older' - ? value.loadBefore(at.anchor, maxBytes, navigation) - : value.loadAfter(at.anchor, maxBytes, navigation), - ).finally(() => { - if (extending[edge]?.task === task) extending[edge] = undefined; - }); - extending[edge] = { anchor: at.anchor, task }; - await task; - return true; - }; - /** - * Main marks the Session read from these and from nothing else, so the window - * reports every watermark it actually reaches, once. A window with newer - * history beyond it reports nothing: the reader is parked off the tail. - */ - let acknowledged: number | undefined; - const acknowledgeTail = () => { - let range: DesktopTranscriptRangeState; - try { - range = store.range(); - } catch { - return; - } - // A cached window's watermark is a fact about the local cache, not about - // the live replica an acknowledgement moves. - if ( - !range.ready || range.hasNewer || range.durableThrough === null || - range.generation.startsWith('cached:') - ) return; - const through = range.durableThrough; - if (acknowledged === through) return; - acknowledged = through; - void (async () => { - try { - await (await current()).acknowledgeTail(through); - } catch { - if (acknowledged === through) acknowledged = undefined; - } - })(); - }; - const unsubscribe = options.acknowledgesTail ? store.subscribe(acknowledgeTail) : () => {}; - return { - store, - async ready() { await current(); }, - async waitForDurableMessage(messageId, timeoutMs) { - await current(); - return store.waitForDurableMessage(messageId, timeoutMs); - }, - loadBefore(maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { - return extend('older', maxBytes); - }, - loadAfter(maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { - return extend('newer', maxBytes); - }, - loadAround(sequence, maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { - return command(true, (value, navigation) => value.loadAround(sequence, maxBytes, navigation)); - }, - loadLatest() { - return command(true, (value, navigation) => value.loadLatest(navigation)); - }, - async reload() { - const previous = handle; - // The replacement consumer has heard nothing yet. - acknowledged = undefined; - openController.abort(); - const replacement = previous - .then((value) => value.close()) - .catch(() => undefined) - .then(() => { - if (closed) throw new Error('Desktop transcript range is closed'); - openController = new AbortController(); - return open(openController.signal); - }); - handle = replacement; - await replacement; - }, - async close() { - if (closed) return; - closed = true; - unsubscribe(); - openController.abort(); - await handle.then((value) => value.close()).catch(() => undefined); - }, - }; -} - export interface DesktopTranscriptReconnectRecovery { transcriptFailed(error: unknown): void; observationChanged(phase: 'pending' | 'ready'): void; @@ -282,68 +111,112 @@ export function createDesktopTranscriptReconnectRecovery(options: { }; } -export interface RecoveringDesktopTranscriptRangeController - extends DesktopTranscriptRangeController { - observationChanged(phase: 'pending' | 'ready'): void; -} - -function isHostEpochChanged(error: unknown): error is Error { - return error instanceof Error && - error.message.includes(`${DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE}:`); -} - -export function createRecoveringDesktopTranscriptRangeController( +export function createDesktopTranscriptRangeController( store: DesktopTranscriptRangeStore, open: (signal: AbortSignal) => Promise, - options: { - onError(error: unknown): void; - }, -): RecoveringDesktopTranscriptRangeController { - // Every visible transcript reader recovers; a projection does not. So this is - // the one place that claims tail acknowledgement on a reader's behalf. - const controller = createDesktopTranscriptRangeController(store, open, { acknowledgesTail: true }); - const cached = () => { + options: { onError(error: unknown): void }, +): DesktopTranscriptRangeController { + let closed = false; + let openController = new AbortController(); + let handle = open(openController.signal); + const current = async () => { + if (closed) throw new Error('Desktop transcript range is closed'); + return handle; + }; + const range = () => { try { - const range = store.range(); - return range.ready && range.generation.startsWith('cached:'); + return store.range(); } catch { - return false; + return undefined; } }; + const cached = () => { + const current = range(); + return current?.ready === true && current.generation.startsWith('cached:'); + }; const requireLive = () => { if (cached()) throw new Error('The cached transcript is waiting for Host reconnection'); }; - /** - * A read the Host refused because its epoch moved under the request says - * nothing about the reader: the replacement replica has already asked every - * consumer to reset, and that reset carries the new epoch, so reopening here - * would only throw the answer away. Retype it so the reading position treats - * the read as superseded instead of failed. - */ - const superseding = (run: () => Promise): Promise => run().catch((error: unknown) => { - if (!isHostEpochChanged(error)) throw error; - throw new TranscriptReadSupersededError(error.message, { cause: error }); - }); + /** Main marks the Session read from these alone, so each watermark the reader reaches is reported once. */ + let acknowledged: number | undefined; + const acknowledgeTail = () => { + const held = range(); + if (!held?.ready || held.durableThrough === null || held.generation.startsWith('cached:')) return; + const through = held.durableThrough; + if (acknowledged === through) return; + acknowledged = through; + void (async () => { + try { + await (await current()).acknowledgeTail(through); + } catch { + if (acknowledged === through) acknowledged = undefined; + } + })(); + }; + const reload = async () => { + const previous = handle; + acknowledged = undefined; + openController.abort(); + const replacement = previous + .then((value) => value.close()) + .catch(() => undefined) + .then(() => { + if (closed) throw new Error('Desktop transcript range is closed'); + openController = new AbortController(); + return open(openController.signal); + }); + handle = replacement; + await replacement; + }; const recovery = createDesktopTranscriptReconnectRecovery({ async reload() { - await controller.reload(); + await reload(); requireLive(); }, onError(error) { if (!cached()) options.onError(error); }, }); - void controller.ready().then(requireLive).catch(recovery.transcriptFailed); + let gapReload: Promise | undefined; + const unsubscribe = store.subscribe(() => { + acknowledgeTail(); + if (!store.needsReload() || gapReload || closed) return; + gapReload = reload() + .catch(recovery.transcriptFailed) + .finally(() => { gapReload = undefined; }); + }); + void handle.then(requireLive).catch(recovery.transcriptFailed); + let earlier: Promise | undefined; return { - ...controller, - loadBefore: (maxBytes = TRANSCRIPT_READING_PAGE_BYTES) => superseding(() => controller.loadBefore(maxBytes)), - loadAfter: (maxBytes = TRANSCRIPT_READING_PAGE_BYTES) => superseding(() => controller.loadAfter(maxBytes)), - loadAround: (sequence, maxBytes = TRANSCRIPT_READING_PAGE_BYTES) => superseding(() => controller.loadAround(sequence, maxBytes)), - loadLatest: () => superseding(() => controller.loadLatest()), + store, + async ready() { await current(); }, + async waitForDurableMessage(messageId, timeoutMs) { + await current(); + return store.waitForDurableMessage(messageId, timeoutMs); + }, + loadEarlier() { + if (earlier) return earlier; + const held = range(); + if (!held?.ready || !held.hasOlder || cached()) return Promise.resolve(); + const reading = handle; + const task = current() + .then((value) => value.loadEarlier()) + .catch((error: unknown) => { + if (!closed && reading === handle) options.onError(error); + }) + .finally(() => { earlier = undefined; }); + earlier = task; + return task; + }, + reload, observationChanged: recovery.observationChanged, async close() { + if (closed) return; + closed = true; recovery.close(); - await controller.close(); + unsubscribe(); + openController.abort(); + await handle.then((value) => value.close()).catch(() => undefined); }, }; } @@ -371,10 +244,8 @@ export interface DesktopTranscriptRangeState { readonly generation: string; readonly hostEpoch: string; readonly durableThrough: number | null; - readonly oldestSequence: number | null; - readonly newestSequence: number | null; + /** Earlier durable history exists that the Renderer has not loaded. */ readonly hasOlder: boolean; - readonly hasNewer: boolean; readonly ready: boolean; } @@ -382,75 +253,56 @@ export interface DesktopTranscriptRangeSnapshot extends DesktopTranscriptRangeSt readonly messages: readonly StoredMessage[]; } -/** - * An immutable window value. `through` is the newer-side watermark the Host has - * proved this window reaches; `hasNewerAtThrough` says whether rows at or below - * it are still missing, which no watermark comparison can tell. - */ -interface TranscriptWindow { +/** An immutable transcript value: durable rows in sequence order, then the overlay. */ +interface TranscriptValue { readonly rows: ReadonlyMap; readonly order: readonly number[]; readonly hasOlder: boolean; - readonly through: number | null; - readonly hasNewerAtThrough: boolean; - readonly newestUserSequence: number | null; -} - -/** The Host tail, which is not a window member: the view shows it only at the tail. */ -interface TranscriptTail { readonly through: number | null; readonly overlay: ReadonlyMap; readonly overlayOrder: readonly string[]; } /** - * One answer under construction. Batches accumulate here so that `#window` + * One answer under construction. Batches accumulate here so that the value * changes exactly once per answer, from one complete value to the next. */ interface TranscriptAssembly { - readonly kind: 'replace' | 'extend' | 'tail'; + readonly kind: 'reset' | 'earlier' | 'tail'; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; - readonly navigation: number | undefined; - readonly extension: DesktopTranscriptExtension | undefined; + readonly earlierThan: number | undefined; readonly coversFrom: number | null | undefined; - /** Decided once: rows the window cannot join are not worth decoding. */ - readonly collects: boolean; durableThrough: number | null; hasOlder: boolean | undefined; - hasNewer: boolean | undefined; readonly fragments: Map; readonly rows: Map; readonly overlay: Map; } -const EMPTY_WINDOW: TranscriptWindow = { +const EMPTY_VALUE: TranscriptValue = { rows: new Map(), order: [], hasOlder: false, through: null, - hasNewerAtThrough: false, - newestUserSequence: null, + overlay: new Map(), + overlayOrder: [], }; -const EMPTY_TAIL: TranscriptTail = { through: null, overlay: new Map(), overlayOrder: [] }; - export class DesktopTranscriptRangeStore { readonly sessionId: string; readonly #hostId: string; readonly #expectedSessionId: string; - #window: TranscriptWindow = EMPTY_WINDOW; - #tail: TranscriptTail = EMPTY_TAIL; + #value: TranscriptValue = EMPTY_VALUE; #assembly: TranscriptAssembly | undefined; - #pendingNavigation: number | undefined; - #navigations = 0; readonly #retiredGenerations = new Set(); #sourceSessionId: string | undefined; #generation: string | undefined; #liveGeneration: string | undefined; #hostEpoch: string | undefined; #ready = false; + #needsReload = false; #snapshot: DesktopTranscriptRangeSnapshot | undefined; readonly #durableWaiters = new Set<() => void>(); readonly #listeners = new Set<() => void>(); @@ -462,35 +314,9 @@ export class DesktopTranscriptRangeStore { this.#expectedSessionId = sessionId; } - /** Names the replacement a navigation is about to ask for. */ - navigate(): number { - this.#navigations += 1; - this.#pendingNavigation = this.#navigations; - return this.#pendingNavigation; - } - - /** The replacement that has been asked for and has not landed yet. */ - pendingNavigation(): number | undefined { - return this.#pendingNavigation; - } - - /** The navigation the window currently sits on. */ - navigation(): number { - return this.#navigations; - } - - /** - * Whether this batch is worth assembling at all. Only Host identity decides - * that; whether its rows reach the window is settled once the whole answer is - * in hand, against the anchor the answer carries. - */ #accepts(batch: DesktopTranscriptBatchPayload): boolean { if (this.#retiredGenerations.has(batch.generation)) return false; - if (batch.reset) { - return batch.navigation === undefined || batch.navigation === this.#pendingNavigation; - } - // A continuation belongs to the answer in flight, whose identity the - // window only adopts once that answer lands. + if (batch.reset) return true; const identity = this.#assembly ?? { sessionId: this.#sourceSessionId, generation: this.#generation, @@ -506,28 +332,17 @@ export class DesktopTranscriptRangeStore { if (batch.reset && batch.sessionId !== this.#expectedSessionId) { throw new Error('Desktop transcript belongs to a different Session'); } - let assembly = this.#assembly; - // A reset is by construction the first batch of its answer, so it starts a - // fresh one even where its identity matches what is in flight. - if (assembly && (batch.reset || !continuesAssembly(assembly, batch))) assembly = undefined; + let assembly = batch.reset ? undefined : this.#assembly; if (!assembly) { - const kind = batch.reset ? 'replace' : batch.extends ? 'extend' : 'tail'; assembly = { - kind, + kind: batch.reset ? 'reset' : batch.earlierThan !== undefined ? 'earlier' : 'tail', sessionId: batch.sessionId, generation: batch.generation, hostEpoch: batch.hostEpoch, - navigation: batch.navigation, - extension: batch.extends, + earlierThan: batch.earlierThan, coversFrom: batch.coversFrom, - // A tail answer this window cannot join contributes nothing but its - // watermark, unless an overlay row is waiting to learn it has settled. - collects: kind !== 'tail' || - this.#tail.overlay.size > 0 || - this.#joinsTail(batch.coversFrom), durableThrough: batch.durableThrough, hasOlder: undefined, - hasNewer: undefined, fragments: new Map(), rows: new Map(), overlay: new Map(), @@ -535,106 +350,63 @@ export class DesktopTranscriptRangeStore { this.#assembly = assembly; } if (batch.hasOlder !== undefined) assembly.hasOlder = batch.hasOlder; - if (batch.hasNewer !== undefined) assembly.hasNewer = batch.hasNewer; assembly.durableThrough = batch.durableThrough; - for (const fragment of batch.fragments) this.#acceptFragment(assembly, fragment, assembly.collects); + for (const fragment of batch.fragments) this.#acceptFragment(assembly, fragment); if (!batch.ready) return false; this.#assembly = undefined; return this.#apply(assembly); } - /** - * Installs one complete answer. The Host facts it carries land whatever the - * window does with its rows; the rows land only where the answer's anchor is - * still the edge it was read from, because nothing else proves them adjacent. - */ #apply(answer: TranscriptAssembly): boolean { - const tail = this.#tail; - const window = this.#window; - let overlay = answer.kind === 'replace' ? answer.overlay : tail.overlay; - let overlayOrder = answer.kind === 'replace' ? orderOverlay(answer.overlay) : tail.overlayOrder; - // A durable row retires the overlay it settles, whether or not this window - // keeps the row: seeing it is what proves the overlay obsolete. - if (overlay.size > 0) { - const settled = new Map(overlay); - for (const record of answer.rows.values()) settled.delete(record.message.id); - if (settled.size !== overlay.size) { - overlay = settled; - overlayOrder = overlayOrder.filter((messageId) => settled.has(messageId)); + const value = this.#value; + const wasReady = this.#ready; + const next = this.#install(answer); + if (next) { + this.#value = next; + if (answer.kind === 'reset') { + this.#adoptHostIdentity(answer); + this.#ready = true; + this.#needsReload = false; } + } else if (answer.kind === 'tail') { + this.#needsReload = true; } - const through = answer.durableThrough !== null && - (tail.through === null || answer.durableThrough > tail.through) - ? answer.durableThrough - : tail.through; - if (through !== tail.through || overlay !== tail.overlay) { - this.#tail = { through, overlay, overlayOrder }; - } - const installed = this.#install(answer); - if (installed) this.#window = installed; - if (installed && answer.kind === 'replace') this.#adoptHostIdentity(answer); - if (answer.navigation !== undefined && answer.navigation === this.#pendingNavigation) { - this.#pendingNavigation = undefined; - } - const ready = this.#ready || (answer.kind === 'replace' && installed !== undefined); - const changed = this.#tail !== tail || this.#window !== window || ready !== this.#ready; - this.#ready = ready; + const changed = this.#value !== value || this.#ready !== wasReady || (!next && answer.kind === 'tail'); if (changed) this.#commit(); for (const notify of this.#durableWaiters) notify(); return changed; } - /** The next window value, or `undefined` where this answer reaches no edge of it. */ - #install(answer: TranscriptAssembly): TranscriptWindow | undefined { - const window = this.#window; - if (answer.kind === 'replace') { - if (answer.navigation !== undefined && answer.navigation !== this.#pendingNavigation) { - return undefined; - } - return sameWindow(window, makeWindow( - answer.rows, - answer.hasOlder ?? false, - answer.durableThrough, - answer.hasNewer ?? false, - )); + /** The next value, or `undefined` where the answer does not continue what is held. */ + #install(answer: TranscriptAssembly): TranscriptValue | undefined { + const value = this.#value; + if (answer.kind === 'reset') { + return makeValue(answer.rows, answer.hasOlder ?? false, answer.durableThrough, answer.overlay); } - if (answer.kind === 'extend') { - const extension = answer.extension!; - if (extension.direction === 'older') { - if (extension.anchor !== (window.order[0] ?? null)) return undefined; - return sameWindow(window, makeWindow( - mergeRows(window, answer.rows), - answer.hasOlder ?? window.hasOlder, - window.through, - window.hasNewerAtThrough, - )); - } - if (extension.anchor !== (window.order.at(-1) ?? null)) return undefined; - // A page read before the tail grew cannot close the newer edge: rows that - // landed meanwhile are not in it, so the edge stays open. - const regressed = answer.durableThrough !== null && window.through !== null && - answer.durableThrough < window.through; - return sameWindow(window, makeWindow( - mergeRows(window, answer.rows), - window.hasOlder, - answer.durableThrough ?? window.through, - (answer.hasNewer ?? false) || regressed, - )); + if (answer.kind === 'earlier') { + if (!this.#ready || answer.earlierThan !== value.order[0]) return undefined; + return makeValue( + mergeRows(value.rows, answer.rows), + answer.hasOlder ?? value.hasOlder, + value.through, + value.overlay, + ); } - if (!this.#joinsTail(answer.coversFrom)) return undefined; - return sameWindow(window, makeWindow( - mergeRows(window, answer.rows), - window.hasOlder, - answer.durableThrough ?? window.through, - false, - )); + if (!this.#ready || answer.coversFrom !== value.through) return undefined; + // A durable row retires the overlay it settles. + const overlay = new Map(value.overlay); + for (const record of answer.rows.values()) overlay.delete(record.message.id); + return makeValue( + mergeRows(value.rows, answer.rows), + value.hasOlder, + answer.durableThrough ?? value.through, + overlay.size === value.overlay.size ? value.overlay : overlay, + ); } - /** Whether a read that started at `coversFrom` continues this window's newer edge. */ - #joinsTail(coversFrom: number | null | undefined): boolean { - return coversFrom !== undefined && - coversFrom === this.#window.through && - !this.#window.hasNewerAtThrough; + /** Set when a tail change did not continue what is held; the controller reopens. */ + needsReload(): boolean { + return this.#needsReload; } /** Fires after every committed change to `snapshot()`. */ @@ -643,34 +415,6 @@ export class DesktopTranscriptRangeStore { return () => { this.#listeners.delete(listener); }; } - /** - * Drops durable rows outside `[oldestSequence, newestSequence]`. Either edge - * that lost rows becomes a history edge again. An extension already in flight - * carries the anchor it was read from, so a trim needs no announcement: the - * anchor no longer matches an edge, and the answer is refused on arrival. - */ - retain(oldestSequence: number | null, newestSequence: number | null): boolean { - const window = this.#window; - let droppedOlder = false; - let droppedNewer = false; - const kept = window.order.filter((sequence) => { - const older = oldestSequence !== null && sequence < oldestSequence; - const newer = newestSequence !== null && sequence > newestSequence; - droppedOlder ||= older; - droppedNewer ||= newer; - return !older && !newer; - }); - if (!droppedOlder && !droppedNewer) return false; - this.#window = makeWindow( - new Map(kept.map((sequence) => [sequence, window.rows.get(sequence)!])), - window.hasOlder || droppedOlder, - droppedNewer ? kept.at(-1) ?? null : window.through, - window.hasNewerAtThrough || droppedNewer, - ); - this.#commit(); - return true; - } - #commit(): void { this.#snapshot = this.#createSnapshot(); for (const listener of [...this.#listeners]) listener(); @@ -681,57 +425,27 @@ export class DesktopTranscriptRangeStore { return this.#snapshot; } - durableEntries(): ReadonlyArray<{ readonly sequence: number; readonly message: StoredMessage }> { - const window = this.#window; - return window.order.map((sequence) => ({ - sequence, - message: structuredClone(window.rows.get(sequence)!.message), - })); - } - range(): DesktopTranscriptRangeState { if (!this.#sourceSessionId || !this.#generation || !this.#hostEpoch) { throw new Error('Desktop transcript range is not initialized'); } - const window = this.#window; return { sessionId: this.sessionId, generation: this.#generation, hostEpoch: this.#hostEpoch, - durableThrough: this.#tail.through, - oldestSequence: window.order[0] ?? null, - newestSequence: window.order.at(-1) ?? null, - hasOlder: window.hasOlder, - hasNewer: this.#hasNewer(), + durableThrough: this.#value.through, + hasOlder: this.#value.hasOlder, ready: this.#ready, }; } - /** The window has newer history whenever anything durable lies beyond it. */ - #hasNewer(): boolean { - const window = this.#window; - return window.hasNewerAtThrough || - (this.#tail.through !== null && - (window.through === null || this.#tail.through > window.through)); - } - hasDurableMessage(messageId: string): boolean { - for (const record of this.#window.rows.values()) { + for (const record of this.#value.rows.values()) { if (record.message.id === messageId) return true; } return false; } - newestDurableUserSequence(): number | null { - return this.#window.newestUserSequence; - } - - sequenceForTurn(turnId: string, edge: 'first' | 'last' = 'first'): number | null { - const window = this.#window; - const order = edge === 'first' ? window.order : [...window.order].reverse(); - return order.find((sequence) => window.rows.get(sequence)?.message.turnId === turnId) ?? null; - } - waitForDurableMessage(messageId: string, timeoutMs: number): Promise { if (this.hasDurableMessage(messageId)) return Promise.resolve(true); return new Promise((resolve) => { @@ -767,11 +481,7 @@ export class DesktopTranscriptRangeStore { this.#hostEpoch = batch.hostEpoch; } - #acceptFragment( - assembly: TranscriptAssembly, - fragment: DesktopTranscriptFragment, - collects: boolean, - ): void { + #acceptFragment(assembly: TranscriptAssembly, fragment: DesktopTranscriptFragment): void { const key = `${fragment.source}:${typeof fragment.identity}:${fragment.identity}`; let pending = assembly.fragments.get(key); if (!pending) { @@ -807,7 +517,6 @@ export class DesktopTranscriptRangeStore { pending.receivedBytes += bytes.byteLength; if (pending.receivedBytes < pending.totalBytes) return; assembly.fragments.delete(key); - if (!collects) return; const encoded = new TextDecoder('utf-8', { fatal: true }).decode(pending.bytes); const message = freezeTranscriptValue(projectDesktopStoredMessage( { hostId: this.#hostId }, @@ -831,40 +540,21 @@ export class DesktopTranscriptRangeStore { } #createSnapshot(): DesktopTranscriptRangeSnapshot { - const window = this.#window; - const tail = this.#tail; + const range = this.range(); + const value = this.#value; const messages = Object.freeze([ - ...window.order.map((sequence) => window.rows.get(sequence)!.message), - // The overlay is a fact about the tail, so it belongs to the view only - // while the window is at the tail. - ...(this.#hasNewer() - ? [] - : tail.overlayOrder.map((messageId) => tail.overlay.get(messageId)!.message)), + ...value.order.map((sequence) => value.rows.get(sequence)!.message), + ...value.overlayOrder.map((messageId) => value.overlay.get(messageId)!.message), ]); - return Object.freeze({ - ...this.range(), - messages, - }); + return Object.freeze({ ...range, messages }); } } -function continuesAssembly( - assembly: TranscriptAssembly, - batch: DesktopTranscriptBatchPayload, -): boolean { - return assembly.sessionId === batch.sessionId && - assembly.generation === batch.generation && - assembly.hostEpoch === batch.hostEpoch && - assembly.navigation === batch.navigation && - assembly.extension?.direction === batch.extends?.direction && - assembly.extension?.anchor === batch.extends?.anchor; -} - function mergeRows( - window: TranscriptWindow, + current: ReadonlyMap, rows: ReadonlyMap, ): Map { - const merged = new Map(window.rows); + const merged = new Map(current); for (const [sequence, record] of rows) { const existing = merged.get(sequence); if (existing && existing.encoded !== record.encoded) { @@ -875,43 +565,23 @@ function mergeRows( return merged; } -function makeWindow( +function makeValue( rows: ReadonlyMap, hasOlder: boolean, through: number | null, - hasNewerAtThrough: boolean, -): TranscriptWindow { - const order = [...rows.keys()].sort((left, right) => left - right); - let newestUserSequence: number | null = null; - for (const sequence of order) { - if (rows.get(sequence)!.message.type === 'user') newestUserSequence = sequence; - } - return { rows, order, hasOlder, through, hasNewerAtThrough, newestUserSequence }; -} - -/** Keeps the current value where the candidate says the same thing, so that a - * fresh snapshot object always means a window that actually moved. */ -function sameWindow(current: TranscriptWindow, candidate: TranscriptWindow): TranscriptWindow { - if ( - current.hasOlder !== candidate.hasOlder || - current.through !== candidate.through || - current.hasNewerAtThrough !== candidate.hasNewerAtThrough || - current.order.length !== candidate.order.length - ) return candidate; - for (const [index, sequence] of current.order.entries()) { - if (candidate.order[index] !== sequence) return candidate; - if (current.rows.get(sequence)!.encoded !== candidate.rows.get(sequence)!.encoded) { - return candidate; - } - } - return current; -} - -function orderOverlay(overlay: ReadonlyMap): string[] { - return [...overlay.keys()].sort((left, right) => { - const order = overlay.get(left)!.order - overlay.get(right)!.order; - return order === 0 ? left.localeCompare(right) : order; - }); + overlay: ReadonlyMap, +): TranscriptValue { + return { + rows, + order: [...rows.keys()].sort((left, right) => left - right), + hasOlder, + through, + overlay, + overlayOrder: [...overlay.keys()].sort((left, right) => { + const order = overlay.get(left)!.order - overlay.get(right)!.order; + return order === 0 ? left.localeCompare(right) : order; + }), + }; } function freezeTranscriptValue(value: T): T { diff --git a/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts b/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts index 91c2d62b8d..d8084e5b23 100644 --- a/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts +++ b/apps/desktop/src/renderer/platform/desktop/session-message-settlement.ts @@ -19,7 +19,6 @@ import type { StoredMessage } from '@maka/core/session'; import type { MakaBridge } from '../../../preload/bridge-contract.js'; -import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../../../preload/transcript-contract.js'; import { DesktopTranscriptRangeStore } from './desktop-transcript-range-store.js'; const COMMITTED_ASSISTANT_SETTLE_TIMEOUT_MS = 480; @@ -31,8 +30,7 @@ export interface RefreshMessagesOptions { } export type TranscriptSettlementSource = { - transcripts: Pick; - sessions: Pick; + transcripts: Pick; }; export async function readSettledMessages( @@ -85,6 +83,7 @@ export async function readSettledMessagesFrom( cancelOpen = close; if (cancelled) close(); }, + 'tail', ); void opening.catch(() => undefined); let handle: Awaited | undefined; @@ -92,48 +91,31 @@ export async function readSettledMessagesFrom( handle = await Promise.race([opening, cancellation]); globalThis.clearTimeout(openTimeout); const requiredTurnId = options.requiredTurnId; - let retainedDurable: ReturnType | undefined; + // The tail may have left the required Turn behind; read that Turn alone. + let turnMessages: readonly StoredMessage[] = []; if ( requiredTurnId !== undefined && !transcriptRecordsTerminalTurn(store.snapshot().messages, requiredTurnId) ) { - retainedDurable = store.durableEntries(); - const readHandle = handle; - const recoverTurn = async () => { - // Main now reads sequence-anchored ranges, not Turn identities. Resolve - // the Host's existing Turn index, then extend only this bounded window - // until the requested terminal record arrives or settlement times out. - const turns = await source.sessions.listTurns(sessionId); - const firstSequence = turns.find((turn) => turn.turnId === requiredTurnId)?.firstSequence; - if (firstSequence === undefined || cancelled || Date.now() >= deadline) return; - await readHandle.loadAround(firstSequence, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, store.navigate()); - let previousSequence: number | null = null; - while (!cancelled && Date.now() < deadline) { - if (transcriptRecordsTerminalTurn(store.snapshot().messages, requiredTurnId)) return; - const range = store.range(); - if (!range.hasNewer || range.newestSequence === previousSequence) return; - previousSequence = range.newestSequence; - await readHandle.loadAfter(range.newestSequence, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, store.navigation()); - } - }; - void recoverTurn().catch(() => undefined); + void source.transcripts.readTurn(sessionId, requiredTurnId).then((messages) => { + if (cancelled) return; + turnMessages = messages; + notify(); + nextChange = changed(); + }).catch(() => undefined); } while (true) { const snapshot = store.snapshot(); + const tailIds = new Set(snapshot.messages.map((message) => message.id)); + const messages = turnMessages + .filter((message) => !tailIds.has(message.id)) + .concat(snapshot.messages); const requiredMessageId = options.requiredAssistantMessageId; const settled = snapshot.ready && (requiredMessageId === undefined || store.hasDurableMessage(requiredMessageId)) && - (requiredTurnId === undefined || - transcriptRecordsTerminalTurn(snapshot.messages, requiredTurnId)); - if (settled || Date.now() >= deadline) { - return { - messages: retainedDurable - ? mergeTranscriptRanges(retainedDurable, store.durableEntries(), snapshot.messages) - : [...snapshot.messages], - settled, - }; - } + (requiredTurnId === undefined || transcriptRecordsTerminalTurn(messages, requiredTurnId)); + if (settled || Date.now() >= deadline) return { messages, settled }; await Promise.race([ nextChange, cancellation, @@ -150,20 +132,6 @@ export async function readSettledMessagesFrom( } } -function mergeTranscriptRanges( - retained: ReturnType, - current: ReturnType, - currentMessages: readonly StoredMessage[], -): StoredMessage[] { - const durableBySequence = new Map(retained.map(({ sequence, message }) => [sequence, message])); - for (const { sequence, message } of current) durableBySequence.set(sequence, message); - const durable = [...durableBySequence] - .sort(([left], [right]) => left - right) - .map(([, message]) => message); - const durableIds = new Set(durable.map((message) => message.id)); - return durable.concat(currentMessages.filter((message) => !durableIds.has(message.id))); -} - function transcriptRecordsTerminalTurn( messages: readonly StoredMessage[], turnId: string, diff --git a/apps/desktop/src/renderer/session-workspace-actions.ts b/apps/desktop/src/renderer/session-workspace-actions.ts index ff42337a00..b9731efe21 100644 --- a/apps/desktop/src/renderer/session-workspace-actions.ts +++ b/apps/desktop/src/renderer/session-workspace-actions.ts @@ -101,14 +101,7 @@ export function createSessionWorkspaceActions(deps: { ): TransientUserMessage[] { const pending = transientMessagesBySessionRef.current.get(sessionId); if (!pending || pending.size === 0) return []; - let includeTransient = true; - try { - const range = transcriptRangeRef.current?.store.range(); - includeTransient = range?.sessionId !== sessionId || !range.hasNewer; - } catch { - // An unopened transcript has no historical range to hide the live tail from. - } - const projected = reconcileTransientMessages(pending, durable, { includeTransient }); + const projected = reconcileTransientMessages(pending, durable); if (pending.size === 0) { transientMessagesBySessionRef.current.delete(sessionId); } diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 6fbebb1f14..03cd0620ab 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2047,12 +2047,18 @@ function dockOffered(): boolean { * real one does. What it cannot do is scroll, so cases that need the reader to * move set `scrollTop` themselves. */ -/** Storybook input is synthetic, so supply its native scroll result explicitly. */ -function scrollAsReader(root: HTMLElement, top: number): void { +/** + * Storybook input is synthetic, so supply its native scroll result explicitly. + * Returns how far the scroller actually went, read before anything can react to + * it: what the reader asked for, which is what their eyes then expect to see. + */ +function scrollAsReader(root: HTMLElement, top: number): number { const deltaY = top - root.scrollTop; - if (deltaY === 0) return; + if (deltaY === 0) return 0; + const before = root.scrollTop; root.dispatchEvent(new WheelEvent('wheel', { deltaY, bubbles: true })); root.scrollTo({ top, behavior: 'instant' }); + return before - root.scrollTop; } function wheelUp(target: Element): void { @@ -2090,54 +2096,11 @@ function transcriptTurns(from: number, count: number, mixed = false): StoredMess }).flat(); } -const PARTIAL_HISTORY_INDEX = Array.from({ length: 8 }, (_, index) => ({ - turnId: `turn-scroll-${index + 1}`, - sequence: index + 1, - label: `第 ${index + 1} 个问题`, -})); - -function PartialHistoryHarness() { - const [range, setRange] = useState({ from: 5, count: 4 }); - const [target, setTarget] = useState<{ turnId: string; nonce: number }>(); - return ( - { - setRange({ from: loaded.sequence, count: 4 }); - setTarget((previous) => ({ turnId: loaded.turnId, nonce: (previous?.nonce ?? 0) + 1 })); - }, - scrollTargetTurn: target, - hasOlderHistory: range.from > 1, - hasNewerHistory: range.from + range.count <= PARTIAL_HISTORY_INDEX.length, - // A fill extends the window; it never replaces what the reader jumped - // to. Like the Host's range controller it answers `false` for a window - // it has already read and settles a frame later: the transcript chains - // the next band check on `true`, so a fill that says `true` without - // having laid anything out is asked again in the same microtask, forever. - onPrefetchHistory: async (edge) => { - if (edge !== 'newer') return false; - const count = PARTIAL_HISTORY_INDEX.length - range.from + 1; - if (count <= range.count) return false; - setRange({ ...range, count }); - await painted(2); - return true; - }, - }} - /> - ); -} - - -// Real path: selecting a prompt outside the loaded transcript range. The -// transcript shows Turns and nothing else — a range boundary is not a thing to -// read — and every inactive prompt-rail tick uses one neutral treatment. +// Real path: selecting a prompt the reader has scrolled far away from. The +// transcript shows Turns and nothing else, and every inactive prompt-rail tick +// uses one neutral treatment. export const PartialHistoryNotice: Story = { - render: () => , + render: () => , play: async ({ canvasElement }) => { const firstPrompt = canvasElement.querySelector( '.maka-prompt-rail-tick[data-prompt-turn-id="turn-scroll-1"]', @@ -2379,21 +2342,20 @@ export const SubmittedPromptSettlesWithoutReversing: Story = { /** Lets a play function drive props React owns. One story renders per page. */ let appendTurn: (() => void) | undefined; -/** Every older fill the transcript asked for, oldest resident turn first. */ +/** Every earlier-history load the transcript asked for, oldest resident turn first. */ const historyLoads: string[] = []; const HISTORY_BATCH = 4; -// More than any story here consumes, so no story reaches the end of history. -const HISTORY_BATCHES_AVAILABLE = 8; - /** A settled transcript with a turn the play function can make arrive. */ function SettledTranscriptHarness({ turns, composer, + mixed, }: { turns: number; composer?: Partial; + mixed?: boolean; }) { const [extra, setExtra] = useState(0); useEffect(() => { @@ -2402,76 +2364,45 @@ function SettledTranscriptHarness({ appendTurn = undefined; }; }, []); - return ; + return ; } /** - * The history seam is two props: `hasOlderHistory`, and a loader that prepends. - * The loader settles a frame later, the way a page fetched over IPC does — the - * transcript reads the band again as soon as a page settles, so a loader that - * settled before its turns were laid out would be asked for the next page - * against the geometry of the previous one. + * The history seam is `hasEarlierHistory` and a loader that prepends + * `HISTORY_BATCH` Turns. The loader settles a frame later, the way an answer + * delivered over IPC does. */ -function HistoryHarness({ turns, bounded = false, olderTurns = HISTORY_BATCH * HISTORY_BATCHES_AVAILABLE, mixed = false }: { turns: number; bounded?: boolean; olderTurns?: number; mixed?: boolean }) { - const [range, setRange] = useState({ from: 0, count: turns }); - const [viewportNavigation] = useState(createTranscriptViewportNavigation); +function HistoryHarness({ turns, olderTurns = 0, mixed = false }: { turns: number; olderTurns?: number; mixed?: boolean }) { + const [from, setFrom] = useState(0); useEffect(() => { historyLoads.length = 0; }, []); return ( -olderTurns, - hasNewerHistory: bounded && range.from + range.count < turns, - onRetainWindow: bounded ? ({ firstTurnId, lastTurnId }) => { - // The real scroll hook chooses the retained band. This fixture only - // supplies the requested slice, standing in for the transcript store. - const from = Number(firstTurnId.replace('turn-scroll-', '')); - const last = Number(lastTurnId.replace('turn-scroll-', '')); - viewportNavigation.commitRange(activeSession!.id, () => setRange({ - from, count: last - from + 1, - })); - } : undefined, - onPrefetchHistory: async (edge) => { - if (bounded && edge === 'newer') { - viewportNavigation.commitRange(activeSession!.id, () => setRange((current) => ({ - ...current, - count: Math.min(turns - current.from, current.count + HISTORY_BATCH), - }))); - await painted(2); - return true; - } - if (edge !== 'older') return false; + messages: transcriptTurns(from, turns - from, mixed), + hasEarlierHistory: from > -olderTurns, + onLoadEarlierHistory: async () => { historyLoads.push(firstResidentTurnId() ?? '(none)'); - viewportNavigation.commitRange(activeSession!.id, () => setRange((current) => ({ - from: Math.max(-olderTurns, current.from - HISTORY_BATCH), - count: current.count + Math.min(HISTORY_BATCH, current.from + olderTurns), - }))); + setFrom((current) => Math.max(-olderTurns, current - HISTORY_BATCH)); await painted(2); - return true; }, }} /> ); } -/** The band inside which the transcript keeps history loaded around the reader. */ -function loadBand(): number { - return Math.max(640, tailScroller().clientHeight * 2); -} - -/** History stops arriving once the band above the reader is full. */ -async function historySettled(): Promise { +/** + * The transcript opened at its tail. Rows mount only after the virtualizer + * measures its scroller, and an empty scroller is trivially at its tail. + */ +async function tailSettled(): Promise { await waitFor(() => { const settled = tailMetrics(); - expect(settled.scrollTop, JSON.stringify(settled)).toBeGreaterThan(loadBand()); + expect(settled.scrollHeight, JSON.stringify(settled)).toBeGreaterThan(settled.clientHeight); expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); }, { timeout: 10_000 }); - const loads = historyLoads.length; - await painted(12); - expect(historyLoads.length, 'history kept arriving after the band was full').toBe(loads); + await painted(4); } export const TailFollowsGrowthOutsideTurns: Story = { @@ -2577,7 +2508,7 @@ export const ReaderScrolledUpIsNotPulledBack: Story = { export const DockAffordanceReturnsToTail: Story = { render: () => , play: async () => { - await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); + await tailSettled(); scrollAsReader(tailScroller(), 0); await painted(6); @@ -2596,54 +2527,41 @@ export const DockAffordanceReturnsToTail: Story = { }; export const NestedScrollerNearHistoryBoundaryAsksForNothing: Story = { - render: () => , + render: () => , play: async () => { - await historySettled(); + await tailSettled(); const nested = injectNestedScroller(messageList()); + scrollAsReader(tailScroller(), 0); await painted(6); - historyLoads.length = 0; wheelUp(nested); + wheelUp(tailScroller()); await painted(6); - // The gesture crossed a scroller that could act on it, so it was never the - // reader asking for what is above the transcript. + // Scrolling never loads history; only the explicit control does. expect(historyLoads).toEqual([]); expect(nested.scrollTop).toBe(600); }, }; -export const TailPrefetchesHistoryUntilTheBandIsFull: Story = { - render: () => , - play: async () => { - const before = firstResidentTurnId(); - // Opening a Session fills the band above the reader without a gesture, and - // the reader stays at the tail while the pages land above them. - await historySettled(); - expect(historyLoads.length).toBeGreaterThan(0); - expect(firstResidentTurnId()).not.toBe(before); - }, -}; - -// Real path: a reader traverses a long session; useChatScroll requests older -// pages and trims distant Turns. Paging/storage is simulated at ChatView's -// callbacks; the production scroll policy, publication bridge and frame run. +// Real path: a reader scrolls through a long loaded Session; only the rows near +// the viewport are mounted. export const HistoryWindowTraversal: Story = { - render: () => , + render: () => , }; -// Real path: the bounded Desktop transcript mounts and evicts mixed prose and -// code turns. The fixed-membership geometry scene below does not virtualize. +// Real path: virtualized mixed prose and code turns. The fixed-membership +// geometry scene below uses the same list at a size it keeps mounted. export const VirtualHistoryMixedContent: Story = { - render: () => , + render: () => , }; // Real path: traverse a long Session, return through already read history, -// and jump to a loaded Turn whose body is currently outside the viewport. +// and keep a selected Turn mounted while it is outside the viewport. export const VirtualHistoryContinuity: Story = { - render: () => , + render: () => , play: async () => { - await historySettled(); + await tailSettled(); const root = tailScroller(); const bodies = () => root.querySelectorAll('.maka-turn[data-turn-id]'); await waitFor(() => { @@ -2660,14 +2578,15 @@ export const VirtualHistoryContinuity: Story = { } throw new Error('History traversal did not reach its edge'); }; - const tailId = bodies().item(bodies().length - 1).dataset.turnId; + const tailId = 'turn-scroll-23'; + const tail = () => root.querySelector(`.maka-turn[data-turn-id="${tailId}"]`); + expect(tail()).not.toBeNull(); await traverse(-1); await traverse(1); await painted(8); - expect(bodies().item(bodies().length - 1).dataset.turnId, 'returning through history must reach the original tail').toBe(tailId); - expect(root.scrollTop, 'the retained history stays inside the eviction band').toBeLessThanOrEqual(root.clientHeight * 6); + expect(tail(), 'returning through history must reach the original tail').not.toBeNull(); - const selected = bodies().item(bodies().length - 1); + const selected = tail()!; const selection = document.getSelection()!; const range = document.createRange(); range.selectNodeContents(selected); @@ -2759,7 +2678,7 @@ export const GeometryMixed24Turns: Story = { const turnId = `geometry-${i}`; return [user(`geometry-u-${i}`, turnId, 50 - i, `检查第 ${i + 1} 组。`), assistant(`geometry-a-${i}`, turnId, 50 - i, mixedTurnText(i))]; - }).flat(), hasOlderHistory: false, hasNewerHistory: false }} />, + }).flat() }} />, }; export const GeometryLongCode: Story = { @@ -2769,7 +2688,7 @@ export const GeometryLongCode: Story = { Array.from({ length: 1200 }, (_, line) => `${line + 1}: ${'wrapped-code-content-'.repeat(9)}`, ).join('\n') + '\n```'), - ], hasOlderHistory: false, hasNewerHistory: false }} />, + ] }} />, }; export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { @@ -2863,47 +2782,55 @@ export const OversizedLiveTurnHoldsAReadingAnchorOnColdScroll: Story = { }; /** - * First upward traversal of a deep fixed transcript: document height and - * the reader's content position must remain stable without a warm-up pass. + * First upward traversal of a deep fixed transcript, without a warm-up pass. + * A reader-sized step: a whole scrollport at a time would carry the Turn being + * tracked out of the mounted rows before it can be measured again. */ -const TRAVERSAL_STEP = 700; +const TRAVERSAL_STEP = 200; -/** The first Turn whose box is still on screen, and where it starts. */ +/** The Turn the reader is looking at — the one across the middle — and where it starts. */ function anchorInView(): { turnId: string; top: number } { const root = tailScroller(); - const rootTop = root.getBoundingClientRect().top; + const middle = root.getBoundingClientRect().top + root.clientHeight / 2; const turn = [...root.querySelectorAll('[data-turn-id]')].find( - (candidate) => candidate.getBoundingClientRect().bottom > rootTop, + (candidate) => candidate.getBoundingClientRect().bottom > middle, ); if (!turn?.dataset.turnId) throw new Error('no turn is on screen'); return { turnId: turn.dataset.turnId, top: Math.round(turn.getBoundingClientRect().top) }; } +// Turn heights have to vary: with uniform rows the virtualizer's estimate is +// right for every unmounted row, which is the one case where mounting a row +// above the reader cannot move them. export const UpwardTraversalHoldsTurnGeometry: Story = { - render: () => , + render: () => , play: async () => { const root = tailScroller(); await document.fonts.ready; + await tailSettled(); await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); - await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); const heightBefore = root.scrollHeight; expect( heightBefore / root.clientHeight, 'the transcript has to be deep enough to hold unrendered Turns', ).toBeGreaterThan(6); + const turnsBefore = document.querySelectorAll('.maka-prompt-rail-tick').length; const drifts: number[] = []; + const thumbs: number[] = []; let steps = 0; - while (root.scrollTop > 0 && steps < 40) { + while (root.scrollTop > 0 && steps < 60) { const anchor = anchorInView(); const scrollBefore = root.scrollTop; - scrollAsReader(root, Math.max(0, scrollBefore - TRAVERSAL_STEP)); + const travelled = scrollAsReader(root, Math.max(0, scrollBefore - TRAVERSAL_STEP)); await painted(4); + thumbs.push(root.scrollTop / (root.scrollHeight - root.clientHeight)); - // The reader moved by what the scroller actually moved, so the Turn under - // them comes down the viewport by that much plus whatever the estimates - // above them were off by. - const travelled = scrollBefore - root.scrollTop; + // The Turn under the reader comes down the viewport by exactly what the + // reader asked the scroller to travel. Rows mounting above them are + // measured and correct the virtualizer's estimates, and the virtualizer + // absorbs that correction into the scroll offset — so it must not reach + // the screen. drifts.push(Math.round(turnTop(anchor.turnId) - (anchor.top + travelled))); steps += 1; } @@ -2913,12 +2840,16 @@ export const UpwardTraversalHoldsTurnGeometry: Story = { expect(worstDrift, `per-step drift: ${drifts.join(' ')}`) .toBeLessThanOrEqual(1); - // The fixed document keeps its full height throughout the traversal. - const heightAfter = root.scrollHeight; - expect( - Math.abs(heightAfter - heightBefore), - JSON.stringify({ heightBefore, heightAfter, steps }), - ).toBeLessThanOrEqual(1); + // Nothing loaded: scrolling reads what is already here. + expect(document.querySelectorAll('.maka-prompt-rail-tick').length).toBe(turnsBefore); + + // The thumb only ever walks towards the top. Measuring unmounted Turns + // refines the document's extent as the reader goes, which moves the thumb + // a little under them; what the reader cannot be shown is the thumb + // running backwards, which is how a range change used to look. + const backwards = thumbs.slice(1).map((thumb, index) => thumb - thumbs[index]!); + expect(Math.max(...backwards), `thumb steps: ${backwards.map((step) => step.toFixed(4)).join(' ')}`) + .toBeLessThanOrEqual(0.01); // And the reader can still get back. dockButton().click(); @@ -2933,7 +2864,7 @@ export const UpwardTraversalHoldsTurnGeometry: Story = { }; export const HistoryAtTheTopStillLandsAboveTheReader: Story = { - render: () => , + render: () => , play: async () => { const root = tailScroller(); // Measure history publication against rendered content, not the cold @@ -2947,19 +2878,21 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { expect(settled.scrollTop, JSON.stringify(settled)).toBeGreaterThan(0); expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); }); - const before = firstResidentTurnId(); - // The one position where the browser declines to anchor, and the one the - // wheel-to-load path puts the reader in. + // load-earlier control sits at. scrollAsReader(root, 0); + await painted(4); + const before = firstResidentTurnId(); const reading = anchorInView(); - wheelUp(root); + within(document.body).getByRole('button', { name: '载入更早的记录' }).click(); await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); await waitFor(() => expect(document.querySelector('.maka-markdown-pending')).toBeNull()); await painted(6); + expect(historyLoads).toEqual([before]); expect(Math.abs(turnTop(reading.turnId) - reading.top)).toBeLessThanOrEqual(1); + expect(within(document.body).queryByRole('button', { name: '载入更早的记录' })).toBeNull(); }, }; @@ -2967,41 +2900,16 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { * The prompt anchor rail (#563). All three of its shipped regressions had the * same shape — the code kept working and the pixels stopped — so the * assertions here are geometric. - * - * Tick count comes from `transcriptTurnIndex`, not from mounted Turns: the - * transcript holds a slice of the history and the index carries the rest of the - * landmarks, so the rail gets all 64 ticks against 10 Turns. That is what the - * Host does in production. */ const PROMPT_RAIL_TURN_COUNT = 120; -/** - * The Turns this story feeds the transcript — the tail a Session opens with, - * `DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS`, restated to keep stories off preload. - * What the reader ends up mounting is the Renderer's own retained band; this - * story never scrolls far enough to grow past its own slice. - */ -const PROMPT_RAIL_TAIL_TURNS = 10; - /** `MAX_PROMPT_RAIL_TICKS` in prompt-anchor-rail.tsx, which does not export it. */ const PROMPT_RAIL_MAX_TICKS = 64; -const PROMPT_RAIL_TAIL_RANGE_START = PROMPT_RAIL_TURN_COUNT - PROMPT_RAIL_TAIL_TURNS + 1; - -const promptRailIndex = Array.from({ length: PROMPT_RAIL_TURN_COUNT }, (_, offset) => ({ - turnId: `turn-scroll-${offset + 1}`, - sequence: offset + 1, - label: `第 ${offset + 1} 个问题`, -})); - -const promptRailMessages = transcriptTurns(PROMPT_RAIL_TAIL_RANGE_START, PROMPT_RAIL_TAIL_TURNS); +const promptRailMessages = transcriptTurns(1, PROMPT_RAIL_TURN_COUNT); function PromptRailHarness() { - return ( - - ); + return ; } function railTicks(): HTMLElement[] { @@ -3132,10 +3040,10 @@ export const PromptRailHasNoGapsBetweenTicks: Story = { }, }; -/** Away from the tail, but still inside the band that would ask for history. */ +/** Away from the tail, with the tail Turn still in view. */ async function scrollAwayFromTail(): Promise { const root = tailScroller(); - scrollAsReader(root, Math.min(root.scrollHeight - root.clientHeight - 100, loadBand() + 200)); + scrollAsReader(root, root.scrollHeight - root.clientHeight - 100); root.dispatchEvent(new Event('scroll')); await painted(4); } @@ -3151,26 +3059,17 @@ export const ActiveTurnsKeepStableDomIdentities: Story = { render: () => , play: async () => { await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); - const sourceCount = Number( - messageList().getAttribute('data-turn-source-count'), - ); - expect(sourceCount).toBe(PROMPT_RAIL_TAIL_TURNS); - expect(document.querySelectorAll('[data-turn-id]')).toHaveLength(sourceCount); - - // Marked on the elements themselves: a remount drops the attribute, which - // a count alone cannot tell apart from a remount that produced the same - // number of Turns. - for (const turn of document.querySelectorAll('[data-turn-id]')) { - turn.dataset.stableMountProbe = turn.dataset.turnId; - } - await scrollTranscriptTo('bottom'); + const tailTurnId = `turn-scroll-${PROMPT_RAIL_TURN_COUNT}`; + const tail = document.querySelector(`[data-turn-id="${tailTurnId}"]`); + if (!tail) throw new Error('the tail Turn is missing'); + + // Marked on the element itself: a remount drops the attribute. + tail.dataset.stableMountProbe = tailTurnId; + await scrollAwayFromTail(); - expect(document.querySelectorAll('[data-turn-id]')).toHaveLength(sourceCount); - expect(document.querySelectorAll('[data-turn-id][data-stable-mount-probe]')).toHaveLength( - sourceCount, - ); + expect(document.querySelector(`[data-turn-id="${tailTurnId}"][data-stable-mount-probe]`)).not.toBe(null); }, }; @@ -3208,36 +3107,6 @@ export const ScrollingAwayPreservesTurnOwnedFocus: Story = { }, }; -export const OffscreenActiveTurnsStayFindable: Story = { - render: () => , - play: async () => { - await waitFor(() => expect(railBars().length).toBeGreaterThan(0)); - const firstTurnId = document - .querySelector('[data-turn-id]') - ?.getAttribute('data-turn-id'); - const turnNumber = Number(firstTurnId?.split('-').at(-1)); - expect(turnNumber).toBeGreaterThan(0); - const needle = `第 ${turnNumber} 个问题`; - - await scrollTranscriptTo('bottom'); - - // `window.find` walks the rendered text, so a Turn skipped by - // `content-visibility` would not be there to find. - // - // The E2E original also asserted the Turn's text was in the accessibility - // tree, which needs CDP and so did not come across. The smoke's AX audit - // is not a substitute: it checks for unnamed actionable nodes and - // duplicate landmarks, never that a given string is exposed. - document.getSelection()?.removeAllRanges(); - // `window.find` is non-standard, so it is not on the DOM lib's Window. - const found = (window as unknown as { find(text: string): boolean }).find(needle); - - expect(found, `searching for ${needle}`).toBe(true); - expect(document.getSelection()?.toString() ?? '').toContain(needle); - document.getSelection()?.removeAllRanges(); - }, -}; - /** Where a Turn sits relative to the top of the scrollport. */ function turnOffsetFromScroller(turnId: string): number { const root = tailScroller(); @@ -3246,32 +3115,8 @@ function turnOffsetFromScroller(turnId: string): number { return Math.round(turn.getBoundingClientRect().top - root.getBoundingClientRect().top); } -/** - * The Host's half of a rail jump, as `createSessionOpenCommand` does it: a tick - * for a Turn outside the active range comes back out as `onLoadTranscriptTurn`, - * the range moves to it and a scroll target names it. ChatView holds the claim - * until the Turn mounts, then aligns the target to the rail's edge. - */ -function PromptRailNavigationHarness() { - const [firstIndex, setFirstIndex] = useState(PROMPT_RAIL_TAIL_RANGE_START); - const [target, setTarget] = useState<{ turnId: string; nonce: number }>(); - return ( - { - setFirstIndex(loaded.sequence); - setTarget((previous) => ({ turnId: loaded.turnId, nonce: (previous?.nonce ?? 0) + 1 })); - }, - scrollTargetTurn: target, - }} - /> - ); -} - export const FirstRailClickLandsOnItsPromptAndHolds: Story = { - render: () => , + render: () => , play: async () => { await waitFor(() => expect(railTicks().length).toBeGreaterThan(0)); @@ -3286,12 +3131,8 @@ export const FirstRailClickLandsOnItsPromptAndHolds: Story = { 'a reduced-motion browser finishes the jump in one frame and this story stops testing anything', ).toBe(false); - // The case that used to fail: the head of the conversation is not mounted, - // so the jump has to bring it in, and the fill that follows changes - // scrollHeight underneath the tail-follow lock. A lock that ignores - // scroll-ups arriving with a changed height stays on and pulls the - // transcript back to the bottom — the click looks dead until the reader - // scrolls by hand. + // The head of the conversation is not mounted, so the jump has to bring it + // in while rows measure underneath the tail-follow lock. const targetTurnId = 'turn-scroll-1'; expect(document.querySelector(`[data-turn-id="${targetTurnId}"]`)).toBe(null); @@ -3379,7 +3220,7 @@ async function expectRailMatchesReadingPosition(where: string): Promise { } export const RailStaysOnTheVisiblePrompt: Story = { - render: () => , + render: () => , play: async () => { const root = tailScroller(); await waitFor(() => expect(railTicks().length).toBeGreaterThan(0)); @@ -3402,8 +3243,7 @@ export const RailStaysOnTheVisiblePrompt: Story = { record(); try { - // Reading positions across the active range, then a jump that replaces - // the range entirely — the two ways the rail's input changes. + // Reading positions across the transcript, then a jump to its head. for (const fraction of [0.75, 0.5, 0.25, 0]) { scrollAsReader(root, Math.round((root.scrollHeight - root.clientHeight) * fraction)); root.dispatchEvent(new Event('scroll')); @@ -3966,8 +3806,12 @@ export const ProcessReplyLifecycleComplete: Story = { export const CompletedProcessCollapsed: Story = { render: () => , play: async ({ canvasElement }) => { - const process = canvasElement.querySelector('.maka-processing-sequence'); - await expect(process).not.toBeNull(); + // Rows mount once the virtualizer has measured its scroller. + const process = await waitFor(() => { + const found = canvasElement.querySelector('.maka-processing-sequence'); + expect(found).not.toBeNull(); + return found; + }); await expect(process!.open).toBe(false); const answer = await within(canvasElement).findByText('已修复登录状态恢复。'); await expect(answer).toBeVisible(); @@ -3986,9 +3830,9 @@ export const CompletedProcessCollapsed: Story = { export const CompletedProcessExpanded: Story = { render: () => , play: async ({ canvasElement }) => { + await within(canvasElement).findByText('已修复登录状态恢复。'); const process = canvasElement.querySelector('.maka-processing-sequence')!; const summary = process.querySelector('summary')!; - await within(canvasElement).findByText('已修复登录状态恢复。'); // Check the browser-applied motion contract without assuming a frame will // run during the transition. A busy runner may paint only the endpoint; // ::details-content does not reliably expose Animation objects/events. diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx index be0bc2e6d6..4f187702be 100644 --- a/apps/desktop/stories/workhub.stories.tsx +++ b/apps/desktop/stories/workhub.stories.tsx @@ -74,7 +74,7 @@ function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory: let questionPending = question; let pendingForm: import('@maka/core/events').FormRequestEvent | undefined; const publishExecution = () => updateExecution?.({ type: 'host_execution', available: true, rootTurn: pendingForm ? { sessionId, turnId: pendingForm.turnId, runId: 'selection-run', status: 'waiting_for_user' } : questionPending ? { sessionId, turnId: 'question-turn', runId: 'question-run', status: 'waiting_for_user' } : null }); - const publish = () => { publishExecution(); updateTranscript?.({ messages, hasOlder: false, hasNewer: false, ready: true }); }; + const publish = () => { publishExecution(); updateTranscript?.({ messages, hasOlder: false, ready: true }); }; return { inspector: { context: async () => ({ ok: true, data: { status: 'available', completedAt: 1, modelId: session.model, providerId: 'openai', inputTokens: 1000, contextWindow: 100_000 } }), @@ -141,7 +141,7 @@ function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory: return { kind: 'committed', session: { ...session, workspace: { target: { kind: 'host_path', path: '/projects/maka' }, hostCwd: '/projects/maka' }, createdAt: 0, activityAt: 0, labelsTruncated: false, llmConnectionId: 'connection-test', collaborationMode: 'agent', orchestrationMode: 'default' } }; }, observe: (_id, _event, _error, _phase, execution) => { updateExecution = execution; publishExecution(); return () => { updateExecution = undefined; }; }, - openTranscript: async (_id, handler) => { updateTranscript = handler; publish(); return { observationChanged: () => {}, prefetchHistory: async () => false, retain: () => {}, loadLatest: async () => {}, close: async () => { updateTranscript = undefined; } }; }, + openTranscript: async (_id, handler) => { updateTranscript = handler; publish(); return { observationChanged: () => {}, loadEarlier: async () => {}, close: async () => { updateTranscript = undefined; } }; }, stop: async () => { questionPending = false; pendingForm = undefined; @@ -485,7 +485,7 @@ function PagedWorkConversation() { {}, navigateWork: () => {}, selectedWork, selectWork, toggleWork: (work) => selectWork((current) => current?.sessionId === work.sessionId ? undefined : work) }}>
{}} onNew={() => {}} scrollBehavior="auto" activeSession={{ id: sessionId, name: 'WorkHub', isFlagged: false, isArchived: false, labels: [], hasUnread: false, status: 'active', runningTurnIds: [], backend: 'ai-sdk', llmConnectionId: 'connection-test', llmConnectionSlug: 'test', connectionLocked: false, model: 'model-a', permissionMode: 'ask' }} - hasOlderHistory={!loaded} onPrefetchHistory={async () => { setLoaded(true); return true; }} + hasEarlierHistory={!loaded} onLoadEarlierHistory={() => setLoaded(true)} workLinks={['older-turn', 'latest-turn'].map((coordinationTurnId) => ({ id: coordinationTurnId, coordinationTurnId, targetSessionId: targetId, targetSessionName: '支付回调幂等性' }))} />
@@ -497,10 +497,10 @@ export const FilterWorkHistoryPages: Story = { const canvas = within(canvasElement); await waitFor(() => expect(canvas.getByText('继续补充异常场景。')).toBeInTheDocument()); expect(canvas.queryByText('请检查支付回调幂等性。')).toBeNull(); - await userEvent.click(canvas.getByRole('button', { name: '更早的历史' })); + await userEvent.click(canvas.getByRole('button', { name: '载入更早的记录' })); await waitFor(() => expect(canvas.getByText('请检查支付回调幂等性。')).toBeInTheDocument()); expect(canvas.queryByText('先讨论一下整体计划。')).toBeNull(); - expect(canvas.queryByRole('button', { name: '更早的历史' })).toBeNull(); + expect(canvas.queryByRole('button', { name: '载入更早的记录' })).toBeNull(); await userEvent.click(canvas.getByRole('button', { name: '显示全部对话' })); await waitFor(() => expect(canvas.getByText('先讨论一下整体计划。')).toBeInTheDocument()); }, diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 201c78a9f4..fa6e85d1c7 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -254,7 +254,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/chat-model-switcher.tsx` | shell-chrome-or-panel | Button, DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuRadioGroup, DropdownMenuRadioItem) | aligned | | `packages/ui/src/chat-surface-layout.tsx` | shell-chrome-or-panel | ChatLayout | aligned — uses Astryx (ChatLayout) | aligned | | `packages/ui/src/chat-turn.tsx` | shell-chrome-or-panel | Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText, HStack, Icon, IconButton, Spinner, Thumbnail, Timestamp, Token, Tooltip | aligned — uses Astryx (Badge, Banner, Button, ChatMessage, ChatMessageBubble, ChatMessageMetadata, ChatSystemMessage, ChatTokenizedText) | aligned | -| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, Spinner | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, Spinner) | aligned | +| `packages/ui/src/chat-view.tsx` | shell-chrome-or-panel | Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner | aligned — uses Astryx (Button, ButtonGroup, ChatMessageList, EmptyState, HStack, Spinner) | aligned | | `packages/ui/src/choice-panel.tsx` | shell-chrome-or-panel | Kbd, RadioList, RadioListItem, Text | aligned — uses Astryx (Kbd, RadioList, RadioListItem, Text) | aligned | | `packages/ui/src/client-capability-prompt.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned | | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/package-lock.json b/package-lock.json index fc2bdd5b5e..8204437052 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16710,6 +16710,36 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/virtua": { + "version": "0.48.8", + "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz", + "integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0", + "solid-js": ">=1.0", + "svelte": ">=5.0", + "vue": ">=3.2" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", @@ -17373,7 +17403,8 @@ "lucide-react": "^1.38.0", "mermaid": "^11.17.2", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "virtua": "0.48.8" }, "devDependencies": { "@types/react": "^19.2.18", diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts index e2cb002e9d..67223c875d 100644 --- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts +++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts @@ -1446,7 +1446,7 @@ test('migrates the released transcript query grant when opening an existing acce } }); -test('adds bounded turn landmarks to an existing turn-query grant', async () => { +test('releases a stored turn-landmark grant and keeps the turn-query grant', async () => { const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-turn-landmarks-')); const credential = 'maka_rh_existing_turn_client'; try { @@ -1461,7 +1461,7 @@ test('adds bounded turn landmarks to an existing turn-query grant', async () => principalId: 'existing-turn-client', principalKind: 'remote_owner', status: 'active', - operationGrants: ['host.status', 'session.turns.query'], + operationGrants: ['host.status', 'session.turns.query', 'session.turn_landmarks.query'], canPublishClientCapabilities: false, canUseHostPaths: false, createdAt: '2026-01-01T00:00:00.000Z', @@ -1475,7 +1475,6 @@ test('adds bounded turn landmarks to an existing turn-query grant', async () => assert.deepEqual(authority.authenticate(credential)?.operationGrants, [ 'host.status', 'session.turns.query', - 'session.turn_landmarks.query', ]); } finally { await rm(directory, { recursive: true, force: true }); diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts index b1626d7995..9e526d631c 100644 --- a/packages/runtime-host/src/__tests__/connection-session.test.ts +++ b/packages/runtime-host/src/__tests__/connection-session.test.ts @@ -180,8 +180,6 @@ test('transcript pages are serialized per connection before their responses are throughSequence: input.throughSequence, rawBytes: 0, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, }, }; @@ -1799,8 +1797,6 @@ function transcriptBootstrapFor(sessionId: string) { data: contents.toString('base64'), }, ], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, }, overlay: { @@ -1811,8 +1807,6 @@ function transcriptBootstrapFor(sessionId: string) { throughSequence: 0, rawBytes: 0, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, }, }; diff --git a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts index 7bad8c5fc9..6b6d2fbcc1 100644 --- a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts +++ b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts @@ -18,7 +18,7 @@ */ import type { StoredMessage } from '@maka/core/session'; -import type { SessionTurnContribution, SessionTurnLandmark } from '@maka/storage/execution-stores'; +import type { SessionTurnContribution } from '@maka/storage/execution-stores'; import { foldTurnContribution } from '@maka/storage/session-message-projection'; import type { SessionTranscriptReader } from '../../server/session-transcript-reader.js'; @@ -172,20 +172,6 @@ export function transcriptReader( nextPosition: null, }; }, - readDurableTurnLandmarks: async (_sessionId, maxLandmarks) => { - const watermark = durableHighWater(); - if (watermark === null) return { throughSequence: null, landmarks: [] }; - const seen = new Set(); - const landmarks: SessionTurnLandmark[] = []; - for (const { sequence, message } of durableRecords()) { - if (landmarks.length >= maxLandmarks) break; - const turnId = message.turnId; - if (message.type !== 'user' || turnId === undefined || seen.has(turnId)) continue; - seen.add(turnId); - landmarks.push({ turnId, sequence, label: message.displayText ?? message.text }); - } - return { throughSequence: watermark, landmarks }; - }, readActiveOverlay: async () => overlay, }; } diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 8512a27443..06c43e1858 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -2057,7 +2057,6 @@ function createFixture( contributions: [], nextPosition: null, }), - readDurableTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), ...options.turnIndex, }; const runtimePolicy = options.runtimePolicy ?? runtimePolicyFixture(options.connection ?? {}); 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 9afe6bb6e1..8ac04dcef6 100644 --- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts +++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts @@ -691,7 +691,7 @@ test('decodes one bounded page without walking the remaining transcript', async assert.deepEqual(requests, []); }); -test('assembles the complete edge Turn while paging newer transcript', async () => { +test('returns a page of complete messages without reading past its cursor', async () => { const prompt = { type: 'user' as const, id: 'user-1', @@ -699,16 +699,7 @@ test('assembles the complete edge Turn while paging newer transcript', async () ts: 1, text: 'prompt', }; - const answer = { - type: 'assistant' as const, - id: 'assistant-1', - turnId: 'turn-1', - ts: 2, - text: 'answer', - modelId: 'model-1', - }; const promptBytes = Buffer.from(JSON.stringify(prompt), 'utf8'); - const answerBytes = Buffer.from(JSON.stringify(answer), 'utf8'); const requests: string[] = []; const initial: SessionTranscriptPage = { ...transcriptPage({ @@ -727,8 +718,6 @@ test('assembles the complete edge Turn while paging newer transcript', async () }), direction: 'newer', throughSequence: 1, - rangeBoundarySequence: 1, - protectedTurnSequence: 1, }; const subscription = new ClientSessionSubscription( openResult('host-1', 'subscription-newer-turn', { @@ -740,25 +729,7 @@ test('assembles the complete edge Turn while paging newer transcript', async () async () => undefined, async (input) => { requests.push(input.cursor!); - return { - ...transcriptPage({ - rawBytes: answerBytes.byteLength, - fragments: [ - { - kind: 'durable', - sequence: 1, - byteOffset: 0, - totalBytes: answerBytes.byteLength, - payloadDigest: null, - data: answerBytes.toString('base64'), - }, - ], - }), - direction: 'newer', - throughSequence: 1, - rangeBoundarySequence: 1, - protectedTurnSequence: 1, - }; + throw new Error('a complete page must not read its continuation'); }, ); @@ -766,12 +737,10 @@ test('assembles the complete edge Turn while paging newer transcript', async () assert.deepEqual( decoded.messages.map(({ identity, message }) => [identity, message.id]), - [ - [0, 'user-1'], - [1, 'assistant-1'], - ], + [[0, 'user-1']], ); - assert.deepEqual(requests, ['answer']); + assert.equal(decoded.nextCursor, 'answer'); + assert.deepEqual(requests, []); }); test('loads and releases only the active overlay', async () => { @@ -1763,8 +1732,6 @@ 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 13ce9dacdb..6eb559aa43 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts @@ -73,8 +73,6 @@ 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); }); @@ -346,7 +344,7 @@ 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 () => { +test('completes a byte-sliced bootstrap message from its continuation', async () => { const prompt = { ...userMessage(0, 'hello'), turnId: 'turn-1' }; const assistant = { ...assistantMessage(1), @@ -370,8 +368,6 @@ test('opens the complete latest Turn when bootstrap starts inside its assistant' projection: 'owner', }); - assert.equal(bootstrap.durable.rangeBoundarySequence, 0); - assert.equal(bootstrap.durable.protectedTurnSequence, 1); assert.ok(bootstrap.durable.nextCursor); const subscription = new ClientSessionSubscription( { @@ -409,14 +405,11 @@ test('opens the complete latest Turn when bootstrap starts inside its assistant' const decoded = await subscription.decodeTranscriptPage(bootstrap.durable, decodeStoredMessage); - assert.deepEqual(decoded.messages, [ - { identity: 0, message: prompt }, - { identity: 1, message: assistant }, - ]); - assert.equal(decoded.nextCursor, null); + assert.deepEqual(decoded.messages, [{ identity: 1, message: assistant }]); + assert.ok(decoded.nextCursor); }); -test('keeps sparse adjacent Turns in separate client ranges while paging in either direction', async () => { +test('pages every sparse durable message in either direction', async () => { const durable: StoredMessage[] = [ ...Array.from({ length: 161 }, (_, index) => ({ ...assistantMessage(index), @@ -432,71 +425,17 @@ test('keeps sparse adjacent Turns in separate client ranges while paging in eith for (const direction of ['older', 'newer'] as const) { for (const projection of ['owner', 'shared'] as const) { - const ranges = await decodeSparseTranscriptRanges(durable, direction, projection); - const expected = direction === 'older' ? [134, 161] : [161, 134]; + const pages = await decodeSparseTranscriptPages(durable, direction, projection); + const identities = pages.flat().map(({ identity }) => identity); - assert.deepEqual( - ranges.map((range) => range.length), - expected, - `${projection} ${direction}`, - ); - assert.equal( - new Set(ranges.flat().map(({ identity }) => identity)).size, - durable.length, - `${projection} ${direction}`, - ); + assert.ok(pages.length > 1, `${projection} ${direction}`); + assert.equal(identities.length, durable.length, `${projection} ${direction}`); + assert.equal(new Set(identities).size, durable.length, `${projection} ${direction}`); } } }); -test('admits a sparse Turn exactly at the client range message bound', async () => { - const durable: StoredMessage[] = [ - ...Array.from({ length: 256 }, (_, index) => ({ - ...assistantMessage(index), - turnId: 'turn-at-bound', - text: 'x'.repeat(1_600), - })), - ...Array.from({ length: 20 }, (_, index) => ({ - ...assistantMessage(index + 256), - turnId: 'turn-after', - text: 'x'.repeat(1_600), - })), - ]; - - for (const projection of ['owner', 'shared'] as const) { - const ranges = await decodeSparseTranscriptRanges(durable, 'newer', projection); - assert.deepEqual( - ranges.map((range) => range.length), - [256, 20], - projection, - ); - } -}); - -test('restarts a partial first row deferred past a sparse range boundary', async () => { - const durable: StoredMessage[] = [ - ...Array.from({ length: 161 }, (_, index) => ({ - ...assistantMessage(index), - turnId: 'turn-older', - text: index === 160 ? 'y'.repeat(600 * 1024) : 'x'.repeat(1_600), - })), - ...Array.from({ length: 134 }, (_, index) => ({ - ...assistantMessage(index + 161), - turnId: 'turn-newer', - text: 'x'.repeat(1_600), - })), - ]; - - const ranges = await decodeSparseTranscriptRanges(durable, 'older', 'owner'); - assert.deepEqual( - ranges.map((range) => range.length), - [134, 161], - ); - assert.equal(new Set(ranges.flat().map(({ identity }) => identity)).size, durable.length); - assert.deepEqual(ranges[1]?.find(({ identity }) => identity === 160 * 8)?.message, durable[160]); -}); - -test('pages through a terminal Turn that exceeds the Host range message bound', async () => { +test('pages through a terminal Turn longer than one page', async () => { const durable: StoredMessage[] = Array.from({ length: 286 }, (_, index) => ({ ...assistantMessage(index), turnId: 'turn-1', @@ -520,8 +459,6 @@ test('pages through a terminal Turn that exceeds the Host range message bound', }); assert.equal(bootstrap.durable.fragments.length, 256); - assert.equal(bootstrap.durable.rangeBoundarySequence, null); - assert.equal(bootstrap.durable.protectedTurnSequence, null); assert.ok(bootstrap.durable.nextCursor); const subscription = new ClientSessionSubscription( @@ -593,291 +530,6 @@ test('pages through a terminal Turn that exceeds the Host range message bound', ); }); -test('degrades the range boundary for an oversized running Turn', async () => { - const durable: StoredMessage[] = Array.from({ length: 286 }, (_, index) => ({ - ...assistantMessage(index), - turnId: 'turn-1', - })); - const { bootstrap } = await createSessionTranscriptBootstrap({ - reader: transcriptReader(durable), - sessionId: 'session-1', - subscriptionId: 'subscription-1', - throughSequence: durable.length - 1, - rootTurn: { - sessionId: 'session-1', - turnId: 'turn-1', - runId: 'run-1', - status: 'running', - }, - activeAssistantStreams: [], - maxBytes: 512 * 1024, - projection: 'owner', - }); - - assert.equal(bootstrap.durable.fragments.length, 256); - assert.equal(bootstrap.durable.rangeBoundarySequence, null); - assert.equal(bootstrap.durable.protectedTurnSequence, null); - assert.ok(bootstrap.durable.nextCursor); -}); - -test('degrades the range boundary for a Turn that exceeds the byte bound', async () => { - const durable: StoredMessage[] = Array.from({ length: 32 }, (_, index) => ({ - ...assistantMessage(index), - turnId: 'turn-1', - text: 'x'.repeat(600 * 1024), - })); - const { bootstrap } = await createSessionTranscriptBootstrap({ - reader: transcriptReader(durable), - sessionId: 'session-1', - subscriptionId: 'subscription-1', - throughSequence: durable.length - 1, - rootTurn: { - sessionId: 'session-1', - turnId: 'turn-1', - runId: 'run-1', - status: 'completed', - terminalEventId: 'terminal-1', - }, - activeAssistantStreams: [], - maxBytes: 512 * 1024, - projection: 'owner', - }); - - assert.ok(bootstrap.durable.fragments.length <= 256); - assert.ok(bootstrap.durable.rawBytes <= 512 * 1024); - assert.equal(bootstrap.durable.rangeBoundarySequence, null); - assert.equal(bootstrap.durable.protectedTurnSequence, null); - assert.ok(bootstrap.durable.nextCursor); -}); - -test('admits a latest Turn exactly at the Host range message bound', async () => { - const durable: StoredMessage[] = [ - { ...assistantMessage(0), turnId: 'turn-before' }, - ...Array.from({ length: 256 }, (_, index) => ({ - ...assistantMessage(index + 1), - turnId: 'turn-latest', - })), - ]; - - for (const projection of ['owner', 'shared'] as const) { - const { bootstrap } = await createSessionTranscriptBootstrap({ - reader: transcriptReader(durable), - sessionId: 'session-1', - subscriptionId: `subscription-${projection}`, - throughSequence: durable.length - 1, - 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('excludes a partial far-edge Turn when the complete range would exceed its bound', async () => { - const durable: StoredMessage[] = [ - ...Array.from({ length: 3 }, (_, index) => ({ - ...assistantMessage(index), - turnId: 'turn-far-edge', - })), - ...Array.from({ length: 254 }, (_, index) => assistantMessage(index + 3)), - ]; - const reader = transcriptReader(durable); - const { bootstrap, state } = await createSessionTranscriptBootstrap({ - reader, - sessionId: 'session-1', - subscriptionId: 'subscription-1', - throughSequence: durable.length - 1, - rootTurn: null, - activeAssistantStreams: [], - maxBytes: 512 * 1024, - projection: 'owner', - }); - - assert.deepEqual( - bootstrap.durable.fragments.map((fragment) => fragment.kind === 'durable' && fragment.sequence), - Array.from({ length: 254 }, (_, index) => 256 - index), - ); - assert.equal(bootstrap.durable.rangeBoundarySequence, 3); - assert.equal(bootstrap.durable.protectedTurnSequence, 256); - assert.ok(bootstrap.durable.nextCursor); - - const page = await readSessionTranscriptPage({ - reader, - state, - request: { - subscriptionId: 'subscription-1', - source: 'durable', - direction: 'older', - throughSequence: durable.length - 1, - cursor: bootstrap.durable.nextCursor, - anchorSequence: null, - maxBytes: 512 * 1024, - }, - }); - assert.deepEqual( - page.fragments.map((fragment) => fragment.kind === 'durable' && fragment.sequence), - [2, 1, 0], - ); - assert.equal(page.rangeBoundarySequence, 0); - assert.equal(page.protectedTurnSequence, 2); -}); - -test('admits a forward Turn exactly at the Host range message bound', async () => { - for (const projection of ['owner', 'shared'] as const) { - const durable: StoredMessage[] = [{ ...assistantMessage(0), turnId: 'turn-before' }]; - const reader = transcriptReader(durable); - const subscriptionId = `subscription-${projection}`; - const { state } = await createSessionTranscriptBootstrap({ - reader, - sessionId: 'session-1', - subscriptionId, - throughSequence: 0, - 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('defers a partial forward edge Turn to the next complete range', async () => { - for (const projection of ['owner', 'shared'] as const) { - const durable: StoredMessage[] = [{ ...assistantMessage(0), turnId: 'turn-before' }]; - const reader = transcriptReader(durable); - const subscriptionId = `subscription-${projection}`; - const { state } = await createSessionTranscriptBootstrap({ - reader, - sessionId: 'session-1', - subscriptionId, - throughSequence: 0, - rootTurn: null, - activeAssistantStreams: [], - maxBytes: 16 * 1024, - projection, - }); - durable.push( - ...Array.from({ length: 254 }, (_, index) => assistantMessage(index + 1)), - ...Array.from({ length: 3 }, (_, index) => ({ - ...assistantMessage(index + 255), - turnId: 'turn-far-edge', - })), - ); - assert.equal(updateSubscriberTranscriptHighWater(state, durable.length - 1), true); - - const first = await readSessionTranscriptPage({ - reader, - state, - request: { - subscriptionId, - source: 'durable', - direction: 'newer', - throughSequence: durable.length - 1, - cursor: null, - anchorSequence: 0, - maxBytes: 512 * 1024, - }, - }); - assert.deepEqual( - first.fragments.map((fragment) => fragment.kind === 'durable' && fragment.sequence), - Array.from({ length: 254 }, (_, index) => index + 1), - ); - assert.equal(first.rangeBoundarySequence, 254); - assert.equal(first.protectedTurnSequence, 254); - assert.ok(first.nextCursor); - - const second = await readSessionTranscriptPage({ - reader, - state, - request: { - subscriptionId, - source: 'durable', - direction: 'newer', - throughSequence: durable.length - 1, - cursor: first.nextCursor, - anchorSequence: null, - maxBytes: 512 * 1024, - }, - }); - assert.deepEqual( - second.fragments.map((fragment) => fragment.kind === 'durable' && fragment.sequence), - [255, 256, 257], - ); - assert.equal(second.rangeBoundarySequence, 257); - assert.equal(second.protectedTurnSequence, 257); - } -}); - -test('protects the latest Turn when a forward page ends in a session note', async () => { - const durable: StoredMessage[] = [{ ...assistantMessage(0), turnId: 'turn-before' }]; - const reader = transcriptReader(durable); - const { state } = await createSessionTranscriptBootstrap({ - reader, - sessionId: 'session-1', - subscriptionId: 'subscription-1', - throughSequence: 0, - 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 }, @@ -910,7 +562,7 @@ test('shared paging skips a full hidden storage batch before a visible message', assert.equal(bootstrap.durable.nextCursor, null); }); -test('shared range edges cross a hidden storage batch between visible messages', async () => { +test('shared paging crosses a hidden storage batch between visible messages', async () => { const durable: StoredMessage[] = [ userMessage(0, 'before'), ...Array.from( @@ -939,8 +591,10 @@ test('shared range edges cross a hidden storage batch between visible messages', projection: 'shared', }); - assert.equal(bootstrap.durable.rangeBoundarySequence, 0); - assert.equal(bootstrap.durable.protectedTurnSequence, 258); + assert.deepEqual( + decodeBootstrap(bootstrap.durable).map(({ id }) => id), + ['message-258', 'message-0'], + ); const forward = await readSessionTranscriptPage({ reader, @@ -955,8 +609,11 @@ test('shared range edges cross a hidden storage batch between visible messages', maxBytes: 512 * 1024, }, }); - assert.equal(forward.rangeBoundarySequence, 258); - assert.equal(forward.protectedTurnSequence, 258); + assert.deepEqual( + decodeBootstrap(forward).map(({ id }) => id), + ['message-0', 'message-258'], + ); + assert.equal(forward.nextCursor, null); }); test('shrinks the raw bootstrap until it fits its aggregate encoded budget', async () => { @@ -1067,7 +724,7 @@ function decodeBootstrap( ); } -async function decodeSparseTranscriptRanges( +async function decodeSparseTranscriptPages( durable: readonly StoredMessage[], direction: 'older' | 'newer', projection: 'owner' | 'shared', @@ -1113,7 +770,7 @@ async function decodeSparseTranscriptRanges( ); const decodeStoredMessage = (value: unknown): StoredMessage => decodePersistedStoredMessage(markPersisted(value)); - const ranges: Array = []; + const pages: Array = []; let cursor: string | null = null; do { const page = await readSessionTranscriptPage({ @@ -1130,10 +787,10 @@ async function decodeSparseTranscriptRanges( }, }); const decoded = await subscription.decodeTranscriptPage(page, decodeStoredMessage); - ranges.push(decoded.messages); + pages.push(decoded.messages); cursor = decoded.nextCursor; } while (cursor !== null); - return ranges; + return pages; } test('shared transcript preserves admitted action identity alongside its physical Turn', () => { diff --git a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts index a2d25e713c..26a364b3ce 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts @@ -36,7 +36,7 @@ const input = { direction: 'older' as const, throughSequence: 3, cursor: null, - anchorSequence: 2, + anchorSequence: null, maxBytes: 1024, }; const payloadDigest = `sha256:${'a'.repeat(64)}` as const; @@ -58,8 +58,6 @@ const page = { data: Buffer.from('test').toString('base64'), }, ], - rangeBoundarySequence: 2, - protectedTurnSequence: 2, nextCursor: 'opaque-cursor', }; @@ -82,8 +80,6 @@ test('Session transcript protocol accepts bounded correlated pages and bootstrap throughSequence: 3, rawBytes: 0, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, }, }; @@ -160,32 +156,24 @@ test('a maximum multi-message page remains transport safe', () => { ); }); -test('Session transcript protocol rejects malformed and uncorrelated values', () => { +test('Session transcript anchors only start durable reads of newer rows', () => { + const newer = { ...input, direction: 'newer' as const, anchorSequence: 2 }; + assert.deepEqual(decodeSessionTranscriptPageInput(newer), newer); assert.throws( - () => decodeSessionTranscriptPageInput({ ...input, cursor: 'cursor', anchorSequence: 2 }), + () => decodeSessionTranscriptPageInput({ ...newer, cursor: 'cursor' }), isProtocolError, ); assert.throws( - () => decodeSessionTranscriptPage({ ...page, rangeBoundarySequence: 4 }), + () => decodeSessionTranscriptPageInput({ ...newer, direction: 'older' }), isProtocolError, ); assert.throws( - () => decodeSessionTranscriptPage({ ...page, protectedTurnSequence: 4 }), - isProtocolError, - ); - assert.throws( - () => - decodeSessionTranscriptPage({ - ...page, - source: 'overlay', - rawBytes: 0, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: 2, - nextCursor: null, - }), + () => decodeSessionTranscriptPageInput({ ...newer, source: 'overlay' }), isProtocolError, ); +}); + +test('Session transcript protocol rejects malformed and uncorrelated values', () => { assert.throws( () => decodeSessionTranscriptPage({ diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts index f9b4e5a60f..a9ef776509 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -613,13 +613,6 @@ test('pages the ledger without materializing Turns it takes no rows from', async ), [], ); - const landmarks = await decoding('landmarks', SMALL_TURN_BUDGET, () => - read.readDurableTurnLandmarks(session.id, 3), - ); - assert.deepEqual( - landmarks.landmarks.map((item) => item.label), - ['prompt 0', 'prompt 2', 'prompt 4'], - ); const contributions: SessionTurnContribution[] = []; let contributionPosition = 0; for (;;) { diff --git a/packages/runtime-host/src/__tests__/session-turns.test.ts b/packages/runtime-host/src/__tests__/session-turns.test.ts index d311a68ed9..153237205e 100644 --- a/packages/runtime-host/src/__tests__/session-turns.test.ts +++ b/packages/runtime-host/src/__tests__/session-turns.test.ts @@ -22,33 +22,11 @@ import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure'; import test from 'node:test'; import { decodeSessionTurnsQueryResult, - decodeSessionTurnLandmarksQueryResult, - projectSessionTurnLandmarkForWire, projectSessionTurnContribution, projectSessionTurnContributionForWire, SESSION_TURN_DIAGNOSTIC_MAX_BYTES, - SESSION_TURN_LANDMARK_RESULT_MAX_BYTES, } from '../protocol/session-turns.js'; -test('keeps a full sampled landmark index inside its encoded result budget', () => { - const result = { - sessionId: 'session-1', - throughSequence: 1_000, - landmarks: Array.from({ length: 64 }, (_, index) => - projectSessionTurnLandmarkForWire({ - turnId: `${index}`.padEnd(128, 't'), - sequence: Number.MAX_SAFE_INTEGER - index, - label: '\0'.repeat(256), - }), - ), - }; - - assert.ok( - Buffer.byteLength(JSON.stringify(result), 'utf8') <= SESSION_TURN_LANDMARK_RESULT_MAX_BYTES, - ); - assert.doesNotThrow(() => decodeSessionTurnLandmarksQueryResult(result)); -}); - test('publishes no Turn until its recorded state is on the page', () => { assert.strictEqual( projectSessionTurnContribution({ diff --git a/packages/runtime-host/src/client/session-subscription.ts b/packages/runtime-host/src/client/session-subscription.ts index 7d2ae126e4..27dd7ff1d2 100644 --- a/packages/runtime-host/src/client/session-subscription.ts +++ b/packages/runtime-host/src/client/session-subscription.ts @@ -25,8 +25,6 @@ import { type SessionDomainChangedFrame, type SessionContinuitySnapshot, SESSION_TRANSCRIPT_PAGE_MAX_BYTES, - SESSION_TRANSCRIPT_RANGE_MAX_BYTES, - SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, type SubscriptionFrame, type SubscriptionOpenResult, type SessionTranscriptBootstrap, @@ -256,15 +254,7 @@ export class ClientSessionSubscription try { assembler.accept(page.fragments); let cursor = page.nextCursor; - let rangeBytes = page.fragments.reduce((total, fragment) => total + fragment.totalBytes, 0); - const rangeIdentities = new Set( - page.fragments.map((fragment) => - fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex, - ), - ); - let reachedBoundary = - page.rangeBoundarySequence === null || rangeIdentities.has(page.rangeBoundarySequence); - while (assembler.continuationBytes !== null || !reachedBoundary) { + while (assembler.continuationBytes !== null) { if (cursor === null) { throw new RuntimeHostSubscriptionError( 'correlation_changed', @@ -278,10 +268,7 @@ export class ClientSessionSubscription throughSequence: page.throughSequence, cursor, anchorSequence: null, - maxBytes: - assembler.continuationBytes === null - ? SESSION_TRANSCRIPT_PAGE_MAX_BYTES - : Math.min(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, assembler.continuationBytes), + maxBytes: Math.min(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, assembler.continuationBytes), }); if (continuation.nextCursor === requestedCursor) { throw new RuntimeHostSubscriptionError( @@ -289,22 +276,7 @@ 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 e3a32337e2..6a29130977 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,10 @@ 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 = 156 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 157 as const; +// 157: Session transcript reads return the whole transcript under a byte budget. +// The windowed read — its turn-range operation, cursors and gap rows — is gone, +// so a peer older than this epoch asks for a window this Host no longer answers. // 154: External Session import results distinguish committed Sessions from typed source limits. // 153: Sessions may select plugin executors and Plugin Platform queries expose them. // 152: Assistant completions and transcript rows preserve interrupted responses. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index deb62c8327..7b5523ed58 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -335,7 +335,6 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'session.revision.create', 'session.transcript.page', 'session.transcript.overlay.release', - 'session.turn_landmarks.query', 'session.turns.query', 'session.workspace.relocate', 'skill.catalog.invocable.query', diff --git a/packages/runtime-host/src/protocol/session-transcript.ts b/packages/runtime-host/src/protocol/session-transcript.ts index 8cdfc8ebd7..daed3c4910 100644 --- a/packages/runtime-host/src/protocol/session-transcript.ts +++ b/packages/runtime-host/src/protocol/session-transcript.ts @@ -32,8 +32,6 @@ import { defineOperation } from './operation-spec.js'; export const SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES = 16 * 1024; export const SESSION_TRANSCRIPT_PAGE_MAX_BYTES = 512 * 1024; export const SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES = 256; -export const SESSION_TRANSCRIPT_RANGE_MAX_BYTES = 16 * 1024 * 1024; -export const SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES = SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES; export const SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES = 4_096; export const SESSION_TRANSCRIPT_PAGE_RESULT_MAX_BYTES = 744 * 1024; export const SESSION_TRANSCRIPT_CURSOR_MAX_BYTES = 1024; @@ -66,10 +64,6 @@ 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; } @@ -175,10 +169,15 @@ export function decodeSessionTranscriptPageInput(value: unknown): SessionTranscr if (cursor !== null && anchorSequence !== null) { throw invalidProtocolFrame('Session transcript cursor and anchor are mutually exclusive'); } + const source = decodeSource(input.source); + const direction = decodeDirection(input.direction); + if (anchorSequence !== null && (source !== 'durable' || direction !== 'newer')) { + throw invalidProtocolFrame('Session transcript anchor requires a durable newer read'); + } return { subscriptionId: requireId(input.subscriptionId, 'subscriptionId'), - source: decodeSource(input.source), - direction: decodeDirection(input.direction), + source, + direction, throughSequence: input.throughSequence === null ? null @@ -244,8 +243,6 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa 'throughSequence', 'rawBytes', 'fragments', - 'rangeBoundarySequence', - 'protectedTurnSequence', 'nextCursor', ]); if (result.kind !== 'page') throw invalidProtocolFrame('Invalid Session transcript page kind'); @@ -281,28 +278,6 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa 'Session transcript cursor', SESSION_TRANSCRIPT_CURSOR_MAX_BYTES, ); - const rangeBoundarySequence = - result.rangeBoundarySequence === null - ? null - : requireCount(result.rangeBoundarySequence, 'Session transcript range boundary sequence'); - const protectedTurnSequence = - result.protectedTurnSequence === null - ? null - : requireCount(result.protectedTurnSequence, 'Session transcript protected Turn sequence'); - if ( - (rangeBoundarySequence !== null && 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'); } @@ -314,8 +289,6 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa throughSequence, rawBytes, fragments, - rangeBoundarySequence, - protectedTurnSequence, nextCursor, }; } diff --git a/packages/runtime-host/src/protocol/session-turns.ts b/packages/runtime-host/src/protocol/session-turns.ts index 136f7883f4..79252a7345 100644 --- a/packages/runtime-host/src/protocol/session-turns.ts +++ b/packages/runtime-host/src/protocol/session-turns.ts @@ -34,36 +34,6 @@ export const SESSION_TURN_QUERY_MAX_CONTRIBUTIONS = 128; export const SESSION_TURN_QUERY_RESULT_MAX_BYTES = 192 * 1024; export const SESSION_TURN_DIAGNOSTIC_MAX_BYTES = 128; export const SESSION_TURN_PROMPT_PREVIEW_MAX_BYTES = 256; -export const SESSION_TURN_LANDMARK_MAX_ITEMS = 64; -export const SESSION_TURN_LANDMARK_LABEL_MAX_BYTES = 96; -export const SESSION_TURN_LANDMARK_RESULT_MAX_BYTES = 64 * 1024; - -export interface SessionTurnLandmark { - readonly turnId: string; - readonly sequence: number; - readonly label: string; -} - -export interface SessionTurnLandmarksQueryInput { - readonly sessionId: string; - readonly maxLandmarks: number; -} - -export interface SessionTurnLandmarksQueryResult { - readonly sessionId: string; - readonly throughSequence: number | null; - readonly landmarks: readonly SessionTurnLandmark[]; -} - -export function projectSessionTurnLandmarkForWire( - landmark: SessionTurnLandmark, -): SessionTurnLandmark { - return { - turnId: requireEntityId(landmark.turnId, 'turnId'), - sequence: requireCount(landmark.sequence, 'Session turn landmark sequence'), - label: truncateUtf8(landmark.label, SESSION_TURN_LANDMARK_LABEL_MAX_BYTES), - }; -} export interface SessionTurnContribution { readonly turnId: string; @@ -223,22 +193,6 @@ const QUERY_ERRORS = [ ] as const; export const SESSION_TURNS_OPERATION_SPECS = { - 'session.turn_landmarks.query': defineOperation< - SessionTurnLandmarksQueryInput, - SessionTurnLandmarksQueryResult, - (typeof QUERY_ERRORS)[number] - >({ - mode: 'query', - availability: 'ready', - errors: QUERY_ERRORS, - decodeInput: decodeSessionTurnLandmarksQueryInput, - decodeOutput: decodeSessionTurnLandmarksQueryResult, - assertOutputForInput: (input, output) => { - if (input.sessionId !== output.sessionId) { - throw invalidProtocolFrame('Session turn landmark query identity changed'); - } - }, - }), 'session.turns.query': defineOperation< SessionTurnsQueryInput, SessionTurnsQueryResult, @@ -260,67 +214,6 @@ export const SESSION_TURNS_OPERATION_SPECS = { }), } as const; -export function decodeSessionTurnLandmarksQueryInput( - value: unknown, -): SessionTurnLandmarksQueryInput { - const input = requireExactRecord(value, 'Session turn landmark query input', [ - 'sessionId', - 'maxLandmarks', - ]); - const maxLandmarks = requireCount(input.maxLandmarks, 'Session turn landmark limit'); - if (maxLandmarks < 1 || maxLandmarks > SESSION_TURN_LANDMARK_MAX_ITEMS) { - throw invalidProtocolFrame('Invalid Session turn landmark limit'); - } - return { - sessionId: requireEntityId(input.sessionId, 'sessionId'), - maxLandmarks, - }; -} - -export function decodeSessionTurnLandmarksQueryResult( - value: unknown, -): SessionTurnLandmarksQueryResult { - requireEncodedByteLimit( - value, - 'Session turn landmark query result', - SESSION_TURN_LANDMARK_RESULT_MAX_BYTES, - ); - const result = requireExactRecord(value, 'Session turn landmark query result', [ - 'sessionId', - 'throughSequence', - 'landmarks', - ]); - if ( - !Array.isArray(result.landmarks) || - result.landmarks.length > SESSION_TURN_LANDMARK_MAX_ITEMS - ) { - throw invalidProtocolFrame('Invalid Session turn landmarks'); - } - return { - sessionId: requireEntityId(result.sessionId, 'sessionId'), - throughSequence: - result.throughSequence === null - ? null - : requireCount(result.throughSequence, 'Session turn landmark watermark'), - landmarks: result.landmarks.map((value) => { - const landmark = requireExactRecord(value, 'Session turn landmark', [ - 'turnId', - 'sequence', - 'label', - ]); - return { - turnId: requireEntityId(landmark.turnId, 'turnId'), - sequence: requireCount(landmark.sequence, 'Session turn landmark sequence'), - label: requireUtf8String( - landmark.label, - 'Session turn landmark label', - SESSION_TURN_LANDMARK_LABEL_MAX_BYTES, - ), - }; - }), - }; -} - export function decodeSessionTurnsQueryInput(value: unknown): SessionTurnsQueryInput { const input = requireExactRecord(value, 'Session turn query input', [ 'sessionId', diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts index 9903bbb44d..5f341d2ebf 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -66,11 +66,6 @@ const PERSISTED_GRANT_MIGRATIONS: ReadonlyMap = successors: ['session.transcript.page', 'session.transcript.overlay.release'], }, ], - // The Turn query kept its name and gained a separate landmark query beside it. - [ - 'session.turns.query', - { kind: 'replace', successors: ['session.turns.query', 'session.turn_landmarks.query'] }, - ], // Resource inventory is a dedicated facet of the existing Host diagnostics authority. [ 'host.diagnostics.query', @@ -84,6 +79,8 @@ const PERSISTED_GRANT_MIGRATIONS: ReadonlyMap = // Retired with the second execution-inspection contract; no shipped surface // called execution.inspect.resolve. ['execution.inspect.resolve', { kind: 'release' }], + // Retired with Desktop's windowed transcript scrollbar, its only caller. + ['session.turn_landmarks.query', { kind: 'release' }], // Direct WorkHub actions and record writes were retired. Their grants do not // authorize actFromTurn, which requires the active coordination Turn. ['workhub.coordination.act', { kind: 'release' }], diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index adf3e743b8..aa04ba561b 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -93,8 +93,6 @@ import { type SessionReadMarkerSetInput, type SessionUpdateResult, type SessionTurnsQueryInput, - type SessionTurnLandmarksQueryInput, - projectSessionTurnLandmarkForWire, SESSION_TURN_QUERY_RESULT_MAX_BYTES, projectSessionTurnContributionForWire, } from '../protocol/index.js'; @@ -119,7 +117,7 @@ type SessionCatalogStores = Pick< /** The Turn index a Session catalog page is built from, read off the ledger. */ type SessionTurnIndexReader = Pick< SessionTranscriptReader, - 'readDurableRecords' | 'readDurableTurnContributions' | 'readDurableTurnLandmarks' + 'readDurableRecords' | 'readDurableTurnContributions' >; /** One page of the backwards scan a read marker walks to find the newest visible message. */ @@ -290,7 +288,6 @@ export class HostSessionCatalogCoordinator { 'session.workspace.relocate': (input) => this.#relocateWorkspace(input), 'session.read_marker.set': (input) => this.#setReadMarker(input), 'session.execution_boundary.query': (input) => this.#queryExecutionBoundary(input), - 'session.turn_landmarks.query': (input) => this.#queryTurnLandmarks(input), 'session.turns.query': (input) => this.#queryTurns(input), }; @@ -549,30 +546,6 @@ export class HostSessionCatalogCoordinator { } } - async #queryTurnLandmarks( - input: SessionTurnLandmarksQueryInput, - ): Promise> { - try { - const snapshot = await this.#turnIndex.readDurableTurnLandmarks( - input.sessionId, - input.maxLandmarks, - ); - return { - ok: true, - result: { - sessionId: input.sessionId, - throughSequence: snapshot.throughSequence, - landmarks: snapshot.landmarks.map(projectSessionTurnLandmarkForWire), - }, - }; - } catch (error) { - if (isNotFound(error)) { - return turnLandmarksFailure('not_found', 'Session does not exist'); - } - return turnLandmarksFailure('persistence_failed', 'Session turn landmarks are unavailable'); - } - } - async #create( input: SessionCreateInput, toolMode?: ToolMode, @@ -1754,13 +1727,6 @@ function turnsFailure( return { ok: false, error: { code, message } }; } -function turnLandmarksFailure( - code: OperationError<'session.turn_landmarks.query'>['code'], - message: string, -): Extract, { readonly ok: false }> { - return { ok: false, error: { code, message } }; -} - function projectExecutionBoundary(boundary: ExecutionBoundary): ExecutionBoundarySummary { if (boundary.kind !== 'managed') return { kind: boundary.kind, revision: boundary.revision }; return { diff --git a/packages/runtime-host/src/server/session-transcript-pager.ts b/packages/runtime-host/src/server/session-transcript-pager.ts index 6b7fec2e35..5bd3d561f2 100644 --- a/packages/runtime-host/src/server/session-transcript-pager.ts +++ b/packages/runtime-host/src/server/session-transcript-pager.ts @@ -21,8 +21,6 @@ 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, @@ -40,6 +38,8 @@ import { projectSharedSessionTranscriptMessage } from './shared-session-transcri type SessionTranscriptProjection = 'owner' | 'shared'; +const SHARED_PROJECTION_HIDDEN_MAX_BYTES = 16 * 1024 * 1024; + interface TranscriptCursorState { readonly version: 1; readonly subscriptionId: string; @@ -49,7 +49,6 @@ interface TranscriptCursorState { readonly throughSequence: number | null; readonly position: number; readonly byteOffset: number | null; - readonly rangeBoundarySequence: number | null; } export interface SubscriberTranscriptState { @@ -112,7 +111,7 @@ export async function createSessionTranscriptBootstrap(input: { direction: 'older', throughSequence: input.throughSequence, maxBytes: durableBudget, - maxMessages: SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, + maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, } as const; const durableStorage = projection === 'shared' @@ -130,14 +129,6 @@ 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, overlayMessageCount: overlayMessages.length, @@ -145,10 +136,8 @@ export async function createSessionTranscriptBootstrap(input: { state, 'durable', 'older', - rangeEdges.selected, + storageSelection(durableStorage), input.throughSequence, - rangeEdges.rangeBoundarySequence, - rangeEdges.protectedTurnSequence, ), overlay: pageFromSelection(state, 'overlay', 'older', selectedOverlay), }; @@ -218,7 +207,6 @@ export async function readSessionTranscriptPage(input: { position.position, position.byteOffset, request.maxBytes, - continuationMessageLimit(position), ); return pageFromSelection( state, @@ -235,233 +223,25 @@ export async function readSessionTranscriptPage(input: { position: position.position, ...(position.byteOffset === null ? {} : { byteOffset: position.byteOffset }), maxBytes: request.maxBytes, - maxMessages: continuationMessageLimit(position), + maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, } as const; const storage = state.projection === 'shared' - ? await readSharedDurablePage( - input.reader, - state.sessionId, - durableRequest, - position.rangeBoundarySequence, - ) + ? await readSharedDurablePage(input.reader, state.sessionId, durableRequest) : await input.reader.readDurablePage(state.sessionId, durableRequest); - const selected = selectionThroughRangeBoundary( - storageSelection(storage), - request.direction, - position.rangeBoundarySequence, - ); - const rangeEdges = await readRangeEdges({ - reader: input.reader, - state, - direction: request.direction, - throughSequence: request.throughSequence, - selected, - }); return pageFromSelection( state, 'durable', request.direction, - rangeEdges.selected, + storageSelection(storage), request.throughSequence, - rangeEdges.rangeBoundarySequence, - rangeEdges.protectedTurnSequence, ); } -async function readRangeEdges(input: { - reader: SessionTranscriptReader; - state: SubscriberTranscriptState; - direction: SessionTranscriptPageDirection; - throughSequence: number | null; - selected: SelectedFragments; -}): Promise<{ - readonly selected: SelectedFragments; - readonly rangeBoundarySequence: number | null; - readonly protectedTurnSequence: number | null; -}> { - if (input.throughSequence === null) { - return { - selected: input.selected, - rangeBoundarySequence: null, - protectedTurnSequence: null, - }; - } - const selectedSequences = input.selected.fragments.flatMap((fragment) => - fragment.kind === 'durable' ? [fragment.sequence] : [], - ); - if (selectedSequences.length === 0) { - return { - selected: input.selected, - rangeBoundarySequence: null, - protectedTurnSequence: null, - }; - } - const boundaryCandidate = - input.direction === 'older' ? Math.min(...selectedSequences) : Math.max(...selectedSequences); - const scanPosition = - input.direction === 'older' ? Math.max(...selectedSequences) : Math.min(...selectedSequences); - const rangeRecords: Array<{ - readonly sequence: number; - readonly turnId: string | undefined; - readonly bytes: number; - }> = []; - let targetTurnId: string | undefined; - let targetStart: number | null = null; - let candidateReached = false; - let hiddenBytes = 0; - let reachedFarEdge = false; - let position: number | null = scanPosition; - while (position !== null && !reachedFarEdge) { - const scanned = await input.reader.readDurableRecords(input.state.sessionId, { - direction: input.direction, - throughSequence: input.throughSequence, - position, - maxStoredBytes: SESSION_TRANSCRIPT_RANGE_MAX_BYTES, - maxMessages: SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, - }); - for (const record of scanned.records) { - const message = - input.state.projection === 'shared' - ? projectSharedSessionTranscriptMessage(record.message, input.state.sessionId) - : record.message; - if (!message) { - hiddenBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); - if (hiddenBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES) { - throw new RangeError('Session transcript projection scan exceeds its capacity limit'); - } - continue; - } - const turnId = messageTurnId(message); - if (targetStart !== null && turnId !== targetTurnId) { - reachedFarEdge = true; - break; - } - rangeRecords.push({ - sequence: record.sequence, - turnId, - bytes: Buffer.byteLength(JSON.stringify(message), 'utf8'), - }); - if (!candidateReached && record.sequence === boundaryCandidate) { - candidateReached = true; - if (turnId === undefined) { - reachedFarEdge = true; - break; - } - targetTurnId = turnId; - targetStart = rangeRecords.length - 1; - while (targetStart > 0 && rangeRecords[targetStart - 1]?.turnId === targetTurnId) { - targetStart -= 1; - } - } - if (targetStart !== null) { - const targetRecords = rangeRecords.slice(targetStart); - const targetBytes = targetRecords.reduce((sum, target) => sum + target.bytes, 0); - if ( - targetRecords.length > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES || - targetBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES - ) { - if (targetStart === 0) { - return { - selected: input.selected, - rangeBoundarySequence: null, - protectedTurnSequence: null, - }; - } - reachedFarEdge = true; - break; - } - } - } - if (reachedFarEdge || scanned.nextPosition === null) { - position = scanned.nextPosition; - break; - } - if (scanned.nextPosition === position) { - throw new Error('Session transcript projection scan did not advance'); - } - position = scanned.nextPosition; - } - if (!candidateReached || rangeRecords.length === 0) { - throw new Error('Session transcript range did not reach its authoritative Turn'); - } - let retainedEnd = 0; - let retainedMessages = 0; - let retainedBytes = 0; - while (retainedEnd < rangeRecords.length) { - const groupStart = retainedEnd; - const groupTurnId = rangeRecords[groupStart]!.turnId; - let groupEnd = groupStart + 1; - if (groupTurnId !== undefined) { - while (groupEnd < rangeRecords.length && rangeRecords[groupEnd]?.turnId === groupTurnId) { - groupEnd += 1; - } - } - const group = rangeRecords.slice(groupStart, groupEnd); - const groupBytes = group.reduce((sum, record) => sum + record.bytes, 0); - if ( - group.length > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES || - groupBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES - ) { - if (groupStart > 0) break; - return { - selected: input.selected, - rangeBoundarySequence: null, - protectedTurnSequence: null, - }; - } - if ( - retainedMessages + group.length > SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES || - retainedBytes + groupBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES - ) { - break; - } - retainedMessages += group.length; - retainedBytes += groupBytes; - retainedEnd = groupEnd; - } - const retainedRecords = rangeRecords.slice(0, retainedEnd); - const boundary = retainedRecords.at(-1)?.sequence; - if (boundary === undefined) { - throw new RangeError('Session transcript Turn range exceeds its capacity limit'); - } - const selected = - retainedEnd === rangeRecords.length - ? input.selected - : (() => { - const retainedSequences = new Set(retainedRecords.map((record) => record.sequence)); - const fragments = input.selected.fragments.filter( - (fragment) => fragment.kind === 'durable' && retainedSequences.has(fragment.sequence), - ); - return { - fragments, - rawBytes: fragments.reduce( - (sum, fragment) => sum + Buffer.byteLength(fragment.data, 'base64'), - 0, - ), - next: { position: rangeRecords[retainedEnd]!.sequence, byteOffset: null }, - }; - })(); - const turnRecords = retainedRecords.filter((record) => record.turnId !== undefined); - const protectedTurnSequence = - input.direction === 'older' ? turnRecords[0]?.sequence : turnRecords.at(-1)?.sequence; - return { - selected, - rangeBoundarySequence: boundary, - protectedTurnSequence: protectedTurnSequence ?? boundary, - }; -} - -function messageTurnId(message: StoredMessage): string | undefined { - const turnId = 'turnId' in message ? message.turnId : undefined; - return typeof turnId === 'string' ? turnId : undefined; -} - async function readSharedDurablePage( reader: SessionTranscriptReader, sessionId: string, request: Parameters[1], - rangeBoundarySequence: number | null = null, ): ReturnType { const position = request.position ?? @@ -491,7 +271,7 @@ async function readSharedDurablePage( const projected = projectSharedSessionTranscriptMessage(record.message, sessionId); if (!projected) { hiddenBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); - if (hiddenBytes > SESSION_TRANSCRIPT_RANGE_MAX_BYTES) { + if (hiddenBytes > SHARED_PROJECTION_HIDDEN_MAX_BYTES) { throw new RangeError('Session transcript projection scan exceeds its capacity limit'); } continue; @@ -523,11 +303,6 @@ async function readSharedDurablePage( 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 }; @@ -576,11 +351,7 @@ export class TranscriptPageRequestError extends Error { function resolvePosition( state: SubscriberTranscriptState, request: SessionTranscriptPageInput, -): { - position: number; - byteOffset: number | null; - rangeBoundarySequence: number | null; -} | null { +): { position: number; byteOffset: number | null } | null { if (request.cursor !== null) { const cursor = decodeCursor(request.cursor, state.cursorSecret); if ( @@ -592,42 +363,20 @@ function resolvePosition( ) { throw new TranscriptPageRequestError('Transcript cursor does not match request'); } - return { - position: cursor.position, - byteOffset: cursor.byteOffset, - rangeBoundarySequence: cursor.rangeBoundarySequence, - }; + return { position: cursor.position, byteOffset: cursor.byteOffset }; } if (request.source === 'overlay') { const overlayMessages = state.overlayMessages; if (overlayMessages === undefined) return null; - const anchor = request.anchorSequence; - const position = - request.direction === 'older' ? (anchor ?? overlayMessages.length) - 1 : (anchor ?? -1) + 1; + const position = request.direction === 'older' ? overlayMessages.length - 1 : 0; return position < 0 || position >= overlayMessages.length ? null - : { position, byteOffset: null, rangeBoundarySequence: null }; + : { position, byteOffset: 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, rangeBoundarySequence: null }; -} - -function continuationMessageLimit(position: { - position: number; - rangeBoundarySequence: number | null; -}): number { - return position.rangeBoundarySequence === null - ? SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES - : Math.min( - SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, - Math.abs(position.position - position.rangeBoundarySequence) + 1, - ); + request.direction === 'older' ? request.throughSequence : (request.anchorSequence ?? -1) + 1; + return position < 0 || position > request.throughSequence ? null : { position, byteOffset: null }; } function storageSelection( @@ -647,44 +396,12 @@ function storageSelection( }; } -function selectionThroughRangeBoundary( - selected: SelectedFragments, - direction: SessionTranscriptPageDirection, - rangeBoundarySequence: number | null, -): SelectedFragments { - if (rangeBoundarySequence === null) return selected; - // RuntimeEvent-backed message sequences are sparse, so a continuation's - // message limit cannot infer how many records remain from sequence distance. - const firstOmittedIndex = selected.fragments.findIndex( - (fragment) => - fragment.kind === 'durable' && - (direction === 'older' - ? fragment.sequence < rangeBoundarySequence - : fragment.sequence > rangeBoundarySequence), - ); - if (firstOmittedIndex === -1) return selected; - const firstOmitted = selected.fragments[firstOmittedIndex]!; - if (firstOmitted.kind !== 'durable') { - throw new Error('Session transcript durable range contained an overlay fragment'); - } - const fragments = selected.fragments.slice(0, firstOmittedIndex); - return { - fragments, - rawBytes: fragments.reduce( - (sum, fragment) => sum + Buffer.from(fragment.data, 'base64').byteLength, - 0, - ), - next: { position: firstOmitted.sequence, byteOffset: null }, - }; -} - function selectOverlay( messages: readonly Buffer[], direction: SessionTranscriptPageDirection, position: number, byteOffset: number | null, maxBytes: number, - maxMessages = SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, ): SelectedFragments { const fragments: SessionTranscriptFragment[] = []; let rawBytes = 0; @@ -694,7 +411,7 @@ function selectOverlay( index >= 0 && index < messages.length && rawBytes < maxBytes && - fragments.length < maxMessages + fragments.length < SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES ) { const message = messages[index]!; const selected = selectBuffer(message, direction, offset, maxBytes - rawBytes); @@ -767,17 +484,7 @@ function pageFromSelection( direction: SessionTranscriptPageDirection, selected: SelectedFragments, throughSequence: number | null = state.openedThroughSequence, - rangeBoundarySequence: number | null = null, - protectedTurnSequence: number | null = null, ): SessionTranscriptPage { - const cursorRangeBoundarySequence = - selected.next !== null && - rangeBoundarySequence !== null && - (direction === 'older' - ? selected.next.position < rangeBoundarySequence - : selected.next.position > rangeBoundarySequence) - ? null - : rangeBoundarySequence; return { kind: 'page', sessionId: state.sessionId, @@ -786,8 +493,6 @@ function pageFromSelection( throughSequence, rawBytes: selected.rawBytes, fragments: selected.fragments, - rangeBoundarySequence, - protectedTurnSequence, nextCursor: selected.next ? encodeCursor( { @@ -797,7 +502,6 @@ function pageFromSelection( source, direction, throughSequence, - rangeBoundarySequence: cursorRangeBoundarySequence, ...selected.next, }, state.cursorSecret, @@ -818,8 +522,6 @@ function emptyPage( throughSequence: request.throughSequence, rawBytes: 0, fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, }; } @@ -863,7 +565,6 @@ function decodeCursor(value: string, secret: Buffer): TranscriptCursorState { 'throughSequence', 'position', 'byteOffset', - 'rangeBoundarySequence', ]; if ( Object.keys(cursor).length !== keys.length || @@ -879,8 +580,7 @@ function decodeCursor(value: string, secret: Buffer): TranscriptCursorState { (cursor.direction !== 'older' && cursor.direction !== 'newer') || (cursor.throughSequence !== null && !isCount(cursor.throughSequence)) || !isCount(cursor.position) || - (cursor.byteOffset !== null && !isCount(cursor.byteOffset)) || - (cursor.rangeBoundarySequence !== null && !isCount(cursor.rangeBoundarySequence)) + (cursor.byteOffset !== null && !isCount(cursor.byteOffset)) ) { throw new TranscriptPageRequestError('Invalid transcript cursor values'); } diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts index 9fa6b47802..b0111a6566 100644 --- a/packages/runtime-host/src/server/session-transcript-reader.ts +++ b/packages/runtime-host/src/server/session-transcript-reader.ts @@ -28,7 +28,6 @@ import { createRuntimeEventStoredMessageProjector, projectTranscriptToolResult, isHardRuntimeEventReadModelDiagnostic, - projectRuntimeEventUserMessage, } from '@maka/runtime/runtime-event-read-model'; import { type CanonicalPermissionOutcomeReader, @@ -44,8 +43,6 @@ import type { SessionTranscriptStoragePage, SessionTurnContribution, SessionTurnContributionPage, - SessionTurnLandmark, - SessionTurnLandmarkSnapshot, RuntimeTranscriptInvocationHeader, } from '@maka/storage/execution-stores'; import { foldTurnContribution } from '@maka/storage/session-message-projection'; @@ -105,8 +102,6 @@ export function createSessionTranscriptReader(input: { position, maxContributions, ), - readDurableTurnLandmarks: async (sessionId, maxLandmarks) => - (await prepared(sessionId)).readTurnLandmarks(sessionId, maxLandmarks), readActiveOverlay: async (sessionId, rootTurn) => { if (!rootTurn || isTerminalTurn(rootTurn)) return []; @@ -174,10 +169,6 @@ export interface SessionTranscriptReader { position: number, maxContributions: number, ): Promise; - readDurableTurnLandmarks( - sessionId: string, - maxLandmarks: number, - ): Promise; readActiveOverlay( sessionId: string, rootTurn: TurnSnapshot | null, @@ -383,33 +374,6 @@ function createDurableLedgerTranscriptReader(input: { nextPosition: next ? next.firstOrdinal * EVENT_SEQUENCE_STRIDE : null, }; }, - - /** Evenly spaced Turn starts, selected in SQL before loading their prompts. */ - async readTurnLandmarks( - sessionId: string, - maxLandmarks: number, - ): Promise { - const throughSequence = await highWater(sessionId); - if (throughSequence === null) return { throughSequence: null, landmarks: [] }; - const turns = await store.readTranscriptLandmarks( - sessionId, - ordinalOf(throughSequence), - maxLandmarks, - ); - const landmarks: SessionTurnLandmark[] = []; - for (const turn of turns) { - if (!turn.prompt) continue; - const message = projectRuntimeEventUserMessage(turn.prompt.event, turn.prompt.event.id); - const label = (message?.displayText ?? message?.text ?? '').trim(); - if (!label) continue; - landmarks.push({ - turnId: turn.invocation.turnId, - sequence: turn.prompt.ordinal * EVENT_SEQUENCE_STRIDE, - label, - }); - } - return { throughSequence, landmarks }; - }, }; } diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 5ca63431f3..8453cdbfd1 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -151,15 +151,10 @@ export type { SessionTranscriptStorageFragment, SessionTurnContribution, SessionTurnContributionPage, - SessionTurnLandmark, - SessionTurnLandmarkSnapshot, } from './session-store-contract.js'; export type ExecutionSessionWriter = SessionAuthorityStore; -export type { - RuntimeTranscriptInvocationHeader, - RuntimeTranscriptLandmark, -} from './runtime-transcript-query.js'; +export type { RuntimeTranscriptInvocationHeader } from './runtime-transcript-query.js'; export type ExecutionAgentRunWriter = DurableAgentRunStore; export type ExecutionRuntimeEventWriter = DurableRuntimeEventStore & RuntimeTranscriptQueries & @@ -737,8 +732,6 @@ async function createExecutionStoresForWrite( run(() => runtimeEventStore.readTranscriptHighWater(sessionId)), readTranscriptInvocations: (sessionId, request, project) => run(() => runtimeEventStore.readTranscriptInvocations(sessionId, request, project)), - readTranscriptLandmarks: (sessionId, throughOrdinal, limit) => - run(() => runtimeEventStore.readTranscriptLandmarks(sessionId, throughOrdinal, limit)), claimContinuation: (input) => run(() => runtimeEventStore.claimContinuation(input)), readContinuationClaimByBoundary: (boundaryDigest) => run(() => runtimeEventStore.readContinuationClaimByBoundary(boundaryDigest)), diff --git a/packages/storage/src/runtime-transcript-query.ts b/packages/storage/src/runtime-transcript-query.ts index 3039394a56..35a5990161 100644 --- a/packages/storage/src/runtime-transcript-query.ts +++ b/packages/storage/src/runtime-transcript-query.ts @@ -52,13 +52,6 @@ export interface RuntimeTranscriptInvocationHeader { readonly lastOrdinal: number; } -/** An invocation start, with the prompt event a landmark is labelled by. */ -export interface RuntimeTranscriptLandmark { - readonly invocation: RuntimeInvocationRecord; - readonly firstOrdinal: number; - readonly prompt?: { readonly ordinal: number; readonly event: RuntimeEvent }; -} - export interface RuntimeTranscriptInvocationRequest { readonly direction: 'older' | 'newer'; readonly throughOrdinal: number; @@ -82,11 +75,6 @@ export interface RuntimeTranscriptQueries { events: Iterable<{ readonly ordinal: number; readonly event: RuntimeEvent }>, ) => T, ): Promise; - readTranscriptLandmarks( - sessionId: string, - throughOrdinal: number, - limit: number, - ): Promise; } export class RuntimeTranscriptOversizedTurnError extends Error { @@ -216,60 +204,6 @@ export class RuntimeTranscriptQuery { ); } - landmarks(sessionId: string, throughOrdinal: number, limit: number): RuntimeTranscriptLandmark[] { - assertOrdinal(throughOrdinal); - if (limit < 1) return []; - // Evenly spaced Turn starts, chosen before any payload is read. - const rows = this.db - .prepare(` - WITH settled AS ( - SELECT e.invocation_id AS invocation_id, o.ordinal AS ordinal ${ledgerOpening} - AND ${endingOrdinal('e.invocation_id')} <= :throughOrdinal - UNION ALL - SELECT legacy.invocation_id, o.ordinal ${migratedOpening} - AND ${endingOrdinal('legacy.invocation_id')} <= :throughOrdinal - ), candidates AS ( - SELECT invocation_id, ordinal, - ROW_NUMBER() OVER (ORDER BY ordinal) - 1 AS rank, COUNT(*) OVER () AS total - FROM settled - ), samples(n) AS ( - SELECT 0 UNION ALL SELECT n + 1 FROM samples WHERE n + 1 < :limit - ) - SELECT DISTINCT invocation_id, ordinal FROM candidates - JOIN samples ON rank = CASE WHEN :limit = 1 THEN total - 1 - ELSE CAST(n * (total - 1) / (:limit - 1) AS INTEGER) END - ORDER BY ordinal - `) - .all({ sessionId, throughOrdinal, limit }) as Array<{ - invocation_id: string; - ordinal: number; - }>; - return rows.map((row) => { - // The prompt is the Turn's first user text event, which is what the read - // model projects a user message from. Only that one event is loaded: a - // landmark is a label, and projecting whole Turns to build a scrollbar - // would read most of the Session. - const prompt = this.db - .prepare(` - SELECT o.ordinal, e.event_id FROM runtime_events e - JOIN runtime_session_event_ordinals o ON o.event_id = e.event_id - WHERE e.invocation_id = ? AND o.ordinal <= ? - AND e.event_kind = 'text' AND json_extract(e.payload_json, '$.role') = 'user' - ORDER BY e.event_seq LIMIT 1 - `) - .get(row.invocation_id, throughOrdinal) as - | { ordinal: number; event_id: string } - | undefined; - return { - invocation: this.invocation(sessionId, row.invocation_id), - firstOrdinal: row.ordinal, - ...(prompt - ? { prompt: { ordinal: prompt.ordinal, event: this.event(prompt.event_id) } } - : {}), - }; - }); - } - /** * The page of settled visible invocations that opened at or before * `position`, newest first. @@ -394,16 +328,6 @@ export class RuntimeTranscriptQuery { yield { ordinal: row.ordinal, event: decodeStoredEvent({ ...row, payload_json: payload }) }; } } - - private event(id: string): RuntimeEvent { - const row = this.db - .prepare( - 'SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json FROM runtime_events WHERE event_id = ?', - ) - .get(id) as StoredEventRow | undefined; - if (!row) throw new Error(`Transcript RuntimeEvent ${id} is missing`); - return decodeStoredEvent(row); - } } type StoredEventRow = { diff --git a/packages/storage/src/session-store-contract.ts b/packages/storage/src/session-store-contract.ts index 2eedf4da45..4e904725e3 100644 --- a/packages/storage/src/session-store-contract.ts +++ b/packages/storage/src/session-store-contract.ts @@ -322,17 +322,6 @@ export interface SessionTurnContributionPage { readonly nextPosition: number | null; } -export interface SessionTurnLandmark { - readonly turnId: string; - readonly sequence: number; - readonly label: string; -} - -export interface SessionTurnLandmarkSnapshot { - readonly throughSequence: number | null; - readonly landmarks: readonly SessionTurnLandmark[]; -} - export interface SessionStore { create(input: CreateSessionInput, initialBoundary?: ExecutionBoundary): Promise; list(filter?: SessionListFilter): Promise; diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 0e7e57d767..83cadaf982 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -83,8 +83,6 @@ export { type SessionTranscriptRecordScanPage, type SessionTurnContribution, type SessionTurnContributionPage, - type SessionTurnLandmark, - type SessionTurnLandmarkSnapshot, type SessionStore, type CoordinationTranscriptReference, type CoordinationTranscriptIndexRecord, diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 055e84f285..fc146666c1 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -165,7 +165,6 @@ import { TERMINAL_RUNTIME_EVENT_SQL, type RuntimeTranscriptInvocationHeader, type RuntimeTranscriptInvocationRequest, - type RuntimeTranscriptLandmark, } from './runtime-transcript-query.js'; export { SQLITE_RUNTIME_SCHEMA_VERSION } from './sqlite-runtime-schema.js'; @@ -570,18 +569,6 @@ export class SqliteRuntimeStore ); } - async readTranscriptLandmarks( - sessionId: string, - throughOrdinal: number, - limit: number, - ): Promise { - assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); - assertInvocationSearchLimit(limit); - return this.readTransaction(() => - this.transcriptQuery().landmarks(sessionId, throughOrdinal, limit), - ); - } - /** * Enumerate a Session's invocations: the opening fact names each one, and its * highest-sequence event says whether it ended. diff --git a/packages/storage/src/test-only/memory-execution-runtime.ts b/packages/storage/src/test-only/memory-execution-runtime.ts index 4fa63359ab..7d0feba3ef 100644 --- a/packages/storage/src/test-only/memory-execution-runtime.ts +++ b/packages/storage/src/test-only/memory-execution-runtime.ts @@ -920,31 +920,6 @@ export function createMemoryRuntimeStore(a: MemoryExecutionAuthority): Execution return project(header, read()); }); }, - readTranscriptLandmarks: async (sessionId, throughOrdinal, n) => - a.read((s) => { - if (!Number.isSafeInteger(throughOrdinal) || throughOrdinal < 0 || !Number.isSafeInteger(n)) - throw new RangeError('Invalid landmark bounds'); - if (n < 1) return []; - const all = transcript(s, sessionId, throughOrdinal); - const positions = new Set( - Array.from({ length: Math.min(n, all.length) }, (_, i) => - n === 1 ? all.length - 1 : Math.floor((i * (all.length - 1)) / (n - 1)), - ), - ); - return [...positions] - .map((i) => all[i]!) - .map((i) => ({ - invocation: i.invocation, - firstOrdinal: i.firstOrdinal, - ...(i.events.find((e) => e.event.role === 'user' && e.event.content?.kind === 'text') - ? { - prompt: i.events.find( - (e) => e.event.role === 'user' && e.event.content?.kind === 'text', - ), - } - : {}), - })); - }), }; return store; } diff --git a/packages/ui/package.json b/packages/ui/package.json index d31ad6d9ed..d8a163f5bb 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -33,7 +33,8 @@ "lucide-react": "^1.38.0", "mermaid": "^11.17.2", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "virtua": "0.48.8" }, "devDependencies": { "@types/react": "^19.2.18", diff --git a/packages/ui/src/__tests__/chat-turn-steering-order.test.ts b/packages/ui/src/__tests__/chat-turn-steering-order.test.ts index e97578743f..f4a2f6e25c 100644 --- a/packages/ui/src/__tests__/chat-turn-steering-order.test.ts +++ b/packages/ui/src/__tests__/chat-turn-steering-order.test.ts @@ -28,6 +28,7 @@ import { materializeTurns, type TurnViewModel } from '../materialize.js'; import { createTranscriptProjection } from '../transcript-projection.js'; import { ChatView } from '../chat-view.js'; import { Composer } from '../composer.js'; +import { renderTranscriptMarkup } from './transcript-test-dom.js'; import { ChatSurfaceLayout } from '../chat-surface-layout.js'; import { armLiveTurn } from '../live-turn-projection.js'; import { applyLiveTurnEvent } from './live-turn-zh.js'; @@ -94,13 +95,13 @@ test('renders steering where it arrived in the assistant timeline', () => { } }); -test('holds steering above the composer while old output continues, then renders its real reply boundary', () => { +test('holds steering above the composer while old output continues, then renders its real reply boundary', async () => { const messages: StoredMessage[] = [{ type: 'user', id: 'original', turnId: 'turn-1', ts: 1, text: 'request' }]; const pending = { id: 'steer', hostTurnId: 'turn-1', ts: 2, text: 'inserted instruction', pendingSteering: true, transientPlacement: 'current_turn' as const }; let live: import('../live-turn-projection.js').LiveTurnProjection | undefined = applyLiveTurnEvent(armLiveTurn('turn-1'), { type: 'text_delta', id: 'first', turnId: 'turn-1', messageId: 'before', ts: 2, text: 'old answer continues', }); - const render = (transientMessages = [pending], durable = messages) => parseHTML(`${renderToStaticMarkup( + const render = async (transientMessages = [pending], durable = messages) => parseHTML(`${await renderTranscriptMarkup( createElement(LocaleProvider, { locale: 'en', children: createElement(ChatSurfaceLayout, { composer: createElement(Composer, { streaming: true, pendingMessages: transientMessages, onSend: () => undefined, onStop: () => undefined }), children: createElement(ChatView, { @@ -110,7 +111,7 @@ test('holds steering above the composer while old output continues, then renders }), }) }), )}`).document; - const waiting = render(); + const waiting = await render(); assert.equal(waiting.querySelector('.maka-composer-queue-text')?.textContent, pending.text); assert.equal(waiting.querySelectorAll('.maka-steering-message').length, 0); const timeline = () => createTranscriptProjection().project({ messages, liveTurns: live ? [live] : undefined, locale: 'en' })[0]!.timeline.map((item) => item.kind === 'user' ? item.message.text : item.kind === 'text' ? item.text : item.kind); @@ -118,7 +119,7 @@ test('holds steering above the composer while old output continues, then renders live = applyLiveTurnEvent(live, { type: 'text_complete', id: 'finished', turnId: 'turn-1', messageId: 'before', ts: 3, text: 'old answer continues' }); live = applyLiveTurnEvent(live, { type: 'steering_message', id: 'accepted', turnId: 'turn-1', messageId: pending.id, ts: 4, content: { text: pending.text } }); live = applyLiveTurnEvent(live, { type: 'text_delta', id: 'reply', turnId: 'turn-1', messageId: 'after', ts: 5, text: 'reply to new instruction' }); - const accepted = render([]); + const accepted = await render([]); assert.equal(accepted.querySelector('.maka-composer-queue'), null); const text = accepted.body.textContent ?? ''; assert.deepEqual(timeline(), ['old answer continues', pending.text, 'reply to new instruction']); diff --git a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx index ccb4c9a5a8..34d1464c70 100644 --- a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx +++ b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx @@ -28,6 +28,7 @@ import { ChatSurfaceLayout } from '../chat-surface-layout.js'; import { ChatView } from '../chat-view.js'; import type { LiveTurnProjection } from '../live-turn-projection.js'; import { LocaleProvider } from '../locale-context.js'; +import { renderTranscriptMarkup } from './transcript-test-dom.js'; const activeSession = { id: 'session-1', @@ -36,8 +37,8 @@ const activeSession = { labels: [] as string[], } as unknown as SessionSummary; -function renderChat(liveTurn?: LiveTurnProjection, overrides: Partial> = {}): string { - return renderToStaticMarkup( +function renderChat(liveTurn?: LiveTurnProjection, overrides: Partial> = {}): Promise { + return renderTranscriptMarkup( { - const markup = renderChat({ +test('renders the live compaction row in a session with no settled messages', async () => { + const markup = await renderChat({ turnId: 'turn-compact', rootExecutionKind: 'context_compact', startedAt: 0, @@ -66,34 +67,34 @@ test('renders the live compaction row in a session with no settled messages', () assert.match(markup, /Compacting context/); }); -test('retains tool and compaction evidence without activity after observation loss', () => { +test('retains tool and compaction evidence without activity after observation loss', async () => { const messages = [{ type: 'user' as const, id: 'user', turnId: 'prior', text: 'Earlier request', ts: 1 }]; const tool: LiveTurnProjection = { turnId: 'tool-turn', steps: [{ stepId: 'step', tools: [ { toolUseId: 'bash', toolName: 'Bash', args: { command: 'echo retained' }, status: 'running' }, ] }] }; const compact: LiveTurnProjection = { turnId: 'compact', rootExecutionKind: 'context_compact', steps: [] }; for (const observed of [true, false, true]) { - const toolDocument = parseHTML(renderChat(tool, { messages, activeTurn: observed ? { turnId: tool.turnId } : undefined })).document; + const toolDocument = parseHTML(await renderChat(tool, { messages, activeTurn: observed ? { turnId: tool.turnId } : undefined })).document; assert.equal(toolDocument.querySelector('.maka-tool-activity-card')?.getAttribute('data-activity-observed'), String(observed)); assert.match(toolDocument.querySelector('.maka-tool-activity-card')?.textContent ?? '', /echo retained/); - const compactDocument = parseHTML(renderChat(compact, { messages, activeTurn: observed ? { turnId: compact.turnId, compacting: true } : undefined })).document; + const compactDocument = parseHTML(await renderChat(compact, { messages, activeTurn: observed ? { turnId: compact.turnId, compacting: true } : undefined })).document; assert.equal(compactDocument.querySelector('[data-compaction-state]')?.getAttribute('data-compaction-state'), observed ? 'running' : 'unavailable'); assert.equal(compactDocument.querySelectorAll('.maka-compaction-status .astryx-spinner').length, observed ? 1 : 0); } assert.equal(tool.steps[0]?.tools[0]?.status, 'running', 'availability never rewrites retained execution evidence'); }); -test('shows one waiting indicator before a named live Turn reaches the transcript', () => { +test('shows one waiting indicator before a named live Turn reaches the transcript', async () => { const liveTurn: LiveTurnProjection = { turnId: 'pending-turn', steps: [], unconfirmed: true }; const pending = { id: 'pending-user', hostTurnId: liveTurn.turnId, text: 'Please help', ts: 1000, transientPlacement: 'current_turn' as const }; for (const messages of [[], [{ type: 'user' as const, id: 'old-user', turnId: 'old-turn', text: 'Earlier request', ts: 1 }]]) { - const markup = renderChat(liveTurn, { messages, transientMessages: [pending], activeTurn: { turnId: liveTurn.turnId! } }); + const markup = await renderChat(liveTurn, { messages, transientMessages: [pending], activeTurn: { turnId: liveTurn.turnId! } }); assert.equal((markup.match(/class="maka-turn-processing"/g) ?? []).length, 1); assert.match(markup, /Pondering/); assert.match(markup, /Please help/); assert.doesNotMatch(markup, /data-transcript-turn-id="pending-turn"/); } - const committed = renderChat(liveTurn, { + const committed = await renderChat(liveTurn, { messages: [{ type: 'user', id: 'durable-user', turnId: liveTurn.turnId, text: pending.text, ts: pending.ts }], activeTurn: { turnId: liveTurn.turnId! }, }); @@ -101,7 +102,7 @@ test('shows one waiting indicator before a named live Turn reaches the transcrip assert.match(committed, /data-transcript-turn-id="pending-turn"/); }); -test('a new Host Turn owns its waiting footer while the previous answer remains buffered', () => { +test('a new Host Turn owns its waiting footer while the previous answer remains buffered', async () => { const oldTurn: LiveTurnProjection = { turnId: 'old-turn', terminal: true, steps: [{ stepId: 'old-answer', text: { text: 'Previous answer', complete: true, truncated: false }, tools: [] }], @@ -109,19 +110,19 @@ test('a new Host Turn owns its waiting footer while the previous answer remains const messages = [{ type: 'user' as const, id: 'old-user', turnId: 'old-turn', text: 'Earlier request', ts: 1 }]; const transientMessages = [{ id: 'new-user', hostTurnId: 'new-turn', text: 'New request', ts: 2, transientPlacement: 'current_turn' as const }]; for (const content of [oldTurn, undefined]) { - const markup = renderChat(content, { messages, transientMessages, activeTurn: { turnId: 'new-turn' } }); + const markup = await renderChat(content, { messages, transientMessages, activeTurn: { turnId: 'new-turn' } }); const { document } = parseHTML(markup); assert.equal(document.querySelectorAll('.maka-turn-processing').length, 1); assert.equal(document.querySelector('[data-transcript-turn-id="old-turn"] .maka-turn-processing'), null); assert.ok(markup.indexOf('New request') < markup.indexOf('maka-turn-processing')); if (content) assert.match(markup, /Previous answer/); } - const idle = renderChat({ ...oldTurn, terminal: undefined }, { messages }); + const idle = await renderChat({ ...oldTurn, terminal: undefined }, { messages }); assert.doesNotMatch(idle, /maka-turn-processing/); }); -test('renders the empty hero when an empty session has no live compaction row', () => { - const markup = renderChat(undefined); +test('renders the empty hero when an empty session has no live compaction row', async () => { + const markup = await renderChat(undefined); assert.doesNotMatch(markup, /Compacting context/); }); @@ -191,9 +192,9 @@ test('ChatSurfaceLayout preserves the public emptyState for absent children', () }); -test('turn identity stays on its exact durable anchor and is absent from ordinary transcripts', () => { +test('turn identity stays on its exact durable anchor and is absent from ordinary transcripts', async () => { const messages = ['one', 'two'].map((turnId) => ({ type: 'user' as const, id: `user-${turnId}`, turnId, text: turnId, ts: 1 })); - const { document } = parseHTML(renderChat(undefined, { + const { document } = parseHTML(await renderChat(undefined, { messages, turnDecorations: new Map([['one', { header: Workspace / Work, accentColor: 'red', promptStatus: Running work }]]), })); @@ -204,12 +205,12 @@ test('turn identity stays on its exact durable anchor and is absent from ordinar assert.equal(document.querySelector('[data-transcript-turn-id="two"] [data-test-status]'), null); assert.match(one.querySelector('.maka-message-meta')!.textContent!, /Running work/); assert.equal(document.querySelector('[data-transcript-turn-id="two"]')!.getAttribute('data-turn-accent'), null); - assert.doesNotMatch(renderChat(undefined, { messages }), /data-turn-accent|Workspace \/ Work/); + assert.doesNotMatch(await renderChat(undefined, { messages }), /data-turn-accent|Workspace \/ Work/); }); -test('an initial optimistic prompt uses the same status projection as a durable prompt', () => { - const { document } = parseHTML(renderChat(undefined, { +test('an initial optimistic prompt uses the same status projection as a durable prompt', async () => { + const { document } = parseHTML(await renderChat(undefined, { messages: [], transientMessages: [{ id: 'pending', hostTurnId: 'choosing', text: 'Choose work', ts: 1, transientPlacement: 'current_turn' }], turnDecorations: new Map([['choosing', { header: <>, promptStatus: Waiting for user }]]), })); diff --git a/packages/ui/src/__tests__/chat-view-load-earlier.test.tsx b/packages/ui/src/__tests__/chat-view-load-earlier.test.tsx new file mode 100644 index 0000000000..02a343412a --- /dev/null +++ b/packages/ui/src/__tests__/chat-view-load-earlier.test.tsx @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Earlier history arrives only when the reader asks for it, and whole Turns + * prepended above them neither move them off the Turn they are reading nor + * change whether the transcript follows its tail. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { Fragment, act, createElement, type ComponentProps, type ReactElement } from 'react'; +import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import { ChatSurfaceLayout } from '../chat-surface-layout.js'; +import { ChatView } from '../chat-view.js'; +import { LocaleProvider } from '../locale-context.js'; +import { + useTranscriptScrollAuthority, + type TranscriptScrollAuthority, +} from '../transcript-scroll-authority.js'; +import { installTranscriptDom, type TranscriptDom } from './transcript-test-dom.js'; + +const TURN_HEIGHT = 400; +const SCROLLPORT_HEIGHT = 600; + +let dom: TranscriptDom | undefined; + +afterEach(async () => { + await dom?.cleanup(); + dom = undefined; +}); + +const activeSession = { + id: 'session-earlier', + name: 'Earlier', + status: 'active', + labels: [] as string[], +} as unknown as SessionSummary; + +function turnMessages(from: number, to: number): StoredMessage[] { + return Array.from({ length: to - from }, (_, offset): StoredMessage => ({ + type: 'user', + id: `user-${from + offset}`, + turnId: `turn-${from + offset}`, + ts: from + offset, + text: `Prompt ${from + offset}`, + })); +} + +function harness() { + dom = installTranscriptDom({ viewportHeight: SCROLLPORT_HEIGHT, boxHeight: TURN_HEIGHT }); + let authority: TranscriptScrollAuthority | undefined; + let turnCount = 0; + const anchors: Array = []; + const Probe = (): ReactElement => { + authority = useTranscriptScrollAuthority(); + return createElement(Fragment); + }; + const current = dom; + return { + anchors, + get authority(): TranscriptScrollAuthority { + assert.ok(authority); + return authority; + }, + async render(props: Partial> & { messages: StoredMessage[] }) { + turnCount = new Set(props.messages.map((message) => message.turnId)).size; + await current.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(ChatSurfaceLayout, { + composer: null, + children: createElement(Fragment, null, createElement(ChatView, { + activeSession, + onNew: () => {}, + scrollBehavior: 'auto', + onReadingAnchorChange: (turnId?: string) => { anchors.push(turnId); }, + ...props, + }), createElement(Probe)), + }), + })); + const scroller = current.container.querySelector('[data-chat-scroll-container]'); + assert.ok(scroller); + if (!Object.hasOwn(scroller, 'scrollHeight')) { + Object.defineProperties(scroller, { + scrollHeight: { get: () => turnCount * TURN_HEIGHT }, + clientHeight: { get: () => SCROLLPORT_HEIGHT }, + }); + } + return scroller; + }, + loadButton(): HTMLButtonElement | null { + return [...current.container.querySelectorAll('button')] + .find((button) => button.textContent === 'Load earlier history') ?? null; + }, + readerScrollTo(scroller: HTMLElement, top: number): void { + const wheel = new current.window.Event('wheel', { bubbles: true }); + Object.defineProperty(wheel, 'deltaY', { value: top < scroller.scrollTop ? -120 : 120 }); + scroller.dispatchEvent(wheel); + scroller.scrollTop = top; + scroller.dispatchEvent(new current.window.Event('scroll')); + }, + click(element: Element): void { + element.dispatchEvent(new current.window.Event('click', { bubbles: true })); + }, + }; +} + +test('the load-earlier button exists only while earlier history does, and waits for its load', async () => { + const view = harness(); + await view.render({ messages: turnMessages(4, 8), onLoadEarlierHistory: () => {} }); + assert.equal(view.loadButton(), null, 'no button without earlier history'); + + let loads = 0; + let finish!: () => void; + const onLoadEarlierHistory = () => { + loads += 1; + return new Promise((resolve) => { finish = resolve; }); + }; + await view.render({ messages: turnMessages(4, 8), hasEarlierHistory: true, onLoadEarlierHistory }); + const button = view.loadButton(); + assert.ok(button); + await act(async () => { view.click(button); }); + assert.equal(loads, 1); + assert.equal(view.loadButton()?.disabled, true, 'a pending load cannot be requested again'); + + await act(async () => { finish(); }); + assert.equal(view.loadButton()?.disabled, false); +}); + +test('prepended Turns keep a released reader on their Turn without re-pinning', async () => { + const view = harness(); + const scroller = await view.render({ messages: turnMessages(4, 8), hasEarlierHistory: true, onLoadEarlierHistory: () => {} }); + scroller.scrollTop = 4 * TURN_HEIGHT - SCROLLPORT_HEIGHT; + await act(() => { view.readerScrollTo(scroller, TURN_HEIGHT + 100); }); + assert.equal(view.authority.getSnapshot().pinned, false); + assert.equal(view.anchors.at(-1), 'turn-5'); + + // The reader asks for history, which is the only way it arrives. + const button = view.loadButton(); + assert.ok(button); + await act(async () => { view.click(button); }); + await view.render({ messages: turnMessages(2, 8), hasEarlierHistory: false, onLoadEarlierHistory: () => {} }); + await act(() => { scroller.dispatchEvent(new dom!.window.Event('scroll')); }); + assert.equal(view.loadButton(), null); + assert.equal(view.authority.getSnapshot().pinned, false, 'arriving history is not reader input'); + assert.equal(scroller.scrollTop, 3 * TURN_HEIGHT + 100, 'the offset moved by the prepended Turns'); + assert.equal(view.anchors.at(-1), 'turn-5'); +}); + +test('prepended Turns do not release a pinned transcript', async () => { + const view = harness(); + await view.render({ messages: turnMessages(4, 8), hasEarlierHistory: true, onLoadEarlierHistory: () => {} }); + assert.equal(view.authority.getSnapshot().pinned, true); + const scroller = await view.render({ messages: turnMessages(0, 8) }); + await act(() => { scroller.dispatchEvent(new dom!.window.Event('scroll')); }); + assert.equal(view.authority.getSnapshot().pinned, true); + assert.equal(view.anchors.at(-1), undefined); +}); diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index a299f66891..25df61bd57 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -20,7 +20,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { - mergePromptAnchorRailTurns, observeActivePromptRailVisibility, selectPromptRailTick, } from '../prompt-anchor-rail.js'; @@ -116,73 +115,6 @@ test('keeps the active tick visible when the rail viewport resizes', () => { assert.equal(disconnected, true); }); -test('merges complete-index landmarks with the resident transcript range', () => { - const turns = mergePromptAnchorRailTurns( - [ - { turnId: 'turn-1', label: 'Prompt 1', reply: 'Answer 1' }, - { turnId: 'turn-3', label: 'Prompt 3', reply: 'Answer 3' }, - ], - [ - { turnId: 'turn-1', sequence: 0, label: 'Prompt 1' }, - { turnId: 'turn-2', sequence: 2, label: 'Prompt 2' }, - { turnId: 'turn-3', sequence: 4, label: 'Prompt 3' }, - ], - ); - - assert.deepEqual(turns, [ - { - turnId: 'turn-1', - label: 'Prompt 1', - reply: 'Answer 1', - sequence: 0, - }, - { - turnId: 'turn-2', - label: 'Prompt 2', - reply: '', - sequence: 2, - }, - { - turnId: 'turn-3', - label: 'Prompt 3', - reply: 'Answer 3', - sequence: 4, - }, - ]); -}); - -test('preserves every projected turn without a durable landmark index', () => { - assert.deepEqual( - mergePromptAnchorRailTurns([ - { turnId: 'overlay-turn', label: 'Streaming prompt', reply: '' }, - ]), - [{ - turnId: 'overlay-turn', - label: 'Streaming prompt', - reply: '', - }], - ); -}); - -test('updates landmark content when its body enters a later resident range', () => { - const index = [ - { turnId: 'turn-1', sequence: 0, label: 'Prompt 1' }, - { turnId: 'turn-2', sequence: 2, label: 'Prompt 2' }, - ]; - const historical = mergePromptAnchorRailTurns( - [{ turnId: 'turn-1', label: 'Prompt 1', reply: 'Answer 1' }], - index, - ); - const intermediate = mergePromptAnchorRailTurns( - [{ turnId: 'turn-2', label: 'Prompt 2', reply: 'Answer 2' }], - index, - ); - - assert.deepEqual(historical.map((turn) => turn.reply), ['Answer 1', '']); - assert.deepEqual(intermediate.map((turn) => turn.reply), ['', 'Answer 2']); -}); - - function box(top: number, bottom: number): DOMRect { return { top, bottom } as DOMRect; } diff --git a/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx b/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx index d3478878f4..f28b27e431 100644 --- a/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx +++ b/packages/ui/src/__tests__/prompt-rail-reading-position.test.tsx @@ -19,57 +19,34 @@ /** * The rail's current tick is the reading position the scroll authority - * publishes — the newest Turn while pinned to the tail, otherwise the Turn - * crossing the top of the scrollport — and nothing else. Mounted through the - * real layout, because that is what hands the authority the scroller the - * reader scrolls. + * publishes — the newest Turn while pinned to the tail, otherwise the Turn the + * virtualizer places under the top of the scrollport — and a tick navigates by + * that same index. Mounted through the real layout, because that is what hands + * the authority and the virtualizer the scroller the reader scrolls. */ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement, type ReactElement } from 'react'; -import { createRoot } from 'react-dom/client'; -import { parseHTML } from 'linkedom'; import type { SessionSummary, StoredMessage } from '@maka/core/session'; -import { AstryxLocaleProvider } from '../astryx-i18n.js'; import { ChatSurfaceLayout } from '../chat-surface-layout.js'; import { ChatView } from '../chat-view.js'; import { LocaleProvider } from '../locale-context.js'; import { PromptAnchorRail, type PromptAnchorRailTurn } from '../prompt-anchor-rail.js'; import { TranscriptScrollAuthorityProvider } from '../transcript-scroll-authority.js'; +import { installTranscriptDom, type TranscriptDom } from './transcript-test-dom.js'; -const originalGlobals = { - CSS: globalThis.CSS, - document: globalThis.document, - Element: globalThis.Element, - HTMLElement: globalThis.HTMLElement, - MutationObserver: globalThis.MutationObserver, - Node: globalThis.Node, - ResizeObserver: globalThis.ResizeObserver, - matchMedia: globalThis.matchMedia, - requestAnimationFrame: globalThis.requestAnimationFrame, - cancelAnimationFrame: globalThis.cancelAnimationFrame, - window: globalThis.window, -}; -const originalActEnvironment = (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; -}).IS_REACT_ACT_ENVIRONMENT; +const TURN_COUNT = 6; +const TURN_HEIGHT = 400; +const SCROLLPORT_HEIGHT = 600; -let mountedRoot: ReturnType | undefined; +let dom: TranscriptDom | undefined; afterEach(async () => { - if (mountedRoot) await act(() => mountedRoot?.unmount()); - mountedRoot = undefined; - Object.assign(globalThis, { - ...originalGlobals, - IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, - }); + await dom?.cleanup(); + dom = undefined; }); -const TURN_COUNT = 6; -const TURN_HEIGHT = 400; -const SCROLLPORT_HEIGHT = 600; - const activeSession: SessionSummary = { id: 'session-rail', name: '提问导航', @@ -108,76 +85,22 @@ function turnMessages(): StoredMessage[] { } function view(messages: StoredMessage[]): ReactElement { - const chat = createElement(ChatView, { messages, activeSession, onNew: () => {} } as never); - const layout = createElement(ChatSurfaceLayout, { - composer: null, - children: chat, - }); - const astryx = createElement(AstryxLocaleProvider, { children: layout }); - return createElement(LocaleProvider, { locale: 'zh-CN', children: astryx }); + const chat = createElement(ChatView, { messages, activeSession, onNew: () => {}, scrollBehavior: 'auto' }); + const layout = createElement(ChatSurfaceLayout, { composer: null, children: chat }); + return createElement(LocaleProvider, { locale: 'zh-CN', children: layout }); } -/** linkedom lays nothing out, so every box this reads is stated here. */ -function harness() { - const { document, window } = parseHTML('
'); - const viewport = { scrollTop: 0 }; - const scrollport = { - bottom: SCROLLPORT_HEIGHT, height: SCROLLPORT_HEIGHT, left: 0, right: 800, top: 0, - width: 800, x: 0, y: 0, toJSON: () => ({}), - } satisfies DOMRect; - window.Element.prototype.getBoundingClientRect = function (this: Element): DOMRect { - const turnId = this.getAttribute('data-turn-id'); - if (turnId === null) return scrollport; - const top = Number(turnId.split('-')[1]) * TURN_HEIGHT - viewport.scrollTop; - return { ...scrollport, top, bottom: top + TURN_HEIGHT, height: TURN_HEIGHT }; - }; - class InertResizeObserver { - observe(): void {} - unobserve(): void {} - disconnect(): void {} - } - Object.assign(globalThis, { - CSS: { supports: () => false, escape: (value: string) => value }, - document, - Element: window.Element, - HTMLElement: window.HTMLElement, - MutationObserver: window.MutationObserver, - Node: window.Node, - ResizeObserver: InertResizeObserver, - matchMedia: () => ({ - matches: false, - addEventListener() {}, - removeEventListener() {}, - }), - requestAnimationFrame: (callback: FrameRequestCallback) => { - callback(0); - return 0; - }, - cancelAnimationFrame: () => {}, - window, - IS_REACT_ACT_ENVIRONMENT: true, +async function mountTranscript(): Promise<{ dom: TranscriptDom; scroller: HTMLElement }> { + dom = installTranscriptDom({ viewportHeight: SCROLLPORT_HEIGHT, boxHeight: TURN_HEIGHT }); + await dom.render(view(turnMessages())); + const scroller = dom.container.querySelector('[data-chat-scroll-container]'); + assert.ok(scroller, 'the layout publishes the scroller the authority attaches to'); + Object.defineProperties(scroller, { + scrollHeight: { get: () => TURN_COUNT * TURN_HEIGHT }, + clientHeight: { get: () => SCROLLPORT_HEIGHT }, }); - const mount = document.querySelector('#mount'); - assert.ok(mount); - return { - mount, - window, - viewport, - /** Give the mounted scroller the geometry a scrolled transcript has. */ - scroller(): HTMLElement { - const element = document.querySelector('[data-chat-scroll-container]'); - assert.ok(element, 'the layout publishes the scroller the authority attaches to'); - Object.defineProperties(element, { - scrollTop: { - get: () => viewport.scrollTop, - set: (value: number) => { viewport.scrollTop = value; }, - }, - scrollHeight: { get: () => TURN_COUNT * TURN_HEIGHT }, - clientHeight: { get: () => SCROLLPORT_HEIGHT }, - }); - return element; - }, - }; + scroller.scrollTop = TURN_COUNT * TURN_HEIGHT - SCROLLPORT_HEIGHT; + return { dom, scroller }; } function activeTickTurnId(mount: HTMLElement): string | null { @@ -185,73 +108,68 @@ function activeTickTurnId(mount: HTMLElement): string | null { ?.getAttribute('data-prompt-turn-id') ?? null; } -test('the current tick follows the reading position the authority publishes', async () => { - const probe = harness(); - const root = createRoot(probe.mount); - mountedRoot = root; - await act(() => { - root.render(view(turnMessages())); - }); - const scroller = probe.scroller(); - - // Pinned to the tail, the reader is on the newest Turn. - probe.viewport.scrollTop = TURN_COUNT * TURN_HEIGHT - SCROLLPORT_HEIGHT; - assert.equal(activeTickTurnId(probe.mount), 'turn-5'); +test('the current tick follows the reading position the virtualizer maps', async () => { + const { dom, scroller } = await mountTranscript(); + assert.equal(activeTickTurnId(dom.container), 'turn-5', 'pinned to the tail, the reader is on the newest Turn'); - // The reader takes the transcript to the third Turn's box. A wheel first: - // a scroll the reader did not cause leaves the pin, and the newest Turn, alone. await act(() => { - const wheel = new probe.window.Event('wheel', { bubbles: true }); + const wheel = new dom.window.Event('wheel', { bubbles: true }); Object.defineProperty(wheel, 'deltaY', { value: -120 }); scroller.dispatchEvent(wheel); - probe.viewport.scrollTop = TURN_HEIGHT * 2 + 100; - scroller.dispatchEvent(new probe.window.Event('scroll')); + scroller.scrollTop = TURN_HEIGHT * 2 + 100; + scroller.dispatchEvent(new dom.window.Event('scroll')); }); - assert.equal(activeTickTurnId(probe.mount), 'turn-2'); + assert.equal(activeTickTurnId(dom.container), 'turn-2'); await act(() => { - probe.viewport.scrollTop = TURN_HEIGHT * 4; - scroller.dispatchEvent(new probe.window.Event('scroll')); + scroller.scrollTop = TURN_HEIGHT * 4; + scroller.dispatchEvent(new dom.window.Event('scroll')); }); - assert.equal(activeTickTurnId(probe.mount), 'turn-4'); + assert.equal(activeTickTurnId(dom.container), 'turn-4'); }); -test('portals unloaded landmarks into the layout host and keeps them actionable', async () => { - const { mount } = harness(); - const root = createRoot(mount); - mountedRoot = root; +test('a tick releases the pin and scrolls its Turn to the top by index', async () => { + const { dom, scroller } = await mountTranscript(); + const tick = dom.container.querySelector('[data-prompt-turn-id="turn-3"]'); + assert.ok(tick); + await act(async () => { tick.dispatchEvent(new dom.window.Event('click', { bubbles: true })); }); + assert.equal(scroller.scrollTop, TURN_HEIGHT * 3); + await act(() => { scroller.dispatchEvent(new dom.window.Event('scroll')); }); + assert.equal(activeTickTurnId(dom.container), 'turn-3'); +}); + +test('portals landmarks into the layout host and keeps them actionable', async () => { + dom = installTranscriptDom(); const rail = createElement(PromptAnchorRail, { turns: [ - { turnId: 'turn-1', label: 'Prompt 1', sequence: 0 }, - { turnId: 'turn-2', label: 'Prompt 2', sequence: 2 }, - { turnId: 'turn-3', label: 'Prompt 3', sequence: 4 }, + { turnId: 'turn-1', label: 'Prompt 1' }, + { turnId: 'turn-2', label: 'Prompt 2' }, + { turnId: 'turn-3', label: 'Prompt 3' }, ], scrollRef: { current: null }, + onNavigateTurn: () => {}, }); // The rail reads its tick from the scroll authority, so the host-less render // still needs one — otherwise this would assert the absence of a rail that // threw rather than one that found no host. - await act(() => root.render(createElement(LocaleProvider, { + await dom.render(createElement(LocaleProvider, { locale: 'en', children: createElement(TranscriptScrollAuthorityProvider, { children: rail }), - }))); - assert.equal(mount.querySelector('.maka-prompt-rail'), null, 'no inline rail before a host exists'); - await act(() => root.render(createElement(LocaleProvider, { + })); + assert.equal(dom.container.querySelector('.maka-prompt-rail'), null, 'no inline rail before a host exists'); + await dom.render(createElement(LocaleProvider, { locale: 'en', children: createElement(ChatSurfaceLayout, { composer: null, children: rail }), - }))); - assert.equal(mount.querySelectorAll('.maka-prompt-rail-host .maka-prompt-rail').length, 1); - assert.match(mount.innerHTML, /data-prompt-turn-id="turn-2"/); - assert.doesNotMatch(mount.innerHTML, /data-resident|Not currently loaded|aria-disabled="true"/); - assert.match(mount.innerHTML, /aria-label="Jump to prompt: Prompt 2"/); + })); + assert.equal(dom.container.querySelectorAll('.maka-prompt-rail-host .maka-prompt-rail').length, 1); + assert.match(dom.container.innerHTML, /data-prompt-turn-id="turn-2"/); + assert.match(dom.container.innerHTML, /aria-label="Jump to prompt: Prompt 2"/); }); test('a retained tick uses updated content, decoration, and navigation callbacks', async () => { - const { mount, window } = harness(); - const root = createRoot(mount); - mountedRoot = root; + dom = installTranscriptDom(); const scrollRef = { current: null }; const turns: PromptAnchorRailTurn[] = Array.from({ length: 3 }, (_, index) => ({ - turnId: `turn-${index}`, label: `Prompt ${index}`, sequence: index, + turnId: `turn-${index}`, label: `Prompt ${index}`, })); const calls: string[] = []; const render = (items: PromptAnchorRailTurn[], navigate: (turn: PromptAnchorRailTurn) => void) => @@ -259,22 +177,21 @@ test('a retained tick uses updated content, decoration, and navigation callbacks locale: 'en', children: createElement(ChatSurfaceLayout, { composer: null, children: createElement(PromptAnchorRail, { turns: items, scrollRef, onNavigateTurn: navigate, - onNavigateStart: () => calls.push('release'), }), }), }); - await act(() => root.render(render(turns, () => calls.push('old')))); - const tick = mount.querySelector('[data-prompt-turn-id="turn-1"]')!; + await dom.render(render(turns, () => calls.push('old'))); + const tick = dom.container.querySelector('[data-prompt-turn-id="turn-1"]')!; const updated = turns.map((turn, index) => index === 1 - ? { ...turn, label: 'Updated prompt', reply: 'Updated answer', sequence: 42, highlighted: true } + ? { ...turn, label: 'Updated prompt', reply: 'Updated answer', highlighted: true } : turn); - await act(() => root.render(render(updated, (turn) => { + await dom.render(render(updated, (turn) => { assert.equal(turn, updated[1]); - calls.push(`new:${turn.sequence}`); - }))); - assert.equal(mount.querySelector('[data-prompt-turn-id="turn-1"]'), tick); + calls.push(`new:${turn.turnId}`); + })); + assert.equal(dom.container.querySelector('[data-prompt-turn-id="turn-1"]'), tick); assert.equal(tick.getAttribute('aria-label'), 'Jump to prompt: Updated prompt'); assert.equal(tick.getAttribute('data-highlighted'), 'true'); - await act(() => { tick.dispatchEvent(new window.Event('click', { bubbles: true })); }); - assert.deepEqual(calls, ['release', 'new:42']); + await act(() => { tick.dispatchEvent(new dom!.window.Event('click', { bubbles: true })); }); + assert.deepEqual(calls, ['new:turn-1']); }); diff --git a/packages/ui/src/__tests__/rail-alignment-claim.test.ts b/packages/ui/src/__tests__/rail-alignment-claim.test.ts deleted file mode 100644 index 98de36ac69..0000000000 --- a/packages/ui/src/__tests__/rail-alignment-claim.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { resolveRailAlignedTarget } from '../chat-view.js'; - -test('a rail claim aims its own navigation and nothing after it', () => { - // The click, before the shell has published anything. - let claim = resolveRailAlignedTarget({ turnId: 'a' }, undefined).claim; - assert.deepEqual(claim, { turnId: 'a' }); - - // The load the click asked for. The reveal has to agree with the rail. - let resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 1 }); - assert.equal(resolved.target?.align, 'start'); - claim = resolved.claim; - - // Still the same command, re-rendered while the loaded range settles. - resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 1 }); - assert.equal(resolved.target?.align, 'start'); - claim = resolved.claim; - - // A later search for the same Turn is a different command, and wants the - // search contract back. - resolved = resolveRailAlignedTarget(claim, { turnId: 'a', nonce: 2 }); - assert.equal(resolved.target?.align, 'center'); - assert.equal(resolved.claim, undefined); -}); - -test('a search for another Turn spends an unconsumed rail claim', () => { - const resolved = resolveRailAlignedTarget({ turnId: 'a' }, { turnId: 'b', nonce: 1 }); - assert.equal(resolved.target?.align, 'center'); - assert.equal(resolved.claim, undefined); -}); - -test('a search with no rail claim behind it is centred', () => { - const resolved = resolveRailAlignedTarget(undefined, { turnId: 'a', nonce: 1 }); - assert.equal(resolved.target?.align, 'center'); - assert.deepEqual(resolved.target, { turnId: 'a', nonce: 1, align: 'center' }); -}); diff --git a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx deleted file mode 100644 index 2a18d4600a..0000000000 --- a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx +++ /dev/null @@ -1,296 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * The return-to-latest command must pin before it loads. An unpinned scroller - * reports the Turn it is leaving as the reading anchor, and the restore effect - * would pull the arriving range straight back — the bug the 60-run E2E guard - * covers, held here as a fast unit so reverting the two-line order goes red - * without a browser. - */ - -import assert from 'node:assert/strict'; -import { afterEach, test } from 'node:test'; -import { Fragment, act, createElement, type ReactElement } from 'react'; -import { createRoot } from 'react-dom/client'; -import { parseHTML } from 'linkedom'; -import type { SessionSummary, StoredMessage } from '@maka/core/session'; -import { AstryxLocaleProvider } from '../astryx-i18n.js'; -import { ChatSurfaceLayout } from '../chat-surface-layout.js'; -import { ChatView } from '../chat-view.js'; -import { LocaleProvider } from '../locale-context.js'; -import { - useTranscriptScrollAuthority, - type TranscriptScrollAuthority, -} from '../transcript-scroll-authority.js'; - -const originalGlobals = { - CSS: globalThis.CSS, - document: globalThis.document, - Element: globalThis.Element, - HTMLElement: globalThis.HTMLElement, - IntersectionObserver: globalThis.IntersectionObserver, - MutationObserver: globalThis.MutationObserver, - Node: globalThis.Node, - ResizeObserver: globalThis.ResizeObserver, - matchMedia: globalThis.matchMedia, - requestAnimationFrame: globalThis.requestAnimationFrame, - cancelAnimationFrame: globalThis.cancelAnimationFrame, - window: globalThis.window, -}; -const originalActEnvironment = (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; -}).IS_REACT_ACT_ENVIRONMENT; - -let mountedRoot: ReturnType | undefined; - -afterEach(async () => { - if (mountedRoot) await act(() => mountedRoot?.unmount()); - mountedRoot = undefined; - Object.assign(globalThis, { - ...originalGlobals, - IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, - }); -}); - -const activeSession: SessionSummary = { - id: 'session-return', - name: '回到最新', - isFlagged: false, - isArchived: false, - labels: [], - hasUnread: false, - status: 'active', - lastMessageAt: 0, - backend: 'ai-sdk', - llmConnectionId: 'connection-anthropic', - llmConnectionSlug: 'anthropic', - connectionLocked: false, - model: 'claude-sonnet-4-5', - permissionMode: 'ask', -}; - -const TURN_COUNT = 6; - -function turnMessages(): StoredMessage[] { - return Array.from({ length: TURN_COUNT }, (_, index): StoredMessage[] => [ - { - type: 'user', - id: `user-${index}`, - turnId: `turn-${index}`, - ts: index * 2, - text: `第 ${index} 个问题`, - }, - { - type: 'assistant', - id: `assistant-${index}`, - turnId: `turn-${index}`, - ts: index * 2 + 1, - text: '答案', - modelId: 'claude-sonnet-4-5', - }, - ]).flat(); -} - -function deferredClick(): { onClick: () => Promise; release: () => void } { - let release!: (value: void) => void; - const promise = new Promise((resolve) => { - release = resolve; - }); - return { - onClick: () => promise, - release: () => { - release(); - }, - }; -} - - -interface ReturnToLatestHarness { - readonly anchors: Array; - /** The authority `ChatSurfaceLayout` provided, read through a probe child. */ - readonly authority: TranscriptScrollAuthority; - readonly scrollRoot: HTMLElement; - readonly scrollButton: HTMLElement; - readonly clickEvent: Event; - readerScroll(): void; - geometryScroll(): void; -} - -/** - * Renders `ChatView` with a deferred return-to-latest load inside the same - * linkedom + act harness `prompt-rail-observer-identity.test.tsx` established. - * A probe child publishes the authority so the test can read the pin. - */ -function harness(options: { readonly onClick: () => Promise | void }): ReturnToLatestHarness { - const { document, window } = parseHTML('
'); - // linkedom lays nothing out, so every box is zero-sized. The geometry this - // test needs is a scroller away from its tail with a Turn visible, which a - // constant viewport rectangle plus scrollTop and scrollHeight fields give. - const rect = { - bottom: 600, height: 600, left: 0, right: 800, top: 0, width: 800, x: 0, y: 0, - toJSON: () => ({}), - } satisfies DOMRect; - window.Element.prototype.getBoundingClientRect = () => rect; - class InertResizeObserver { - observe(): void {} - unobserve(): void {} - disconnect(): void {} - } - class InertIntersectionObserver { - observe(): void {} - unobserve(): void {} - disconnect(): void {} - takeRecords(): IntersectionObserverEntry[] { - return []; - } - } - Object.assign(globalThis, { - CSS: { supports: () => false }, - document, - Element: window.Element, - HTMLElement: window.HTMLElement, - IntersectionObserver: InertIntersectionObserver, - MutationObserver: window.MutationObserver, - Node: window.Node, - ResizeObserver: InertResizeObserver, - matchMedia: () => ({ - matches: false, - addEventListener() {}, - removeEventListener() {}, - }), - requestAnimationFrame: (callback: FrameRequestCallback) => { - callback(0); - return 0; - }, - cancelAnimationFrame: () => {}, - window, - IS_REACT_ACT_ENVIRONMENT: true, - }); - const anchors: Array = []; - let authority: TranscriptScrollAuthority | undefined; - const AuthorityProbe = (): ReactElement => { - authority = useTranscriptScrollAuthority(); - return createElement(Fragment, null); - }; - const view = (): ReactElement => { - const chat = createElement(ChatView, { - messages: turnMessages(), - activeSession, - onNew: () => {}, - scrollBehavior: 'auto' as const, - hasOlderHistory: true, - hasNewerHistory: true, - onReadingAnchorChange: (turnId?: string) => { - anchors.push(turnId); - }, - } as never); - const layout = createElement(ChatSurfaceLayout, { - scrollToBottomLabel: '回到最新', - onReturnToTail: options.onClick, - composer: null, - children: createElement(Fragment, null, chat, createElement(AuthorityProbe)), - }); - const astryx = createElement(AstryxLocaleProvider, { children: layout }); - return createElement(LocaleProvider, { - locale: 'zh-CN', - children: astryx, - }); - }; - const mount = document.querySelector('#mount'); - assert.ok(mount); - const root = createRoot(mount); - mountedRoot = root; - act(() => { - root.render(view()); - }); - const scrollRoot = mount.querySelector('[data-chat-scroll-container]'); - assert.ok(scrollRoot, 'the layout mounts a scroll container'); - const scrollButton = mount.querySelector('button[aria-label="回到最新"]'); - assert.ok(scrollButton, 'the return-to-latest affordance is rendered'); - // LinkeDOM has no layout. Start at the tail; readerScroll supplies input and - // its resulting offset, whereas geometryScroll supplies no reading intent. - Object.assign(scrollRoot, { scrollHeight: 2_400, clientHeight: 600 }); - scrollRoot.scrollTop = 1_800; - // linkedom ships Event but not MouseEvent; a bubbling Event still reaches - // React's root listener, which reads only the type for onClick. - const clickEvent = new window.Event('click', { bubbles: true }); - return { - anchors, - get authority(): TranscriptScrollAuthority { - assert.ok(authority, 'the layout provided a scroll authority'); - return authority; - }, - scrollRoot, - scrollButton, - clickEvent, - readerScroll() { - const event = new window.Event('wheel', { bubbles: true }); - Object.defineProperty(event, 'deltaY', { value: -120 }); - scrollRoot.dispatchEvent(event); - scrollRoot.scrollTop = 600; - scrollRoot.dispatchEvent(new window.Event('scroll')); - }, - geometryScroll() { - scrollRoot.dispatchEvent(new window.Event('scroll')); - }, - }; -} - -test('returning to latest pins before it loads, so the anchor reports nothing', async () => { - const click = deferredClick(); - const view = harness({ onClick: click.onClick }); - - // Mount reports the pinned (cleared) anchor once; the reader scroll that - // parks away from the tail then reports the Turn being left. - view.readerScroll(); - assert.deepEqual(view.anchors, [undefined, 'turn-0'], 'an unpinned scroller reports the left Turn'); - - view.scrollButton.dispatchEvent(view.clickEvent); - await act(async () => {}); - assert.equal( - view.authority.getSnapshot().pinned, - true, - 'the click pinned synchronously, before the load settled', - ); - assert.deepEqual( - view.anchors, - [undefined, 'turn-0', undefined], - 'the pin cleared the reading anchor while the load was in flight', - ); - - click.release(); - await act(async () => {}); -}); - -test('the anchor stays cleared while the range is still loading', async () => { - const click = deferredClick(); - const view = harness({ onClick: click.onClick }); - view.readerScroll(); - view.scrollButton.dispatchEvent(view.clickEvent); - await act(async () => {}); - - // The arriving range is what used to re-report the anchor; whatever moves - // the scroller while the load is pending must not hand the shell a Turn. - view.geometryScroll(); - assert.deepEqual(view.anchors, [undefined, 'turn-0', undefined]); - - click.release(); - await act(async () => {}); -}); diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index b06577ea3e..ec4276ecdc 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -22,14 +22,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createTranscriptScrollAuthority } from '../transcript-scroll-authority.js'; -import { createTranscriptViewportNavigation } from '../transcript-viewport-navigation.js'; - -interface FakeTurn { - turnId: string; - /** Offset within the scrolled content, which `scrollTop` then shifts. */ - top: number; - height: number; -} interface FakeRoot { ownerDocument: EventTarget; @@ -39,15 +31,10 @@ interface FakeRoot { clientHeight: number; /** The boxes `scrollHeight` is made of, which is what the authority watches. */ children: readonly unknown[]; - /** Mounted Turns, laid out relative to `scrollTop`. */ - turns: FakeTurn[]; - getBoundingClientRect(): DOMRect; - querySelectorAll(selector: string): readonly unknown[]; addEventListener(type: string, listener: (event: unknown) => void): void; removeEventListener(type: string, listener: (event: unknown) => void): void; input(deltaY: number, modifiers?: { ctrlKey?: boolean; metaKey?: boolean }): void; grabScrollbar(): void; - touch(type: 'touchstart' | 'touchend' | 'touchcancel', count: number): void; end(): void; /** Dispatch the scroll event the browser would, one frame later. */ emitScroll(): void; @@ -68,15 +55,6 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F scrollHeight: options?.scrollHeight ?? 3_000, clientHeight: options?.clientHeight ?? 600, children: [{}], - turns: [], - getBoundingClientRect: () => ({ top: 0 }) as DOMRect, - querySelectorAll: () => root.turns.map((turn) => ({ - getAttribute: () => turn.turnId, - getBoundingClientRect: () => ({ - top: turn.top - root.scrollTop, - bottom: turn.top + turn.height - root.scrollTop, - }) as DOMRect, - })), addEventListener(type, listener) { if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type)!.add(listener); @@ -92,7 +70,6 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F emit('pointerdown', { button: 0, pointerType: 'mouse', pointerId: 1, target: proxy }); }, end() { emit('scrollend'); }, - touch(type, count) { emit(type, { touches: Array.from({ length: count }, () => ({ clientY: 100 })) }); }, grow(by) { root.scrollHeight += by; }, @@ -115,20 +92,16 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F } /** - * The authority watches the scroller's box and its children's boxes, and keeps - * that set current with a `MutationObserver`, so the suite owns both. `resize` + * The authority watches the scroller's box and its children's boxes. `resize` * is every box changing at once, which is the only distinction the authority * draws between them: none. * * End-of-operation frame callbacks are advanced explicitly. */ -function withObservers(run: (resize: () => void, frame: () => void, mutate: () => void) => T): T { +function withObservers(run: (resize: () => void, frame: () => void) => T): T { const observers = new Set<() => void>(); - const mutations = new Set<() => void>(); const frames: FrameRequestCallback[] = []; - const globals = globalThis as { CSS?: unknown; ResizeObserver?: unknown; MutationObserver?: unknown; requestAnimationFrame?: unknown }; - const originalCss = globals.CSS; - globals.CSS = { escape: (value: string) => value }; + const globals = globalThis as { ResizeObserver?: unknown; MutationObserver?: unknown; requestAnimationFrame?: unknown }; const originalResize = globals.ResizeObserver; const originalMutation = globals.MutationObserver; const originalFrame = globals.requestAnimationFrame; @@ -145,144 +118,86 @@ function withObservers(run: (resize: () => void, frame: () => void, mutate: ( observers.delete(this.callback); } }; - // The set of children only changes when the transcript mounts or unmounts - // one, and `resize` already stands for every box in that set changing. globals.MutationObserver = class { - constructor(private readonly callback: () => void) {} - observe(): void { - mutations.add(this.callback); - } - disconnect(): void { - mutations.delete(this.callback); - } + observe(): void {} + disconnect(): void {} }; try { return run(() => { for (const observer of [...observers]) observer(); - }, () => { for (const callback of frames.splice(0)) callback(0); }, () => { - for (const mutation of [...mutations]) mutation(); - }); + }, () => { for (const callback of frames.splice(0)) callback(0); }); } finally { - globals.CSS = originalCss; globals.ResizeObserver = originalResize; globals.MutationObserver = originalMutation; globals.requestAnimationFrame = originalFrame; } } -test('Ctrl and Meta wheel zoom preserve following without requesting history', () => { +test('native scroll anchoring stays off while attached and is restored on detach', () => { + withObservers(() => { + const root = fakeRoot(); + root.style.overflowAnchor = 'auto'; + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + assert.equal(root.style.overflowAnchor, 'none'); + root.input(-100); + root.scrollTop = 1_000; + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, false); + assert.equal(root.style.overflowAnchor, 'none', 'releasing the pin does not hand anchoring back'); + detach(); + assert.equal(root.style.overflowAnchor, 'auto'); + }); +}); + +test('Ctrl and Meta wheel zoom preserve following', () => { withObservers((resize) => { for (const modifiers of [{ ctrlKey: true }, { metaKey: true }]) { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); const detach = authority.attach(root as unknown as HTMLElement); - let readerReports = 0; - authority.subscribeToReaderScroll(() => { readerReports += 1; }); root.input(-100, modifiers); root.grow(200); resize(); assert.equal(authority.getSnapshot().pinned, true); assert.equal(root.scrollTop, root.scrollHeight - root.clientHeight); - assert.equal(readerReports, 0); detach(); } }); }); -test('range publication leaves native input and reading geometry with the browser', () => { - withObservers(() => { - for (const input of ['wheel', 'touch', 'scrollbar'] as const) { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - const detach = authority.attach(root as unknown as HTMLElement); - let commits = 0; - if (input === 'wheel') root.input(-100); - else if (input === 'touch') root.touch('touchstart', 2); - else root.grabScrollbar(); - assert.equal(authority.isInputActive(), true); - authority.commitRange(() => commits++); - assert.equal(commits, 1); - assert.equal(authority.isInputActive(), true, 'publication does not retire native input'); - if (input === 'scrollbar') { - root.ownerDocument.dispatchEvent(new Event('pointerup')); - authority.commitRange(() => commits++); - assert.equal(commits, 2, 'publication remains available after release'); - } - detach(); - } - }); -}); - -test('explicit navigation during range publication outranks the old reading anchor', () => { - withObservers(() => { - for (const command of ['reveal', 'tail'] as const) { - const root = fakeRoot(); - root.turns = [{ turnId: 'old', top: 0, height: 400 }]; - const anchor = { - isConnected: true, - dataset: { turnId: 'old' }, - getBoundingClientRect: () => ({ top: -root.scrollTop, bottom: 400 - root.scrollTop }), - }; - root.querySelectorAll = () => [anchor]; - Object.assign(root, { querySelector: () => anchor }); - const authority = createTranscriptScrollAuthority(); - const detach = authority.attach(root as unknown as HTMLElement); - authority.releasePin(); - root.scrollTop = 0; - authority.commitRange(() => { - if (command === 'tail') authority.pinToTail(); - else authority.revealTurn({ scrollIntoView: () => { root.scrollTop = 1_200; } } as unknown as HTMLElement, - { block: 'start' }); - }); - assert.equal(root.scrollTop, command === 'tail' ? root.scrollHeight - root.clientHeight : 1_200); - detach(); - } - }); -}); - -test('range publication coalesces within a microtask and uses the viewport anchor owner', async () => { - const publication = createTranscriptViewportNavigation(); - const commits: number[] = []; - let scheduled = 0; - publication.attachCommitScheduler('session', { - subscribeToReaderScroll: () => () => {}, - commitRange(commit) { scheduled++; commit(); }, +test('an upward gesture at an unmoving edge keeps following', () => { + withObservers((resize, frame) => { + const root = fakeRoot({ scrollHeight: 600, clientHeight: 600 }); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + root.input(-100); + frame(); + frame(); + assert.equal(authority.getSnapshot().pinned, true); + root.grow(400); + resize(); + assert.equal(root.scrollTop, 400); }); - publication.commitRange('session', () => commits.push(1)); - publication.commitRange('session', () => commits.push(2)); - assert.deepEqual([...commits], []); - await Promise.resolve(); - assert.deepEqual(commits, [2]); - assert.equal(scheduled, 1); }); -test('held publication retains only the latest update and drains on settle or detach', async () => { - const publication = createTranscriptViewportNavigation(); - const commits: number[] = []; - let held = true; - let settled!: () => void; - const detach = publication.attachCommitScheduler('session', { - commitRange(commit) { if (!held) commit(); }, - subscribeToReaderScroll(listener) { - settled = () => { listener('settled'); }; - return () => {}; - }, +test('a passive wheel delivered after its threaded scroll reached the top releases the tail', () => { + withObservers((resize, frame) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(root.scrollTop, 2_400); + root.scrollTop = 0; + root.emitScroll(); + root.end(); + root.input(-4_000); + frame(); + frame(); + assert.equal(authority.getSnapshot().pinned, false); + root.grow(200); + resize(); + assert.equal(root.scrollTop, 0); }); - publication.commitRange('session', () => commits.push(1)); - await Promise.resolve(); - publication.commitRange('session', () => commits.push(2)); - await Promise.resolve(); - assert.deepEqual([...commits], []); - held = false; - settled(); - await Promise.resolve(); - assert.deepEqual(commits, [2]); - held = true; - publication.commitRange('session', () => commits.push(3)); - await Promise.resolve(); - detach(); - await Promise.resolve(); - assert.deepEqual(commits, [2, 3], 'closing the viewport cannot strand source publication'); }); test('content that grows under a pinned transcript keeps the tail on screen', () => { @@ -308,23 +223,53 @@ test('identical shrink/grow geometry follows only when no reader input intervene const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); - let readerMoves = 0; - authority.subscribeToReaderScroll((phase) => { - if (phase === 'scroll') readerMoves += 1; - }); if (readerInput) root.input(-100); root.grow(-190); root.scrollTop = 2_210; // Browser clamps at the intermediate bottom. root.grow(22); root.emitScroll(); assert.equal(authority.getSnapshot().pinned, !readerInput); - assert.equal(readerMoves, readerInput ? 1 : 0); resize(); assert.equal(root.scrollTop, readerInput ? 2_210 : 2_232); }); } }); +test('history prepended above a pinned reader moves the offset without releasing the pin', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + // The virtualizer shifts the offset by the prepended height itself. + root.grow(4_000); + root.scrollTop += 4_000; + root.emitScroll(); + resize(); + assert.equal(authority.getSnapshot().pinned, true); + assert.equal(root.scrollTop, root.scrollHeight - root.clientHeight); + }); +}); + +test('content landing above a released reader does not re-pin them', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + authority.releasePin(); + assert.equal(authority.getSnapshot().pinned, false); + + // Distance to the tail is unchanged — which is exactly the reading that + // used to put the pin back and scroll the new turns away. + root.grow(4_000); + root.scrollTop = 6_400; + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, false); + + resize(); + assert.equal(root.scrollTop, 6_400); + }); +}); + test('scrollend cannot retire a continuing operation or a newer input', () => { withObservers((resize, frame) => { const root = fakeRoot(); @@ -381,24 +326,6 @@ test('scrollbar defaults can land after pointerup, while an unmoved click retire }); }); -test('explicit navigation cancels input provenance before positioning its target', () => { - withObservers(() => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - root.input(-100); - root.scrollTop = 1_700; - root.emitScroll(); - authority.releasePin(); - let reports = 0; - authority.subscribeToReaderScroll(() => { reports += 1; }); - root.scrollTop = 200; - root.emitScroll(); - assert.equal(reports, 0); - assert.equal(authority.getSnapshot().pinned, false); - }); -}); - test('user input releases the tail before content can overwrite the scroll', () => { withObservers((resize) => { const root = fakeRoot(); @@ -411,9 +338,6 @@ test('user input releases the tail before content can overwrite the scroll', () assert.equal(authority.getSnapshot().pinned, false); assert.equal(authority.getSnapshot().awayFromTail, true); - // Nothing arriving afterwards may move the reader: with the pin released - // this authority writes nothing at all, and native anchoring holds the - // position the reader chose. root.grow(4_000); resize(); assert.equal(root.scrollTop, 1_000); @@ -440,7 +364,7 @@ test('returning to the tail re-pins, and following resumes', () => { }); }); -test('a detached authority writes nothing and reports the tail', () => { +test('a detached authority writes nothing', () => { withObservers((resize) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); @@ -458,9 +382,6 @@ test('a viewport that loses height takes the pinned reader back to the tail', () const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); - - // The transcript did not change at all — the box looking at it did, which - // is a window resize, a composer gaining a line, or a dock growing taller. root.shrinkViewport(300); resize(); assert.equal(root.scrollTop, 2_700); @@ -483,54 +404,23 @@ test('a reader who scrolls up while the answer grows is still the reader', () => assert.equal(authority.getSnapshot().pinned, false); assert.equal(authority.getSnapshot().awayFromTail, true); - // And the pin stays off: what arrives next is more of the same answer, and - // following it would take the transcript away from where they went. root.grow(300); resize(); assert.equal(root.scrollTop, 1_900); }); }); -test('reports both moves even when the reader returns to the last written offset', () => { - withObservers(() => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - let readerMoves = 0; - authority.subscribeToReaderScroll((phase) => { if (phase === 'scroll') readerMoves += 1; }); - root.emitScroll(); - root.input(-100); - root.scrollTop = 900; - root.emitScroll(); - root.input(100); - root.scrollTop = 2_400; - root.emitScroll(); - root.end(); - assert.equal(readerMoves, 2); - }); -}); - test('a slow reader is a reader, however small each step is', () => { withObservers((resize) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); - let readerMoves = 0; - authority.subscribeToReaderScroll(() => { - readerMoves += 1; - }); - - // A trackpad crossing the transcript unhurriedly. Judged one event at a - // time against the rounding this has to tolerate, every one of these is - // noise and the reader never moves at all; they only mean anything added - // up. Nothing grows here, so there is nothing else they could be. for (let step = 0; step < 90; step += 1) { root.input(-2); root.scrollTop -= 2; root.emitScroll(); } assert.equal(authority.getSnapshot().pinned, false); - assert.ok(readerMoves > 0, 'the reader moved 180px and was never heard'); root.grow(500); resize(); @@ -538,69 +428,17 @@ test('a slow reader is a reader, however small each step is', () => { }); }); -test('content leaving from above the reader is not the reader either', () => { - withObservers(() => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - authority.releasePin(); - root.scrollTop = 1_500; - root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, false); - let readerMoves = 0; - authority.subscribeToReaderScroll(() => { - readerMoves += 1; - }); - - // A tool block above them folds away. Anchoring answers a removal the same - // way it answers an arrival — by moving the offset exactly as far — so the - // reader is still looking at the same content and has asked for nothing. - root.grow(-60); - root.scrollTop = 1_440; - root.emitScroll(); - assert.equal(readerMoves, 0); - assert.equal(authority.getSnapshot().pinned, false); - }); -}); - -test('content landing above a released reader does not re-pin them', () => { +test('the reading position is the Turn the attached reader names under the offset', () => { withObservers((resize) => { const root = fakeRoot(); + let turnIds = ['turn-1', 'turn-2', 'turn-3']; + let offset = 0; const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - - // The reader is at the tail and asks for what is above them: a wheel the - // scroller cannot act on, so only the command says so. - authority.releasePin(); - assert.equal(authority.getSnapshot().pinned, false); - - // History lands above them and native anchoring moves the offset to keep - // them still. Distance to the tail is unchanged — which is exactly the - // reading that used to put the pin back and scroll the new turns away. - root.grow(4_000); - root.scrollTop = 6_400; - root.emitScroll(); - assert.equal(authority.getSnapshot().pinned, false); - - resize(); - assert.equal(root.scrollTop, 6_400); - }); -}); - -test('the reading position names the Turn crossing the top of the scrollport', () => { - withObservers((resize, _frame, mutate) => { - const root = fakeRoot(); - root.turns = [ - { turnId: 'turn-1', top: 0, height: 1_000 }, - { turnId: 'turn-2', top: 1_000, height: 1_000 }, - { turnId: 'turn-3', top: 2_000, height: 1_000 }, - ]; - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); + authority.attach(root as unknown as HTMLElement, (scrollTop) => + turnIds[Math.min(Math.floor((scrollTop - offset) / 1_000), turnIds.length - 1)]); let publications = 0; authority.subscribe(() => { publications += 1; }); - // Attached pinned, so the authority wrote the tail under the reader. assert.equal(root.scrollTop, 2_400); assert.equal(authority.getSnapshot().readingTurnId, 'turn-3'); @@ -610,26 +448,24 @@ test('the reading position names the Turn crossing the top of the scrollport', ( assert.equal(authority.getSnapshot().readingTurnId, 'turn-2'); assert.ok(publications > 0, 'a new reading position is published'); - // Same Turn still under the top edge: nothing new to say. const published = publications; root.scrollTop = 1_400; root.emitScroll(); - assert.equal(publications, published); + assert.equal(publications, published, 'the same Turn under the top edge says nothing new'); - // A Turn arriving above the reader moves the position without the reader. - for (const turn of root.turns) turn.top += 500; - root.turns.unshift({ turnId: 'turn-0', top: 0, height: 500 }); - root.grow(500); - root.scrollTop = 1_900; - mutate(); + // A Turn prepended above the reader, with the offset shifted to match. + turnIds = ['turn-0', ...turnIds]; + root.grow(1_000); + root.scrollTop = 2_400; + root.emitScroll(); assert.equal(authority.getSnapshot().readingTurnId, 'turn-2'); - root.scrollTop = 400; + offset = 500; resize(); - assert.equal(authority.getSnapshot().readingTurnId, 'turn-0'); + assert.equal(authority.getSnapshot().readingTurnId, 'turn-1'); }); }); -test('a transcript without Turns has no reading position', () => { +test('a transcript without a Turn reader has no reading position', () => { withObservers(() => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); @@ -637,36 +473,3 @@ test('a transcript without Turns has no reading position', () => { assert.equal(authority.getSnapshot().readingTurnId, undefined); }); }); - -test('only the reader\'s own movement reaches a reader-scroll listener', () => { - withObservers(() => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - let heard = 0; - const stop = authority.subscribeToReaderScroll((phase) => { - if (phase === 'scroll') heard += 1; - }); - authority.attach(root as unknown as HTMLElement); - - // This authority's own write, echoed back late. - root.emitScroll(); - assert.equal(heard, 0); - - // Content arriving, with anchoring moving the offset to hold the reader. - root.grow(500); - root.scrollTop = 2_900; - root.emitScroll(); - assert.equal(heard, 0); - - // The reader, at last. - root.input(-100); - root.scrollTop = 900; - root.emitScroll(); - assert.equal(heard, 1); - - stop(); - root.scrollTop = 400; - root.emitScroll(); - assert.equal(heard, 1); - }); -}); diff --git a/packages/ui/src/__tests__/transcript-test-dom.ts b/packages/ui/src/__tests__/transcript-test-dom.ts new file mode 100644 index 0000000000..ffea29da08 --- /dev/null +++ b/packages/ui/src/__tests__/transcript-test-dom.ts @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The virtualized transcript mounts Turns only after its scroller reports a + * size, so static server markup no longer contains them. This renders on a + * LinkeDOM client whose ResizeObserver reports every box as `boxHeight` tall + * inside a `viewportHeight` scroller. Scroll geometry is zero unless a test + * defines `scrollHeight` and `clientHeight` on an element. + */ + +import { act, type ReactElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; + +const GLOBAL_KEYS = [ + 'CSS', + 'Element', + 'HTMLElement', + 'IS_REACT_ACT_ENVIRONMENT', + 'IntersectionObserver', + 'MutationObserver', + 'Node', + 'ResizeObserver', + 'cancelAnimationFrame', + 'document', + 'getComputedStyle', + 'matchMedia', + 'requestAnimationFrame', + 'window', +] as const; + +export interface TranscriptDom { + document: Document; + window: ReturnType['window']; + container: HTMLElement; + render(element: ReactElement): Promise; + cleanup(): Promise; +} + +export function installTranscriptDom(options: { viewportHeight?: number; boxHeight?: number } = {}): TranscriptDom { + const viewportHeight = options.viewportHeight ?? 100_000; + const boxHeight = options.boxHeight ?? 100; + const originals = new Map( + GLOBAL_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ); + const { document, window } = parseHTML('
'); + const scrollTops = new WeakMap(); + Object.defineProperties(window.HTMLElement.prototype, { + offsetParent: { configurable: true, get(this: HTMLElement) { return this.parentElement; } }, + scrollTop: { + configurable: true, + get(this: HTMLElement) { return scrollTops.get(this) ?? 0; }, + set(this: HTMLElement, value: number) { + scrollTops.set(this, Math.max(0, Math.min(value, this.scrollHeight - this.clientHeight))); + }, + }, + scrollHeight: { configurable: true, get: () => 0 }, + clientHeight: { configurable: true, get: () => 0 }, + // LinkeDOM has no scroll methods, and a scroller that silently ignores them + // would make every programmatic scroll look like it landed. + scroll: { + configurable: true, + value(this: HTMLElement, options?: ScrollToOptions) { + if (options?.top !== undefined) this.scrollTop = options.top; + }, + }, + }); + class MeasuringResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + queueMicrotask(() => { + const height = target.hasAttribute('data-chat-scroll-container') ? viewportHeight : boxHeight; + this.callback( + [{ target, contentRect: { height, width: 800 } } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + }); + } + unobserve(): void {} + disconnect(): void {} + } + class InertObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + takeRecords(): [] { return []; } + } + Object.assign(window, { ResizeObserver: MeasuringResizeObserver }); + Object.assign(globalThis, { + CSS: { escape: String, supports: () => false }, + Element: window.Element, + HTMLElement: window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + IntersectionObserver: InertObserver, + MutationObserver: InertObserver, + Node: window.Node, + ResizeObserver: MeasuringResizeObserver, + cancelAnimationFrame: () => undefined, + document, + getComputedStyle: () => ({ overflowY: 'visible' }), + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 0, + window, + }); + const container = document.querySelector('#root')!; + const root = createRoot(container); + return { + document, + window, + container, + async render(element) { + await act(async () => { root.render(element); }); + // Viewport measurement mounts the Turns; their own measurement settles them. + await act(async () => {}); + await act(async () => {}); + }, + async cleanup() { + await act(async () => { root.unmount(); }); + for (const [key, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + }, + }; +} + +/** Client-rendered markup of `element`, with every Turn of a transcript mounted. */ +export async function renderTranscriptMarkup(element: ReactElement): Promise { + const dom = installTranscriptDom(); + try { + await dom.render(element); + return dom.container.innerHTML; + } finally { + await dom.cleanup(); + } +} diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index 1b6595619d..e14783dd49 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -19,10 +19,10 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; -import { act, useRef, useState } from 'react'; +import { act, useRef } from 'react'; import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; -import type { StoredMessage } from '@maka/core/session'; +import type { VirtualizerHandle } from 'virtua'; import { TranscriptScrollAuthorityProvider, useTranscriptScrollAuthority, @@ -31,18 +31,21 @@ import { import { useChatScroll } from '../use-chat-scroll.js'; import { createTranscriptViewportNavigation } from '../transcript-viewport-navigation.js'; +const TURN_HEIGHT = 500; +const CLIENT_HEIGHT = 600; + const originalGlobals = { CSS: globalThis.CSS, document: globalThis.document, Element: globalThis.Element, HTMLElement: globalThis.HTMLElement, - IntersectionObserver: globalThis.IntersectionObserver, getComputedStyle: globalThis.getComputedStyle, MutationObserver: globalThis.MutationObserver, Node: globalThis.Node, ResizeObserver: globalThis.ResizeObserver, window: globalThis.window, requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, }; const originalActEnvironment = (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; @@ -50,15 +53,6 @@ const originalActEnvironment = (globalThis as typeof globalThis & { let mountedRoot: ReturnType | undefined; -function wheel(target: HTMLElement, deltaY: number): void { - const event = new window.Event('wheel', { bubbles: true }); - Object.defineProperties(event, { - deltaY: { value: deltaY }, - composedPath: { value: () => [target] }, - }); - target.dispatchEvent(event); -} - afterEach(async () => { if (mountedRoot) await act(() => mountedRoot?.unmount()); mountedRoot = undefined; @@ -68,53 +62,35 @@ afterEach(async () => { }); }); +interface ScrollCall { index: number; align?: string; smooth?: boolean } + /** - * Installs the globals the scroll hook reads onto the LinkeDOM window, either - * queueing rAF frames for explicit flushes or running them inline. + * A scroller whose Turns are `TURN_HEIGHT` tall rows of `turnIds`, addressed + * through a virtualizer handle. Row elements exist only for `mounted` Turns, the + * way a virtualizer mounts rows around the viewport. */ -const installScrollTestEnvironment = ( - document: Document, - window: ReturnType['window'], - { queueFrames = true }: { queueFrames?: boolean } = {}, -): { - frames: Map; - resizeCallbacks: ResizeObserverCallback[]; - /** Delivers a resize to whoever is observing `target` at this moment. */ - deliverResizeOf: (target: unknown) => void; -} => { +function setup(turnIds: string[]) { + const { document, window } = parseHTML('
'); let frameId = 0; const frames = new Map(); - const resizeCallbacks: ResizeObserverCallback[] = []; - const observers: TestResizeObserver[] = []; + const resizeCallbacks = new Set<() => void>(); class TestResizeObserver { - readonly targets = new Set(); - constructor(readonly callback: ResizeObserverCallback) { - resizeCallbacks.push(callback); - observers.push(this); - } - disconnect() { this.targets.clear(); } - observe(target: unknown) { this.targets.add(target); } - unobserve(target: unknown) { this.targets.delete(target); } + constructor(private readonly callback: () => void) {} + observe() { resizeCallbacks.add(this.callback); } + unobserve() {} + disconnect() { resizeCallbacks.delete(this.callback); } } class TestMutationObserver { - disconnect() {} observe() {} - unobserve() {} - takeRecords(): MutationRecord[] { return []; } + disconnect() {} } - Object.assign(window, { - cancelAnimationFrame: (id: number) => frames.delete(id), - requestAnimationFrame: queueFrames - ? (callback: FrameRequestCallback) => { - const id = ++frameId; - frames.set(id, callback); - return id; - } - : (callback: FrameRequestCallback) => { - callback(0); - return 0; - }, - }); + const requestFrame = (callback: FrameRequestCallback) => { + const id = ++frameId; + frames.set(id, callback); + return id; + }; + const cancelFrame = (id: number) => { frames.delete(id); }; + Object.assign(window, { requestAnimationFrame: requestFrame, cancelAnimationFrame: cancelFrame }); Object.assign(globalThis, { CSS: { escape: (value: string) => value }, document, @@ -125,979 +101,223 @@ const installScrollTestEnvironment = ( Node: window.Node, ResizeObserver: TestResizeObserver, window, - requestAnimationFrame: window.requestAnimationFrame, + requestAnimationFrame: requestFrame, + cancelAnimationFrame: cancelFrame, IS_REACT_ACT_ENVIRONMENT: true, }); - return { - frames, - resizeCallbacks, - deliverResizeOf: (target: unknown): void => { - for (const observer of [...observers]) { - if (observer.targets.has(target)) observer.callback([], observer as unknown as ResizeObserver); - } - }, - }; -}; - -function boxOf(top: number, bottom: number): DOMRect { - return { - top, - bottom, - height: bottom - top, - left: 0, - right: 800, - width: 800, - x: 0, - y: top, - toJSON: () => undefined, - } as DOMRect; -} -/** - * A scroller of `turnCount` equal Turns whose geometry the test drives. Turn - * boxes are derived from the current offset, so a scroll moves every box the - * way a real one does. - */ -function createTranscript( - document: Document, - window: ReturnType['window'], - options: { clientHeight: number; turnHeight: number; turnCount: number }, -) { - const scroller = document.querySelector('#scroller'); - assert.ok(scroller); + const scroller = document.querySelector('#scroller')!; let scrollTop = 0; - let turnCount = options.turnCount; - let clientHeight = options.clientHeight; - const contentHeight = (): number => - Math.max(clientHeight, turnCount * options.turnHeight); + const transcript = { turnIds }; + const contentHeight = (): number => Math.max(CLIENT_HEIGHT, transcript.turnIds.length * TURN_HEIGHT); Object.defineProperties(scroller, { - clientHeight: { get: () => clientHeight }, - scrollHeight: { get: () => contentHeight() }, + clientHeight: { get: () => CLIENT_HEIGHT }, + scrollHeight: { get: contentHeight }, scrollTop: { get: () => scrollTop, - set: (value: number) => { - scrollTop = Math.max(0, Math.min(value, contentHeight() - clientHeight)); - }, + set: (value: number) => { scrollTop = Math.max(0, Math.min(value, contentHeight() - CLIENT_HEIGHT)); }, }, }); - scroller.getBoundingClientRect = () => boxOf(0, clientHeight); - const install = (): void => { + const calls: ScrollCall[] = []; + const handle = { + findItemIndex: (offset: number) => Math.max(0, Math.floor(offset / TURN_HEIGHT)), + scrollToIndex: (index: number, options: { align?: string; smooth?: boolean } = {}) => { + calls.push({ index, ...options }); + scroller.scrollTop = options.align === 'center' + ? index * TURN_HEIGHT - (CLIENT_HEIGHT - TURN_HEIGHT) / 2 + : index * TURN_HEIGHT; + }, + } as unknown as VirtualizerHandle; + + const focused: string[] = []; + const mountRows = (mounted: readonly string[]): void => { scroller.replaceChildren(); - for (let index = 0; index < turnCount; index += 1) { - const turn = document.createElement('article'); - turn.dataset.turnId = `turn-${index}`; - const start = index * options.turnHeight; - turn.getBoundingClientRect = () => - boxOf(start - scrollTop, start + options.turnHeight - scrollTop); - turn.scrollIntoView = () => { scroller.scrollTop = start; }; - scroller.append(turn); + for (const turnId of mounted) { + const row = document.createElement('section'); + row.dataset.turnId = turnId; + row.focus = () => { focused.push(turnId); }; + scroller.append(row); } }; - install(); + return { + document, + window, scroller, - get scrollTop(): number { return scrollTop; }, - /** The viewport alone changes; a real one re-clamps its offset too. */ - setClientHeight(next: number): void { - clientHeight = next; - scroller.scrollTop = scrollTop; - }, - setTurnCount(next: number): void { - turnCount = next; - install(); - // A real scroller clamps its offset the moment its content shrinks. - scroller.scrollTop = scrollTop; + transcript, + calls, + focused, + handle, + mountRows, + resize(): void { for (const callback of [...resizeCallbacks]) callback(); }, + async flushFrames(): Promise { + await act(() => { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(0); + }); }, - /** A reader gesture and the scroll it produces, in that order. */ readerScrollTo(top: number): void { - const delta = top - scrollTop; - if (delta === 0) return; - wheel(scroller, delta); + const event = new window.Event('wheel', { bubbles: true }); + Object.defineProperties(event, { + deltaY: { value: top < scrollTop ? -100 : 100 }, + composedPath: { value: () => [scroller] }, + }); + scroller.dispatchEvent(event); scroller.scrollTop = top; scroller.dispatchEvent(new window.Event('scroll')); }, }; } -test('history loads follow the reader band, in both directions, once per direction', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 4, - }); - - const calls: string[] = []; - const resolvers: Array<() => void> = []; - const load = (direction: string) => (): Promise => { - calls.push(direction); - return new Promise((resolve) => resolvers.push(() => resolve(true))); - }; - let history = { older: true, newer: true }; - function Harness({ older, newer }: { older: boolean; newer: boolean }) { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-band', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - hasOlderHistory: older, - hasNewerHistory: newer, - onPrefetchHistory: (edge) => load(edge === 'older' ? 'up' : 'down')(), - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - const render = async (): Promise => act(() => mountedRoot?.render( - - - , - )); - - await render(); - // Opened at the tail: nothing lies below, so the newer edge is inside the - // band even though the reader never moved. - assert.equal(transcript.scrollTop, 1_800); - assert.deepEqual(calls, ['down']); - - calls.length = 0; - transcript.readerScrollTo(900); - // 900px above and 900px below, both inside two screens. The downward fetch - // is already in flight, so only the older edge is asked. - assert.deepEqual(calls, ['up']); - transcript.readerScrollTo(800); - assert.deepEqual(calls, ['up'], 'a direction with a request in flight is not asked again'); - - // Both pages land, and the edges they established close the transcript. - history = { older: false, newer: false }; - await render(); - await act(async () => { - for (const resolve of resolvers.splice(0)) resolve(); - }); - calls.length = 0; - transcript.readerScrollTo(200); - assert.deepEqual(calls, [], 'no request beyond an authoritative history edge'); - - // Only the tail is open now: the reader moving up still asks for it, - // because what decides is the band, not the direction of the gesture. - history = { older: false, newer: true }; - await render(); - transcript.readerScrollTo(1_000); - assert.deepEqual(calls, ['down']); -}); - -test('a failed fill is not reissued until the reader moves again', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 8, - }); - - let requests = 0; - function Harness() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-failing', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - hasOlderHistory: true, - onPrefetchHistory: () => { - requests += 1; - return Promise.reject(new Error('the range read failed')); - }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render( - , - )); - - await act(async () => { transcript.readerScrollTo(0); }); - // A failed read leaves the geometry and the history flags exactly as they - // were, so re-checking on its own would ask again forever. - assert.equal(requests, 1); - await act(async () => {}); - assert.equal(requests, 1); -}); - -test('a fill that issued no read is not chained into another one', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 8, - }); +type Env = ReturnType; - let requests = 0; - function Harness() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-idle', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - hasOlderHistory: true, - // The first read issued; the window it answered is then the window the - // next ask is made against, so the range refuses to read it again. - onPrefetchHistory: () => Promise.resolve(++requests === 1), - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render( - , - )); - - await act(async () => { transcript.readerScrollTo(0); }); - - assert.equal(requests, 1, 'the landed read waits for input settlement'); - await act(() => transcript.scroller.dispatchEvent(new window.Event('scrollend'))); - assert.equal(requests, 2, 'settlement rechecks the published range, whose refusal ends it'); -}); - -test('an older request at offset zero does not move the reader', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 8, - }); - - let requests = 0; - function Harness() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ +function mountHook(env: Env) { + const state: { + authority?: TranscriptScrollAuthority; + highlighted: string | null; + revealTurnAtStart?: (turnId: string) => void; + anchors: Map; + } = { highlighted: null, anchors: new Map() }; + const viewportNavigation = createTranscriptViewportNavigation(); + function Harness(props: { + sessionId: string; + target?: { turnId: string; nonce: number; preserveFocus?: boolean }; + restoreTarget?: { turnId: string; unavailable?: boolean }; + }) { + const scrollRef = useRef(env.scroller); + const virtualizerRef = useRef(env.handle); + state.authority = useTranscriptScrollAuthority(); + const result = useChatScroll({ scrollRef, - sessionId: 'session-top', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - hasOlderHistory: true, - onPrefetchHistory: () => { - requests += 1; - return new Promise(() => undefined); - }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render( - , - )); - assert.equal(requests, 0, 'the tail of a deep transcript is nowhere near the older edge'); - - transcript.readerScrollTo(0); - assert.equal(requests, 1); - assert.equal(transcript.scrollTop, 0, 'publication owns anchoring; input must not nudge the reader'); -}); - -test('range publication commits React synchronously through native input', async () => { - const navigation = createTranscriptViewportNavigation(); - const { document, window } = parseHTML('
'); - const { frames } = installScrollTestEnvironment(document, window); - const transcript = createTranscript(document, window, { - clientHeight: 400, turnHeight: 400, turnCount: 12, - }); - let authority!: TranscriptScrollAuthority; - let publish!: (value: string) => void; - function Harness() { - const [value, setValue] = useState('old'); - publish = setValue; - authority = useTranscriptScrollAuthority(); - const scrollRef = useRef(transcript.scroller); - useChatScroll({ scrollRef, sessionId: 'admission', messages: [], behavior: 'auto', viewportNavigation: navigation }); - return {value}; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render()); - await act(async () => { - navigation.commitRange('admission', () => publish('new')); - await Promise.resolve(); - assert.equal(document.querySelector('#mount')!.textContent, 'new', - 'an admitted update must not remain in React scheduling after publication'); - }); - await act(async () => { - navigation.commitRange('admission', () => publish('held')); - const down = new window.Event('pointerdown'); - Object.defineProperties(down, { - button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 1 }, - }); - transcript.scroller.dispatchEvent(down); - await Promise.resolve(); - assert.equal(document.querySelector('#mount')!.textContent, 'held', - 'an arriving range publishes while the browser owns the reading anchor'); - }); - await act(() => document.dispatchEvent(new window.Event('pointerup'))); - await act(() => { - const pending = [...frames.values()]; frames.clear(); - for (const callback of pending) callback(0); - }); - assert.equal(document.querySelector('#mount')!.textContent, 'held'); - await act(async () => { - const down = new window.Event('pointerdown'); - Object.defineProperties(down, { - button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 3 }, - }); - transcript.scroller.dispatchEvent(down); - navigation.commitRange('admission', () => publish('navigation')); - await Promise.resolve(); - authority.releasePin(); - assert.equal(document.querySelector('#mount')!.textContent, 'navigation'); - document.dispatchEvent(new window.Event('pointerup')); - }); - assert.equal(document.querySelector('#mount')!.textContent, 'navigation', - 'ending physical input publishes navigation even when its gesture was superseded'); - await act(async () => { - const down = new window.Event('pointerdown'); - Object.defineProperties(down, { - button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 2 }, - }); - transcript.scroller.dispatchEvent(down); - navigation.commitRange('admission', () => publish('latest')); - await Promise.resolve(); - assert.equal(document.querySelector('#mount')!.textContent, 'latest'); - authority.pinToTail(); - }); - assert.equal(document.querySelector('#mount')!.textContent, 'latest', - 'explicit tail navigation must not strand a deferred range'); -}); - -test('a source publication survives viewport unmount without another source update', async () => { - const { document, window } = parseHTML('
'); - installScrollTestEnvironment(document, window); - const transcript = createTranscript(document, window, { clientHeight: 400, turnHeight: 400, turnCount: 12 }); - const navigation = createTranscriptViewportNavigation(); - let publish!: (value: string) => void; - let show!: (value: boolean) => void; - function Surface() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ scrollRef, sessionId: 'session', messages: [], behavior: 'auto', viewportNavigation: navigation }); - return null; - } - function Harness() { - const [value, setValue] = useState('old'); - const [visible, setVisible] = useState(true); - publish = setValue; show = setVisible; - return <>{value}{visible && }; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render()); - await act(() => wheel(transcript.scroller, -100)); - await act(() => { - navigation.commitRange('session', () => publish('latest')); - show(false); - }); - assert.equal(document.querySelector('#mount')!.textContent, 'latest'); - await act(() => show(true)); - assert.equal(document.querySelector('#mount')!.textContent, 'latest'); -}); - -for (const hasOlder of [false, true]) { - test(`stationary upward input ${hasOlder ? 'reads available history' : 'keeps following without history'}`, async () => { - const navigation = createTranscriptViewportNavigation(); - const { document, window } = parseHTML('
'); - const { frames, deliverResizeOf } = installScrollTestEnvironment(document, window); - const transcript = createTranscript(document, window, { - clientHeight: 400, turnHeight: 200, turnCount: 1, - }); - let authority!: TranscriptScrollAuthority; - let requests = 0; - function Harness() { - authority = useTranscriptScrollAuthority(); - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, sessionId: 'short', messages: [], behavior: 'auto', viewportNavigation: navigation, - hasOlderHistory: hasOlder, - onPrefetchHistory: () => { requests++; return new Promise(() => {}); }, - }); - return null; - } - const frame = async () => act(() => { - const pending = [...frames.values()]; - frames.clear(); - for (const callback of pending) callback(0); - }); - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render()); - await frame(); // Initial fill may already be in flight when the reader asks. - await act(() => wheel(transcript.scroller, -100)); - let publications = 0; - await act(() => navigation.commitRange('short', () => { - publications++; - transcript.setTurnCount(3); - if (hasOlder) { - [...transcript.scroller.children].forEach((turn, index) => { - (turn as HTMLElement).dataset.turnId = `turn-${index - 2}`; - }); - } - })); - assert.equal(publications, 1, 'input must not starve an accepted history request'); - await frame(); await frame(); - assert.equal(requests, hasOlder ? 1 : 0); - assert.equal(publications, 1); - assert.equal(authority.getSnapshot().pinned, !hasOlder); - const beforeGrowth = transcript.scrollTop; - await act(() => { - transcript.setTurnCount(4); - deliverResizeOf(transcript.scroller); - }); - assert.equal(transcript.scrollTop, hasOlder ? beforeGrowth : 400); - }); -} - -for (const settlesBeforePublication of [false, true]) { -test(`a fill publishes before eviction when input settles ${settlesBeforePublication ? 'before' : 'after'} publication`, async () => { - const navigation = createTranscriptViewportNavigation(); - const { document, window } = parseHTML('
'); - const { frames } = installScrollTestEnvironment(document, window); - const transcript = createTranscript(document, window, { - clientHeight: 400, turnHeight: 400, turnCount: 12, - }); - let authority!: TranscriptScrollAuthority; - let finishRead!: () => void; - let requests = 0; - let publications = 0; - const retained: string[] = []; - function Harness() { - authority = useTranscriptScrollAuthority(); - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, sessionId: 'held-fill', messages: [], behavior: 'auto', viewportNavigation: navigation, - hasOlderHistory: true, - onPrefetchHistory: () => { - requests++; - return new Promise((resolve) => { - finishRead = () => { - navigation.commitRange('held-fill', () => { - publications++; - transcript.setTurnCount(14); - [...transcript.scroller.children].forEach((turn, index) => { - (turn as HTMLElement).dataset.turnId = `turn-${index - 2}`; - }); - // Linkedom has no layout or native scroll anchoring. Model the - // browser retaining turn-0; real geometry is checked in Chromium. - transcript.scroller.scrollTop += 800; - }); - resolve(true); - }; - }); - }, - onRetainWindow: (range) => { retained.push(range.firstTurnId); }, + virtualizerRef, + sessionId: props.sessionId, + turnIds: env.transcript.turnIds, + measureStartMargin: () => 0, + target: props.target, + restoreTarget: props.restoreTarget, + viewportNavigation, + onReadingAnchorChange: (turnId) => { state.anchors.set(props.sessionId, turnId); }, + behavior: 'smooth', }); + state.highlighted = result.highlightedTurnId; + state.revealTurnAtStart = result.revealTurnAtStart; return null; } - const frame = async () => act(() => { - const pending = [...frames.values()]; frames.clear(); - for (const callback of pending) callback(0); - }); - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render()); - await frame(); - retained.length = 0; - await act(() => { - const down = new window.Event('pointerdown'); - Object.defineProperties(down, { - button: { value: 0 }, pointerType: { value: 'mouse' }, pointerId: { value: 1 }, - }); - transcript.scroller.dispatchEvent(down); - transcript.scroller.scrollTop = 0; - transcript.scroller.dispatchEvent(new window.Event('scroll')); - }); - assert.equal(requests, 1); - if (settlesBeforePublication) { - await act(() => document.dispatchEvent(new window.Event('pointerup'))); - await frame(); await frame(); - assert.equal(authority.isInputActive(), false); - assert.deepEqual(retained, [], 'an unfinished read cannot be trimmed using the old window'); - } - await act(() => finishRead()); - assert.equal(publications, 1); - if (!settlesBeforePublication) { - assert.deepEqual(retained, [], 'active input still prevents eviction'); - assert.equal(requests, 1, 'the fill does not eagerly chain while input is active'); - await act(() => document.dispatchEvent(new window.Event('pointerup'))); - } - await frame(); await frame(); - assert.equal(publications, 1); - assert.equal(retained.at(-1), 'turn--2', 'the new published band includes the older page'); -}); + mountedRoot = createRoot(env.document.querySelector('#mount')!); + return { + state, + viewportNavigation, + async render(props: Parameters[0]): Promise { + await act(() => mountedRoot?.render( + , + )); + }, + }; } -test('a transcript change re-reads the band while the reader stays at the tail', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 8, - }); - - let requests = 0; - function Harness({ messages }: { messages: readonly StoredMessage[] }) { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-tail', - messages, - behavior: 'auto', - hasOlderHistory: true, - onPrefetchHistory: () => { - requests += 1; - return new Promise(() => undefined); - }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - const render = async (messages: readonly StoredMessage[]): Promise => - act(() => mountedRoot?.render( - , - )); +const turns = (count: number, prefix = 'turn'): string[] => + Array.from({ length: count }, (_, index) => `${prefix}-${index}`); - await render([{ id: 'message-1' }] as StoredMessage[]); - assert.equal(requests, 0); - assert.equal(transcript.scrollTop, 4_200); +test('the reading anchor is the Turn the virtualizer maps under the offset', async () => { + const env = setup(turns(6)); + const hook = mountHook(env); + await hook.render({ sessionId: 's' }); + assert.equal(env.scroller.scrollTop, 6 * TURN_HEIGHT - CLIENT_HEIGHT); + assert.equal(hook.state.anchors.get('s'), undefined, 'a pinned reader has no anchor'); - // A trim leaves the reader pinned at a tail with barely a screen above it. - // Nobody scrolled, so only the transcript itself can report the band. - transcript.setTurnCount(2); - transcript.scroller.scrollTop = transcript.scroller.scrollHeight; - await render([{ id: 'message-2' }] as StoredMessage[]); - assert.equal(requests, 1); -}); + // No row is mounted at all: the mapping, not DOM measurement, names the Turn. + await act(() => { env.readerScrollTo(2 * TURN_HEIGHT + 10); }); + assert.equal(hook.state.anchors.get('s'), 'turn-2'); -test('the retained window is the band around the reader, and an unmounted bookmark cannot freeze it', async () => { - const { document, window } = parseHTML( - '
', - ); - installScrollTestEnvironment(document, window, { queueFrames: false }); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 20, + // Earlier Turns prepended, with the offset shifted by their height. + env.transcript.turnIds = [...turns(2, 'earlier'), ...env.transcript.turnIds]; + await act(() => { + env.scroller.scrollTop += 2 * TURN_HEIGHT; + env.scroller.dispatchEvent(new env.window.Event('scroll')); }); - // Start at the existing reading position. A pending bookmark need not visit - // the tail as a side effect of attaching the scroll authority. - transcript.scroller.scrollTop = 11_400; - - const retained: Array<{ firstTurnId: string; lastTurnId: string }> = []; - function Harness({ - messages, - unavailable, - }: { messages: readonly StoredMessage[]; unavailable: boolean }) { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-window', - messages, - restoreTarget: { turnId: 'turn-never-mounted', unavailable }, - behavior: 'auto', - onRetainWindow: (value) => { retained.push(value); }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - const render = async ( - messages: readonly StoredMessage[], - unavailable: boolean, - ): Promise => act(() => mountedRoot?.render( - - - , - )); - - // A bookmark whose Turn is not mounted cannot be trimmed away, so waiting - // for it would only let the window grow without a bound. The window is four - // screens of Turns around a scrollport 20 screens deep. - await render([{ id: 'message-1' }] as StoredMessage[], false); - assert.equal(transcript.scrollTop, 11_400); - assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-14', lastTurnId: 'turn-19' }); - - retained.length = 0; - await render([{ id: 'message-2' }] as StoredMessage[], true); - assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-14', lastTurnId: 'turn-19' }); - - retained.length = 0; - transcript.readerScrollTo(6_000); - assert.deepEqual(retained, [], 'trim waits for the input to finish'); - transcript.scroller.dispatchEvent(new window.Event('scrollend')); - assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-5', lastTurnId: 'turn-15' }); - - // Six screens is the threshold: with less than that beyond the scrollport in - // both directions there is nothing worth dropping. - transcript.setTurnCount(8); - transcript.readerScrollTo(2_000); - retained.length = 0; - transcript.readerScrollTo(2_100); - assert.deepEqual(retained, []); + await hook.render({ sessionId: 's' }); + env.resize(); + assert.equal(hook.state.anchors.get('s'), 'turn-2'); + assert.equal(hook.state.authority?.getSnapshot().pinned, false); }); -test('a viewport that grows fills the band it just widened, without a reader gesture', async () => { - const { document, window } = parseHTML( - '
', - ); - const { deliverResizeOf } = installScrollTestEnvironment(document, window, { - queueFrames: false, - }); - const transcript = createTranscript(document, window, { - clientHeight: 400, turnHeight: 600, turnCount: 10, - }); - - const edges: string[] = []; - function Harness() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-grow', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - hasOlderHistory: true, - onPrefetchHistory: (edge) => { - edges.push(edge); - return new Promise(() => undefined); - }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render( - , - )); - - transcript.readerScrollTo(1_000); - assert.deepEqual(edges, [], '1000px above is outside two 400px screens'); - - transcript.setClientHeight(800); - await act(async () => { deliverResizeOf(transcript.scroller); }); - assert.deepEqual(edges, ['older'], 'the same 1000px is inside two 800px screens'); - - await act(async () => { deliverResizeOf(transcript.scroller); }); - assert.deepEqual(edges, ['older'], 'the in-flight guard still holds across resizes'); +test('a search target is revealed by index once per nonce, then focused and highlighted', async () => { + const env = setup(turns(3)); + const hook = mountHook(env); + await hook.render({ sessionId: 's', target: { turnId: 'turn-5', nonce: 1 } }); + await env.flushFrames(); + assert.deepEqual(env.calls, [], 'a Turn that is not loaded cannot be revealed yet'); + assert.equal(hook.state.highlighted, null); + + env.transcript.turnIds = turns(8); + await hook.render({ sessionId: 's', target: { turnId: 'turn-5', nonce: 1 } }); + assert.deepEqual(env.calls, [{ index: 5, align: 'center', smooth: true }]); + assert.equal(hook.state.authority?.getSnapshot().pinned, false); + + // The row mounts a few frames after the scroll starts. + await env.flushFrames(); + assert.equal(hook.state.highlighted, null); + env.mountRows(['turn-4', 'turn-5']); + await env.flushFrames(); + assert.equal(hook.state.highlighted, 'turn-5'); + assert.deepEqual(env.focused, ['turn-5']); + assert.equal(hook.state.anchors.get('s'), 'turn-4'); + + env.scroller.scrollTop = 100; + await hook.render({ sessionId: 's', target: { turnId: 'turn-5', nonce: 1 } }); + await env.flushFrames(); + assert.equal(env.calls.length, 1, 'a landed command does not repeat on render'); + assert.equal(env.scroller.scrollTop, 100); + + await hook.render({ sessionId: 's', target: { turnId: 'turn-5', nonce: 2 } }); + assert.equal(env.calls.length, 2, 'a new nonce is a new command'); }); -test('a viewport that shrinks trims what it just pushed beyond the band', async () => { - const { document, window } = parseHTML( - '
', - ); - const { deliverResizeOf } = installScrollTestEnvironment(document, window, { - queueFrames: false, - }); - const transcript = createTranscript(document, window, { - clientHeight: 1_000, turnHeight: 600, turnCount: 18, - }); - - const retained: Array<{ firstTurnId: string; lastTurnId: string }> = []; - function Harness() { - const scrollRef = useRef(transcript.scroller); - useChatScroll({ - scrollRef, - sessionId: 'session-shrink', - messages: [{ id: 'message-1' }] as StoredMessage[], - behavior: 'auto', - onRetainWindow: (value) => { retained.push(value); }, - }); - return null; - } - mountedRoot = createRoot(document.querySelector('#mount')!); - await act(() => mountedRoot?.render( - , - )); - - transcript.readerScrollTo(4_000); - retained.length = 0; - transcript.readerScrollTo(4_100); - transcript.scroller.dispatchEvent(new window.Event('scrollend')); - assert.deepEqual(retained, [], 'nothing lies six 1000px screens away'); - - transcript.setClientHeight(400); - await act(async () => { deliverResizeOf(transcript.scroller); }); - assert.deepEqual(retained.at(-1), { firstTurnId: 'turn-4', lastTurnId: 'turn-10' }); +test('a bookmark restores its Turn at the top edge, and an unavailable one falls back to the tail', async () => { + const env = setup(turns(6, 'a')); + const hook = mountHook(env); + await hook.render({ sessionId: 'a', restoreTarget: { turnId: 'a-2' } }); + assert.deepEqual(env.calls, [{ index: 2, align: 'start', smooth: false }]); + env.mountRows(['a-2']); + await env.flushFrames(); + assert.equal(hook.state.anchors.get('a'), 'a-2'); + assert.equal(hook.state.highlighted, null, 'a restore is not a search result'); + assert.equal(hook.state.authority?.getSnapshot().pinned, false); + + env.transcript.turnIds = turns(2, 'b'); + env.mountRows([]); + await hook.render({ sessionId: 'b', restoreTarget: { turnId: 'b-gone' } }); + assert.equal(hook.state.authority?.getSnapshot().pinned, false, 'a bookmark waits for its Turn'); + await hook.render({ sessionId: 'b', restoreTarget: { turnId: 'b-gone', unavailable: true } }); + assert.equal(env.calls.length, 1); + assert.equal(hook.state.anchors.get('b'), 'b-1', 'the Turn under the offset replaces the lost bookmark'); }); -test('a session switch restores a Turn anchor after async fill and preserves tail intent', async () => { - const { document, window } = parseHTML( - '
', - ); - const mount = document.querySelector('#mount'); - const scroller = document.querySelector('#scroller'); - assert.ok(mount); - assert.ok(scroller); - - let scrollHeight = 600; - let scrollTop = 0; - let dispatchCommandScroll = true; - Object.defineProperties(scroller, { - clientHeight: { value: 600 }, - scrollHeight: { get: () => scrollHeight }, - scrollTop: { - get: () => scrollTop, - set: (value: number) => { - scrollTop = Math.max(0, Math.min(value, scrollHeight - 600)); - }, - }, - }); - scroller.getBoundingClientRect = () => boxOf(0, 600); - - const { frames, resizeCallbacks } = installScrollTestEnvironment(document, window); - - const installTranscript = ( - height: number, - turns: ReadonlyArray<{ id: string; start: number; height: number }>, - ): void => { - scrollHeight = height; - scroller.replaceChildren(); - for (const turn of turns) { - const element = document.createElement('article'); - element.dataset.turnId = turn.id; - element.getBoundingClientRect = () => - boxOf(turn.start - scrollTop, turn.start + turn.height - scrollTop); - element.scrollIntoView = (options?: boolean | ScrollIntoViewOptions) => { - const block = typeof options === 'object' ? options.block : undefined; - scroller.scrollTop = block === 'center' - ? turn.start - 300 + turn.height / 2 - : turn.start; - if (dispatchCommandScroll) scroller.dispatchEvent(new window.Event('scroll')); - }; - scroller.append(element); - } - }; - const collapseTranscript = (): void => { - scrollHeight = 600; - scrollTop = 0; - scroller.replaceChildren(); - }; - const deliverResize = (): void => { - for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); - }; - const flushFrames = async (): Promise => { - await act(() => { - const pending = [...frames.values()]; - frames.clear(); - for (const callback of pending) callback(0); - }); - }; - - const anchors = new Map(); - const viewportNavigation = createTranscriptViewportNavigation(); - const unavailableRestores = new Map(); - let authority: TranscriptScrollAuthority | undefined; - let messageRevision = 0; - let target: { turnId: string; nonce: number } | undefined; - function Harness({ sessionId }: { sessionId: string }) { - const scrollRef = useRef(scroller); - authority = useTranscriptScrollAuthority(); - const unavailableTurnId = unavailableRestores.get(sessionId); - const restoreTurnId = unavailableTurnId ?? anchors.get(sessionId); - const restoreTarget = restoreTurnId - ? { turnId: restoreTurnId, unavailable: unavailableTurnId === restoreTurnId } - : undefined; - useChatScroll({ - scrollRef, - sessionId, - messages: [{ id: `message-${messageRevision}` }] as StoredMessage[], - target, - restoreTarget, - viewportNavigation, - onReadingAnchorChange: (turnId) => { - unavailableRestores.delete(sessionId); - if (turnId) anchors.set(sessionId, turnId); - else anchors.delete(sessionId); - }, - behavior: 'auto', - }); - return null; - } - - const renderSession = async (sessionId: string): Promise => { - messageRevision += 1; - await act(() => mountedRoot?.render( - - - , - )); - }; - - installTranscript(3_000, [ - { id: 'turn-a-1', start: 0, height: 800 }, - { id: 'turn-a-2', start: 800, height: 600 }, - { id: 'turn-a-3', start: 1_400, height: 1_600 }, - ]); - mountedRoot = createRoot(mount); - await renderSession('session-a'); - assert.equal(scroller.scrollTop, 2_400); - - wheel(scroller, -100); - scroller.scrollTop = 900; - scroller.dispatchEvent(new window.Event('scroll')); - assert.equal(anchors.get('session-a'), 'turn-a-2'); - - collapseTranscript(); - await renderSession('session-b'); - installTranscript(2_000, [{ id: 'turn-b-1', start: 0, height: 2_000 }]); - deliverResize(); - assert.equal(scroller.scrollTop, 1_400); - assert.equal(anchors.has('session-b'), false); - - collapseTranscript(); - await renderSession('session-a'); - assert.equal(authority?.getSnapshot().pinned, false); - installTranscript(2_000, [{ id: 'turn-a-latest', start: 0, height: 2_000 }]); - await renderSession('session-a'); - deliverResize(); - assert.equal(anchors.get('session-a'), 'turn-a-2'); - installTranscript(3_000, [ - { id: 'turn-a-1', start: 0, height: 800 }, - { id: 'turn-a-2', start: 800, height: 600 }, - { id: 'turn-a-3', start: 1_400, height: 1_600 }, - ]); - await renderSession('session-a'); - await flushFrames(); - assert.equal(scroller.scrollTop, 800); - assert.equal(scroller.querySelector('[data-turn-id="turn-a-2"]') - ?.getBoundingClientRect().top, 0); - assert.equal(authority?.getSnapshot().pinned, false); - - collapseTranscript(); - await renderSession('session-b'); - installTranscript(2_400, [{ id: 'turn-b-1', start: 0, height: 2_400 }]); - deliverResize(); - assert.equal(scroller.scrollTop, 1_800); - assert.equal(authority?.getSnapshot().pinned, true); - - // The same restore key can be handled successfully on one activation and - // become unavailable on the next. The earlier success must not swallow the - // later terminal result. - collapseTranscript(); - await renderSession('session-a'); - installTranscript(2_000, [{ id: 'turn-a-visible', start: 0, height: 2_000 }]); - await renderSession('session-a'); - await flushFrames(); - assert.equal(anchors.get('session-a'), 'turn-a-2'); - - unavailableRestores.set('session-a', 'turn-a-2'); - await renderSession('session-a'); - await flushFrames(); - assert.equal(anchors.get('session-a'), 'turn-a-visible'); - assert.equal(unavailableRestores.has('session-a'), false); - - collapseTranscript(); - await renderSession('session-b'); - installTranscript(2_400, [{ id: 'turn-b-1', start: 0, height: 2_400 }]); - deliverResize(); - assert.equal(scroller.scrollTop, 1_800); - assert.equal(authority?.getSnapshot().pinned, true); - - // A command can land without producing a scroll event when layout or native - // anchoring already put the Turn at the requested offset. Its semantic - // reading position must still be reported before the user switches away. - dispatchCommandScroll = false; - target = { turnId: 'turn-b-1', nonce: 1 }; - await renderSession('session-b'); - await flushFrames(); - assert.equal(anchors.get('session-b'), 'turn-b-1'); - scroller.scrollTop = 100; - await renderSession('session-b'); - await flushFrames(); - assert.equal(scroller.scrollTop, 100, 'a completed navigation must not repeat on render'); - - target = undefined; - // With no resident Turn to re-anchor to, abandoning the restore falls back - // to the default tail intent and clears the stale reading anchor. - anchors.set('session-b', 'turn-b-never-renders'); - collapseTranscript(); - await renderSession('session-c'); - collapseTranscript(); - await renderSession('session-b'); - assert.equal(authority?.getSnapshot().pinned, false); - unavailableRestores.set('session-b', 'turn-b-never-renders'); - await renderSession('session-b'); - await flushFrames(); - assert.equal(authority?.getSnapshot().pinned, true); - assert.equal(anchors.has('session-b'), false); - - // A send/return-to-latest clears a pending bookmark. Its old frame must not - // scroll to the historical Turn if that Turn arrives in a later batch. - anchors.set('session-a', 'turn-a-2'); - collapseTranscript(); - await renderSession('session-a'); - assert.equal(authority?.getSnapshot().pinned, false); - anchors.delete('session-a'); - await renderSession('session-a'); - assert.equal(authority?.getSnapshot().pinned, false, 'clearing a bookmark is not a viewport command'); - await act(() => viewportNavigation.followLatest('session-a')); - installTranscript(3_000, [ - { id: 'turn-a-2', start: 0, height: 800 }, - { id: 'turn-a-latest', start: 800, height: 2_200 }, - ]); - await renderSession('session-a'); - deliverResize(); - await flushFrames(); - assert.equal(authority?.getSnapshot().pinned, true); - assert.equal(scroller.scrollTop, 2_400); - assert.equal(anchors.has('session-a'), false); - - wheel(scroller, -100); - scroller.scrollTop = 1_000; - scroller.dispatchEvent(new window.Event('scroll')); - assert.equal(anchors.get('session-a'), 'turn-a-latest'); - scroller.dispatchEvent(new window.Event('scrollend')); - installTranscript(800, [{ id: 'geometry-resident', start: 0, height: 800 }]); - scroller.scrollTop = scroller.scrollTop; - await renderSession('session-a'); - deliverResize(); - assert.equal(authority?.getSnapshot().pinned, false); - assert.equal(authority?.getSnapshot().awayFromTail, false); - assert.equal(anchors.get('session-a'), 'turn-a-latest', 'range geometry does not report a new reading intent'); +test('following the latest cancels a bookmark that has not landed', async () => { + const env = setup(turns(1, 'a')); + const hook = mountHook(env); + await hook.render({ sessionId: 'a', restoreTarget: { turnId: 'a-9' } }); + assert.equal(hook.state.authority?.getSnapshot().pinned, false); + await act(() => hook.viewportNavigation.followLatest('a')); + env.transcript.turnIds = turns(10, 'a'); + await hook.render({ sessionId: 'a', restoreTarget: { turnId: 'a-9' } }); + env.resize(); + assert.deepEqual(env.calls, []); + assert.equal(hook.state.authority?.getSnapshot().pinned, true); + assert.equal(env.scroller.scrollTop, 10 * TURN_HEIGHT - CLIENT_HEIGHT); }); -test('a target lands on the render that mounts its Turn, whatever moved the range', async () => { - const { document, window } = parseHTML( - '
', - ); - const { frames } = installScrollTestEnvironment(document, window); - const transcript = createTranscript(document, window, { - clientHeight: 600, turnHeight: 600, turnCount: 3, - }); - - // The Renderer owns the window now: a jump to an unloaded Turn changes the - // resident range without touching the message list the shell passes down. - const messages = [{ id: 'message-1' }] as StoredMessage[]; - let highlighted: string | null = null; - function Harness() { - const scrollRef = useRef(transcript.scroller); - const result = useChatScroll({ - scrollRef, - sessionId: 'session-jump', - messages, - target: { turnId: 'turn-5', nonce: 7 }, - behavior: 'auto', - }); - highlighted = result.highlightedTurnId; - return null; - } - const render = async (): Promise => act(() => mountedRoot?.render( - , - )); - const flushFrames = async (): Promise => { - await act(() => { - const pending = [...frames.values()]; - frames.clear(); - for (const callback of pending) callback(0); - }); - }; - - mountedRoot = createRoot(document.querySelector('#mount')!); - await render(); - await flushFrames(); - assert.equal(highlighted, null, 'a Turn that is not mounted cannot be revealed yet'); - - transcript.setTurnCount(8); - await render(); - await flushFrames(); - assert.equal(highlighted, 'turn-5'); - assert.equal(transcript.scrollTop, 3_000, 'the reveal puts the Turn at the top edge'); +test('a rail navigation releases the pin and scrolls its Turn to the top by index', async () => { + const env = setup(turns(6)); + const hook = mountHook(env); + await hook.render({ sessionId: 's' }); + assert.equal(hook.state.authority?.getSnapshot().pinned, true); + await act(() => { hook.state.revealTurnAtStart?.('turn-3'); }); + assert.deepEqual(env.calls, [{ index: 3, align: 'start' }]); + assert.equal(env.scroller.scrollTop, 3 * TURN_HEIGHT); + assert.equal(hook.state.authority?.getSnapshot().pinned, false); + + env.resize(); + assert.equal(env.scroller.scrollTop, 3 * TURN_HEIGHT, 'growth does not take a navigated reader back to the tail'); }); diff --git a/packages/ui/src/chat-surface-layout.tsx b/packages/ui/src/chat-surface-layout.tsx index 321845e652..0547161bd7 100644 --- a/packages/ui/src/chat-surface-layout.tsx +++ b/packages/ui/src/chat-surface-layout.tsx @@ -35,8 +35,6 @@ import { PromptAnchorRailHostContext } from './prompt-anchor-rail.js'; */ export type ChatSurfaceLayoutProps = Omit, 'autoScroll'> & { scrollToBottomLabel?: string; - /** Loads the durable tail after the scroll authority pins to it. */ - onReturnToTail?(): Promise | void; }; /** @@ -61,7 +59,6 @@ export function ChatSurfaceLayout({ children, density = 'balanced', scrollToBottomLabel, - onReturnToTail, ...props }: ChatSurfaceLayoutProps) { const [railHost, setRailHost] = useState(null); @@ -85,7 +82,7 @@ export function ChatSurfaceLayout({ // Astryx's default button reads `isScrolledUp`, which stops updating the // moment its scroll layer is off. Maka's reads Maka's pin instead. scrollButton={props.scrollButton === null ? null - : } + : } density={density} className={cn('maka-chat-layout', className)} data-chat-scroll-container="true" diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index e3a668a533..caaa1bec6e 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -17,7 +17,19 @@ * under the License. */ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; +import { + Fragment, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode, + type RefObject, +} from 'react'; +import { Virtualizer, type CustomContainerComponentProps, type VirtualizerHandle } from 'virtua'; import { ICON_SIZE, AlertTriangle, @@ -26,7 +38,6 @@ import { import { DeepResearchEmptyHero, EmptyChatHero } from './chat-empty-hero.js'; import type { ChatModelChoice } from './chat-model-helpers.js'; import { - mergePromptAnchorRailTurns, PromptAnchorRail, type PromptAnchorRailTurn, } from './prompt-anchor-rail.js'; @@ -58,8 +69,7 @@ import { type TurnFooterActionMeta, type TurnPresentationDeriver, } from './chat-turn.js'; -import { useChatScroll } from './use-chat-scroll.js'; -import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; +import { useChatScroll, useTranscriptStartMargin } from './use-chat-scroll.js'; import type { TranscriptViewportNavigation } from './transcript-viewport-navigation.js'; import { placeChatConversationItems } from './chat-conversation-items.js'; import { useUiLocale } from './locale-context.js'; @@ -87,35 +97,6 @@ export interface ChatViewGoalIndicatorProps { goalIndicator?: SessionContextGoal; } -/** A rail click's outstanding request that the reveal for its Turn agree with it. */ -export type RailAlignmentClaim = { turnId: string; nonce?: number }; - -/** - * Which edge the transcript's reveal should use for the target the shell is - * publishing, and what is left of the rail's claim afterwards. - * - * A claim belongs to the one navigation its click asked for, not to the Turn: - * it binds to the first target that arrives for that Turn and is spent on - * anything else. A later search for the same Turn is a different command with - * its own nonce, and gets the search contract back. - */ -export function resolveRailAlignedTarget( - claim: RailAlignmentClaim | undefined, - target: T | undefined, -): { - claim: RailAlignmentClaim | undefined; - target: (T & { align: 'start' | 'center' }) | undefined; -} { - if (!target) return { claim, target: undefined }; - const aimedByRail = claim !== undefined - && claim.turnId === target.turnId - && (claim.nonce === undefined || claim.nonce === target.nonce); - return { - claim: aimedByRail ? { turnId: target.turnId, nonce: target.nonce } : undefined, - target: { ...target, align: aimedByRail ? 'start' : 'center' }, - }; -} - /** * A user Message this client has shown but cannot yet prove is durable. * @@ -259,21 +240,21 @@ export function ChatView(props: { * chat view only scrolls/highlights the already-rendered turn. */ scrollTargetTurn?: { turnId: string; nonce: number; preserveFocus?: boolean }; - /** Runtime-only reading position restored without search focus or highlight. */ + /** + * Runtime-only reading position restored without search focus or highlight. + * `unavailable`: the Turn is not in `messages` and no earlier history remains. + */ restoreTargetTurn?: { turnId: string; unavailable?: boolean }; viewportNavigation?: TranscriptViewportNavigation; onReadingAnchorChange?(turnId?: string): void; scrollBehavior: ScrollBehavior; - hasOlderHistory?: boolean; - hasNewerHistory?: boolean; - /** Fills the window at an edge the reader is approaching. */ - onPrefetchHistory?(edge: 'older' | 'newer'): Promise; - onRetainWindow?(window: { firstTurnId: string; lastTurnId: string }): void; - transcriptTurnIndex?: ReadonlyArray<{ turnId: string; sequence: number; label: string }>; + /** Turns older than the first one in `messages` exist and can be loaded. */ + hasEarlierHistory?: boolean; + /** Prepends whole earlier Turns to `messages`. */ + onLoadEarlierHistory?(): void | Promise; /** Optional identity decorations shared with a host's work navigation. */ promptRailDecorations?: ReadonlyMap>; onPromptRailHighlight?(turnId: string | undefined): void; - onLoadTranscriptTurn?(target: { turnId: string; sequence: number }): void; /** * PR109f: when the active session is a branched session * (`parentSessionId` set on its summary), show a banner above the @@ -406,9 +387,6 @@ export function ChatView(props: { const hasRenderedLiveTurn = tailTurnId !== undefined && turns.some((turn) => turn.turnId === tailTurnId); const boundaryOverlayTurnId = activeContent?.turnId ?? (streamingActive ? tailTurnId : undefined); - // One rail tick per turn that carries a user prompt (Codex-style prompt - // navigation). Memoized so the rail's IntersectionObserver isn't rebuilt - // on every render. const transformedUserTurnIds = useMemo( () => new Set( props.messages.flatMap((message) => @@ -421,12 +399,12 @@ export function ChatView(props: { ), [props.messages], ); - // The rail's entries change only when a turn's persisted prompt/answer text - // does, but `turns` gets a new array on every delta. Handing the previous - // array back when nothing it reads moved keeps the memoized rail — and its - // transcript-wide IntersectionObserver — out of the streaming path. The - // per-entry comparison is O(1) per turn because an unaffected turn keeps its - // object identity, so its text is the same string reference. + // One rail tick per turn that carries a user prompt. The rail's entries + // change only when a turn's persisted prompt/answer text does, but `turns` + // gets a new array on every delta. Handing the previous array back when + // nothing it reads moved keeps the memoized rail out of the streaming path. + // The per-entry comparison is O(1) per turn because an unaffected turn keeps + // its object identity, so its text is the same string reference. const promptRailTurnsRef = useRef>([]); const loadedPromptRailTurns = useMemo(() => { const next = turns @@ -450,14 +428,20 @@ export function ChatView(props: { return next; }, [turns]); const promptRailTurns = useMemo( - () => { - const merged = mergePromptAnchorRailTurns(loadedPromptRailTurns, props.transcriptTurnIndex); - return props.promptRailDecorations - ? merged.map((turn) => ({ ...turn, ...props.promptRailDecorations?.get(turn.turnId) })) - : merged; - }, - [loadedPromptRailTurns, props.transcriptTurnIndex, props.promptRailDecorations], + () => props.promptRailDecorations + ? loadedPromptRailTurns.map((turn) => ({ ...turn, ...props.promptRailDecorations?.get(turn.turnId) })) + : loadedPromptRailTurns, + [loadedPromptRailTurns, props.promptRailDecorations], ); + // Turn identity and order only, so a streaming delta keeps the same array. + const orderedTurnIdsRef = useRef([]); + if ( + orderedTurnIdsRef.current.length !== turns.length + || turns.some((turn, index) => orderedTurnIdsRef.current[index] !== turn.turnId) + ) { + orderedTurnIdsRef.current = turns.map((turn) => turn.turnId); + } + const orderedTurnIds = orderedTurnIdsRef.current; // Stable event wrappers (advanced-use-latest): parent handlers are // recreated per render upstream; routing through refs keeps the // memoized TurnView's function props identity-stable without @@ -501,28 +485,12 @@ export function ChatView(props: { })), turnIds, ), [props.conversationItems, turnIds]); - const turnIdsRef = useRef(turnIds); - turnIdsRef.current = turnIds; - const loadTranscriptTurnRef = useRef(props.onLoadTranscriptTurn); - loadTranscriptTurnRef.current = props.onLoadTranscriptTurn; const chatLayout = useChatLayoutContext(); if (!chatLayout) { throw new Error('ChatView must be rendered inside ChatSurfaceLayout'); } const scrollRef = chatLayout.scrollContainerRef; - const scrollAuthority = useTranscriptScrollAuthority(); - // The rail uses the same semantic navigation as search, but asks the shared - // reveal to place the prompt at the top without animation. - const railClaimRef = useRef(undefined); - const navigatePromptRail = useCallback((turn: PromptAnchorRailTurn) => { - if (turn.sequence !== undefined) { - railClaimRef.current = { turnId: turn.turnId }; - loadTranscriptTurnRef.current?.({ turnId: turn.turnId, sequence: turn.sequence }); - } - }, []); - const railAlignment = resolveRailAlignedTarget(railClaimRef.current, props.scrollTargetTurn); - railClaimRef.current = railAlignment.claim; - const scrollTargetTurn = railAlignment.target; + const virtualizerRef = useRef(null); // Ownership also groups retained prompts after their Turn stops running or // a successor starts. Execution recency must not move a prompt below its reply. const turnsById = new Map(turns.map((turn) => [turn.turnId, turn])); @@ -548,20 +516,49 @@ export function ChatView(props: { inlineTransientMessageIds, turns, ); - const { highlightedTurnId } = useChatScroll({ + const { startMargin, listRef, measureStartMargin } = useTranscriptStartMargin(scrollRef); + const { highlightedTurnId, commandTurnId, revealTurnAtStart, holdReader } = useChatScroll({ scrollRef, + measureStartMargin, + virtualizerRef, sessionId: props.activeSession?.id, - messages: props.messages, - target: scrollTargetTurn, + turnIds: orderedTurnIds, + target: props.scrollTargetTurn, restoreTarget: props.restoreTargetTurn, viewportNavigation: props.viewportNavigation, onReadingAnchorChange: props.onReadingAnchorChange, behavior: props.scrollBehavior, - hasOlderHistory: props.hasOlderHistory, - hasNewerHistory: props.hasNewerHistory, - onPrefetchHistory: props.onPrefetchHistory, - onRetainWindow: props.onRetainWindow, }); + const navigatePromptRail = useCallback( + (turn: PromptAnchorRailTurn) => revealTurnAtStart(turn.turnId), + [revealTurnAtStart], + ); + const interaction = useTurnsHoldingInteraction(scrollRef); + const keepMountedIndexes = new Set(); + for (const turnId of [tailTurnId, commandTurnId, highlightedTurnId, interaction.focusTurnId]) { + const index = turnId ? orderedTurnIds.indexOf(turnId) : -1; + if (index !== -1) keepMountedIndexes.add(index); + } + // Unmounting a Turn inside a selection would drop that part of it. + const selectionIndexes = interaction.selectionTurnIds + .map((turnId) => orderedTurnIds.indexOf(turnId)) + .filter((index) => index !== -1); + if (selectionIndexes.length > 0) { + for (let index = Math.min(...selectionIndexes); index <= Math.max(...selectionIndexes); index += 1) { + keepMountedIndexes.add(index); + } + } + const [loadingEarlierHistory, setLoadingEarlierHistory] = useState(false); + const loadEarlierHistory = (): void => { + holdReader(); + const pending = props.onLoadEarlierHistory?.(); + if (!pending) return; + setLoadingEarlierHistory(true); + void pending.then( + () => setLoadingEarlierHistory(false), + () => setLoadingEarlierHistory(false), + ); + }; const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( scrollRef, Boolean(props.onQuoteSelection || props.onAskAboutSelection), @@ -734,7 +731,6 @@ export function ChatView(props: { onHighlightTurn={props.onPromptRailHighlight ? (turn) => props.onPromptRailHighlight?.(turn?.turnId) : undefined} scrollRef={scrollRef} onNavigateTurn={navigatePromptRail} - onNavigateStart={scrollAuthority.releasePin} /> { - const decoration = props.turnDecorations?.get(turn.turnId); - return ( -
- - {conversationItemPlacement.byTurn.get(turn.turnId)?.map((item) => ( - {item.content} - ))} -
- ); - })} + {props.hasEarlierHistory && props.onLoadEarlierHistory ? ( + +