From 5859542cfbfe944074adf77a1f283f119d040220 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Mon, 7 Sep 2026 18:49:37 +0800 Subject: [PATCH 1/5] fix(desktop): preserve transcript navigation ownership Complete bookmark restoration once per Session activation and separate history reading from following the latest Turn. Admit navigation before asynchronous work so stale pages and recovery responses cannot replace the range selected by a newer action. Protect the current and newly loaded neighboring Turns while paging in either direction. Preserve the identity of a live Turn until RuntimeEvent persistence supplies its sequence, and settle completed overlays without allowing a newer oversized Turn to evict the reader's selected history. Keep local Message admission independent of history loading and move reading-position coordination into the conversation feature. Cover oversized history, navigation races, recovery, and live-to-durable Session switching with regression tests. Generated-by: OpenAI Codex --- apps/desktop/e2e-budget.json | 4 + .../transcript-navigation-ownership.spec.ts | 168 ++++++ apps/desktop/renderer-architecture.json | 7 +- .../app-shell-chat-actions-fixture.ts | 1 + .../app-shell-first-send-cleanup.test.ts | 48 +- .../app-shell-session-ui-state.test.ts | 14 +- .../desktop-transcript-range-store.test.ts | 26 +- ...me-host-session-execution-ipc-main.test.ts | 2 + .../runtime-host-session-observer.test.ts | 9 +- .../transcript-navigation-pager.test.ts | 216 +++++++ .../transcript-navigation-race.test.ts | 393 ++++++++++++ .../transcript-navigation-regression.test.ts | 474 +++++++++++++++ .../transcript-navigation-test-fixture.ts | 566 ++++++++++++++++++ .../transcript-overlay-settlement.test.ts | 439 ++++++++++++++ ...script-reading-position-controller.test.ts | 332 ++++++++++ .../transcript-send-viewport.test.ts | 347 +++++++++++ .../__tests__/workhub-session-port.test.ts | 2 +- .../src/main/desktop-transcript-ipc.ts | 1 + .../src/main/desktop-transcript-replica.ts | 295 ++++++++- ...runtime-host-session-execution-ipc-main.ts | 14 + ...ntime-host-session-observation-registry.ts | 85 ++- .../src/main/runtime-host-session-observer.ts | 68 ++- apps/desktop/src/preload/preload.ts | 39 +- .../src/preload/transcript-contract.ts | 20 +- .../src/renderer/app-shell-chat-actions.ts | 9 +- apps/desktop/src/renderer/app-shell.tsx | 108 +--- .../src/renderer/chat-message-surface.tsx | 1 + .../desktop-transcript-range-store.ts | 115 +++- .../renderer/features/conversation/README.md | 20 +- ...transcript-reading-position-controller.tsx | 205 +++++++ .../controller/transcript-reading-position.ts | 232 +++++-- .../renderer/features/conversation/index.ts | 10 +- .../conversation/model/session-ui-state.ts | 4 +- docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + .../src/__tests__/fake-backend.test.ts | 53 +- .../runtime/src/test-only/fake-backend.ts | 36 +- packages/ui/package.json | 1 + .../ui/src/__tests__/use-chat-scroll.test.tsx | 70 +++ packages/ui/src/chat-view.tsx | 3 + packages/ui/src/index.ts | 1 + packages/ui/src/testing.ts | 27 + .../ui/src/transcript-viewport-navigation.ts | 34 ++ packages/ui/src/use-chat-scroll.ts | 32 +- scripts/check-app-shell-hooks.mjs | 2 +- 45 files changed, 4269 insertions(+), 268 deletions(-) create mode 100644 apps/desktop/e2e/transcript-navigation-ownership.spec.ts create mode 100644 apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts create mode 100644 apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts create mode 100644 apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts create mode 100644 apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts create mode 100644 apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts create mode 100644 apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts create mode 100644 apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts create mode 100644 apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx create mode 100644 packages/ui/src/testing.ts create mode 100644 packages/ui/src/transcript-viewport-navigation.ts diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index 930837c812..b514b45fb1 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -65,6 +65,10 @@ "tests": 4, "electron": "observation seeding, reconnect and settle are Host subscriptions surviving a renderer remount" }, + "transcript-navigation-ownership.spec.ts": { + "tests": 1, + "electron": "reading ownership must survive Host streaming settlement and Session switches through main, preload and renderer" + }, "transcript-scroll-cost.spec.ts": { "tests": 3, "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping" diff --git a/apps/desktop/e2e/transcript-navigation-ownership.spec.ts b/apps/desktop/e2e/transcript-navigation-ownership.spec.ts new file mode 100644 index 0000000000..5b71ecb506 --- /dev/null +++ b/apps/desktop/e2e/transcript-navigation-ownership.spec.ts @@ -0,0 +1,168 @@ +/* + * 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 { + FAKE_COMPLETE_HELD_TURN_STEERING, + FAKE_HOLD_OPEN_COMPLETE_PROMPT, +} from '@maka/runtime/test-only/fake-backend'; +import { + awaitSendReady, + COMPOSER_INPUT, + ensureSidebarExpanded, + expect, + test, +} from './fixtures'; + +test('sending from history follows the second Turn through Session switches and durable settlement', async ({ + window: page, +}) => { + test.setTimeout(90_000); + const composer = page.locator(COMPOSER_INPUT); + const log = page.getByRole('log'); + const scroller = page.locator('[data-chat-scroll-container="true"]'); + const distanceToTail = () => scroller.evaluate((element) => + element.scrollHeight - element.clientHeight - element.scrollTop); + // Keep both Turns well below the range budget while making the transcript + // tall enough that loading the same latest range cannot reveal B by clamping. + const firstQuestion = 'First question before the live navigation regression.'; + const firstPrompt = [ + firstQuestion, + ...Array.from({ length: 60 }, (_, index) => `History line ${index + 1}.`), + ].join('\n\n'); + await composer.fill(firstPrompt); + await awaitSendReady(page); + await composer.press('Enter'); + await expect(log).toContainText(`Fake backend received: ${firstQuestion}`, { timeout: 20_000 }); + await expect(page.getByRole('button', { name: '停止', exact: true })).toHaveCount(0); + + await composer.fill(FAKE_HOLD_OPEN_COMPLETE_PROMPT); + await awaitSendReady(page); + const scrollBox = await scroller.boundingBox(); + expect(scrollBox).not.toBeNull(); + await page.mouse.move(scrollBox!.x + scrollBox!.width / 2, scrollBox!.y + scrollBox!.height / 2); + await page.mouse.wheel(0, -800); + await expect.poll(distanceToTail).toBeGreaterThan(500); + await composer.press('Enter'); + const liveText = 'Fake backend waiting for the test to stop the Turn.'; + await expect(page.locator('.maka-bubble-streaming')).toContainText(liveText, { timeout: 20_000 }); + await expect(log).toContainText(firstPrompt); + await expect.poll(distanceToTail).toBeLessThanOrEqual(3); + + await ensureSidebarExpanded(page); + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + const activeRow = sidebar.locator('[data-session-id]:has([aria-current="page"])'); + const originalSessionId = await activeRow.getAttribute('data-session-id'); + expect(originalSessionId).toBeTruthy(); + await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); + await composer.fill('Temporary conversation while the original Turn streams.'); + await awaitSendReady(page); + await composer.press('Enter'); + await expect(log).toContainText('Fake backend received: Temporary conversation', { timeout: 20_000 }); + await expect(page.getByRole('button', { name: '停止', exact: true })).toHaveCount(0); + const temporarySessionId = await activeRow.getAttribute('data-session-id'); + expect(temporarySessionId).toBeTruthy(); + expect(temporarySessionId).not.toBe(originalSessionId); + + const backgroundText = 'Durable second-turn navigation marker.'; + await page.evaluate(({ sessionId, text }) => new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + unsubscribe(); + reject(new Error('The isolated fake backend did not publish the background text.')); + }, 10_000); + const unsubscribe = window.maka.sessions.subscribeEvents(sessionId, (event) => { + if (event.type !== 'text_delta' || !event.text.includes(text)) return; + window.clearTimeout(timeout); + unsubscribe(); + resolve(); + }); + void window.maka.sessions.submitMessage(sessionId, 'current_turn', { + messageId: crypto.randomUUID(), text, + }).then((result) => { + if (result.ok) return; + window.clearTimeout(timeout); + unsubscribe(); + reject(new Error(`Background fixture steering was refused: ${result.reason}`)); + }, (error) => { + window.clearTimeout(timeout); + unsubscribe(); + reject(error); + }); + }), { sessionId: originalSessionId!, text: backgroundText }); + + const originalRow = sidebar.locator(`[data-session-id=${JSON.stringify(originalSessionId)}]`); + const temporaryRow = sidebar.locator(`[data-session-id=${JSON.stringify(temporarySessionId)}]`); + await originalRow.click(); + await expect(page.locator('.maka-bubble-streaming')).toContainText(backgroundText); + await expect(log).toContainText(firstPrompt); + const completed = await page.evaluate(({ sessionId, text }) => window.maka.sessions.submitMessage( + sessionId, 'current_turn', { messageId: crypto.randomUUID(), text }, + ), { sessionId: originalSessionId!, text: FAKE_COMPLETE_HELD_TURN_STEERING }); + expect(completed.ok).toBe(true); + await expect(page.getByRole('button', { name: '停止', exact: true })).toHaveCount(0, { timeout: 20_000 }); + await expect(log).toContainText(backgroundText); + + // Inspect the actual Desktop transcript transport. A rendered settled bubble + // alone cannot prove that the Host persisted its accumulated overlay. + const readDurableTranscript = () => page.evaluate(async (sessionId) => { + const records = new Map(); + const navigationVersions = new Set(); + let markReady!: () => void; + const ready = new Promise((resolve) => { markReady = resolve; }); + const handle = await window.maka.transcripts.open(sessionId, (batch) => { + // The cached bootstrap uses the contract's implicit version zero. + navigationVersions.add(batch.navigationVersion ?? 0); + for (const fragment of batch.fragments) { + if (fragment.source !== 'durable' || typeof fragment.identity !== 'number') continue; + const bytes = records.get(fragment.identity) ?? new Uint8Array(fragment.totalBytes); + bytes.set(fragment.data, fragment.byteOffset); + records.set(fragment.identity, bytes); + } + if (batch.ready) markReady(); + }); + try { + await ready; + return { + navigationVersions: [...navigationVersions], + messages: [...records.values()].map((bytes) => JSON.parse(new TextDecoder().decode(bytes)) as { + id: string; type: string; text?: string; + }), + }; + } finally { + await handle.close(); + } + }, originalSessionId!); + await expect.poll(async () => (await readDurableTranscript()).messages.filter(({ type, text }) => + type === 'assistant' && text?.includes(backgroundText)).length, { timeout: 20_000 }).toBe(1); + const durableTranscript = await readDurableTranscript(); + expect(durableTranscript.navigationVersions).toEqual([0]); + + await temporaryRow.click(); + await expect(log).toContainText('Temporary conversation'); + await originalRow.click(); + await expect(log).toContainText(firstPrompt); + await expect(log).toContainText(backgroundText); + await expect(page.locator('.maka-bubble-streaming')).toHaveCount(0); + const settledAnswer = log.getByRole('article', { name: 'Maka 的回答', exact: true }) + .filter({ hasText: backgroundText }); + await expect(settledAnswer).toHaveCount(1); + expect((await settledAnswer.textContent())?.split(backgroundText)).toHaveLength(2); + expect((await settledAnswer.textContent())?.split(liveText)).toHaveLength(2); + expect(durableTranscript.messages.filter(({ type, text }) => + type === 'assistant' && text?.includes(backgroundText))).toHaveLength(1); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 51b23b7387..5cd57c8da1 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -334,7 +334,6 @@ "./app-shell-copy.js": 1, "./attachment-preflight.js": 1, "./composer-attachments.js": 1, - "./features/conversation/index.js": 1, "./locales/shell-copy.js": 1, "./model-connection-errors.js": 1, "./session-workspace-errors.js": 1, @@ -343,7 +342,7 @@ "@maka/ui": 1 }, "importSpecifiers": 13, - "nonTriviaTokens": 4076 + "nonTriviaTokens": 4057 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 4, @@ -770,7 +769,7 @@ "useAppShellTurnPresentation": 1, "useCommandPalette": 1, "useComposerAttachments": 1, - "useEffect": 10, + "useEffect": 8, "useKeyboardHelp": 1, "useLayoutEffect": 2, "useNewTaskChoice": 1, @@ -895,7 +894,7 @@ "react": 1 }, "importSpecifiers": 121, - "nonTriviaTokens": 14630 + "nonTriviaTokens": 14433 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, 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 3f40151bf2..1ac89f00be 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 @@ -94,6 +94,7 @@ export function createTransientState() { export function createActionsDeps() { const activeIdRef = { current: undefined as string | undefined }; return { + onFollowLatest: async (_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 025aa25044..aed5f421fa 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 @@ -39,6 +39,7 @@ import { describe, it } from 'node:test'; import type { LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; +import { transcriptReadingPosition } from '../../renderer/features/conversation/index.js'; import { createActionsDeps, @@ -498,13 +499,13 @@ describe('composer first-send cleanup', () => { assert.equal(resolved, 0); }); - it('accepts a message while a sparse existing Session catches up in the background', async () => { + it('cancels restoration and accepts a message while latest history catches up in the background', async () => { const latest = deferred(); const order: string[] = []; const activeIdRef = { current: 'existing-session' as string | undefined }; const transcript = { store: { - range: () => ({ sessionId: 'existing-session', hasNewer: true }), + range: () => ({ sessionId: 'existing-session', hasNewer: false }), snapshot: () => ({ messages: [] }), }, async loadLatest() { @@ -527,12 +528,51 @@ describe('composer first-send cleanup', () => { ...createActionsDeps(), activeIdRef, transcriptRangeRef, + onFollowLatest: (sessionId) => transcriptReadingPosition.prepareForSend({ + sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef, + cancel: () => { order.push('cancel-restore'); }, setMessages: () => {}, followLatest: () => {}, + }), }).send('hello'); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(order, ['latest', 'send']); + assert.deepEqual(order, ['cancel-restore', 'latest', 'send']); assert.equal(await sending, true); latest.resolve(); - assert.deepEqual(order, ['latest', 'send']); + assert.deepEqual(order, ['cancel-restore', 'latest', 'send']); + } finally { + restoreWindow(); + } + }); + + it('does not load or project the previous Session controller while sending into the selected Session', async () => { + const submissions: string[] = []; + const transcript = { + store: { range: () => ({ sessionId: 'previous-session' }) }, + loadLatest: async () => { assert.fail('the previous Session must not be navigated'); }, + } 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) => transcriptReadingPosition.prepareForSend({ + sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef, + cancel: () => {}, setMessages: () => { assert.fail('the previous range must not project'); }, + followLatest: (sessionId) => { assert.equal(sessionId, 'selected-session'); }, + }), + setMessages: () => { assert.fail('the previous range must not replace selected messages'); }, + }).send('hello'); + assert.equal(result, true); + assert.deepEqual(submissions, ['selected-session']); } finally { restoreWindow(); } 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 3a2e76ebd8..8420e98385 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 @@ -400,9 +400,11 @@ describe('app shell session UI state controller', () => { assert.equal(index, undefined); }); - it('enriches a Turn-only reading anchor when its range sequence arrives later', () => { + it('enriches a Turn-only reading anchor when its range sequence arrives later', async () => { let anchor: { turnId: string; sequence?: number } | undefined; + const admitted: Array = []; transcriptReadingPosition.restoreRange({ + lifecycle: transcriptReadingPosition.createRestoreLifecycle(), sessionId: 'session', readingAnchor: { turnId: 'turn' }, controller: { @@ -413,6 +415,7 @@ describe('app shell session UI state controller', () => { snapshot: () => ({ messages: [] }), }, ready: async () => undefined, + setReadingAnchor: async (sequence) => { admitted.push(sequence); }, loadAround: async () => assert.fail('the resident Turn must not load another range'), }, isCurrent: () => true, @@ -424,12 +427,15 @@ describe('app shell session UI state controller', () => { }); assert.deepEqual(anchor, { turnId: 'turn', sequence: 17 }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(admitted, [17]); }); it('does not enrich a reading anchor from another Session range', () => { let sequenceReads = 0; let anchor: { turnId: string; sequence?: number } | undefined; transcriptReadingPosition.restoreRange({ + lifecycle: transcriptReadingPosition.createRestoreLifecycle(), sessionId: 'active', readingAnchor: { turnId: 'turn' }, controller: { @@ -461,6 +467,7 @@ describe('app shell session UI state controller', () => { const anchorWrites: Array<{ turnId: string; sequence?: number } | undefined> = []; let unavailable: { sessionId: string; turnId: string } | undefined; const options = { + lifecycle: transcriptReadingPosition.createRestoreLifecycle(), sessionId: 'session', readingAnchor: { turnId: 'missing' }, controller: { @@ -497,6 +504,7 @@ describe('app shell session UI state controller', () => { let messages: Array<{ id: string }> | undefined; const anchorWrites: Array<{ turnId: string; sequence?: number } | undefined> = []; const options = { + lifecycle: transcriptReadingPosition.createRestoreLifecycle(), sessionId: 'session', readingAnchor: { turnId: 'removed', sequence: 23 }, controller: { @@ -580,7 +588,7 @@ describe('app shell session UI state controller', () => { scenario.sides.a.failBefore(new Error('earlier read failed')); await stale; assert.deepEqual(scenario.errors.a, []); - assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }, undefined]); + assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }]); assert.deepEqual(scenario.pending.b, []); }); @@ -657,7 +665,7 @@ describe('app shell session UI state controller', () => { await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(scenario.sides.a.calls, ['before']); assert.deepEqual(scenario.sides.b.calls, []); - assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }, undefined]); + assert.deepEqual(scenario.pending.a, [{ target: 'earlier' }]); scenario.sides.a.settleLatest(); await queued; }); 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 5cd33479e2..3aaf0eb6c8 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 @@ -611,18 +611,25 @@ for (const { stride, textBytes } of [1, 3].flatMap((stride) => loadTranscriptPage: async (input) => makePage(input.direction, input.anchorSequence), }); const store = transcriptStore(); + let navigationVersion = 0; const replica = await DesktopTranscriptReplica.prepare(handle, { generation: 'generation-1', onChange: (current, change) => { - for (const batch of encodeDesktopTranscriptChange(current.snapshot(), change)) store.accept(batch); + for (const batch of encodeDesktopTranscriptChange({ ...current.snapshot(), navigationVersion }, change)) store.accept(batch); }, }); for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) store.accept(batch); const controller = createDesktopTranscriptRangeController(store, async () => ({ sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch, readThroughMessageId: null, - loadBefore: (anchor, maxBytes) => replica.loadBefore(anchor, maxBytes!), - loadAfter: (anchor, maxBytes) => replica.loadAfter(anchor, maxBytes!), + loadBefore: (anchor, maxBytes, navigation) => { + navigationVersion = navigation?.navigationVersion ?? navigationVersion; + return replica.loadBefore(anchor, maxBytes!); + }, + loadAfter: (anchor, maxBytes, navigation) => { + navigationVersion = navigation?.navigationVersion ?? navigationVersion; + return replica.loadAfter(anchor, maxBytes!); + }, loadAround: async () => { throw new Error('ordinary scrolling must not replace the range'); }, close: async () => replica.close(), })); @@ -659,14 +666,9 @@ for (const { stride, textBytes } of [1, 3].flatMap((stride) => }); } -test('delivers a mid-session tail append even while a history window is resident', async () => { - // Reproduces the "active session does not show the newest message until you - // switch away and back" bug. Once the resident window has been trimmed off - // the tail (hasNewer === true, e.g. after loading older history), a Host - // `transcript_advanced` for a freshly persisted message must still reach an - // already-open consumer. Before the fix, `advance()` short-circuited on - // hasNewer and published an empty change, so the append was silently dropped - // and only a fresh subscription (session switch) re-read it. +test('delivers a mid-session tail append after following the tail from a history window', async () => { + // A follow-tail intent must recover the tail even if the resident cache + // still contains history when the Host advances. const messages = [0, 1, 2, 3, 4].map((sequence) => ({ identity: sequence, message: { @@ -724,6 +726,7 @@ test('delivers a mid-session tail append even while a history window is resident await replica.loadBefore(3, 128 * 1024); assert.equal(replica.snapshot().hasNewer, true); + replica.setNavigation('followTail'); changes.splice(0); // The Host persists a new assistant message (sequence 5) and advances. @@ -954,6 +957,7 @@ test('does not resurrect a discarded replica when a tail re-anchor is in flight' await replica.loadBefore(3, 128 * 1024); assert.equal(replica.snapshot().hasNewer, true); + replica.setNavigation('followTail'); changes.splice(0); // Start the tail re-anchor; wait until catch-up is parked inside its page 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 c8a73a0652..0f08bffd35 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 @@ -67,6 +67,8 @@ test('forward transcript paging is an observation operation scoped to the render const request = { consumerId: 'guest-consumer', sessionId: 'shared-session', hostEpoch: 'host-1', anchorSequence: 42, maxBytes: 512 * 1024, + navigationVersion: 7, intent: 'history' as const, preserveRange: false, + readingTurnId: 'reading-turn', }; await ipc.invoke('sessions:transcript:load-after', request); assert.deepEqual(calls, [{ request, targetId: 9 }]); 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 549ef02d70..a2efb98c7f 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 @@ -670,6 +670,7 @@ test('fences transcript range failures across same-source replica recovery', asy }; let opens = 0; let staleRangeStarted = false; + let currentRangeStarted = false; const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => { @@ -708,6 +709,7 @@ test('fences transcript range failures across same-source replica recovery', asy return staleRange.promise; } : async () => { + currentRangeStarted = true; throw currentFailure; }, async close() { @@ -756,7 +758,12 @@ test('fences transcript range failures across same-source replica recovery', asy sequence: 1, reason: 'slow_consumer', }); - await waitFor(() => batches.at(-1)?.generation !== opened.generation); + await waitFor(() => currentRangeStarted); + assert.equal( + batches.at(-1)?.generation, + opened.generation, + 'a failed recovery range does not replace the visible snapshot with an unrelated bootstrap', + ); staleRange.reject(new Error('stale replica rejected its range')); await assert.doesNotReject(staleLoad); diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts new file mode 100644 index 0000000000..f5bc33aec1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts @@ -0,0 +1,216 @@ +/* + * 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 { markPersisted } from '@maka/core/persisted-value'; +import { decodeStoredMessage, isRuntimeSystemNoteKind, type StoredMessage } from '@maka/core/session'; +import { SESSION_CONTINUITY_SCHEMA_VERSION } from '@maka/runtime-host/protocol'; +import { ClientSessionSubscription } from '../../../../../packages/runtime-host/dist/client/session-subscription.js'; +import { + createSessionTranscriptBootstrap, + readSessionTranscriptPage, + updateSubscriberTranscriptHighWater, +} from '../../../../../packages/runtime-host/dist/server/session-transcript-pager.js'; +import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../../preload/transcript-contract.js'; +import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; +import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; +import { + createTranscriptNavigationTestFixture, + openTranscriptNavigationLedger, +} from './transcript-navigation-test-fixture.js'; + +const PAGE_BYTES = 128 * 1024; +const HOST_EPOCH = 'transcript-navigation-host'; +const SUBSCRIPTION_ID = 'transcript-navigation-subscription'; + +test('keeps both Turns reachable when an oversized ledger Turn is followed by a new durable tail', async () => { + const source = createTranscriptNavigationTestFixture(); + assert.ok(source.turns.first.encodedBytes > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + assert.equal(source.records.length, source.checkpoints.completeThrough + 1); + const ledger = await openTranscriptNavigationLedger(source.records.map(({ message }) => message)); + let opened: Awaited> | undefined; + try { + const firstThrough = await ledger.appendThrough(source.checkpoints.firstTurnThrough); + const first = await ledger.durableRecords(); + assertSourcePayloads(first, source.records.slice(0, source.checkpoints.firstTurnThrough + 1)); + 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'); + + // RuntimeEvent transcripts publish a Turn durably only after it ends. The + // running checkpoints below stay in the overlay, then this terminal event + // advances the actual Host watermark and exercises live-to-durable eviction. + const completeThrough = await ledger.appendThrough(source.checkpoints.completeThrough); + assert.ok(completeThrough !== null); + const complete = await ledger.durableRecords(); + const second = complete.filter(({ message }) => message.turnId === source.turns.second.turnId); + assertSourcePayloads(second, source.records.slice(source.turns.second.firstSequence)); + assert.ok(first.some((record, index) => index > 0 && record.sequence > first[index - 1]!.sequence + 1)); + assert.ok(completeThrough > complete.at(-1)!.sequence, 'the real watermark includes unused event sequence slots'); + assert.equal(updateSubscriberTranscriptHighWater(state, completeThrough), true); + subscription.accept({ + kind: 'subscription.transcript_advanced', hostEpoch: HOST_EPOCH, + subscriptionId: SUBSCRIPTION_ID, sequence: 1, sessionId: ledger.sessionId, + throughSequence: completeThrough, + }); + assert.equal((await subscription.next()).value?.kind, 'subscription.transcript_advanced'); + await replica.advance(completeThrough); + assertRecords(replica, second); + + await replica.loadBefore(second[0]!.sequence, PAGE_BYTES); + assertRecords(replica, complete); + assert.equal(replica.snapshot().hasOlder, false, + 'older paging keeps the complete oversized Turn and its adjacent anchor'); + await replica.readAt(first[0]!.sequence); + assertRecords(replica, first); + assert.equal(replica.snapshot().hasNewer, true); + + for (let attempt = 0; attempt < 2; attempt += 1) { + await replica.followLatest(PAGE_BYTES); + assertRecords(replica, second); + assert.equal(replica.snapshot().hasOlder, true); + assert.equal(replica.snapshot().hasNewer, false); + await replica.loadAround(first[0]!.sequence, PAGE_BYTES); + assertRecords(replica, first); + assert.equal(replica.snapshot().hasOlder, false); + assert.equal(replica.snapshot().hasNewer, true); + } + await replica.followLatest(PAGE_BYTES); + assertRecords(replica, second); + } finally { + opened?.replica.close(); + await opened?.subscription.close(); + await ledger.close(); + } +}); + +for (const checkpoint of ['secondTurnStartedThrough', 'secondTurnFirstToolsThrough'] as const) { + test(`retains the full oversized durable Turn while the running second Turn reaches ${checkpoint}`, async () => { + const source = createTranscriptNavigationTestFixture(); + const ledger = await openTranscriptNavigationLedger(source.records.map(({ message }) => message)); + let opened: Awaited> | undefined; + try { + const firstThrough = await ledger.appendThrough(source.checkpoints.firstTurnThrough); + const first = await ledger.durableRecords(); + assert.equal(await ledger.appendThrough(source.checkpoints[checkpoint]), firstThrough, + 'a running invocation changes its overlay, not the durable watermark'); + const rootTurn = { + sessionId: ledger.sessionId, turnId: source.turns.second.turnId, + runId: `run-${source.turns.second.turnId}`, status: 'running' as const, + }; + opened = await openReplica(ledger, firstThrough, rootTurn); + const { replica } = opened; + assertRecords(replica, first); + const expected = source.records.slice(source.turns.second.firstSequence, source.checkpoints[checkpoint] + 1) + .filter(({ message }) => message.type !== 'turn_state').map(({ message }) => message.id); + assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), expected); + await replica.readAt(first[0]!.sequence); + assertRecords(replica, first); + await replica.followLatest(PAGE_BYTES); + assertRecords(replica, first); + assert.ok(replica.messages().some(({ id }) => id === expected.at(-1)), 'the running Turn remains reachable'); + + const throughSequence = await ledger.appendThrough(source.checkpoints.completeThrough); + assert.ok(throughSequence !== null); + assert.equal(updateSubscriberTranscriptHighWater(opened.state, throughSequence), true); + opened.subscription.accept({ + kind: 'subscription.transcript_advanced', hostEpoch: HOST_EPOCH, + subscriptionId: SUBSCRIPTION_ID, sequence: 1, sessionId: ledger.sessionId, + throughSequence, + }); + assert.equal((await opened.subscription.next()).value?.kind, 'subscription.transcript_advanced'); + await replica.advance(throughSequence); + 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(); + await ledger.close(); + } + }); +} + +type Ledger = Awaited>; +async function openReplica( + ledger: Ledger, + throughSequence: number | null, + rootTurn: { sessionId: string; turnId: string; runId: string; status: 'running' } | null = null, +) { + const { reader, sessionId } = ledger; + const opened = await createSessionTranscriptBootstrap({ + reader, sessionId, subscriptionId: SUBSCRIPTION_ID, throughSequence, rootTurn, + activeAssistantStreams: [], maxBytes: 16 * 1024, projection: 'owner', + }); + const subscription = new ClientSessionSubscription({ + hostEpoch: HOST_EPOCH, subscriptionId: SUBSCRIPTION_ID, nextSequence: 1, + activeAssistantStreams: [], transcript: opened.bootstrap, + snapshot: { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { sessionId, metadataRevision: 1, status: 'active', createdAt: 1, isArchived: false }, + projectionRevision: 1, rootTurn, goal: null, + queue: { hostEpoch: HOST_EPOCH, queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + }, + }, async () => undefined, (request) => readSessionTranscriptPage({ reader, state: opened.state, request })); + const decodeMessage = (value: unknown) => decodeStoredMessage(markPersisted(value)); + const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ + snapshot: subscription.snapshot, transcript: Promise.resolve([]), events: subscription, + transcriptBootstrap: opened.bootstrap, + loadTranscriptOverlay: (maxBytes, accountBytes) => subscription.loadTranscriptOverlay(decodeMessage, maxBytes, accountBytes), + decodeTranscriptPage: (page, maxBytes, accountBytes) => subscription.decodeTranscriptPage(page, decodeMessage, maxBytes, accountBytes), + loadTranscriptPage: (request) => subscription.loadTranscriptPage(request), + close: () => subscription.close(), + })); + return { replica, subscription, state: opened.state }; +} + +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'); +} + +function assertSourcePayloads( + records: readonly { message: StoredMessage }[], + source: readonly { message: StoredMessage }[], +) { + // Running status and Session-level notices live outside the durable ledger; + // every content/tool/usage row and invocation-owned note must survive. + const expected = source.filter(({ message }) => + (message.type !== 'turn_state' || message.status !== 'running') && + (message.type !== 'system_note' || isRuntimeSystemNoteKind(message.kind))); + assert.deepEqual(new Set(records.map(({ message }) => message.id)), new Set(expected.map(({ message }) => message.id))); + for (const { message } of expected) { + const projected = records.find((record) => record.message.id === message.id)!.message; + if (message.type === 'tool_result') { + assert.equal(projected.type, 'tool_result'); + if (projected.type === 'tool_result') assert.deepEqual(projected.content, message.content); + } + if (message.type === 'assistant') { + assert.equal(projected.type, 'assistant'); + if (projected.type === 'assistant') { + assert.equal(projected.text, message.text); + assert.equal(projected.thinking?.text, message.thinking?.text); + } + } + } +} diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts new file mode 100644 index 0000000000..9fa9ea37b5 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts @@ -0,0 +1,393 @@ +/* + * 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 { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol'; +import type { DesktopTranscriptBatch, DesktopTranscriptHandle, DesktopTranscriptNavigation, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; +import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/desktop-transcript-range-store.js'; +import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; +import { RuntimeHostSessionObserver } from '../runtime-host-session-observer.js'; +import { RuntimeHostSessionObservationRegistry } from '../runtime-host-session-observation-registry.js'; +import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; + +for (const kind of ['before', 'around', 'catch-up'] as const) { + test(`a newer reading intent invalidates an in-flight ${kind} page before it mutates or publishes`, async () => { + const entered = deferred(); + const release = deferred(); + const installed: number[][] = []; + const bootstrap = page(1); + const pending = page(kind === 'catch-up' ? 2 : 1); + const older = record(0); + const latest = record(1); + const appended = record(2); + const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 1, overlayMessageCount: 0, + durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (candidate) => ({ + messages: candidate === bootstrap ? [latest] : kind === 'catch-up' ? [appended] : [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)) }); + const loading = kind === 'before' ? replica.loadBefore(1, 128 * 1024) + : kind === 'around' ? replica.loadAround(0, 128 * 1024) : replica.advance(2); + await entered.promise; + const reading = replica.readAt(1); + release.resolve(); + await Promise.all([loading, reading]); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [1]); + assert.ok(installed.every((sequences) => sequences.length === 0), 'superseded pages cannot upsert or evict the new reading range'); + replica.close(); + }); +} + +test('repeated older paging retains at most the adjacent anchor pair and releases it as the reader moves', async () => { + const records = Array.from({ length: 8 }, (_, sequence) => record(sequence)); + const bootstrap = page(7); + const pages = new Map([[bootstrap, 7]]); + const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 7, overlayMessageCount: 0, + durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (candidate) => { + const sequence = pages.get(candidate)!; + return { messages: [records[sequence]!], nextCursor: sequence > 0 ? 'older' : null }; + }, + loadTranscriptPage: async (request) => { + const candidate = page(7); + pages.set(candidate, request.anchorSequence! - 1); + return candidate; + }, + async close() {}, + }), { maxResidentBytes: 64, maxResidentTurns: 2 }); + const maxTurnBytes = Math.max(...records.map(({ message }) => Buffer.byteLength(JSON.stringify(message)))); + assert.ok(maxTurnBytes > 64, 'each complete turn exceeds the soft byte budget'); + for (let anchor = 7; anchor > 0; anchor -= 1) { + await replica.loadBefore(anchor, 128 * 1024); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [anchor - 1, anchor]); + assert.ok(replica.residentBytes <= maxTurnBytes * 2, 'successive pages cannot accumulate protected turns'); + // First prove consecutive paging itself releases the old pair, then prove + // reading-anchor movement trims each remaining pair down to one turn. + if (anchor <= 4) { + await replica.readAt(anchor - 1); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [anchor - 1]); + assert.ok(replica.residentBytes <= maxTurnBytes); + } + } + assert.equal(replica.snapshot().hasOlder, false); + assert.equal(replica.snapshot().hasNewer, true); + replica.close(); +}); + +test('memory trimming cannot turn an already durable reading anchor into an unresolved live Turn', async () => { + const bootstrap = page(1); + const decoded = new Map>([[bootstrap, record(1)]]); + const requests: 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 }); + try { + await replica.readAt(1, undefined, record(1).message.turnId); + await replica.advance(2); + assert.equal(replica.snapshot().hasNewer, true); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [1]); + replica.trimDurable(0); + assert.deepEqual(replica.snapshot().durable, []); + requests.length = 0; + await replica.advance(3); + assert.deepEqual(replica.snapshot().durable, [], 'budget reclaim must not authorize following a later Turn'); + assert.equal(replica.durableThrough, 3); + assert.deepEqual(requests, [], 'a known durable anchor remains history after reclaim'); + } finally { + replica.close(); + } +}); + +test('a superseded fragmented reset cannot clear or complete the next navigation', () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + acceptSnapshot(store, 0, 'generation-1', [record(1)]); + store.expectNavigation(1); + const stale = [...encodeDesktopTranscriptSnapshot({ + ...identity, navigationVersion: 1, durableThrough: 1, + durable: [{ sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }], + overlay: [], hasOlder: false, hasNewer: true, + })]; + assert.equal(store.accept(stale[0]!), false); + store.expectNavigation(2); + 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('follow latest invalidates before open resolves and a reload replays only that latest intent', async () => { + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + const opening = deferred(); + const requests: Array<{ generation: string; anchor: number | null; navigation?: DesktopTranscriptNavigation }> = []; + const handle = (generation: string): DesktopTranscriptHandle => ({ + ...identity, generation, readThroughMessageId: null, + async loadBefore() { assert.fail('an obsolete history request was replayed'); }, + async loadAfter() { assert.fail('an obsolete newer request was replayed'); }, + async loadAround(anchor, _bytes, navigation) { + requests.push({ generation, anchor, navigation }); + acceptSnapshot(store, navigation?.navigationVersion ?? 0, generation, [record(1)]); + }, + async close() {}, + }); + let opens = 0; + const controller = createDesktopTranscriptRangeController(store, async () => { + opens += 1; + if (opens === 1) return opening.promise; + // A new preload can initialize and ACK version-zero bootstrap while the + // range store continues showing the last committed view until replay. + acceptSnapshot(store, 0, 'generation-2', [record(0)]); + return handle('generation-2'); + }); + const history = controller.loadAround(0); + const latest = controller.loadLatest(); + opening.resolve(handle('generation-1')); + await Promise.all([history, latest]); + assert.deepEqual(requests.map(({ anchor, navigation }) => [anchor, navigation?.intent, navigation?.navigationVersion]), [[null, 'followTail', 2]]); + await controller.reload(); + assert.deepEqual(requests.map(({ generation, navigation }) => [generation, navigation?.intent, navigation?.navigationVersion]), [ + ['generation-1', 'followTail', 2], ['generation-2', 'followTail', 2], + ]); + assert.equal(store.range().generation, 'generation-2'); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); + await controller.close(); +}); + +test('a rejected older navigation cannot fail the newer follow-tail intent', 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, + async loadBefore() {}, async loadAfter() {}, + async loadAround(anchor, _bytes, navigation) { + if (anchor === 0) { + historyEntered.resolve(); + await historyResult; + } else acceptSnapshot(store, navigation!.navigationVersion, identity.generation, [record(1)]); + }, + async 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 range 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.navigationVersion === 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: 128 * 1024, navigationVersion: 1, intent: 'history', + }; + store.expectNavigation(1); + const history = observer.loadTranscriptAround(request, 1); + await firstOldBatch.promise; + store.expectNavigation(2); + const following = observer.loadTranscriptAround({ ...request, navigationVersion: 2, intent: 'followTail', anchorSequence: null }, 1); + releaseAcks = true; + for (const batch of blocked) ack(batch); + await Promise.all([history, following]); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-1']); + const snapshot = store.snapshot(); + for (const batch of blocked) assert.equal(store.accept(batch), false); + assert.strictEqual(store.snapshot(), snapshot); + await observer.close(); +}); + +for (const changedEpoch of [false, true]) { +for (const latest of [false, true]) { +test(`${changedEpoch ? 'cross-epoch' : 'same-Host'} registry recovery admits newer ${latest ? 'latest' : 'Turn reading'} while old replay is pending`, { timeout: 10_000 }, async () => { + const registry = new RuntimeHostSessionObservationRegistry(); + const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); + const replayEntered = deferred(); + const replayRelease = deferred(); + const latestEntered = deferred(); + const calls: Array<{ generation: string; request: DesktopTranscriptRangeRequest }> = []; + const replacementEpoch = changedEpoch ? 'host-2' : 'host-1'; + const makeSource = (generation: string, hostEpoch: string) => ({ + async observe() {}, async unobserve() {}, async closeTranscript() {}, + async openTranscript(sessionId: string) { + return { sessionId, generation, hostEpoch, readThroughMessageId: null }; + }, + async loadTranscriptBefore() {}, async loadTranscriptAfter() {}, + async loadTranscriptAround(request: DesktopTranscriptRangeRequest) { + assert.equal(request.hostEpoch, hostEpoch, 'only the successfully opened source epoch is accepted'); + calls.push({ generation, request }); + if (generation === 'generation-2' && request.navigationVersion === 1) { + replayEntered.resolve(); + await replayRelease.promise; + } + const row = record(request.navigationVersion === 2 ? 2 : 0); + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, generation, hostEpoch, navigationVersion: request.navigationVersion, + durableThrough: 2, durable: [{ sequence: row.identity, message: row.message }], + overlay: [], hasOlder: false, hasNewer: false, + })) store.accept(batch); + if (generation === 'generation-2' && request.navigationVersion === 2) latestEntered.resolve(); + }, + }); + const target = { id: 1, send() {}, once() {}, off() {} }; + const first = makeSource('generation-1', 'host-1'); + await registry.attach(first); + await registry.openTranscript('session-1', 'consumer-1', target); + const request: DesktopTranscriptRangeRequest = { + consumerId: 'consumer-1', sessionId: 'session-1', hostEpoch: 'host-1', + anchorSequence: 0, maxBytes: 128 * 1024, navigationVersion: 1, intent: 'history', + }; + store.expectNavigation(1); + await registry.loadTranscriptAround(request, target.id); + registry.detach(first); + await registry.attach(makeSource('generation-2', replacementEpoch)); + await replayEntered.promise; + store.expectNavigation(2); + const next = registry.loadTranscriptAround({ ...request, navigationVersion: 2, + intent: latest ? 'followTail' : 'history', anchorSequence: latest ? null : 2, + readingTurnId: latest ? undefined : 'turn-2', preserveRange: true, + }, target.id); + await latestEntered.promise; + replayRelease.resolve(); + await next; + await flush(); + await registry.loadTranscriptAround(request, target.id); + assert.deepEqual(calls.map(({ generation, request: entry }) => [generation, entry.navigationVersion, entry.intent]), [ + ['generation-1', 1, 'history'], ['generation-2', 1, changedEpoch ? 'followTail' : 'history'], + ['generation-2', 2, latest ? 'followTail' : 'history'], + ]); + assert.equal(calls[2]!.request.hostEpoch, replacementEpoch); + assert.equal(calls[2]!.request.anchorSequence, latest || changedEpoch ? null : 2); + assert.equal(calls[2]!.request.readingTurnId, latest ? undefined : 'turn-2'); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-2'], 'late replay cannot replace the new navigation'); + await assert.rejects(registry.loadTranscriptAround({ ...request, navigationVersion: 3, hostEpoch: 'unrelated-epoch' }, target.id)); + await registry.close(); +}); +} +} + +const identity = { sessionId: 'session-1', hostEpoch: 'host-1', generation: 'generation-1' }; +function acceptSnapshot(store: DesktopTranscriptRangeStore, navigationVersion: number, generation: string, records: Array>) { + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, navigationVersion, generation, durableThrough: 1, + durable: records.map(({ identity: sequence, message }) => ({ sequence, message })), + overlay: [], hasOlder: true, hasNewer: false, + })) 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: [] } }; +} +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((complete) => { resolve = complete; }); + return { promise, resolve }; +} +async function flush() { await new Promise((resolve) => setImmediate(resolve)); } diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts new file mode 100644 index 0000000000..612f25d436 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts @@ -0,0 +1,474 @@ +/* + * 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 { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionTranscriptPage, +} from '@maka/runtime-host/protocol'; +import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../../preload/transcript-contract.js'; +import { transcriptReadingPosition } from '../../renderer/features/conversation/index.js'; +const { createRestoreLifecycle: createTranscriptRestoreLifecycle, restoreRange: restoreSessionTranscriptRange } = transcriptReadingPosition; +import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; +import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; + +const PAGE_BYTES = 128 * 1024; + +test('loading history before a small latest Turn makes an oversized earlier Turn reachable', async () => { + const fixture = await oversizedHistoryFixture(); + try { + assert.deepEqual(sequences(fixture.replica), [2, 3]); + + await fixture.replica.loadBefore(2, PAGE_BYTES); + + assert.equal( + sequences(fixture.replica)[0], + 0, + 'the successfully fetched earlier Turn must survive eviction so history paging makes progress', + ); + assert.equal(fixture.replica.snapshot().hasOlder, false); + assert.equal(fixture.replica.messages()[1]?.id, 'assistant-a'); + + await fixture.replica.readAt(0); + + assert.deepEqual(sequences(fixture.replica), [0, 1]); + assert.equal(fixture.replica.snapshot().hasNewer, true); + } finally { + fixture.replica.close(); + } +}); + +test('durable tail advancement preserves an explicitly selected oversized history Turn', async () => { + const fixture = await oversizedHistoryFixture(); + try { + await fixture.replica.loadAround(0, PAGE_BYTES); + assert.deepEqual(sequences(fixture.replica), [0, 1]); + fixture.requests.length = 0; + + await fixture.replica.advance(4); + + assert.deepEqual( + sequences(fixture.replica), + [0, 1], + 'persisting a new answer must not replace the history range selected by the reader', + ); + assert.equal(fixture.replica.durableThrough, 4); + assert.equal(fixture.replica.snapshot().hasNewer, true); + assert.deepEqual(fixture.requests, [], 'history ownership only advances the durable watermark'); + + await fixture.replica.followLatest(PAGE_BYTES); + + assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); + assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-b-later'); + assert.equal(fixture.replica.snapshot().hasNewer, false); + } finally { + fixture.replica.close(); + } +}); + +test('a completed resident bookmark does not reload after streaming settlement evicts its Turn', async () => { + const fixture = await oversizedHistoryFixture({ live: true }); + const lifecycle = createTranscriptRestoreLifecycle(); + let loaded = 0; + const controller = { + ready: async () => {}, + // This test isolates restore command lifetime from reader navigation. + setReadingAnchor: async () => {}, + loadAround: async (sequence: number) => { + loaded += 1; + await fixture.replica.loadAround(sequence, PAGE_BYTES); + }, + store: { + range: () => ({ sessionId: 'session-1' }), + sequenceForTurn: (turnId: string) => fixture.replica.snapshot().durable + .find(({ message }) => message.turnId === turnId)?.sequence ?? null, + newestDurableUserSequence: () => 2, + snapshot: () => ({ messages: fixture.replica.messages() }), + }, + }; + const restore = () => restoreSessionTranscriptRange({ + lifecycle, + sessionId: 'session-1', + readingAnchor: { turnId: 'turn-a', sequence: 0 }, + controller, + isCurrent: () => true, + setMessages: () => {}, + setReadingAnchor: () => {}, + onError: (error) => assert.fail(String(error)), + }); + try { + restore(); + await settleRestore(); + assert.equal(loaded, 0, 'an already resident bookmark completes without a range read'); + + await fixture.replica.advance(3); + restore(); + await settleRestore(); + await fixture.replica.advance(4); + restore(); + await settleRestore(); + + assert.equal(loaded, 0, 'message notifications cannot revive the completed bookmark command'); + assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); + assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-b-later'); + } finally { + fixture.replica.close(); + } +}); + +test('reopening a bookmark at the current Turn retains content persisted later in that same Turn', async () => { + const fixture = await oversizedHistoryFixture(); + try { + await fixture.replica.readAt(2); + assert.deepEqual(sequences(fixture.replica), [2, 3]); + await fixture.replica.advance(4); + assert.equal(fixture.replica.durableThrough, 4, 'the Host has persisted the final answer segment'); + + // 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: { + ready: async () => {}, + setReadingAnchor: (sequence) => fixture.replica.readAt(sequence), + loadAround: (sequence) => fixture.replica.loadAround(sequence, PAGE_BYTES), + store: { + range: () => ({ sessionId: 'session-1' }), + sequenceForTurn: (turnId) => fixture.replica.snapshot().durable + .find(({ message }) => message.turnId === turnId)?.sequence ?? null, + newestDurableUserSequence: () => 2, + snapshot: () => ({ messages: fixture.replica.messages() }), + }, + }, + isCurrent: () => true, + setMessages: () => {}, + 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('an oversized new Turn cannot evict the current Turn being read while its own answer finishes', async () => { + const fixture = await oversizedHistoryFixture(); + try { + await fixture.replica.readAt(2); + await fixture.replica.advance(4); + await fixture.replica.advance(6); + + assert.deepEqual( + sequences(fixture.replica), + [2, 3, 4], + 'the reader keeps the complete selected Turn B when a new oversized Turn C is persisted', + ); + assert.equal(fixture.replica.durableThrough, 6); + assert.equal(fixture.replica.snapshot().hasNewer, true); + assert.equal(fixture.replica.messages().some(({ id }) => id === 'assistant-c'), false); + + await fixture.replica.followLatest(PAGE_BYTES); + + assert.deepEqual(sequences(fixture.replica), [5, 6]); + assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-c'); + assert.equal(fixture.replica.snapshot().hasNewer, false); + } finally { + fixture.replica.close(); + } +}); + +test('streaming persistence retains a newly loaded oversized neighbor until the reader chooses an anchor', async () => { + const fixture = await oversizedHistoryFixture(); + try { + await fixture.replica.loadBefore(2, PAGE_BYTES); + assert.deepEqual(sequences(fixture.replica), [0, 1, 2, 3]); + + await fixture.replica.advance(4); + + assert.deepEqual( + sequences(fixture.replica), + [0, 1, 2, 3, 4], + 'new durable text in B must not erase the older A that the reader just requested', + ); + await fixture.replica.readAt(2); + assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); + assert.equal(fixture.replica.snapshot().hasOlder, true); + } finally { + fixture.replica.close(); + } +}); + +test('repeated message notifications share one pending restore and cancellation prevents a late UI commit', async () => { + const lifecycle = createTranscriptRestoreLifecycle(); + let finishLoad!: () => void; + const loading = new Promise((resolve) => { finishLoad = resolve; }); + let reads = 0; + const committed: string[][] = []; + const options = { + lifecycle, + sessionId: 'session-1', + readingAnchor: { turnId: 'turn-a', sequence: 0 }, + controller: { + ready: async () => {}, + loadAround: async () => { reads += 1; await loading; }, + store: { + range: () => ({ sessionId: 'session-1' }), + sequenceForTurn: () => null, + newestDurableUserSequence: () => 2, + snapshot: () => ({ messages: ['old restored range'] }), + }, + }, + isCurrent: () => true, + setMessages: (messages: string[]) => { committed.push(messages); }, + setReadingAnchor: () => {}, + onError: (error: unknown) => assert.fail(String(error)), + }; + restoreSessionTranscriptRange(options); + restoreSessionTranscriptRange(options); + await settleRestore(); + assert.equal(reads, 1); + + lifecycle.cancel('session-1'); + finishLoad(); + await settleRestore(); + restoreSessionTranscriptRange(options); + await settleRestore(); + + assert.equal(reads, 1, 'cancellation must not recapture the bookmark in the same activation'); + assert.deepEqual(committed, [], 'a cancelled restore cannot replace the newer visible range'); +}); + +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 options = { + lifecycle, + sessionId: 'session-1', + profileId: 'profile-1', + readingAnchor: { turnId: 'turn-a', sequence: 0 }, + controller: { + ready: async () => {}, + loadAround: async (sequence: number) => { reads.push(sequence); }, + store: { + range: () => ({ sessionId: 'session-1' }), + sequenceForTurn: () => null, + newestDurableUserSequence: () => 2, + snapshot: () => ({ messages: [] as string[] }), + }, + }, + isCurrent: () => true, + setMessages: () => {}, + 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, + } }); + await settleRestore(); + restoreSessionTranscriptRange(options); + await settleRestore(); + assert.deepEqual(reads, [0, 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'); + + restoreSessionTranscriptRange({ ...options, profileId: 'profile-2' }); + await settleRestore(); + assert.deepEqual(reads, [0, 2, 0, 0], 'changing Hosts also creates a fresh activation'); +}); + +test('effect teardown followed by setup replaces the first Session restore without a late commit', async () => { + const lifecycle = createTranscriptRestoreLifecycle(); + const loads: Array<() => void> = []; + const committed: string[][] = []; + const options = { + lifecycle, + sessionId: 'session-1', + readingAnchor: { turnId: 'turn-a', sequence: 0 }, + controller: { + ready: async () => {}, + loadAround: () => new Promise((resolve) => { loads.push(resolve); }), + store: { + range: () => ({ sessionId: 'session-1' }), + sequenceForTurn: () => null, + newestDurableUserSequence: () => 2, + snapshot: () => ({ messages: ['replacement range'] }), + }, + }, + isCurrent: () => true, + setMessages: (messages: string[]) => { committed.push(messages); }, + setReadingAnchor: () => {}, + onError: (error: unknown) => assert.fail(String(error)), + }; + restoreSessionTranscriptRange(options); + assert.equal(loads.length, 1, 'the first command admits its navigation synchronously'); + lifecycle.deactivate(); + restoreSessionTranscriptRange(options); + assert.equal(loads.length, 2, 'StrictMode replay must admit a replacement navigation'); + + loads[0]!(); + await settleRestore(); + assert.deepEqual(committed, [], 'the deactivated command cannot commit after replacement'); + loads[1]!(); + await settleRestore(); + assert.deepEqual(committed, [['replacement range']], 'only the replacement restore commits'); +}); + +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 { + assert.deepEqual(sequences(fixture.replica), [0, 1]); + assert.deepEqual(fixture.replica.snapshot().overlay.map(({ id }) => id), ['user-b', 'assistant-b']); + + await fixture.replica.advance(3); + + assert.deepEqual(sequences(fixture.replica), [2, 3]); + assert.deepEqual(fixture.replica.snapshot().overlay, []); + assert.equal(fixture.replica.messages().filter(({ id }) => id === 'assistant-b').length, 1); + const answer = fixture.replica.messages().at(-1); + 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(); + } +}); + +function sequences(replica: DesktopTranscriptReplica): number[] { + return replica.snapshot().durable.map(({ sequence }) => sequence); +} + +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('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)), + ].map((message, identity) => ({ identity, message })); + const decodedPages = new Map(); + const page = (input: { + direction: 'older' | 'newer'; + through: number; + records: typeof records; + hasMore: boolean; + protectedSequence: number | null; + }): SessionTranscriptPage => { + const result: SessionTranscriptPage = { + kind: 'page', + sessionId: 'session-1', + source: 'durable', + direction: input.direction, + 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 }); + return result; + }; + const through = options.live ? 1 : 3; + const bootstrapPage = page({ + direction: 'older', + 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, + session: { + sessionId: 'session-1', metadataRevision: 1, status: 'running', createdAt: 1, isArchived: false, + }, + projectionRevision: 1, + rootTurn: null, + goal: null, + queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + }, + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: through, + overlayMessageCount: options.live ? 2 : 0, + durable: bootstrapPage, + overlay: { ...bootstrapPage, source: 'overlay', nextCursor: null }, + }, + loadTranscriptOverlay: async () => options.live ? records.slice(2, 4).map(({ message }) => message) : [], + decodeTranscriptPage: async (candidate) => { + const decoded = decodedPages.get(candidate); + assert.ok(decoded, 'the replica must decode the page returned by its Host request'); + return decoded; + }, + 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, + }); + }, + async close() {}, + }); + return { replica: await DesktopTranscriptReplica.prepare(handle), requests }; +} + +function message( + type: 'user' | 'assistant', + id: string, + turnId: string, + text: string, +): StoredMessage { + const common = { id, turnId, ts: 1, text }; + return type === 'user' ? { type, ...common } : { type, ...common, modelId: 'fixture-model' }; +} diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts b/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts new file mode 100644 index 0000000000..69cce28913 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts @@ -0,0 +1,566 @@ +/* + * 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 { Buffer } from 'node:buffer'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; +import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; +import { backfillRuntimeEventsFromStoredMessages } from '../../../../../packages/runtime/dist/runtime-event-backfill.js'; +import { createSessionTranscriptReader } from '../../../../../packages/runtime-host/dist/server/session-transcript-reader.js'; +import { isRuntimeSystemNoteKind, type StoredMessage } from '@maka/core/session'; + +export const TRANSCRIPT_NAVIGATION_SESSION_ID = 'transcript-navigation-fixture'; +export const TRANSCRIPT_NAVIGATION_FIRST_TURN_ID = 'transcript-navigation-turn-a'; +export const TRANSCRIPT_NAVIGATION_SECOND_TURN_ID = 'transcript-navigation-turn-b'; + +/** + * Source-row checkpoints from the production-shaped two-Turn failure. + * + * These identify the original content boundaries. The ledger fixture reads + * its sparse durable sequences and watermarks from the production reader; + * running checkpoints now belong to the active overlay. + */ +export const TRANSCRIPT_NAVIGATION_CHECKPOINTS = { + firstTurnThrough: 194, + secondTurnStartedThrough: 196, + secondTurnFirstToolsThrough: 204, + completeThrough: 232, +} as const; + +export interface TranscriptNavigationRecord { + readonly identity: number; + readonly message: StoredMessage; +} + +export interface TranscriptNavigationTurnFixture { + readonly turnId: string; + readonly firstSequence: number; + readonly lastSequence: number; + readonly encodedBytes: number; +} + +export interface TranscriptNavigationTestFixture { + readonly sessionId: string; + readonly records: readonly TranscriptNavigationRecord[]; + readonly checkpoints: typeof TRANSCRIPT_NAVIGATION_CHECKPOINTS; + readonly turns: { + readonly first: TranscriptNavigationTurnFixture; + readonly second: TranscriptNavigationTurnFixture; + }; +} + +interface ToolSeries { + ordinal: number; +} + +interface ToolBatchOptions { + readonly resultKind: 'json' | 'terminal'; + readonly paddingBytes?: (ordinal: number) => number; +} + +const FIXTURE_EPOCH = Date.UTC(2026, 0, 2, 3, 4, 5); +const FIRST_TURN_STANDARD_RESULT_PADDING_BYTES = 4 * 1024; +const FIRST_TURN_LARGE_RESULT_PADDING_BYTES = new Map([ + [12, 425 * 1024], + [17, 935 * 1024], +]); +const SECOND_TURN_ASSISTANT_PADDING_BYTES = 12 * 1024; + +/** + * A privacy-safe transcript with the same useful boundaries as the diagnosed + * failure: Turn A owns sequences 0..194 and is larger than the Desktop range; + * Turn B starts at 195, first advances at 196 and 204, then settles at 232. + * + * Every record is a real StoredMessage. Tool call/result ids are paired and + * every record in a Turn carries that Turn's stable id. Large payloads are + * deterministic filler rather than copied command output or user text. + */ +export function createTranscriptNavigationTestFixture(): TranscriptNavigationTestFixture { + const records: TranscriptNavigationRecord[] = []; + const append = (message: StoredMessage): number => { + const identity = records.length; + records.push({ identity, message }); + return identity; + }; + + append(userMessage(TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 'first')); + append(turnStateMessage(TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 'running')); + let firstStepId = appendAssistant( + append, + TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, + 'first-step-1', + ); + const firstTools: ToolSeries = { ordinal: 0 }; + + // 30 two-tool steps plus 13 one-tool steps produce the diagnosed Turn's + // 73 call/result pairs and leave its final assistant at sequence 191. + for (let step = 0; step < 43; step += 1) { + appendToolBatch( + append, + TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, + firstStepId, + step < 30 ? 2 : 1, + firstTools, + { + resultKind: 'json', + paddingBytes: (ordinal) => + FIRST_TURN_LARGE_RESULT_PADDING_BYTES.get(ordinal) + ?? FIRST_TURN_STANDARD_RESULT_PADDING_BYTES, + }, + ); + firstStepId = appendAssistant( + append, + TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, + `first-step-${step + 2}`, + ); + } + + append(tokenUsageMessage(TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 'first')); + append(turnStateMessage(TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 'completed')); + append(sessionResumeMessage(TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 'first')); + + append(userMessage(TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 'second')); + append(turnStateMessage(TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 'running')); + const secondTools: ToolSeries = { ordinal: 0 }; + const secondToolOptions: ToolBatchOptions = { + resultKind: 'terminal', + paddingBytes: () => 256, + }; + + const secondStep1 = appendAssistant( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + 'second-step-1', + SECOND_TURN_ASSISTANT_PADDING_BYTES, + ); + appendToolBatch( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + secondStep1, + 2, + secondTools, + secondToolOptions, + ); + + const secondStep2 = appendAssistant( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + 'second-step-2', + SECOND_TURN_ASSISTANT_PADDING_BYTES, + ); + appendToolBatch( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + secondStep2, + 2, + secondTools, + secondToolOptions, + ); + + const secondStep3 = appendAssistant( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + 'second-step-3', + SECOND_TURN_ASSISTANT_PADDING_BYTES, + ); + appendToolBatch( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + secondStep3, + 2, + secondTools, + secondToolOptions, + ); + appendToolBatch( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + secondStep3, + 2, + secondTools, + secondToolOptions, + ); + + const secondStep4 = appendAssistant( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + 'second-step-4', + SECOND_TURN_ASSISTANT_PADDING_BYTES, + ); + appendToolBatch( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + secondStep4, + 2, + secondTools, + secondToolOptions, + ); + + const secondStep5 = appendAssistant( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + 'second-step-5', + SECOND_TURN_ASSISTANT_PADDING_BYTES, + ); + appendToolBatch( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + secondStep5, + 2, + secondTools, + secondToolOptions, + ); + + const secondStep6 = appendAssistant( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + 'second-step-6', + SECOND_TURN_ASSISTANT_PADDING_BYTES, + ); + appendToolBatch( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + secondStep6, + 1, + secondTools, + secondToolOptions, + ); + appendAssistant( + append, + TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, + 'second-step-7', + SECOND_TURN_ASSISTANT_PADDING_BYTES, + ); + append(tokenUsageMessage(TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 'second')); + append(turnStateMessage(TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 'completed')); + append(sessionResumeMessage(TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 'second')); + + assertFixtureBoundary(records, 194, 'system_note', TRANSCRIPT_NAVIGATION_FIRST_TURN_ID); + assertFixtureBoundary(records, 195, 'user', TRANSCRIPT_NAVIGATION_SECOND_TURN_ID); + assertFixtureBoundary(records, 196, 'turn_state', TRANSCRIPT_NAVIGATION_SECOND_TURN_ID); + assertFixtureBoundary(records, 204, 'tool_call', TRANSCRIPT_NAVIGATION_SECOND_TURN_ID); + assertFixtureBoundary(records, 232, 'system_note', TRANSCRIPT_NAVIGATION_SECOND_TURN_ID); + + return { + sessionId: TRANSCRIPT_NAVIGATION_SESSION_ID, + records, + checkpoints: TRANSCRIPT_NAVIGATION_CHECKPOINTS, + turns: { + first: turnFixture(records, TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 0, 194), + second: turnFixture(records, TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 195, 232), + }, + }; +} + +function appendAssistant( + append: (message: StoredMessage) => number, + turnId: string, + label: string, + thinkingPaddingBytes = 0, +): string { + const id = `assistant-${label}`; + append({ + type: 'assistant', + id, + turnId, + ts: timestampForNextId(id), + text: `Synthetic assistant checkpoint ${label}.`, + ...(thinkingPaddingBytes > 0 + ? { thinking: { text: `Synthetic reasoning ${label}.\n${'r'.repeat(thinkingPaddingBytes)}` } } + : {}), + modelId: 'fixture-model', + }); + return id; +} + +function appendToolBatch( + append: (message: StoredMessage) => number, + turnId: string, + stepId: string, + count: number, + series: ToolSeries, + options: ToolBatchOptions, +): void { + const calls = Array.from({ length: count }, () => { + const ordinal = series.ordinal; + series.ordinal += 1; + const id = `tool-${turnId}-${ordinal}`; + append({ + type: 'tool_call', + id, + turnId, + ts: timestampForNextId(id), + toolName: 'fixture_lookup', + displayName: 'Synthetic fixture lookup', + args: { ordinal, query: `fixture-query-${ordinal}` }, + stepId, + origin: 'provider', + modelVisibility: 'visible', + }); + return { id, ordinal }; + }); + + for (const call of calls) { + const padding = 'x'.repeat(options.paddingBytes?.(call.ordinal) ?? 0); + append({ + type: 'tool_result', + id: `result-${turnId}-${call.ordinal}`, + turnId, + ts: timestampForNextId(`result-${turnId}-${call.ordinal}`), + toolUseId: call.id, + isError: false, + content: options.resultKind === 'json' + ? { + kind: 'json', + value: { + ordinal: call.ordinal, + summary: 'Deterministic synthetic fixture result.', + payload: padding, + }, + } + : { + kind: 'terminal', + cwd: '/workspace/fixture', + cmd: `fixture-command-${call.ordinal}`, + status: 'completed', + exitCode: 0, + output: { + mode: 'pipes', + stdout: `Synthetic terminal result ${call.ordinal}.\n${padding}`, + stderr: '', + stdoutTruncated: false, + stderrTruncated: false, + redacted: false, + }, + }, + durationMs: call.ordinal + 1, + origin: 'provider', + modelVisibility: 'visible', + }); + } +} + +function userMessage( + turnId: string, + label: string, +): Extract { + const id = `user-${label}`; + return { + type: 'user', + id, + turnId, + ts: timestampForNextId(id), + text: `Synthetic ${label} prompt.`, + inlineReferences: [], + }; +} + +function turnStateMessage( + turnId: string, + status: 'running' | 'completed', +): Extract { + const id = `state-${turnId}-${status}`; + return { + type: 'turn_state', + id, + turnId, + ts: timestampForNextId(id), + status, + }; +} + +function tokenUsageMessage( + turnId: string, + label: string, +): Extract { + const id = `usage-${label}`; + return { + type: 'token_usage', + id, + turnId, + ts: timestampForNextId(id), + input: 1_000, + output: 200, + cacheRead: 100, + }; +} + +function sessionResumeMessage( + turnId: string, + label: string, +): Extract { + const id = `session-resume-${label}`; + return { + type: 'system_note', + id, + turnId, + ts: timestampForNextId(id), + kind: 'session_resume', + }; +} + +function timestampForNextId(id: string): number { + let checksum = 0; + for (let index = 0; index < id.length; index += 1) checksum += id.charCodeAt(index); + return FIXTURE_EPOCH + checksum; +} + +function turnFixture( + records: readonly TranscriptNavigationRecord[], + turnId: string, + firstSequence: number, + lastSequence: number, +): TranscriptNavigationTurnFixture { + return { + turnId, + firstSequence, + lastSequence, + encodedBytes: records + .slice(firstSequence, lastSequence + 1) + .reduce( + (total, { message }) => total + Buffer.byteLength(JSON.stringify(message), 'utf8'), + 0, + ), + }; +} + +function assertFixtureBoundary( + records: readonly TranscriptNavigationRecord[], + sequence: number, + type: StoredMessage['type'], + turnId: string, +): void { + const record = records[sequence]; + if ( + record?.identity !== sequence + || record.message.type !== type + || record.message.turnId !== turnId + ) { + throw new Error(`Transcript navigation fixture boundary ${sequence} is invalid`); + } +} + +/** + * The real SQLite ledger and Host reader used by the navigation regressions. + * Legacy-shaped input keeps the payload fixture legible, but every page is + * 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[]) { + const base = await mkdtemp(join(tmpdir(), 'maka-transcript-navigation-')); + const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + let stores: Awaited> | undefined; + try { + stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const runtimeEventStore = stores.runtimeEventStore; + const session = await stores.sessionStore.create({ + cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fixture', model: 'fixture-model', permissionMode: 'ask', + }); + const sessionId = session.id; + const byTurn = new Map>(); + messages.forEach((message, index) => { + assert.ok(message.turnId); + const records = byTurn.get(message.turnId) ?? []; + records.push({ index, message: { ...message, ts: FIXTURE_EPOCH + index } }); + byTurn.set(message.turnId, records); + }); + const pending: Array<{ index: number; event: RuntimeEvent }> = []; + for (const [turnId, records] of byTurn) { + const runId = `run-${turnId}`; + let eventIndex = 0; + const converted = backfillRuntimeEventsFromStoredMessages({ + run: { sessionId, runId, invocationId: runId, turnId }, + // Session-resume notes belong to Session metadata in main; only an + // invocation-owned note can enter its RuntimeEvent transcript. + messages: records.map(({ message }) => message).filter((message) => + message.type !== 'system_note' || isRuntimeSystemNoteKind(message.kind)), + outcome: { status: 'completed', ts: FIXTURE_EPOCH + records.at(-1)!.index }, + modelHistory: 'full', now: () => FIXTURE_EPOCH, + newId: () => `${runId}-event-${eventIndex++}`, + }); + assert.deepEqual(converted.diagnostics, [], 'the fixture must retain every source payload'); + for (const event of converted.events) { + const index = event.actions?.endInvocation ? records.at(-1)!.index + : records.find(({ message }) => message.id === event.refs?.storedMessageId)?.index; + assert.notEqual(index, undefined); + pending.push({ index: index!, event }); + } + } + pending.sort((left, right) => left.index - right.index); + const opened = new Set(); + let appendedThrough = -1; + const reader = createSessionTranscriptReader({ + stores, canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, + }); + return { + sessionId, reader, + async appendThrough(index: number) { + assert.ok(index >= appendedThrough, 'fixture writes advance monotonically'); + for (const pendingEvent of pending) { + if (pendingEvent.index <= appendedThrough || pendingEvent.index > index) continue; + const { event } = pendingEvent; + if (!opened.has(event.turnId)) { + await seedInvocation(runtimeEventStore, { + sessionId, turnId: event.turnId, runId: event.runId, openedAt: event.ts - 0.5, + }); + opened.add(event.turnId); + } + await runtimeEventStore.appendRuntimeEvent(sessionId, event.runId, event); + } + appendedThrough = index; + return reader.readDurableHighWater(sessionId); + }, + async appendPartialAssistant(turnId: string, messageId: string, text: string) { + const runId = `run-${turnId}`; + assert.ok(opened.has(turnId)); + await runtimeEventStore.appendRuntimeEvent(sessionId, runId, { + id: `partial-${messageId}`, sessionId, runId, invocationId: runId, turnId, + ts: FIXTURE_EPOCH + appendedThrough + 0.5, + partial: true, role: 'model', author: 'agent', + content: { kind: 'text', text }, refs: { providerEventId: messageId }, + }); + }, + async durableRecords() { + const result = await reader.readDurableRecords(sessionId, { + direction: 'newer', maxMessages: 1_000, maxStoredBytes: 16 * 1024 * 1024, + }); + assert.equal(result.nextPosition, null, 'the assertion sweep must include every durable row'); + return result.records; + }, + async close() { + await stores!.sessionStore.close?.(); + await owner.close(); + await rm(base, { recursive: true, force: true }); + }, + }; + } catch (error) { + await stores?.sessionStore.close?.(); + await owner.close(); + await rm(base, { recursive: true, force: true }); + throw error; + } +} diff --git a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts new file mode 100644 index 0000000000..05f393fd02 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts @@ -0,0 +1,439 @@ +/* + * 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 { markPersisted } from '@maka/core/persisted-value'; +import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionTranscriptPageInput, +} from '@maka/runtime-host/protocol'; +import { ClientSessionSubscription } from '../../../../../packages/runtime-host/dist/client/session-subscription.js'; +import { + createSessionTranscriptBootstrap, + readSessionTranscriptPage, + updateSubscriberTranscriptHighWater, +} from '../../../../../packages/runtime-host/dist/server/session-transcript-pager.js'; +import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/desktop-transcript-range-store.js'; +import type { DesktopTranscriptNavigation } from '../../preload/transcript-contract.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'; + +const HOST_EPOCH = 'host-1'; +const SUBSCRIPTION_ID = 'overlay-settlement-subscription'; +const PAGE_BYTES = 128 * 1024; +const BOOTSTRAP_THROUGH = 4; +const B_STEERING_THROUGH = 5; +const B_COMPLETED_THROUGH = 7; +const C_COMPLETED_THROUGH = 11; + +for (const coalesced of [false, true]) { + test(`settles a bootstrap overlay outside history through ${coalesced ? 'a coalesced B+C watermark' : 'separate B and C watermarks'}`, async () => { + const fixture = await openFixture(); + try { + const { replica, renderer, changes } = fixture; + assert.equal(replica.snapshot().overlay.find(({ id }) => id === 'answer-b')?.id, 'answer-b'); + await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); + assertHistoryRange(fixture); + const before = changes.length; + + if (!coalesced) { + await fixture.advance(B_COMPLETED_THROUGH); + assertHistoryRange(fixture); + assert.deepEqual(replica.snapshot().overlay, []); + } + await fixture.advance(C_COMPLETED_THROUGH); + assertHistoryRange(fixture); + assert.equal(replica.durableThrough, fixture.watermark(C_COMPLETED_THROUGH)); + assert.deepEqual(replica.snapshot().overlay, []); + assert.deepEqual(changes.slice(before).flatMap((change) => change.completedOverlayMessageIds), ['user-b', 'answer-b']); + assert.ok(changes.slice(before).every((change) => change.durableUpserts.length === 0)); + assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), ['user-a', 'answer-a', 'completed-a']); + + await replica.followLatest(PAGE_BYTES); + assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), ['user-c', 'answer-c', 'completed-c']); + assert.equal(replica.snapshot().hasNewer, false); + assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), ['user-c', 'answer-c', 'completed-c']); + } finally { + await fixture.close(); + } + }); +} + +test('retains an unfinished overlay through runtime checkpoints and skips scans after settlement', async () => { + const fixture = await openFixture(); + try { + const { replica, changes, requests } = fixture; + await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); + await fixture.advance(B_STEERING_THROUGH); + assert.equal(replica.durableThrough, fixture.bootstrapThrough, 'running B has no durable ending yet'); + assertHistoryRange(fixture); + const unfinished = replica.snapshot().overlay.find(({ id }) => id === 'answer-b'); + assert.equal(unfinished?.type === 'assistant' ? unfinished.text : undefined, 'B partial'); + assert.deepEqual(changes.flatMap((change) => change.completedOverlayMessageIds), []); + + await fixture.advance(B_COMPLETED_THROUGH); + assert.deepEqual(replica.snapshot().overlay, []); + const before = requests.length; + await fixture.advance(C_COMPLETED_THROUGH); + assert.equal(requests.length, before, 'history with no pending overlay needs no durable page read'); + assertHistoryRange(fixture); + } finally { + await fixture.close(); + } +}); + +test('a latest range jump settles skipped overlays without waiting for another advance', async () => { + const fixture = await openFixture(); + try { + const { replica } = fixture; + await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); + await fixture.announce(C_COMPLETED_THROUGH); + // Both commands are queued synchronously. The range jump owns the newer + // navigation before the queued catch-up starts handling the watermark. + const latest = replica.followLatest(PAGE_BYTES); + const advance = replica.advance(fixture.watermark(C_COMPLETED_THROUGH)); + await latest; + assert.deepEqual(replica.snapshot().overlay, []); + assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), ['user-c', 'answer-c', 'completed-c']); + await advance; + } finally { + await fixture.close(); + } +}); + +for (const intent of ['followTail', 'history'] as const) { + test(`the ${intent} range retires the completed overlay in one notification`, async () => { + const fixture = await openFixture(); + try { + const { replica, changes, requests } = fixture; + if (intent === 'history') await replica.readAt(fixture.history[0]!.sequence); + const before = requests.length; + await fixture.advance(B_COMPLETED_THROUGH); + const settled = changes.filter((change) => change.completedOverlayMessageIds.includes('answer-b')); + assert.equal(settled.length, 1); + if (intent === 'followTail') { + assert.ok(settled[0]!.durableUpserts.some(({ message }) => message.id === 'answer-b')); + const answer = replica.messages().find(({ id }) => id === 'answer-b'); + assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'B partial and completed answer'); + } else { + assertHistoryRange(fixture); + assert.equal(settled[0]!.durableUpserts.length, 0, 'settlement preserves the selected oversized history Turn'); + } + assert.equal(requests.length - before, 1, 'normal catch-up settles through the same page it installs'); + assert.deepEqual(replica.snapshot().overlay, []); + } finally { + await fixture.close(); + } + }); +} + +for (const coalesced of [false, true]) { + test(`reading an overlay-only B survives ${coalesced ? 'coalesced B+C completion' : 'B completion followed by oversized C'}`, async () => { + const fixture = await openFixture(); + const { replica, renderer } = fixture; + const navigations: Array<{ anchor: number | null; navigation: DesktopTranscriptNavigation }> = []; + // Only the process boundary is in-process here: controller invalidation, + // replica ownership, Host cursors, SQLite ledger, and renderer batches all + // use their production implementations. + const controller = createDesktopTranscriptRangeController(renderer, async () => ({ + sessionId: replica.sessionId, generation: replica.generation, + hostEpoch: replica.hostEpoch, readThroughMessageId: null, + async loadBefore(anchor, maxBytes = PAGE_BYTES, navigation) { + assert.ok(navigation); + fixture.acceptNavigation(navigation); + await replica.loadBefore(anchor, maxBytes, replica.setNavigation(navigation.intent)); + }, + async loadAfter(anchor, maxBytes = PAGE_BYTES, navigation) { + assert.ok(navigation); + fixture.acceptNavigation(navigation); + await replica.loadAfter(anchor, maxBytes, replica.setNavigation(navigation.intent)); + }, + async loadAround(anchor, maxBytes = PAGE_BYTES, navigation) { + assert.ok(navigation); + navigations.push({ anchor, navigation }); + fixture.acceptNavigation(navigation); + const token = replica.setNavigation(navigation.intent); + if (navigation.preserveRange) await replica.readAt(anchor, token, navigation.readingTurnId); + else if (navigation.intent === 'followTail') await replica.followLatest(maxBytes, token); + else { + assert.notEqual(anchor, null); + await replica.loadAround(anchor!, maxBytes, token); + } + }, + async close() {}, + })); + try { + await controller.ready(); + assert.equal(renderer.sequenceForTurn('b'), null, + 'a running ledger invocation has no durable user sequence to use as a bookmark'); + assert.ok(renderer.snapshot().messages.some(({ id }) => id === 'answer-b')); + await controller.setReadingAnchor(renderer.sequenceForTurn('b'), 'b'); + assert.equal(navigations[0]?.anchor, null, 'the old A sequence cannot impersonate B'); + assert.equal(navigations[0]?.navigation.readingTurnId, 'b'); + assert.equal(navigations[0]?.navigation.intent, 'history'); + + const expectedB = ['user-b', 'steering-b', 'answer-b', 'completed-b']; + if (!coalesced) { + await fixture.advance(B_COMPLETED_THROUGH); + assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), expectedB); + assert.ok(renderer.sequenceForTurn('b') !== null, 'the selected Turn now resolves to its own durable sequence'); + } + await fixture.advance(C_COMPLETED_THROUGH); + assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), expectedB); + assert.deepEqual(replica.snapshot().overlay, []); + assert.equal(replica.snapshot().hasNewer, true); + assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), expectedB); + const answer = renderer.snapshot().messages.find(({ id }) => id === 'answer-b'); + assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'B partial and completed answer'); + assert.equal(renderer.snapshot().messages.some(({ turnId }) => turnId === 'c'), false, + 'finishing C cannot replace the reader-selected B range'); + + await controller.loadLatest(); + assert.deepEqual(renderer.snapshot().messages.map(({ id }) => id), ['user-c', 'answer-c', 'completed-c']); + assert.equal(replica.snapshot().hasNewer, false); + } finally { + await controller.close(); + await fixture.close(); + } + }); +} + +test('a fresh replica restores B from an overlay-only bookmark after oversized C owns the tail', async () => { + const fixture = await openFixture(); + let reopened: Awaited> | undefined; + try { + const bookmark = { turnId: 'b', sequence: fixture.renderer.sequenceForTurn('b') }; + assert.equal(bookmark.sequence, null); + await fixture.replica.readAt(bookmark.sequence, undefined, bookmark.turnId); + await fixture.advance(B_COMPLETED_THROUGH); + await fixture.advance(C_COMPLETED_THROUGH); + + reopened = await openSettledReplica(fixture.ledger); + assert.deepEqual(reopened.replica.snapshot().durable.map(({ message }) => message.id), + ['user-c', 'answer-c', 'completed-c']); + assert.deepEqual(reopened.replica.snapshot().overlay, []); + const before = reopened.requests.length; + await reopened.replica.readAt(bookmark.sequence, undefined, bookmark.turnId); + assert.deepEqual(reopened.replica.snapshot().durable.map(({ message }) => message.id), + ['user-b', 'steering-b', 'answer-b', 'completed-b']); + assert.ok(reopened.requests.slice(before).some((request) => request.direction === 'older'), + 'a no-sequence bookmark finds its durable Turn through the real bounded pager'); + assert.ok(reopened.requests.slice(before).every((request) => request.maxBytes <= 512 * 1024)); + assert.equal(reopened.replica.snapshot().hasNewer, true); + await reopened.replica.advance(fixture.watermark(C_COMPLETED_THROUGH)); + assert.deepEqual(new Set(reopened.replica.snapshot().durable.map(({ message }) => message.turnId)), new Set(['b'])); + } finally { + await reopened?.close(); + await fixture.close(); + } +}); + +test('superseded settlement pages cannot retire overlays or skip the current navigation retry', async () => { + const firstStarted = deferred(); + const releaseFirst = deferred(); + const secondStarted = deferred(); + const releaseSecond = deferred(); + let settlementReads = 0; + const fixture = await openFixture(async (request) => { + if (request.direction !== 'newer' || request.anchorSequence !== fixture.bootstrapThrough) return; + settlementReads += 1; + if (settlementReads === 1) { + firstStarted.resolve(); + await releaseFirst.promise; + } else if (settlementReads === 2) { + secondStarted.resolve(); + await releaseSecond.promise; + } + }); + try { + const { replica, changes } = fixture; + await replica.loadAround(fixture.history[0]!.sequence, PAGE_BYTES); + const advance = fixture.advance(C_COMPLETED_THROUGH); + await firstStarted.promise; + const latest = replica.followLatest(PAGE_BYTES); + releaseFirst.resolve(); + await secondStarted.promise; + assert.equal(replica.snapshot().overlay.find(({ id }) => id === 'answer-b')?.id, 'answer-b'); + assert.deepEqual(changes.flatMap((change) => change.completedOverlayMessageIds), []); + releaseSecond.resolve(); + await latest; + await advance; + assert.equal(settlementReads, 2, 'the new command retries from the last actually checked overlay watermark'); + assert.deepEqual(replica.snapshot().overlay, []); + assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), ['user-c', 'answer-c', 'completed-c']); + assert.deepEqual(changes.flatMap((change) => change.completedOverlayMessageIds), ['user-b', 'answer-b']); + } finally { + releaseFirst.resolve(); + releaseSecond.resolve(); + await fixture.close(); + } +}); + +function assertHistoryRange(fixture: Awaited>): void { + assert.deepEqual(fixture.replica.snapshot().durable, fixture.history); + assert.equal(fixture.replica.snapshot().hasNewer, fixture.replica.durableThrough! > fixture.bootstrapThrough); +} + +async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => Promise) { + const messages: StoredMessage[] = [ + user('a'), assistant('a', 'A'.repeat(600 * 1024)), turnState('a', 'completed'), + user('b'), turnState('b', 'running'), + { ...user('b'), id: 'steering-b', steeringEventId: 'steering-event-b', text: 'Continue B' }, + 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 { reader, sessionId } = ledger; + const bootstrapThrough = await ledger.appendThrough(BOOTSTRAP_THROUGH); + assert.ok(bootstrapThrough !== null); + const history = await ledger.durableRecords(); + await ledger.appendPartialAssistant('b', 'answer-b', 'B partial'); + const rootTurn = { sessionId, turnId: 'b', runId: 'run-b', status: 'running' as const }; + const activeAssistantStreams = [{ turnId: 'b', messageId: 'answer-b', kind: 'text' as const, text: 'B partial' }]; + const opened = await createSessionTranscriptBootstrap({ + reader, sessionId, subscriptionId: SUBSCRIPTION_ID, + throughSequence: bootstrapThrough, rootTurn, activeAssistantStreams, + maxBytes: 16 * 1024, projection: 'owner', + }); + const requests: SessionTranscriptPageInput[] = []; + const subscription = new ClientSessionSubscription({ + hostEpoch: HOST_EPOCH, subscriptionId: SUBSCRIPTION_ID, nextSequence: 1, + activeAssistantStreams, transcript: opened.bootstrap, + snapshot: { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { sessionId, metadataRevision: 1, status: 'running', createdAt: 1, isArchived: false }, + projectionRevision: 1, rootTurn, goal: null, + queue: { hostEpoch: HOST_EPOCH, queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + }, + }, async () => undefined, async (request) => { + requests.push(request); + await beforePage?.(request); + return readSessionTranscriptPage({ reader, state: opened.state, request }); + }); + const decodeMessage = (value: unknown) => decodeStoredMessage(markPersisted(value)); + const changes: DesktopTranscriptReplicaChange[] = []; + let navigationVersion = 0; + const renderer = new DesktopTranscriptRangeStore(JSON.stringify(['local', sessionId])); + const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ + snapshot: subscription.snapshot, activeAssistantStreams, events: subscription, + transcript: Promise.resolve([]), transcriptBootstrap: opened.bootstrap, + loadTranscriptOverlay: (maxMessageBytes, accountAssemblyBytes) => + subscription.loadTranscriptOverlay(decodeMessage, maxMessageBytes, accountAssemblyBytes), + decodeTranscriptPage: (page, maxMessageBytes, accountAssemblyBytes) => + subscription.decodeTranscriptPage(page, decodeMessage, maxMessageBytes, accountAssemblyBytes), + loadTranscriptPage: (request) => subscription.loadTranscriptPage(request), + close: () => subscription.close(), + }), { + onChange: (current, change) => { + changes.push(change); + for (const batch of encodeDesktopTranscriptChange({ ...current.snapshot(), navigationVersion }, change)) renderer.accept(batch); + }, + }); + for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) renderer.accept(batch); + const watermarks = new Map(); + let frameSequence = 0; + const announce = async (checkpoint: number) => { + const throughSequence = await ledger.appendThrough(checkpoint); + assert.ok(throughSequence !== null); + watermarks.set(checkpoint, throughSequence); + const advanced = updateSubscriberTranscriptHighWater(opened.state, throughSequence); + if (checkpoint === B_STEERING_THROUGH) { + assert.equal(advanced, false, 'persisting a running Turn does not publish durable rows'); + return; + } + assert.equal(advanced, true); + subscription.accept({ + kind: 'subscription.transcript_advanced', hostEpoch: HOST_EPOCH, + subscriptionId: SUBSCRIPTION_ID, sequence: ++frameSequence, sessionId, throughSequence, + }); + const frame = await subscription.next(); + assert.equal(frame.done, false); + assert.equal(frame.value?.kind, 'subscription.transcript_advanced'); + }; + return { + replica, renderer, changes, requests, announce, history, bootstrapThrough, ledger, + acceptNavigation: (navigation: DesktopTranscriptNavigation) => { navigationVersion = navigation.navigationVersion; }, + watermark: (checkpoint: number) => { + const value = watermarks.get(checkpoint); + assert.notEqual(value, undefined); + return value!; + }, + async advance(throughSequence: number) { + await announce(throughSequence); + await replica.advance(watermarks.get(throughSequence)!); + }, + async close() { + replica.close(); + await subscription.close(); + await ledger.close(); + }, + }; +} + +async function openSettledReplica(ledger: Awaited>) { + const { sessionId, reader } = ledger; + const opened = await createSessionTranscriptBootstrap({ + reader, sessionId, subscriptionId: `${SUBSCRIPTION_ID}-reopened`, + throughSequence: await reader.readDurableHighWater(sessionId), rootTurn: null, + activeAssistantStreams: [], maxBytes: 16 * 1024, projection: 'owner', + }); + const requests: SessionTranscriptPageInput[] = []; + const subscription = new ClientSessionSubscription({ + hostEpoch: HOST_EPOCH, subscriptionId: `${SUBSCRIPTION_ID}-reopened`, nextSequence: 1, + activeAssistantStreams: [], transcript: opened.bootstrap, + snapshot: { + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, + session: { sessionId, metadataRevision: 1, status: 'active', createdAt: 1, isArchived: false }, + projectionRevision: 1, rootTurn: null, goal: null, + queue: { hostEpoch: HOST_EPOCH, queueRevision: 0, steering: [], followup: [] }, + interactions: { pending: [] }, + }, + }, async () => undefined, (request) => { + requests.push(request); + return readSessionTranscriptPage({ reader, state: opened.state, request }); + }); + const decodeMessage = (value: unknown) => decodeStoredMessage(markPersisted(value)); + const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ + snapshot: subscription.snapshot, events: subscription, transcript: Promise.resolve([]), + transcriptBootstrap: opened.bootstrap, + loadTranscriptOverlay: (maxBytes, accountBytes) => subscription.loadTranscriptOverlay(decodeMessage, maxBytes, accountBytes), + decodeTranscriptPage: (page, maxBytes, accountBytes) => subscription.decodeTranscriptPage(page, decodeMessage, maxBytes, accountBytes), + loadTranscriptPage: (request) => subscription.loadTranscriptPage(request), + close: () => subscription.close(), + })); + return { replica, requests, async close() { replica.close(); await subscription.close(); } }; +} + +function user(turnId: string): Extract { + return { type: 'user', id: `user-${turnId}`, turnId, text: turnId, ts: 1 }; +} + +function assistant(turnId: string, text: string): Extract { + return { type: 'assistant', id: `answer-${turnId}`, turnId, text, ts: 1, modelId: 'fixture-model' }; +} + +function turnState(turnId: string, status: 'running' | 'completed'): StoredMessage { + return { type: 'turn_state', id: `${status}-${turnId}`, turnId, ts: 1, status }; +} 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 new file mode 100644 index 0000000000..23457a8956 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -0,0 +1,332 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement, createRef, type ComponentProps } from 'react'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { StoredMessage } from '@maka/core/session'; +import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; +import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/desktop-transcript-range-store.js'; +import { + createAppShellSessionUiStateController, + TranscriptReadingPositionController, + type TranscriptReadingPositionCommands, + type TranscriptHistoryPending, + transcriptReadingPosition, +} from '../../renderer/features/conversation/index.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(cleanupFakeDom); + +test('a resident search admits history before an older latest response can evict its target', async () => { + const sessionId = JSON.stringify(['host-1', 'session-1']); + const store = new DesktopTranscriptRangeStore(sessionId); + const latestStarted = deferred(); + const releaseLatest = deferred(); + const readingAdmitted = deferred(); + const admissions: Array<{ sequence: number | null; version: number; preserveRange?: boolean }> = []; + const publish = (turnId: string, sequence: number, navigationVersion: number) => { + const message: StoredMessage = { + type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', + }; + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion, + durableThrough: 20, durable: [{ sequence, message }], overlay: [], hasOlder: false, hasNewer: turnId === 'a', + })) store.accept(batch); + }; + publish('a', 10, 0); + const controller = createDesktopTranscriptRangeController(store, async () => ({ + sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, + loadBefore: async () => {}, loadAfter: async () => {}, + async loadAround(sequence, _maxBytes, navigation) { + const version = navigation!.navigationVersion; + admissions.push({ sequence, version, preserveRange: navigation!.preserveRange }); + if (sequence === null) { + latestStarted.resolve(); + await releaseLatest.promise; + publish('b', 20, version); + } else { + publish('a', 10, version); + readingAdmitted.resolve(); + } + }, + close: async () => {}, + })); + try { + const latest = controller.loadLatest(); + await latestStarted.promise; + const lifecycle = transcriptReadingPosition.createRestoreLifecycle(); + const restore = () => transcriptReadingPosition.restoreRange({ + lifecycle, sessionId, controller, + searchTarget: { sessionId, turnId: 'a', sequence: 10, nonce: 1 }, + isCurrent: () => true, setMessages: () => {}, setReadingAnchor: () => {}, + onError: (error) => assert.fail(String(error)), + }); + restore(); + await readingAdmitted.promise; + releaseLatest.resolve(); + await latest; + await new Promise((resolve) => setImmediate(resolve)); + restore(); + assert.deepEqual(admissions, [ + { sequence: null, version: 1, preserveRange: undefined }, + { sequence: 10, version: 2, preserveRange: true }, + ]); + assert.equal(store.sequenceForTurn('a'), 10); + assert.equal(store.sequenceForTurn('b'), null); + } finally { + releaseLatest.resolve(); + await controller.close(); + } +}); + +for (const source of ['bootstrap overlay', 'live projection'] as const) { +test(`a ${source} bookmark admits its Turn once and retains it across range reload`, async () => { + const sessionId = JSON.stringify(['host-1', 'session-1']); + const store = new DesktopTranscriptRangeStore(sessionId); + const overlay: StoredMessage = { + type: 'assistant', id: 'answer-b', turnId: 'b', text: 'partial B', ts: 1, modelId: 'fixture', + }; + const publish = (navigationVersion: number) => { + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion, + durableThrough: null, durable: [], overlay: source === 'bootstrap overlay' ? [overlay] : [], + hasOlder: false, hasNewer: false, + })) store.accept(batch); + }; + publish(0); + const admissions: Array<{ sequence: number | null; turnId?: string; version: number; preserveRange?: boolean }> = []; + const controller = createDesktopTranscriptRangeController(store, async () => ({ + sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, + loadBefore: async () => {}, loadAfter: async () => {}, close: async () => {}, + async loadAround(sequence, _maxBytes, navigation) { + admissions.push({ sequence, turnId: navigation!.readingTurnId, + version: navigation!.navigationVersion, preserveRange: navigation!.preserveRange }); + publish(navigation!.navigationVersion); + }, + })); + const lifecycle = transcriptReadingPosition.createRestoreLifecycle(); + let unavailable = 0; + let cleared = 0; + const restore = () => transcriptReadingPosition.restoreRange({ + lifecycle, sessionId, controller, readingAnchor: { turnId: 'b' }, + isCurrent: () => true, setMessages: () => {}, + isLiveTurn: (candidateSessionId, turnId) => source === 'live projection' && + candidateSessionId === sessionId && turnId === 'b', + setReadingAnchor: (_sessionId, anchor) => { if (!anchor) cleared += 1; }, + onRestoreUnavailable: () => { unavailable += 1; }, onError: (error) => assert.fail(String(error)), + }); + try { + restore(); + await new Promise((resolve) => setImmediate(resolve)); + restore(); + assert.equal(store.sequenceForTurn('b'), null); + assert.equal(unavailable, 0); + assert.equal(cleared, 0); + assert.deepEqual(admissions, [{ sequence: null, turnId: 'b', version: 1, preserveRange: true }]); + await controller.reload(); + assert.deepEqual(admissions[1], { sequence: null, turnId: 'b', version: 1, preserveRange: false }); + } finally { + await controller.close(); + } +}); +} + +test('both paging directions retain an overlay-only Turn identity and admit a fresh intent', async () => { + const sessionId = JSON.stringify(['host-1', 'session-1']); + const store = new DesktopTranscriptRangeStore(sessionId); + const message = (turnId: string): StoredMessage => ({ + type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', + }); + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion: 0, + durableThrough: 40, durable: [{ sequence: 10, message: message('a') }], + overlay: [message('b')], hasOlder: true, hasNewer: true, + })) store.accept(batch); + const calls: Array<{ operation: string; sequence: number | null; turnId?: string; version?: number }> = []; + const record = (operation: string) => async (sequence: number | null, _maxBytes?: number, + navigation?: import('../../preload/transcript-contract.js').DesktopTranscriptNavigation) => { + calls.push({ operation, sequence, turnId: navigation?.readingTurnId, version: navigation?.navigationVersion }); + }; + const controller = createDesktopTranscriptRangeController(store, async () => ({ + sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, + loadBefore: record('before'), loadAfter: record('after'), loadAround: record('around'), close: async () => {}, + })); + try { + await controller.setReadingAnchor(null, 'b'); + await controller.loadBefore(undefined, 'b'); + await controller.loadAfter(undefined, 'b'); + await controller.loadBefore(undefined, 'a'); + await controller.loadAfter(undefined, 'a'); + assert.deepEqual(calls, [ + { operation: 'around', sequence: null, turnId: 'b', version: 1 }, + { operation: 'around', sequence: null, turnId: 'b', version: 2 }, + { operation: 'around', sequence: null, turnId: 'b', version: 3 }, + { operation: 'before', sequence: 10, turnId: 'a', version: 4 }, + { operation: 'after', sequence: 10, turnId: 'a', version: 5 }, + ]); + } finally { + await controller.close(); + } +}); + +test('returning to latest supersedes pending history without letting its completion clear the new pending state', async () => { + const fixture = controllerFixture(); + const older = deferred(); + const latest = deferred(); + const calls: string[] = []; + fixture.controller.loadBefore = async () => { calls.push('older'); await older.promise; }; + fixture.controller.loadLatest = async () => { calls.push('latest'); await latest.promise; }; + await fixture.render(); + + const loadingOlder = fixture.commands.current!.loadHistory('earlier'); + await fixture.commands.current!.loadHistory('earlier'); + const loadingLatest = fixture.commands.current!.loadHistory('latest'); + assert.deepEqual(calls, ['older', 'latest']); + + older.reject(new Error('superseded history request failed')); + await loadingOlder; + assert.equal(fixture.pending(), 'session-1'); + latest.resolve(); + await loadingLatest; + assert.equal(fixture.pending(), undefined); +}); + +test('a new Session can load history while the previous Session request is still pending', async () => { + const fixture = controllerFixture(); + const first = deferred(); + const second = deferred(); + fixture.controller.loadBefore = () => first.promise; + await fixture.render(); + const loadingFirst = fixture.commands.current!.loadHistory('earlier'); + + const secondController = { ...fixture.controller, + store: { ...fixture.controller.store, range: () => ({ sessionId: 'session-2' }) }, + loadBefore: () => second.promise, + }; + fixture.props.currentSessionId.current = 'session-2'; + fixture.props.rangeController.current = secondController; + fixture.props.sessionId = 'session-2'; + await fixture.render(); + const loadingSecond = fixture.commands.current!.loadHistory('earlier'); + assert.equal(fixture.pending(), 'session-2'); + + first.resolve(); + await loadingFirst; + assert.equal(fixture.pending(), 'session-2'); + second.resolve(); + await loadingSecond; + assert.equal(fixture.pending(), undefined); +}); + +test('a new earlier request supersedes pending return-to-latest navigation', async () => { + const fixture = controllerFixture(); + const latest = deferred(); + const earlier = deferred(); + const calls: string[] = []; + fixture.controller.loadLatest = async () => { calls.push('latest'); await latest.promise; }; + fixture.controller.loadBefore = async () => { calls.push('earlier'); await earlier.promise; }; + await fixture.render(); + + const loadingLatest = fixture.commands.current!.loadHistory('latest'); + const loadingEarlier = fixture.commands.current!.loadHistory('earlier'); + assert.deepEqual(calls, ['latest', 'earlier']); + latest.resolve(); + await loadingLatest; + assert.equal(fixture.pending(), 'session-1'); + earlier.resolve(); + await loadingEarlier; + assert.equal(fixture.pending(), undefined); +}); + +test('an old Session controller cannot clear pending history after returning to the same Session', async () => { + const fixture = controllerFixture(); + const first = deferred(); + const replacement = deferred(); + fixture.controller.loadBefore = () => first.promise; + await fixture.render(); + const loadingFirst = fixture.commands.current!.loadHistory('earlier'); + + fixture.props.currentSessionId.current = 'session-2'; + fixture.props.sessionId = 'session-2'; + fixture.props.rangeController.current = { + ...fixture.controller, + store: { ...fixture.controller.store, range: () => ({ sessionId: 'session-2' }) }, + }; + await fixture.render(); + fixture.props.currentSessionId.current = 'session-1'; + fixture.props.sessionId = 'session-1'; + fixture.props.rangeController.current = { + ...fixture.controller, + loadBefore: () => replacement.promise, + }; + await fixture.render(); + const loadingReplacement = fixture.commands.current!.loadHistory('earlier'); + assert.equal(fixture.pending(), 'session-1'); + + first.resolve(); + await loadingFirst; + assert.equal(fixture.pending(), 'session-1'); + replacement.resolve(); + await loadingReplacement; + assert.equal(fixture.pending(), undefined); +}); + +function controllerFixture() { + const { root } = installReactRenderer(); + const commands = createRef(); + const controller = { + ready: async () => {}, + loadAround: async () => {}, + loadBefore: async () => {}, + loadAfter: async () => {}, + loadLatest: async () => {}, + setReadingAnchor: async () => {}, + store: { + range: () => ({ sessionId: 'session-1' }), + sequenceForTurn: () => null, + newestDurableUserSequence: () => null, + snapshot: () => ({ messages: [] }), + }, + }; + let pending: TranscriptHistoryPending | undefined; + const props: ComponentProps = { + commands, + sessionId: 'session-1', + currentSessionId: { current: 'session-1' }, + rangeController: { current: controller }, + messages: [], + searchTarget: undefined, + clearSearchTarget: () => {}, + sessionUi: createAppShellSessionUiStateController(), + turnIndex: undefined, + setTurnIndex: () => {}, + listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), + setMessages: () => {}, + setHistoryPending: (next) => { pending = typeof next === 'function' ? next(pending) : next; }, + historyPageBytes: 512 * 1024, + onRestoreError: (error) => assert.fail(String(error)), + onNavigationError: (error) => assert.fail(String(error)), + }; + return { + commands, controller, props, pending: () => pending?.sessionId, + render: () => act(() => root.render(createElement(TranscriptReadingPositionController, props))), + }; +} diff --git a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts new file mode 100644 index 0000000000..d8eb5bb8ca --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -0,0 +1,347 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement, createRef, Fragment, useRef, useState, type ComponentProps } from 'react'; +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, + useTranscriptScrollAuthority, + useChatScroll, + type TranscriptScrollAuthority, +} from '@maka/ui/testing'; +import { + createAppShellSessionUiStateController, + TranscriptReadingPositionController, + type TranscriptReadingPositionCommands, + type TranscriptHistoryPending, +} from '../../renderer/features/conversation/index.js'; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { while (cleanups.length) await cleanups.pop()!(); }); + +test('preparing a send follows the new prompt and streaming growth, then lets the reader leave again', async () => { + const fixture = viewportFixture(); + await fixture.render(); + await fixture.readAt(1000); + assert.equal(fixture.pinned(), false); + 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'); }); + assert.equal(prepared, true); + assert.equal(fixture.latestReads(), 1); + assert.equal(fixture.pinned(), true); + assert.equal(fixture.scroller.scrollTop, 2400); + + await fixture.append('new-question', 200); + const prompt = fixture.scroller.querySelector('[data-turn-id="new-question"]'); + assert.ok(prompt); + assert.ok(prompt.getBoundingClientRect().top < 600, 'the newly submitted prompt enters the viewport'); + assert.equal(fixture.scroller.scrollTop, 2600); + await fixture.append('streaming-answer', 1200); + assert.equal(fixture.scroller.scrollTop, 3800, 'stream growth remains under the same tail pin'); + + await fixture.readAt(500); + await fixture.append('later-streaming-content', 500); + assert.equal(fixture.pinned(), false, 'ordinary message updates must not replay the consumed send command'); + assert.equal(fixture.scroller.scrollTop, 500); +}); + +test('reading a live Turn without a durable sequence preserves its Turn identity', async () => { + const fixture = viewportFixture(); + const sequenceForTurn = fixture.controller.store.sequenceForTurn; + fixture.controller.store.sequenceForTurn = (turnId) => turnId === 'latest' ? null : sequenceForTurn(turnId); + const readingCalls: Array<{ sequence: number | null; turnId?: string }> = []; + fixture.controller.setReadingAnchor = async (sequence, turnId) => { readingCalls.push({ sequence, turnId }); }; + await fixture.render(); + await fixture.readAt(1900); + + assert.equal(fixture.pinned(), false); + assert.deepEqual(fixture.sessionUi.transcriptReadingAnchorBySessionRef.current['session-a'], { turnId: 'latest' }); + assert.deepEqual(readingCalls, [{ sequence: null, turnId: 'latest' }]); +}); + +test('a send is accepted before latest history loads and its old completion cannot move a new Session viewport', async () => { + 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'); + const projections = fixture.projections(); + + await fixture.switchSession('session-b'); + await fixture.readAt(900); + await act(async () => { latest.resolve(); }); + + assert.equal(fixture.projections(), projections, 'the old range must not replace the new Session'); + assert.equal(fixture.pinned(), false); + assert.equal(fixture.scroller.scrollTop, 900); + await fixture.append('session-b-growth', 600); + assert.equal(fixture.scroller.scrollTop, 900); +}); + +for (const direction of ['earlier', 'later'] as const) { + test(`${direction} history navigation supersedes the background range load of an accepted send`, async () => { + 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); }); + const projections = fixture.projections(); + await fixture.activateHistoryGap(direction); + const readerTop = fixture.scroller.scrollTop; + await act(async () => { latest.resolve(); }); + + assert.equal(fixture.projections(), projections, 'history intent must discard the stale latest projection'); + 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 }); + await fixture.render(); + assert.equal(fixture.pinned(), false); + + await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); + await fixture.flushFrames(); + await fixture.append('new-question', 200); + + assert.equal(fixture.pinned(), true); + assert.equal(fixture.scroller.scrollTop, 2600); +}); + +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 }); + await fixture.render(); + assert.equal(fixture.pinned(), false); + + await fixture.clickReturnToLatest(); + await fixture.flushFrames(); + assert.equal(fixture.pinned(), true, 'the captured restore must not reclaim the explicit tail pin'); + 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); + const readingCalls: Array = []; + fixture.controller.setReadingAnchor = async (sequence: number | null) => { readingCalls.push(sequence); }; + await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); + const projections = fixture.projections(); + + await fixture.replaceRangeFromHost(); + await act(async () => { latest.resolve(); }); + + assert.equal(fixture.projections(), projections + 1, 'Host range replacement must not cancel its own latest load'); + assert.deepEqual(readingCalls, [], 'content geometry must not install a new history reading intent'); + assert.equal(fixture.pinned(), true); + await fixture.append('new-question', 200); + assert.equal(fixture.scroller.scrollTop, 400); +}); + +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); }); + const projections = fixture.projections(); + + await fixture.readAt(1000); + await act(async () => { latest.resolve(); }); + + assert.equal(fixture.projections(), projections, 'reader navigation must discard the stale latest projection'); + assert.equal(fixture.pinned(), false); + assert.equal(fixture.scroller.scrollTop, 1000); +}); + +function viewportFixture(options: { returnButton?: boolean } = {}) { + const original = { + CSS: globalThis.CSS, document: globalThis.document, window: globalThis.window, + Element: globalThis.Element, HTMLElement: globalThis.HTMLElement, Node: globalThis.Node, + MutationObserver: globalThis.MutationObserver, ResizeObserver: globalThis.ResizeObserver, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + const mount = document.querySelector('#mount')!; + const scroller = document.querySelector('#scroller')!; + let height = 3000; + let top = 0; + let nextFrame = 0; + const frames = new Map(); + const resizeCallbacks = new Set(); + Object.defineProperties(scroller, { + clientHeight: { value: 600 }, scrollHeight: { get: () => height }, + scrollTop: { get: () => top, set: (value: number) => { top = Math.max(0, Math.min(value, height - 600)); } }, + }); + const rectangle = (y: number, size: number): DOMRect => ({ + top: y, bottom: y + size, height: size, left: 0, right: 800, width: 800, + x: 0, y, toJSON: () => undefined, + }); + scroller.getBoundingClientRect = () => rectangle(0, 600); + class TestResizeObserver { + constructor(callback: ResizeObserverCallback) { resizeCallbacks.add(callback); } + disconnect() {} + observe() {} + unobserve() {} + } + class TestMutationObserver { + disconnect() {} + observe() {} + takeRecords(): MutationRecord[] { return []; } + } + Object.assign(window, { + requestAnimationFrame: (callback: FrameRequestCallback) => { frames.set(++nextFrame, callback); return nextFrame; }, + cancelAnimationFrame: (id: number) => frames.delete(id), + }); + Object.assign(globalThis, { + CSS: { escape: (value: string) => value }, document, window, + Element: window.Element, HTMLElement: window.HTMLElement, Node: window.Node, + MutationObserver: TestMutationObserver, ResizeObserver: TestResizeObserver, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const messages: StoredMessage[] = []; + 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); + messages.push({ id, type: 'user', turnId: id, ts: 1, text: id }); + }; + addTurn('history', 0, 1800); + addTurn('latest', 1800, 1200); + let reads = 0; + let projections = 0; + const controller = { + ready: async () => {}, loadAround: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, + loadLatest: async () => { reads += 1; }, setReadingAnchor: async (_sequence: number | null, _turnId?: string) => {}, + store: { + range: () => ({ sessionId: 'session-a' }), + sequenceForTurn: (turnId: string) => { + const sequence = messages.findIndex((message) => message.turnId === turnId); + return sequence < 0 ? null : sequence; + }, + newestDurableUserSequence: () => 1, + snapshot: () => ({ messages }), + }, + }; + const sessionUi = createAppShellSessionUiStateController(); + const commands = createRef(); + const props: ComponentProps = { + commands, sessionId: 'session-a', currentSessionId: { current: 'session-a' }, + rangeController: { current: controller }, messages, sessionUi, + searchTarget: undefined, clearSearchTarget: () => {}, + turnIndex: undefined, setTurnIndex: () => {}, + listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), + setMessages: (next) => { projections += 1; props.messages = next; }, setHistoryPending: () => {}, historyPageBytes: 512 * 1024, + onRestoreError: (error) => assert.fail(String(error)), onNavigationError: (error) => assert.fail(String(error)), + }; + let authority: TranscriptScrollAuthority | undefined; + function Harness() { + const scrollRef = useRef(scroller); + const [, setHistoryPending] = useState(); + props.setHistoryPending = setHistoryPending; + authority = useTranscriptScrollAuthority(); + const anchor = sessionUi.transcriptReadingAnchorBySessionRef.current[props.sessionId!]; + useChatScroll({ + scrollRef, sessionId: props.sessionId, messages: props.messages, + 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?.loadHistory('latest'), + }) : null, + ); + } + const root = createRoot(mount); + cleanups.push(async () => { await act(() => root.unmount()); Object.assign(globalThis, original); }); + const render = () => act(() => root.render(createElement(TranscriptScrollAuthorityProvider, null, createElement(Harness)))); + return { + scroller, controller, sessionUi, commands, render, + pinned: () => authority!.getSnapshot().pinned, latestReads: () => reads, projections: () => projections, + async clickReturnToLatest() { + const button = mount.querySelector('button'); + assert.ok(button); + await act(() => { button.dispatchEvent(new window.Event('click', { bubbles: true })); }); + }, + async activateHistoryGap(direction: 'earlier' | 'later') { + // ChatView releases the pin before invoking either history gap action. + await act(async () => { authority!.releasePin(); await commands.current!.loadHistory(direction); }); + }, + async readAt(offset: number) { + await act(() => { scroller.scrollTop = offset; scroller.dispatchEvent(new window.Event('scroll')); }); + await render(); + }, + async append(id: string, size: number) { + addTurn(id, height, size); height += size; + props.messages = [...messages]; + 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, range: () => ({ sessionId }) } }; + await render(); + }, + async flushFrames() { + await act(() => { const pending = [...frames.values()]; frames.clear(); for (const callback of pending) callback(0); }); + }, + }; +} diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index 91022b9777..77331b68e3 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -380,7 +380,7 @@ test('Coordination transcript adapter never replays history and completes only t const sessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'coordination' }); const snapshots: unknown[] = []; let closes = 0; - const latestLoads: Array<{ sequence: number; maxBytes: number | undefined }> = []; + const latestLoads: Array<{ sequence: number | null; maxBytes: number | undefined }> = []; let deliver: ((batch: DesktopTranscriptBatch) => void) | undefined; const adapter = createDesktopWorkHubCoordinationPort({ sessionId, diff --git a/apps/desktop/src/main/desktop-transcript-ipc.ts b/apps/desktop/src/main/desktop-transcript-ipc.ts index 86bf93495b..26c7efb2bc 100644 --- a/apps/desktop/src/main/desktop-transcript-ipc.ts +++ b/apps/desktop/src/main/desktop-transcript-ipc.ts @@ -30,6 +30,7 @@ import type { } from './desktop-transcript-replica.js'; interface TranscriptBatchIdentity { + readonly navigationVersion?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index 0d6ace1efe..d7c8568f3c 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -32,6 +32,7 @@ import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + type DesktopTranscriptNavigation, } from '../preload/transcript-contract.js'; import type { DesktopRuntimeHostSession } from './runtime-host-client.js'; @@ -54,6 +55,7 @@ export interface DesktopSequencedTranscriptMessage { } export interface DesktopTranscriptReplicaSnapshot { + readonly navigationVersion?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; @@ -96,6 +98,7 @@ export class DesktopTranscriptReplica { #residentBytes = 0; #overlayBytes = 0; #durableThrough: number | null; + #overlaySettledThrough: number | null; #targetThrough: number | null; #hasOlder: boolean; #hasNewer = false; @@ -104,6 +107,11 @@ export class DesktopTranscriptReplica { #closed = false; #catchUpTask: Promise | undefined; #operationTail = Promise.resolve(); + #navigationToken = 0; + #intent: DesktopTranscriptNavigation['intent'] = 'followTail'; + #readingAnchorSequence: number | undefined; + #readingAnchorTurnId: string | undefined; + #adjacentReadingSequence: number | undefined; private constructor( handle: DesktopRuntimeHostSession, @@ -123,6 +131,7 @@ export class DesktopTranscriptReplica { this.#accountPreparationBytes = options.accountPreparationBytes ?? (() => undefined); this.#onChange = options.onChange ?? (() => undefined); this.#durableThrough = handle.transcriptBootstrap.throughSequence; + this.#overlaySettledThrough = this.#durableThrough; this.#targetThrough = this.#durableThrough; this.#hasOlder = handle.transcriptBootstrap.durable.nextCursor !== null; } @@ -224,23 +233,135 @@ export class DesktopTranscriptReplica { return latest?.message.id ?? null; } + setNavigation(intent: DesktopTranscriptNavigation['intent']): number { + this.#assertOpen(); + this.#intent = intent; + if (intent === 'followTail') { + this.#readingAnchorSequence = undefined; + this.#readingAnchorTurnId = undefined; + this.#adjacentReadingSequence = undefined; + } + return ++this.#navigationToken; + } + + readAt( + sequence: number | null, + token = this.setNavigation('history'), + readingTurnId?: string, + ): Promise { + return this.#enqueue(async () => { + if (!this.#isNavigationCurrent(token)) return; + let anchor = readingTurnId === undefined + ? sequence + : this.#sequenceForTurn(readingTurnId) ?? sequence; + const through = this.#targetThrough ?? this.#durableThrough; + if (anchor === null && readingTurnId !== undefined && through !== null && + !(through <= (this.#overlaySettledThrough ?? -1) && + [...this.#overlay.values()].some((message) => message.turnId === readingTurnId))) { + // After reconnect, an overlay-only bookmark may already be durable and + // outside the bootstrap tail. Locate it through the existing bounded + // pager; retaining only its sequence keeps the scan's memory bounded. + anchor = await this.#findTurnSequence(readingTurnId, through, token); + } + if (!this.#isNavigationCurrent(token)) return; + if (anchor === null) { + // Active RuntimeEvent invocations have no durable sequence. Put the + // durable range at its current tail, then protect the requested Turn + // when catch-up first projects it. Never borrow another Turn's anchor. + if (through !== null && this.#hasNewer) { + await this.#replaceWithRange(through, through, this.#maxResidentBytes, token); + } + if (!this.#isNavigationCurrent(token)) return; + this.#readingAnchorTurnId = readingTurnId; + this.#readingAnchorSequence = undefined; + this.#adjacentReadingSequence = undefined; + this.#publish([], [], []); + return; + } + if (!this.#durable.has(anchor)) { + if (through !== null && anchor <= through) { + await this.#replaceWithRange(through, anchor, this.#maxResidentBytes, token); + } + return; + } + this.#readingAnchorSequence = anchor; + this.#readingAnchorTurnId = readingTurnId ?? this.#durable.get(anchor)?.message.turnId; + this.#adjacentReadingSequence = undefined; + const evicted = this.#evictToBudget(undefined, 'newest', anchor); + this.#publish([], [], evicted); + }); + } + + #sequenceForTurn(turnId: string): number | undefined { + for (const entry of this.#orderedDurable(false)) { + if (entry.message.turnId === turnId) return entry.sequence; + } + return undefined; + } + + #resolveReadingAnchor(): number | undefined { + if (this.#readingAnchorTurnId !== undefined) { + this.#readingAnchorSequence = this.#sequenceForTurn(this.#readingAnchorTurnId) + ?? this.#readingAnchorSequence; + } + return this.#readingAnchorSequence; + } + + #awaitingReadingTurn(): boolean { + return this.#readingAnchorTurnId !== undefined && this.#resolveReadingAnchor() === undefined; + } + + async #findTurnSequence(turnId: string, throughSequence: number, token: number): Promise { + let cursor: string | null = null; + do { + if (!this.#isNavigationCurrent(token)) return null; + const page = await this.#handle.loadTranscriptPage({ + source: 'durable', direction: 'older', throughSequence, + cursor, anchorSequence: null, maxBytes: this.#maxResidentBytes, + }); + let sequence: number | undefined; + await this.#withDecodedPage(page, (decoded) => { + if (!this.#isNavigationCurrent(token)) return; + sequence = decoded.messages.find((entry) => entry.message.turnId === turnId)?.identity; + cursor = decoded.nextCursor; + }); + if (!this.#isNavigationCurrent(token)) return null; + if (sequence !== undefined) return sequence; + } while (cursor !== null); + return null; + } + + async followLatest(maxBytes: number, token = this.setNavigation('followTail')): Promise { + return this.#enqueue(async () => { + if (!this.#isNavigationCurrent(token)) return; + const through = this.#targetThrough ?? this.#durableThrough; + if (through !== null) await this.#replaceWithRange(through, through, maxBytes, token); + }); + } + async loadBefore( anchorSequence: number | null, maxBytes: number, + token = this.setNavigation('history'), ): Promise { - return this.#enqueue(() => this.#loadAdjacent('older', anchorSequence, maxBytes)); + return this.#enqueue(() => this.#loadAdjacent('older', anchorSequence, maxBytes, token)); } - async loadAfter(anchorSequence: number | null, maxBytes: number): Promise { - return this.#enqueue(() => this.#loadAdjacent('newer', anchorSequence, maxBytes)); + async loadAfter( + anchorSequence: number | null, + maxBytes: number, + token = this.setNavigation('history'), + ): Promise { + return this.#enqueue(() => this.#loadAdjacent('newer', anchorSequence, maxBytes, token)); } async #loadAdjacent( direction: 'older' | 'newer', anchorSequence: number | null, maxBytes: number, + token: number, ): Promise { - this.#assertOpen(); + if (!this.#isNavigationCurrent(token)) return; const throughSequence = this.#durableThrough; if (throughSequence === null) return; const anchor = anchorSequence ?? (direction === 'older' @@ -255,7 +376,7 @@ export class DesktopTranscriptReplica { maxBytes, }); await this.#withDecodedPage(page, (decoded) => { - this.#assertOpen(); + if (!this.#isNavigationCurrent(token)) return; // Same post-await `#resident` invariant as `#replaceWithRange` and the // paged catch-up: a concurrent `discard()` may have reclaimed this // replica while the adjacent page was in flight. Installing the page here @@ -279,6 +400,9 @@ export class DesktopTranscriptReplica { const adjacent = towardEdge.find(({ message }) => messageTurnId(message) !== undefined && messageTurnId(message) !== anchorTurnId, ) ?? towardEdge.at(-1); + this.#readingAnchorSequence = anchor ?? undefined; + this.#readingAnchorTurnId = anchorTurnId; + this.#adjacentReadingSequence = adjacent?.identity; const evictedDurableSequences = this.#evictToBudget( undefined, direction === 'older' ? 'newest' : 'oldest', @@ -289,21 +413,26 @@ export class DesktopTranscriptReplica { }); } - async loadAround(sequence: number, maxBytes: number): Promise { - return this.#enqueue(() => this.#loadAround(sequence, maxBytes)); + async loadAround( + sequence: number, + maxBytes: number, + token = this.setNavigation('history'), + ): Promise { + return this.#enqueue(() => this.#loadAround(sequence, maxBytes, token)); } - async #loadAround(sequence: number, maxBytes: number): Promise { - this.#assertOpen(); + async #loadAround(sequence: number, maxBytes: number, token: number): Promise { + if (!this.#isNavigationCurrent(token)) return; const throughSequence = this.#durableThrough; if (throughSequence === null || sequence > throughSequence) return; - await this.#replaceWithRange(throughSequence, sequence, maxBytes); + await this.#replaceWithRange(throughSequence, sequence, maxBytes, token); } async #replaceWithRange( throughSequence: number, sequence: number, maxBytes: number, + token: number, ): Promise { const loadTail = sequence === throughSequence; const page = await this.#handle.loadTranscriptPage({ @@ -314,6 +443,7 @@ export class DesktopTranscriptReplica { anchorSequence: loadTail ? sequence + 1 : sequence === 0 ? null : sequence - 1, maxBytes, }); + if (!this.#isNavigationCurrent(token)) return; // A durable sequence is an event ordinal times its stride, so the oldest row // of a Session is at no fixed number and `sequence > 0` cannot answer this. // Ask for one row older than the anchor instead; a jump is user-initiated, @@ -329,7 +459,7 @@ export class DesktopTranscriptReplica { maxBytes: 1, }); await this.#withDecodedPage(page, (decoded) => { - this.#assertOpen(); + if (!this.#isNavigationCurrent(token)) return; // `#resident` can flip to false across the `await` above (a concurrent // `discard()` reclaims memory for a non-visible session while the page is // in flight). Re-anchoring here would repopulate durable state and undo @@ -350,6 +480,10 @@ export class DesktopTranscriptReplica { this.#clearDurable(); const completedOverlayMessageIds = this.#installDurable(decoded.messages); this.#durableThrough = throughSequence; + this.#readingAnchorSequence = this.#intent === 'history' ? sequence : undefined; + this.#readingAnchorTurnId = this.#intent === 'history' + ? this.#durable.get(sequence)?.message.turnId : undefined; + this.#adjacentReadingSequence = undefined; this.#hasOlder = loadTail ? decoded.nextCursor !== null : older!.fragments.length > 0; this.#hasNewer = loadTail ? false : decoded.nextCursor !== null; evictedDurableSequences.push( @@ -361,6 +495,9 @@ export class DesktopTranscriptReplica { ); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); }); + if (this.#isNavigationCurrent(token) && this.#needsOverlaySettlement(throughSequence)) { + await this.#settleOverlayThrough(throughSequence, token); + } } advance(throughSequence: number): Promise { @@ -377,7 +514,8 @@ export class DesktopTranscriptReplica { if ( !this.#closed && this.#targetThrough !== null && - (this.#durableThrough === null || this.#targetThrough > this.#durableThrough) + (this.#durableThrough === null || this.#targetThrough > this.#durableThrough || + this.#needsOverlaySettlement(this.#targetThrough)) ) { void this.advance(this.#targetThrough).catch(() => undefined); } @@ -421,27 +559,32 @@ export class DesktopTranscriptReplica { async #catchUp(): Promise { while (!this.#closed && this.#resident) { const target = this.#targetThrough; - if (target === null || (this.#durableThrough !== null && target <= this.#durableThrough)) { + if (target === null) return; + if ( + this.#durableThrough !== null && target <= this.#durableThrough && + !this.#needsOverlaySettlement(target) + ) return; + const token = this.#navigationToken; + if ( + (this.#durableThrough !== null && target <= this.#durableThrough) || + (this.#intent === 'history' && this.#hasNewer && !this.#awaitingReadingTurn()) + ) { + await this.#settleOverlayThrough(target, token); + if (!this.#isNavigationCurrent(token)) return; + if (this.#durableThrough !== null && target <= this.#durableThrough) return; + this.#durableThrough = target; + this.#publish([], [], []); return; } - if (this.#hasNewer) { - // The resident window was trimmed off the tail (e.g. after loading - // older history, `#evictToBudget(..., 'newest')` set `#hasNewer`), so - // `target` cannot be appended contiguously to what is resident. Bumping - // the watermark and publishing an empty change here silently dropped - // the freshly persisted message: an already-open consumer never learned - // about it and only a fresh subscription (the user switching sessions - // and back) re-read it. Re-anchor to the newest window instead — the - // same recovery a fresh subscription performs — so the append reaches - // open consumers live. - await this.#replaceWithRange(target, target, 512 * 1024); + if (this.#hasNewer && !this.#awaitingReadingTurn()) { + await this.#replaceWithRange(target, target, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, token); return; } let cursor: string | null = null; const anchorSequence = this.#durableThrough; let nextSequence = (anchorSequence ?? -1) + 1; do { - if (!this.#resident) return; + if (!this.#isNavigationCurrent(token)) return; const page: SessionTranscriptPage = await this.#handle.loadTranscriptPage({ source: 'durable', direction: 'newer', @@ -451,8 +594,7 @@ export class DesktopTranscriptReplica { maxBytes: 512 * 1024, }); await this.#withDecodedPage(page, (decoded) => { - this.#assertOpen(); - if (!this.#resident) return; + if (!this.#isNavigationCurrent(token)) return; if (decoded.messages.length === 0 && decoded.nextCursor !== null) { throw correlationError('Desktop transcript catch-up returned an empty continuation'); } @@ -467,14 +609,33 @@ export class DesktopTranscriptReplica { nextSequence = decoded.messages.at(-1)!.identity + 1; } const completedOverlayMessageIds = this.#installDurable(decoded.messages); + this.#acknowledgeOverlayCoverage(anchorSequence, decoded.messages.at(-1)?.identity); + // Reading the start of the still-growing latest Turn must continue + // receiving its durable text. Preserve the reader's anchor (and an + // older page awaiting the reader), rather than protecting a new Turn + // that could evict the range they are reading. + const readingHistory = this.#intent === 'history'; + const readingSequence = readingHistory ? this.#resolveReadingAnchor() : undefined; + const awaitingReadingTurn = readingHistory && this.#awaitingReadingTurn(); const evictedDurableSequences = this.#evictToBudget( undefined, - 'oldest', - page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, + readingHistory && !awaitingReadingTurn ? 'newest' : 'oldest', + readingHistory && !awaitingReadingTurn + ? readingSequence ?? this.#oldestSequence() ?? undefined + : page.protectedTurnSequence ?? decoded.messages.at(-1)?.identity, + readingHistory ? this.#adjacentReadingSequence : undefined, ); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); cursor = decoded.nextCursor; }); + if (!this.#isNavigationCurrent(token)) return; + if (this.#intent === 'history' && this.#hasNewer && !this.#awaitingReadingTurn()) { + await this.#settleOverlayThrough(target, token); + if (!this.#isNavigationCurrent(token)) return; + this.#durableThrough = target; + this.#publish([], [], []); + return; + } } while (cursor !== null); // A concurrent `discard()` (LRU reclaim for another observed session) can // flip `#resident` to false across any page `await` above. The per-page @@ -483,7 +644,8 @@ export class DesktopTranscriptReplica { // turn a benign memory reclaim into a fatal `correlation_changed` that // drives the session terminal. A discarded replica has no watermark to // meet, so return cleanly and let a later resume re-catch-up. - if (!this.#resident) return; + if (!this.#isNavigationCurrent(token)) return; + this.#acknowledgeOverlayCoverage(anchorSequence, target); this.#durableThrough = target; this.#publish([], [], []); } @@ -498,13 +660,73 @@ export class DesktopTranscriptReplica { } } + #needsOverlaySettlement(throughSequence: number): boolean { + return this.#overlay.size > 0 && + (this.#overlaySettledThrough === null || throughSequence > this.#overlaySettledThrough); + } + + #acknowledgeOverlayCoverage(anchorSequence: number | null, throughSequence: number | undefined): void { + if ( + throughSequence !== undefined && + (anchorSequence ?? -1) <= (this.#overlaySettledThrough ?? -1) + ) { + this.#overlaySettledThrough = Math.max(this.#overlaySettledThrough ?? -1, throughSequence); + } + } + + async #settleOverlayThrough(throughSequence: number, token: number): Promise { + // A navigation can skip durable pages while the bootstrap overlay still + // contains an unfinished message from one of those pages. Its settlement + // watermark must therefore be independent of the visible range watermark. + // Only matching durable identities retire overlay records; unrelated new + // messages are decoded one page at a time without entering the range. + if (!this.#needsOverlaySettlement(throughSequence)) return; + const anchorSequence = this.#overlaySettledThrough; + let nextSequence = (anchorSequence ?? -1) + 1; + let cursor: string | null = null; + do { + if (!this.#isNavigationCurrent(token)) return; + const page = await this.#handle.loadTranscriptPage({ + source: 'durable', + direction: 'newer', + throughSequence, + cursor, + anchorSequence: cursor === null ? anchorSequence : null, + maxBytes: 512 * 1024, + }); + await this.#withDecodedPage(page, (decoded) => { + if (!this.#isNavigationCurrent(token)) return; + if (decoded.messages.length === 0 && decoded.nextCursor !== null) { + throw correlationError('Desktop transcript overlay settlement returned an empty continuation'); + } + this.#acceptRange(decoded.messages); + if (decoded.messages.length > 0) { + if (!this.#matchesCoverageStep(decoded.messages[0]!.identity, nextSequence)) { + throw correlationError('Desktop transcript overlay settlement has a sequence gap'); + } + const lastSequence = decoded.messages.at(-1)!.identity; + nextSequence = lastSequence + 1; + this.#overlaySettledThrough = lastSequence; + } + const completedOverlayMessageIds = this.#completeOverlay(decoded.messages); + if (completedOverlayMessageIds.length > 0) { + this.#publish([], completedOverlayMessageIds, []); + } + cursor = decoded.nextCursor; + }); + if (!this.#isNavigationCurrent(token) || this.#overlay.size === 0) return; + } while (cursor !== null); + // RuntimeEvent projection can leave gaps and a watermark beyond its last + // visible row. Exhausting the correlated cursor establishes coverage. + this.#overlaySettledThrough = throughSequence; + } + #installDurable( messages: readonly { readonly identity: number; readonly message: StoredMessage; }[], ): string[] { - const completedOverlayMessageIds: string[] = []; for (const item of messages) { const previous = this.#durable.get(item.identity); if (previous && previous.message.id !== item.message.id) { @@ -519,6 +741,13 @@ export class DesktopTranscriptReplica { encodedBytes, }); this.#adjustResidentBytes(encodedBytes); + } + return this.#completeOverlay(messages); + } + + #completeOverlay(messages: readonly { readonly message: StoredMessage }[]): string[] { + const completedOverlayMessageIds: string[] = []; + for (const { message } of messages) { const overlay = this.#overlay.get(message.id); if (overlay) { this.#overlay.delete(message.id); @@ -731,6 +960,10 @@ export class DesktopTranscriptReplica { } } + #isNavigationCurrent(token: number): boolean { + return !this.#closed && this.#resident && token === this.#navigationToken; + } + #assertOpen(): void { if (this.#closed) throw new Error('Desktop transcript replica is closed'); } 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 02022899b9..fa1f0d0504 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 @@ -827,12 +827,26 @@ function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRange if (!Number.isSafeInteger(maxBytes)) { throw new Error('Invalid Desktop transcript range byte limit'); } + if ( + (value.navigationVersion !== undefined && + (!Number.isSafeInteger(value.navigationVersion) || (value.navigationVersion as number) < 0)) || + (value.intent !== undefined && value.intent !== 'history' && value.intent !== 'followTail') || + (value.preserveRange !== undefined && typeof value.preserveRange !== 'boolean') || + (value.readingTurnId !== undefined && + (typeof value.readingTurnId !== 'string' || value.readingTurnId.length === 0)) + ) { + 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, + navigationVersion: value.navigationVersion as number | undefined, + intent: value.intent as DesktopTranscriptRangeRequest['intent'], + preserveRange: value.preserveRange as boolean | undefined, + readingTurnId: value.readingTurnId as string | undefined, }; } 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 b826971f11..78138741ca 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -95,6 +95,19 @@ function isMissingRuntimeHostSessionError(error: unknown): boolean { return error.code === "not_found"; } +function recoveredTranscriptRequest( + request: DesktopTranscriptRangeRequest, + hostEpoch: string, +): DesktopTranscriptRangeRequest { + return { + ...request, preserveRange: false, + ...(request.hostEpoch === hostEpoch ? {} : { + hostEpoch, anchorSequence: null, + ...(request.readingTurnId === undefined ? { intent: 'followTail' as const } : {}), + }), + }; +} + interface SessionObservationRegistration { readonly sessionId: string; readonly messageAdmissions: boolean; @@ -110,7 +123,15 @@ interface TranscriptRegistration { readonly destroyedListener: () => void; readonly ready: TranscriptReadiness; restore: ObservationReadiness | undefined; + restoreOpened: boolean; + hostEpoch?: string; + recoveredIdentity?: { + readonly source: SessionObservationSource; + readonly previousHostEpoch: string; + readonly hostEpoch: string; + }; lifecycle: 'pending' | 'active'; + navigation?: { readonly request: DesktopTranscriptRangeRequest }; } interface TranscriptReadiness { @@ -352,6 +373,7 @@ export class RuntimeHostSessionObservationRegistry { destroyedListener, ready, restore: undefined, + restoreOpened: false, lifecycle: 'pending', }; this.#transcripts.set(consumerId, registration); @@ -367,6 +389,7 @@ export class RuntimeHostSessionObservationRegistry { ); if (this.#source === source && this.#transcripts.get(consumerId) === registration) { registration.lifecycle = 'active'; + registration.hostEpoch = result.hostEpoch; registration.ready.resolve(result); } else { await transcriptSource.closeTranscript(consumerId); @@ -385,8 +408,10 @@ export class RuntimeHostSessionObservationRegistry { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptOperation(request.consumerId, (source) => - source.loadTranscriptBefore(request, targetId), + await this.#runTranscriptOperation(request, (source, accepted) => + accepted === request + ? source.loadTranscriptBefore(accepted, targetId) + : source.loadTranscriptAround(accepted, targetId), ); } @@ -394,8 +419,8 @@ export class RuntimeHostSessionObservationRegistry { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptOperation(request.consumerId, (source) => - source.loadTranscriptAround(request, targetId), + await this.#runTranscriptOperation(request, (source, accepted) => + source.loadTranscriptAround(accepted, targetId), ); } @@ -403,8 +428,10 @@ export class RuntimeHostSessionObservationRegistry { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptOperation(request.consumerId, (source) => - source.loadTranscriptAfter(request, targetId), + await this.#runTranscriptOperation(request, (source, accepted) => + accepted === request + ? source.loadTranscriptAfter(accepted, targetId) + : source.loadTranscriptAround(accepted, targetId), ); } @@ -488,30 +515,46 @@ export class RuntimeHostSessionObservationRegistry { } async #runTranscriptOperation( - consumerId: string, - operation: (source: SessionObservationSource & TranscriptSource) => Promise, + request: DesktopTranscriptRangeRequest, + operation: ( + source: SessionObservationSource & TranscriptSource, + accepted: DesktopTranscriptRangeRequest, + ) => Promise, ): Promise { + const consumerId = request.consumerId; const registration = this.#transcripts.get(consumerId); if (!registration) { throw new Error('Desktop transcript consumer does not exist'); } + if ((request.navigationVersion ?? 0) < (registration.navigation?.request.navigationVersion ?? 0)) return; + const navigation = { request }; + registration.navigation = navigation; const source = requireTranscriptSource(this.#source); try { const restore = registration.restore; - if (restore) await restore.promise; + if (restore && !registration.restoreOpened) await restore.promise; if ( this.#source !== source || - this.#transcripts.get(consumerId) !== registration + this.#transcripts.get(consumerId) !== registration || + registration.navigation !== navigation ) { return; } - await operation(source); + const recovered = registration.recoveredIdentity; + if (recovered?.source === source && request.hostEpoch === recovered.previousHostEpoch) { + // A successful open proves this consumer moved to this source. Its + // preload may still await the replay snapshot before learning the new + // epoch; admit a newer intent without accepting arbitrary stale epochs. + navigation.request = recoveredTranscriptRequest(request, recovered.hostEpoch); + } + await operation(source, navigation.request); } catch (error) { // Once either owner changes, this rejection belongs to stale work and // must not escape as a failure of the current renderer intent. if ( this.#source !== source || - this.#transcripts.get(consumerId) !== registration + this.#transcripts.get(consumerId) !== registration || + registration.navigation !== navigation ) { return; } @@ -525,6 +568,7 @@ export class RuntimeHostSessionObservationRegistry { const restore = observationReadiness(); void restore.promise.catch(() => undefined); registration.restore = restore; + registration.restoreOpened = false; void this.#restoreTranscript(source, consumerId, registration, restore); } } @@ -547,6 +591,23 @@ export class RuntimeHostSessionObservationRegistry { this.#transcripts.get(consumerId) === registration && registration.restore === restore ) { + const previousHostEpoch = registration.hostEpoch; + if (previousHostEpoch && previousHostEpoch !== result.hostEpoch) { + registration.recoveredIdentity = { source, previousHostEpoch, hostEpoch: result.hostEpoch }; + } + registration.hostEpoch = result.hostEpoch; + registration.restoreOpened = true; + // Reconnection owns no new navigation intent. Reapply only the latest + // command admitted while the old source was alive or recovery waited. + const navigation = registration.navigation; + if (navigation) { + const request = recoveredTranscriptRequest(navigation.request, result.hostEpoch); + await transcriptSource.loadTranscriptAround(request, registration.target.id); + if (this.#source !== source || registration.restore !== restore) { + restore.resolve(); + return; + } + } registration.lifecycle = 'active'; registration.ready.resolve(result); registration.restore = undefined; diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 011fdbe5da..2993097826 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -129,6 +129,9 @@ interface TranscriptConsumer { readonly consumerId: string; readonly target: RuntimeHostTranscriptTarget; generation: string; + navigationVersion: number; + navigationPending: boolean; + navigationRequest?: DesktopTranscriptRangeRequest; deliverySequence: number; deliveryBytes: number; deliveryTask?: Promise; @@ -293,6 +296,8 @@ export class RuntimeHostSessionObserver { consumerId, target, generation: replica.generation, + navigationVersion: 0, + navigationPending: false, deliverySequence: 0, deliveryBytes: 0, resetRequested: false, @@ -332,10 +337,11 @@ export class RuntimeHostSessionObserver { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, (replica) => + await this.#runTranscriptRangeOperation(request, targetId, (replica, token) => replica.loadBefore( request.anchorSequence, requireTranscriptRangeBytes(request.maxBytes), + token, ), ); } @@ -344,13 +350,21 @@ export class RuntimeHostSessionObserver { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, (replica) => { + await this.#runTranscriptRangeOperation(request, targetId, (replica, token) => { + if (request.intent === 'followTail') { + return replica.followLatest(requireTranscriptRangeBytes(request.maxBytes), token); + } + if (request.readingTurnId !== undefined) { + return replica.readAt(request.anchorSequence, token, request.readingTurnId); + } if (request.anchorSequence === null) { throw new Error('Desktop transcript around request requires an anchor'); } + if (request.preserveRange) return replica.readAt(request.anchorSequence, token); return replica.loadAround( request.anchorSequence, requireTranscriptRangeBytes(request.maxBytes), + token, ); }); } @@ -359,10 +373,11 @@ export class RuntimeHostSessionObserver { request: DesktopTranscriptRangeRequest, targetId?: number, ): Promise { - await this.#runTranscriptRangeOperation(request, targetId, (replica) => + await this.#runTranscriptRangeOperation(request, targetId, (replica, token) => replica.loadAfter( request.anchorSequence, requireTranscriptRangeBytes(request.maxBytes), + token, ), ); } @@ -370,18 +385,40 @@ export class RuntimeHostSessionObserver { async #runTranscriptRangeOperation( request: DesktopTranscriptRangeRequest, targetId: number | undefined, - operation: (replica: DesktopTranscriptReplica) => Promise, + operation: (replica: DesktopTranscriptReplica, token: number) => Promise, ): Promise { const { state, replica, consumer } = this.#requireTranscriptConsumer(request, targetId); + const version = request.navigationVersion ?? consumer.navigationVersion; + if (!Number.isSafeInteger(version) || version < 0) throw new Error('Invalid transcript navigation version'); + if (version < consumer.navigationVersion) return; + if (request.intent !== undefined && request.intent !== 'history' && request.intent !== 'followTail') { + throw new Error('Invalid transcript navigation intent'); + } + consumer.navigationVersion = version; + consumer.navigationRequest = request; + consumer.navigationPending = true; + consumer.resetRequested = true; + this.#clearPendingTranscriptChange(consumer); + // Admission invalidates in-flight pages immediately, before the replica's + // operation queue can run the newer command. + const token = replica.setNavigation(request.intent ?? 'history'); const isCurrent = () => state.replica === replica && - state.transcriptConsumers.get(request.consumerId) === consumer; - const task = operation(replica); + state.transcriptConsumers.get(request.consumerId) === consumer && + consumer.navigationRequest === request; try { - await task; + await operation(replica, token); + if (!isCurrent()) return; + consumer.navigationPending = false; + // Already dispatched batches remain ACKable; finish draining them before + // issuing the authoritative snapshot for this navigation. await consumer.deliveryTask; + if (!isCurrent()) return; + consumer.resetRequested = true; + await this.#scheduleTranscriptDelivery(state, consumer); } catch (error) { if (!isCurrent()) return; + consumer.navigationPending = false; throw error; } if (isCurrent()) this.#touchReplica(state); @@ -1147,7 +1184,14 @@ export class RuntimeHostSessionObserver { #resetTranscriptConsumers(state: ObservedSessionState): void { for (const consumer of [...state.transcriptConsumers.values()]) { - this.#requestTranscriptReset(state, consumer); + const request = consumer.navigationRequest; + if (request && request.hostEpoch === state.replica?.hostEpoch) { + const recoveryRequest = { ...request, preserveRange: false }; + void this.loadTranscriptAround(recoveryRequest, consumer.target.id).catch(() => undefined); + } else { + consumer.navigationPending = false; + this.#requestTranscriptReset(state, consumer); + } } } @@ -1160,6 +1204,7 @@ export class RuntimeHostSessionObserver { task = (async () => { try { while (state.transcriptConsumers.get(consumer.consumerId) === consumer) { + if (consumer.navigationPending) return; if (consumer.resetRequested) { consumer.resetRequested = false; this.#clearPendingTranscriptChange(consumer); @@ -1173,7 +1218,10 @@ export class RuntimeHostSessionObserver { try { await this.#sendTranscriptBatches( consumer, - encodeDesktopTranscriptSnapshot(replica.snapshot()), + encodeDesktopTranscriptSnapshot({ + ...replica.snapshot(), + navigationVersion: consumer.navigationVersion, + }), ); } finally { this.#adjustTranscriptDeliveryBytes(consumer, -deliveryBytes); @@ -1196,6 +1244,7 @@ export class RuntimeHostSessionObserver { sessionId: replica.sessionId, generation: replica.generation, hostEpoch: replica.hostEpoch, + navigationVersion: consumer.navigationVersion, }, { durableThrough: pending.durableThrough, @@ -1370,6 +1419,7 @@ export class RuntimeHostSessionObserver { ): Promise { const deliveries = new Set>(); for (const batch of batches) { + if ((batch.navigationVersion ?? 0) !== consumer.navigationVersion || consumer.navigationPending) break; let delivery!: Promise; delivery = this.#deliverTranscriptBatch(consumer, batch).finally(() => { deliveries.delete(delivery); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 99790e2880..dd3b7975ad 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -84,6 +84,7 @@ import { type DesktopTranscriptBatch, type DesktopTranscriptHandle, type DesktopTranscriptOpenResult, + type DesktopTranscriptNavigation, } from './transcript-contract.js'; import { adoptTranscriptIdentity, @@ -2477,6 +2478,8 @@ const makaBridge = { const channel = `sessions:transcript:${consumerId}`; let identity: DesktopTranscriptIdentity | undefined; let cachedIdentity: DesktopTranscriptIdentity | undefined; + let navigationVersion = 0; + const retiredGenerations = new Set(); let closed = false; let requestClose = () => {}; let consumerScope: DesktopTargetScope | undefined; @@ -2495,12 +2498,15 @@ const makaBridge = { host.targetEpoch !== consumerScope.targetEpoch ) return; batch = assertDesktopTranscriptBatch(value); - const adopted = adoptTranscriptIdentity(identity, batch); - if (adopted !== identity) { - identity = adopted; - consumerScope = host; + if ((batch.navigationVersion ?? 0) === navigationVersion && !retiredGenerations.has(batch.generation)) { + const adopted = adoptTranscriptIdentity(identity, batch); + if (adopted !== identity) { + if (identity && identity.generation !== adopted.generation) retiredGenerations.add(identity.generation); + identity = adopted; + consumerScope = host; + } + if (identity !== undefined && batch.generation === identity.generation) handler(batch); } - if (identity !== undefined && batch.generation === identity.generation) handler(batch); } catch (error) { requestClose(); throw error; @@ -2570,7 +2576,14 @@ const makaBridge = { operation: 'sessions:transcript:load-before' | 'sessions:transcript:load-after' | 'sessions:transcript:load-around', anchorSequence: number | null, maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + navigation?: DesktopTranscriptNavigation, ): Promise => { + const nextNavigation = navigation ?? { + navigationVersion: navigationVersion + 1, + intent: 'history' as const, + }; + if (nextNavigation.navigationVersion < navigationVersion) return Promise.resolve(); + navigationVersion = nextNavigation.navigationVersion; const currentIdentity = identity; if (!currentIdentity) { throw new Error('Desktop transcript identity is unavailable'); @@ -2581,17 +2594,21 @@ const makaBridge = { hostEpoch: currentIdentity.hostEpoch, anchorSequence, maxBytes, + navigationVersion: nextNavigation.navigationVersion, + intent: nextNavigation.intent, + preserveRange: nextNavigation.preserveRange, + readingTurnId: nextNavigation.readingTurnId, }) as Promise; }; return { ...opened, sessionId, - loadBefore: (anchorSequence, maxBytes) => - range('sessions:transcript:load-before', anchorSequence, maxBytes), - loadAfter: (anchorSequence, maxBytes) => - range('sessions:transcript:load-after', anchorSequence, maxBytes), - loadAround: (sequence, maxBytes) => - range('sessions:transcript:load-around', sequence, maxBytes), + 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), 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 d01176dd51..f729fefdce 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -23,6 +23,13 @@ export const DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS = 10; export const DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES = 16 * 1024 * 1024; export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; +export interface DesktopTranscriptNavigation { + readonly navigationVersion: number; + readonly intent: 'history' | 'followTail'; + readonly preserveRange?: boolean; + readonly readingTurnId?: string; +} + export interface DesktopTranscriptFragment { readonly source: 'durable' | 'overlay'; readonly identity: number | string; @@ -33,6 +40,8 @@ export interface DesktopTranscriptFragment { } export interface DesktopTranscriptBatchPayload { + /** An omitted version is zero, including cached bootstrap snapshots. */ + readonly navigationVersion?: number; readonly sessionId: string; readonly generation: string; readonly hostEpoch: string; @@ -58,6 +67,10 @@ export interface DesktopTranscriptOpenResult { } export interface DesktopTranscriptRangeRequest { + readonly navigationVersion?: number; + readonly intent?: DesktopTranscriptNavigation['intent']; + readonly preserveRange?: boolean; + readonly readingTurnId?: string; readonly consumerId: string; readonly sessionId: string; readonly hostEpoch: string; @@ -66,9 +79,9 @@ export interface DesktopTranscriptRangeRequest { } export interface DesktopTranscriptHandle extends DesktopTranscriptOpenResult { - loadBefore(anchorSequence: number | null, maxBytes?: number): Promise; - loadAfter(anchorSequence: number | null, maxBytes?: number): Promise; - loadAround(sequence: number, maxBytes?: number): Promise; + loadBefore(anchorSequence: number | null, maxBytes?: number, navigation?: DesktopTranscriptNavigation): Promise; + loadAfter(anchorSequence: number | null, maxBytes?: number, navigation?: DesktopTranscriptNavigation): Promise; + loadAround(sequence: number | null, maxBytes?: number, navigation?: DesktopTranscriptNavigation): Promise; close(): Promise; } @@ -79,6 +92,7 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB const batch = value as Record; if ( typeof batch.sessionId !== 'string' || + (batch.navigationVersion !== undefined && !isSequence(batch.navigationVersion)) || !isSequence(batch.deliverySequence) || typeof batch.generation !== 'string' || typeof batch.hostEpoch !== 'string' || diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 1e99389840..1739b1a35f 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -46,7 +46,6 @@ import { showSessionWorkspaceUnavailableToast, } from './session-workspace-errors.js'; import * as skillFeedback from './skill-invocation-feedback.js'; -import { restoreTranscriptTailAfterSend } from './features/conversation/index.js'; import type { DesktopTranscriptRangeController } from './desktop-transcript-range-store.js'; import type { SessionPendingClaim } from './app-shell-session-ui-state.js'; import { @@ -176,6 +175,7 @@ export function createAppShellChatActions(deps: { ) => void; removeTransientMessage: (sessionId: string, messageId: string) => void; transcriptRangeRef: RefBox; + onFollowLatest: (sessionId: string) => Promise; /** #646: arm the "正在处理…" indicator locally at send() — the model-wait * window opens before any SessionEvent arrives (turn_started is not one). */ setLiveTurnBySession: LiveTurnRecordUpdater; @@ -225,6 +225,7 @@ export function createAppShellChatActions(deps: { updateTransientMessage, removeTransientMessage, transcriptRangeRef, + onFollowLatest, setLiveTurnBySession, setInteractionBySession, onInteractionChanged, @@ -558,11 +559,7 @@ export function createAppShellChatActions(deps: { return true; } const sessionId = initialSessionId; - const transcript = transcriptRangeRef.current; - void restoreTranscriptTailAfterSend({ - sessionId, controller: transcript, setMessages, - isCurrent: () => activeIdRef.current === sessionId && transcriptRangeRef.current === transcript, - }); + if (!await onFollowLatest(sessionId)) return false; optimisticSessionId = sessionId; optimisticMessageId = messageId; showTransientUserMessage( diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 24518aa030..c851b3b158 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -73,7 +73,7 @@ import { useCommandPalette } from './command-palette'; import { ChatMessageSurface } from './chat-message-surface'; import { useTaskSubmissionReadiness } from './use-task-submission-readiness'; import * as Conversation from './features/conversation'; -import type { TranscriptHistoryGates, TranscriptHistoryPending } from './features/conversation'; +import type { TranscriptHistoryPending } from './features/conversation'; import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import { LiveTurnReconciler } from './live-turn-reconciler'; import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; @@ -427,7 +427,7 @@ function AppShellContent({ const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPending, setHistoryLoadPending] = useState(); - const historyLoadGatesRef = useRef(new WeakMap()); + const transcriptReadingCommands = useRef(null); const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ sessionId: string; throughSequence: number | null; @@ -1606,6 +1606,7 @@ function AppShellContent({ updateTransientMessage, removeTransientMessage, transcriptRangeRef, + onFollowLatest: (sessionId) => transcriptReadingCommands.current?.prepareSend(sessionId) ?? Promise.resolve(true), setLiveTurnBySession: sessionUiController.setLiveTurnBySession, setInteractionBySession: sessionUiController.setInteractionBySession, onInteractionChanged: markInteractionChanged, @@ -2165,43 +2166,6 @@ function AppShellContent({ setSessionEventHealthBySession: sessionUiController.setSessionEventHealthBySession, toastApi, }); - const newestDurablePromptSequence = Conversation.transcriptReadingPosition.newestDurablePromptSequence( - transcriptRangeRef.current, - activeId, - ); - useEffect(() => Conversation.transcriptReadingPosition.refreshLandmarks({ - sessionId: ownerActiveId, - newestDurablePromptSequence, - current: transcriptTurnIndex, - list: (sessionId) => window.maka.sessions.listTurnLandmarks(sessionId), - isCurrent: (sessionId) => activeIdRef.current === sessionId, - setIndex: setTranscriptTurnIndex, - }), [ownerActiveId, activeIdRef, newestDurablePromptSequence, transcriptTurnIndex]); - useEffect(() => Conversation.transcriptReadingPosition.restoreRange({ - sessionId: activeId, - searchTarget: searchScrollTarget?.handled ? null : searchScrollTarget, - readingAnchor: activeId - ? sessionUiController.transcriptReadingAnchorBySessionRef.current[activeId] - : undefined, - controller: transcriptRangeRef.current, - isCurrent: (sessionId, controller) => - activeIdRef.current === sessionId && transcriptRangeRef.current === controller, - setMessages, - setReadingAnchor: sessionUiController.setTranscriptReadingAnchor, - onRestoreUnavailable: (sessionId, turnId) => { - sessionUiController.setTranscriptRestoreUnavailable(sessionId, turnId); - }, - onError: (error, sessionId) => { - sessionUiController.setMessageLoadErrorBySession((current) => ({ - ...current, - [sessionId]: localizedShellErrorMessage( - error, - desktopConversationCopy.actions.operationFailedFallback, - uiLocale, - ), - })); - }, - }), [activeId, activeSession?.profileId, messages, searchScrollTarget]); useShellRunUpdates({ activeId, setShellRunUpdatesBySession: sessionUiController.setShellRunUpdatesBySession, @@ -2365,41 +2329,6 @@ function AppShellContent({ transcriptRangeRef.current, activeId, ); - function handleTranscriptReadingAnchorChange(turnId?: string) { - if (activeId && activeUnavailableTranscriptRestore) - sessionUiController.setTranscriptRestoreUnavailable(activeId, undefined); - Conversation.transcriptReadingPosition.captureAnchor({ - sessionId: activeId, - currentSessionId: activeIdRef.current, - turnId, - controller: transcriptRangeRef.current, - setAnchor: sessionUiController.setTranscriptReadingAnchor, - }); - } - function loadTranscriptHistory(target: 'earlier' | 'later' | 'latest', anchorTurnId?: string) { - const controller = transcriptRangeRef.current; - const sessionId = activeId; - if (!controller || !sessionId) return; - if (target !== 'earlier') handleTranscriptReadingAnchorChange(); - return Conversation.transcriptReadingPosition.loadHistory({ - gates: historyLoadGatesRef.current, - sessionId, - request: { target, anchorTurnId }, - controller, - maxBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, - isCurrent: () => activeIdRef.current === sessionId && transcriptRangeRef.current === controller, - setPending: setHistoryLoadPending, - onError: (error) => showSessionError( - sessionId, - desktopConversationCopy.actions.messageReadFailedTitle, - localizedShellErrorMessage( - error, - desktopConversationCopy.actions.operationFailedFallback, - uiLocale, - ), - ), - }); - } const homeSurfaceActive = navSelection.section === 'sessions' && messages.length === 0 && @@ -2507,6 +2436,31 @@ function AppShellContent({ } as CSSProperties) } > + setSearchScrollTarget(null)} + sessionUi={sessionUiController} + turnIndex={transcriptTurnIndex} + setTurnIndex={setTranscriptTurnIndex} + listTurnLandmarks={(sessionId) => window.maka.sessions.listTurnLandmarks(sessionId)} + setMessages={setMessages} + setHistoryPending={setHistoryLoadPending} + historyPageBytes={DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES} + 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))} + /> loadTranscriptHistory('latest') + ? () => transcriptReadingCommands.current?.loadHistory('latest') : undefined} hidden={navSelection.section !== 'sessions'} composer={ @@ -2884,7 +2838,7 @@ function AppShellContent({ hasOlderHistory={activeTranscriptRange?.hasOlder} hasNewerHistory={activeTranscriptRange?.hasNewer} historyLoadPending={historyLoadPending} - onLoadHistory={loadTranscriptHistory} + onLoadHistory={(target, anchorTurnId) => transcriptReadingCommands.current?.loadHistory(target, anchorTurnId)} liveContentSeedRevision={liveContent.liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} transientMessages={transientMessages} @@ -2934,7 +2888,7 @@ function AppShellContent({ activeUnavailableTranscriptRestore, )} onReadingAnchorChange={activeId - ? handleTranscriptReadingAnchorChange + ? (turnId) => transcriptReadingCommands.current?.captureAnchor(turnId) : undefined} transcriptTurnIndex={ transcriptTurnIndex && transcriptTurnIndex.sessionId === activeId diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index bcdf041690..df5534a0a8 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -237,6 +237,7 @@ export function ChatMessageSurface({ {(goalProjection) => ( ; loadAfter(maxBytes?: number, anchorTurnId?: string): Promise; loadAround(sequence: number): Promise; + setReadingAnchor(sequence: number | null, readingTurnId?: string): Promise; loadLatest(): Promise; reload(): Promise; close(): Promise; @@ -46,51 +48,95 @@ export function createDesktopTranscriptRangeController( let closed = false; let openController = new AbortController(); let handle = open(openController.signal); + type Navigation = DesktopTranscriptNavigation & { + readonly kind: 'before' | 'after' | 'around' | 'latest' | 'anchor'; + readonly sequence: number | null; + readonly maxBytes?: number; + }; + let navigation: Navigation = { + navigationVersion: 0, intent: 'followTail', kind: 'latest', sequence: null, + }; const current = async () => { if (closed) throw new Error('Desktop transcript range is closed'); return handle; }; + const dispatch = async (command: Navigation) => { + const opening = handle; + const isCurrent = () => !closed && navigation === command && opening === handle; + try { + const value = await current(); + if (!isCurrent()) return; + if (command.kind === 'before') { + await value.loadBefore(command.sequence, command.maxBytes, command); + } else if (command.kind === 'after') { + await value.loadAfter(command.sequence, command.maxBytes, command); + } else { + await value.loadAround(command.sequence, command.maxBytes, command); + } + } catch (error) { + if (isCurrent()) throw error; + } + }; + const navigate = (command: Omit) => { + navigation = { ...command, navigationVersion: navigation.navigationVersion + 1 }; + // Invalidate before awaiting an open handle or any previous page request. + store.expectNavigation(navigation.navigationVersion); + return dispatch(navigation); + }; return { store, - async ready() { - await current(); - }, + async ready() { await current(); }, async waitForDurableMessage(messageId, timeoutMs) { await current(); return store.waitForDurableMessage(messageId, timeoutMs); }, async loadBefore(maxBytes, anchorTurnId) { const range = store.range(); + const anchor = anchorTurnId === undefined ? undefined : store.sequenceForTurn(anchorTurnId); + if (anchor === null) { + await navigate({ intent: 'history', kind: 'anchor', sequence: null, + readingTurnId: anchorTurnId, preserveRange: true }); + return; + } if (!range.hasOlder) return; - await (await current()).loadBefore( - anchorTurnId === undefined - ? range.oldestSequence - : store.sequenceForTurn(anchorTurnId) ?? range.oldestSequence, - maxBytes, - ); + await navigate({ + intent: 'history', kind: 'before', maxBytes, + sequence: anchor ?? range.oldestSequence, + readingTurnId: anchorTurnId, + }); }, - async loadAround(sequence) { - await (await current()).loadAround(sequence); + loadAround(sequence) { + return navigate({ intent: 'history', kind: 'around', sequence }); }, async loadAfter(maxBytes, anchorTurnId) { const range = store.range(); + const anchor = anchorTurnId === undefined ? undefined : store.sequenceForTurn(anchorTurnId, 'last'); + if (anchor === null) { + await navigate({ intent: 'history', kind: 'anchor', sequence: null, + readingTurnId: anchorTurnId, preserveRange: true }); + return; + } if (!range.hasNewer) return; - await (await current()).loadAfter( - anchorTurnId === undefined - ? range.newestSequence - : store.sequenceForTurn(anchorTurnId, 'last') ?? range.newestSequence, - maxBytes, - ); + await navigate({ + intent: 'history', kind: 'after', maxBytes, + sequence: anchor ?? range.newestSequence, + readingTurnId: anchorTurnId, + }); }, - async loadLatest() { - const range = store.range(); - if (!range.hasNewer || range.durableThrough === null) return; - await (await current()).loadAround(range.durableThrough); + setReadingAnchor(sequence, readingTurnId) { + if (navigation.kind === 'anchor' && navigation.sequence === sequence && + navigation.readingTurnId === readingTurnId) { + return Promise.resolve(); + } + return navigate({ intent: 'history', kind: 'anchor', sequence, readingTurnId, preserveRange: true }); + }, + loadLatest() { + return navigate({ intent: 'followTail', kind: 'latest', sequence: null }); }, async reload() { const previous = handle; openController.abort(); - handle = previous + const replacement = previous .then((value) => value.close()) .catch(() => undefined) .then(() => { @@ -98,7 +144,15 @@ export function createDesktopTranscriptRangeController( openController = new AbortController(); return open(openController.signal); }); - await handle; + handle = replacement; + await replacement; + if (closed || handle !== replacement) return; + const command = navigation; + // An anchor notification needs a real range read on a replacement handle. + if (command.kind === 'anchor' || command.kind === 'before' || command.kind === 'after') { + navigation = { ...command, kind: 'around', preserveRange: false }; + } + await dispatch(navigation); }, async close() { if (closed) return; @@ -266,6 +320,8 @@ export class DesktopTranscriptRangeStore { readonly #durableOrder: number[] = []; readonly #overlayOrder: string[] = []; readonly #pending = new Map(); + #navigationVersion = 0; + readonly #retiredGenerations = new Set(); #sourceSessionId: string | undefined; #generation: string | undefined; #hostEpoch: string | undefined; @@ -287,7 +343,17 @@ export class DesktopTranscriptRangeStore { this.#expectedSessionId = sessionId; } + expectNavigation(navigationVersion: number): void { + if (navigationVersion <= this.#navigationVersion) return; + this.#navigationVersion = navigationVersion; + this.#pending.clear(); + this.#batchChanged = false; + } + accept(batch: DesktopTranscriptBatchPayload): boolean { + // A stale reset must be rejected before it can clear the current range. + if ((batch.navigationVersion ?? 0) !== this.#navigationVersion) return false; + if (this.#retiredGenerations.has(batch.generation)) return false; if (batch.reset) this.#reset(batch); if ( batch.sessionId !== this.#sourceSessionId || @@ -401,6 +467,9 @@ export class DesktopTranscriptRangeStore { if (batch.sessionId !== this.#expectedSessionId) { throw new Error('Desktop transcript belongs to a different Session'); } + if (this.#generation && this.#generation !== batch.generation) { + this.#retiredGenerations.add(this.#generation); + } this.#durable.clear(); this.#overlay.clear(); this.#durableOrder.length = 0; diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index 334b948410..039eca4892 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -21,10 +21,22 @@ Conversation owns runtime-only Session presentation state and the policies that connect transcript identity to the Desktop bounded-range controller. Its -public API includes task-readiness presentation and the semantic reading -position operations used by AppShell. +public API includes task-readiness presentation and a headless +`TranscriptReadingPositionController` component. That component owns bookmark +restoration, landmark refresh, and history navigation, while exposing explicit +cancel, capture, and load commands to AppShell. + +Successful send preparation publishes a one-shot viewport command through the +Session UI controller. The message surface forwards that port to ChatView, +where the scroll authority follows the tail. History catches up in the background +so local Message admission does not wait for it. Message growth and bookmark +updates do not replay the command; newer navigation discards stale catch-up results. + +Running Turns have no durable sequence in the RuntimeEvent transcript. Reading +intent therefore carries the Turn ID until persistence supplies its sequence; +later Turns must not displace it just because it began in the live projection. The feature does not access the Desktop bridge. AppShell supplies bounded-range -and landmark ports, and remains responsible for rejecting stale Session and -controller instances. Session Navigation supplies explicit navigation intent +and landmark ports plus current Session and controller identities; the feature +rejects stale completions against those identities. Session Navigation supplies explicit navigation intent only; it does not own transcript state. 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 new file mode 100644 index 0000000000..78a8f00c5e --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx @@ -0,0 +1,205 @@ +/* + * 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 { useEffect, useImperativeHandle, useRef, useState, type Dispatch, type Ref, type SetStateAction } from 'react'; +import type { StoredMessage } from '@maka/core/session'; +import type { AppShellSessionUiStateController } from '../model/session-ui-state.js'; +import { + captureTranscriptReadingAnchor, + createTranscriptRestoreLifecycle, + currentTranscriptRange, + loadTranscriptHistory, + newestDurablePromptSequence, + prepareTranscriptForSend, + refreshTranscriptTurnLandmarks, + restoreSessionTranscriptRange, + type TranscriptHistoryGate, + type TranscriptHistoryGates, + type TranscriptHistoryPending, + type TranscriptHistoryRequest, +} from './transcript-reading-position.js'; + +type RangeController = NonNullable>[0]['controller']> & { + loadBefore(maxBytes?: number, anchorTurnId?: string): Promise; + loadAfter(maxBytes?: number, anchorTurnId?: string): Promise; + loadLatest(): Promise; + setReadingAnchor(sequence: number | null, readingTurnId?: string): Promise; +}; + +interface TurnIndex { + sessionId: string; + throughSequence: number | null; + turns: readonly { turnId: string; sequence: number; label: string }[]; +} + +export interface TranscriptReadingPositionCommands { + cancel(sessionId: string, clearAnchor?: boolean): void; + prepareSend(sessionId: string): Promise; + captureAnchor(turnId?: string): void; + loadHistory(target: TranscriptHistoryRequest['target'], anchorTurnId?: string): Promise; +} + +/** The conversation owns restoration lifetime; the shell supplies explicit ports. */ +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']; + setMessages(messages: StoredMessage[]): void; + setHistoryPending: Dispatch>; + historyPageBytes: number; + onRestoreError(error: unknown, sessionId: string): void; + onNavigationError(error: unknown, sessionId: string): void; +}) { + const [lifecycle] = useState(createTranscriptRestoreLifecycle); + const navigationRevision = useRef(0); + const historyGates = useRef(new WeakMap()); + const isCurrent = (sessionId: string, controller: object) => + props.currentSessionId.current === sessionId && props.rangeController.current === controller; + const cancelHistory = (sessionId: string) => { + const controller = props.rangeController.current; + if (currentTranscriptRange(controller, sessionId) === undefined) return; + if (controller) historyGates.current.delete(controller); + props.setHistoryPending((current) => current?.sessionId === sessionId ? undefined : current); + }; + const cancel = (sessionId: string, clearAnchor = false) => { + lifecycle.cancel(sessionId); + if (props.searchTarget?.sessionId === sessionId) props.clearSearchTarget(); + if (clearAnchor) { + props.sessionUi.setTranscriptReadingAnchor(sessionId, undefined); + props.sessionUi.setTranscriptRestoreUnavailable(sessionId, undefined); + } + }; + useImperativeHandle(props.commands, () => ({ + cancel(sessionId, clearAnchor) { + navigationRevision.current += 1; + cancelHistory(sessionId); + cancel(sessionId, clearAnchor); + }, + prepareSend(sessionId) { + const revision = ++navigationRevision.current; + cancelHistory(sessionId); + return prepareTranscriptForSend({ + sessionId, currentSessionId: props.currentSessionId, + controller: props.rangeController, cancel, setMessages: props.setMessages, + followLatest: props.sessionUi.transcriptViewportNavigation.followLatest, + isCurrent: () => navigationRevision.current === revision, + }); + }, + captureAnchor(turnId) { + const { sessionId } = props; + const controller = props.rangeController.current; + if (!sessionId || props.currentSessionId.current !== sessionId) return; + const previous = props.sessionUi.transcriptReadingAnchorBySessionRef.current[sessionId]; + props.sessionUi.setTranscriptRestoreUnavailable(sessionId, undefined); + captureTranscriptReadingAnchor({ + sessionId, currentSessionId: props.currentSessionId.current, turnId, controller, + setAnchor: props.sessionUi.setTranscriptReadingAnchor, + }); + const range = currentTranscriptRange(controller, sessionId); + if (range === undefined) return; + const sequence = turnId ? controller?.store.sequenceForTurn(turnId) : undefined; + // The send command already cleared its bookmark before publishing the + // pin. Its empty-anchor acknowledgement is not another reader intent. + if (previous?.turnId === turnId && previous?.sequence === (sequence ?? undefined)) return; + let navigation: Promise | undefined; + navigationRevision.current += 1; + cancelHistory(sessionId); + if (turnId) navigation = controller?.setReadingAnchor(sequence ?? null, turnId); + else if (!turnId && previous && !range.hasNewer) { + cancel(sessionId, true); + navigation = controller?.loadLatest(); + } + void navigation?.catch((error) => { + if (controller && isCurrent(sessionId, controller)) props.onNavigationError(error, sessionId); + }); + }, + async loadHistory(target, anchorTurnId) { + const controller = props.rangeController.current; + const { sessionId } = props; + if (!controller || !sessionId || !isCurrent(sessionId, controller)) return; + navigationRevision.current += 1; + cancel(sessionId, target === 'latest'); + // A direct latest command must enter the range controller now, so it + // invalidates older pages rather than waiting behind a paging gate. + if (target === 'latest' || historyGates.current.get(controller)?.active?.target === 'latest') { + cancelHistory(sessionId); + } + const gates = historyGates.current; + const gate: TranscriptHistoryGate = gates.get(controller) ?? { pending: false }; + gates.set(controller, gate); + await loadTranscriptHistory({ + gates, sessionId, request: { target, anchorTurnId }, controller, + maxBytes: props.historyPageBytes, + isCurrent: () => isCurrent(sessionId, controller) && gates.get(controller) === gate, + setPending: props.setHistoryPending, + onError: (error) => props.onNavigationError(error, sessionId), + }); + }, + })); + + 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(() => () => { + navigationRevision.current += 1; + lifecycle.deactivate(); + }, [props.sessionId, props.profileId, lifecycle]); + useEffect(() => { + if (props.searchTarget) { + navigationRevision.current += 1; + cancelHistory(props.searchTarget.sessionId); + } + }, [props.searchTarget?.nonce]); + useEffect(() => restoreSessionTranscriptRange({ + lifecycle, + sessionId: props.sessionId, + profileId: props.profileId, + searchTarget: props.searchTarget, + readingAnchor: props.sessionId + ? props.sessionUi.transcriptReadingAnchorBySessionRef.current[props.sessionId] + : undefined, + controller: props.rangeController.current, + isCurrent, + isLiveTurn: (sessionId, turnId) => props.sessionUi.liveTurnBySessionRef.current[sessionId]?.turnId === turnId, + setMessages: props.setMessages, + setReadingAnchor: props.sessionUi.setTranscriptReadingAnchor, + onRestoreUnavailable: props.sessionUi.setTranscriptRestoreUnavailable, + onError: props.onRestoreError, + }), [props.sessionId, props.profileId, props.messages, props.searchTarget?.nonce]); + 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 7ef337fc8d..fb88a3be72 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 @@ -20,7 +20,7 @@ import type { TranscriptReadingAnchor } from '../model/session-ui-state.js'; interface TranscriptRangeStore { - range(): { readonly sessionId: string }; + range(): { readonly sessionId: string; readonly hasNewer?: boolean }; sequenceForTurn(turnId: string): number | null; newestDurableUserSequence(): number | null; snapshot(): { readonly messages: readonly Message[] }; @@ -30,12 +30,117 @@ interface TranscriptRangeController { readonly store: TranscriptRangeStore; ready(): Promise; loadAround(sequence: number): Promise; + setReadingAnchor?(sequence: number | null, readingTurnId?: string): Promise; } interface SearchTarget { readonly sessionId: string; readonly turnId: string; readonly sequence?: number; + readonly nonce?: number; +} + +interface TranscriptRestoreCommand { + target: TranscriptReadingAnchor; + readonly fromSearch: boolean; + completed: boolean; + controller?: object; + attempt?: object; +} + +/** A bookmark survives navigation; a command to restore it does not. */ +export function createTranscriptRestoreLifecycle() { + let activation: { + sessionId?: string; + profileId?: string; + searchKey?: string; + command?: TranscriptRestoreCommand; + } | undefined; + return { + request(input: { + sessionId?: string; + profileId?: string; + searchTarget?: SearchTarget | null; + readingAnchor?: TranscriptReadingAnchor; + }): TranscriptRestoreCommand | undefined { + const search = input.searchTarget?.sessionId === input.sessionId + ? input.searchTarget + : undefined; + const searchKey = search ? `${search.turnId}:${search.nonce ?? 0}` : undefined; + const switched = !activation + || activation.sessionId !== input.sessionId + || activation.profileId !== input.profileId; + if (switched || activation?.searchKey !== searchKey) { + // Clearing a search in the same activation must not restart the old + // bookmark. Only entering a Session captures a bookmark to restore. + const target = search ?? (switched ? input.readingAnchor : undefined); + activation = { + sessionId: input.sessionId, + profileId: input.profileId, + searchKey, + command: input.sessionId && target + ? { target: { turnId: target.turnId, sequence: target.sequence }, 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; + }, + isCurrent(command: TranscriptRestoreCommand): boolean { + return activation?.command === command && !command.completed; + }, + cancel(sessionId?: string): void { + if (activation && (sessionId === undefined || activation.sessionId === sessionId)) { + activation.command = undefined; + } + }, + deactivate(): void { + // Effect teardown ends the activation, including StrictMode's setup + // replay. Explicit navigation cancellation instead keeps it consumed. + activation = undefined; + }, + }; +} + +export type TranscriptRestoreLifecycle = ReturnType; + +export async function prepareTranscriptForSend(options: { + sessionId: string; + currentSessionId: { current: string | undefined }; + controller: { current: (TranscriptRangeController & { loadLatest(): Promise }) | undefined }; + cancel(sessionId: string, clearAnchor: boolean): void; + setMessages(messages: Message[]): void; + followLatest(sessionId: string): void; + isCurrent?(): boolean; +}): Promise { + const { sessionId } = options; + const current = () => options.currentSessionId.current === sessionId && options.isCurrent?.() !== false; + if (!current()) return false; + options.cancel(sessionId, true); + const controller = options.controller.current; + options.followLatest(sessionId); + if (!controller || !currentTranscriptRange(controller, 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(); + if (!current() || options.controller.current !== controller + || !currentTranscriptRange(controller, sessionId)) return; + options.setMessages([...controller.store.snapshot().messages]); + } catch { + // Catch-up failure must not prevent the Message from being saved locally. + } + })(); + return true; } export function currentTranscriptRange( @@ -126,6 +231,7 @@ export interface TranscriptHistoryPending { export interface TranscriptHistoryGate { pending: boolean; + active?: TranscriptHistoryRequest; queued?: TranscriptHistoryRequest; } @@ -170,6 +276,7 @@ export async function loadTranscriptHistory(options: { return; } gate.pending = true; + gate.active = request; options.setPending((current) => updateTranscriptHistoryPending(current, options.sessionId, request)); try { @@ -181,20 +288,29 @@ export async function loadTranscriptHistory(options: { if (options.isCurrent()) options.onError(error); } finally { gate.pending = false; - options.setPending((current) => - updateTranscriptHistoryPending(current, options.sessionId, undefined)); - const queued = gate.queued; - gate.queued = undefined; - if (queued && options.isCurrent()) void loadTranscriptHistory({ ...options, request: queued }); + gate.active = undefined; + // A send, search or explicit return to latest may replace this gate while + // its page is in flight. Its cleanup cannot clear the replacement's state + // or replay an older queued direction after the new navigation. + if (gates.get(controller) === gate && options.isCurrent()) { + options.setPending((current) => + updateTranscriptHistoryPending(current, options.sessionId, undefined)); + const queued = gate.queued; + gate.queued = undefined; + if (queued) void loadTranscriptHistory({ ...options, request: queued }); + } } } export function restoreSessionTranscriptRange(options: { + readonly lifecycle: TranscriptRestoreLifecycle; readonly sessionId?: string; + readonly profileId?: string; readonly searchTarget?: SearchTarget | null; readonly readingAnchor?: TranscriptReadingAnchor; readonly controller?: TranscriptRangeController; readonly isCurrent: (sessionId: string, controller: TranscriptRangeController) => boolean; + readonly isLiveTurn?: (sessionId: string, turnId: string) => boolean; readonly setMessages: (messages: Message[]) => void; readonly setReadingAnchor: ( sessionId: string, @@ -202,44 +318,82 @@ export function restoreSessionTranscriptRange(options: { ) => void; readonly onRestoreUnavailable?: (sessionId: string, turnId: string) => void; readonly onError: (error: unknown, sessionId: string) => void; -}): (() => void) | undefined { +}): void { const { controller, sessionId } = options; - if (!controller || !sessionId) return; - let readingAnchor = options.readingAnchor; - if (readingAnchor && readingAnchor.sequence === undefined) { - const { turnId } = readingAnchor; + const command = options.lifecycle.request(options); + if (!command || !controller || !sessionId) 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) { - readingAnchor = { turnId, sequence }; - options.setReadingAnchor(sessionId, readingAnchor); + command.target = { turnId, sequence }; + options.setReadingAnchor(sessionId, command.target); } } catch { // A stale range cannot enrich the anchor, but also cannot invalidate it. } } - const searchTarget = options.searchTarget?.sessionId === sessionId - ? options.searchTarget - : undefined; - const target = searchTarget ?? readingAnchor; - if (!target || (searchTarget && target.sequence === undefined)) return; - const restoringReadingAnchor = searchTarget === undefined && readingAnchor !== undefined; - let disposed = false; - const current = (): boolean => !disposed && options.isCurrent(sessionId, controller); - void controller.ready() + 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); + let admitted: Promise | undefined; + let loadedAtAdmission = false; + if (current()) { + const residentSequence = currentTranscriptRange(controller, sessionId) + ? controller.store.sequenceForTurn(target.turnId) + : null; + const sequence = residentSequence ?? target.sequence; + if (sequence !== undefined || controller.setReadingAnchor) { + // The range controller admits intent before awaiting its open handle. + // A resident search/bookmark must invalidate older pages just as a + // missing target does, without needlessly replacing its current range. + try { + loadedAtAdmission = residentSequence === null || !controller.setReadingAnchor; + admitted = controller.setReadingAnchor && (residentSequence !== null || sequence === undefined) + ? controller.setReadingAnchor(sequence ?? null, target.turnId) + : controller.loadAround(sequence!); + } catch (error) { + admitted = Promise.reject(error); + } + } + } + void (admitted ?? controller.ready()) .then(async () => { if (!current() || controller.store.range().sessionId !== sessionId) { return { loaded: false, unavailable: false }; } const residentSequence = controller.store.sequenceForTurn(target.turnId); if (residentSequence !== null) { - if (restoringReadingAnchor && readingAnchor?.sequence === undefined) { + if (restoringReadingAnchor && target.sequence === undefined) { options.setReadingAnchor(sessionId, { turnId: target.turnId, sequence: residentSequence }); } - return { loaded: false, unavailable: false }; + if (!admitted) { + if (controller.setReadingAnchor) await controller.setReadingAnchor(residentSequence, target.turnId); + else { + await controller.loadAround(residentSequence); + loadedAtAdmission = true; + } + } + return { loaded: loadedAtAdmission, unavailable: false }; } + if (options.isLiveTurn?.(sessionId, target.turnId) || 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 { loaded: loadedAtAdmission, unavailable: false }; + } + if (admitted) return { loaded: loadedAtAdmission, unavailable: restoringReadingAnchor }; if (target.sequence === undefined) { return { loaded: false, unavailable: restoringReadingAnchor }; } @@ -255,6 +409,7 @@ export function restoreSessionTranscriptRange(options: { }) .then(({ loaded, unavailable }) => { if (!current()) return; + command.completed = true; if (loaded) options.setMessages([...controller.store.snapshot().messages]); if (unavailable) { options.setReadingAnchor(sessionId, undefined); @@ -263,10 +418,10 @@ export function restoreSessionTranscriptRange(options: { }) .catch((error) => { if (current()) options.onError(error, sessionId); + }) + .finally(() => { + if (command.attempt === attempt) command.attempt = undefined; }); - return () => { - disposed = true; - }; } export function captureTranscriptReadingAnchor(options: { @@ -290,26 +445,3 @@ export function captureTranscriptReadingAnchor(options: { // A stale range says nothing new about the reader's current intent. } } - -/** Sending restores the tail in the background; local admission never waits for it. */ -export async function restoreTranscriptTailAfterSend(options: { - readonly sessionId: string; - readonly controller: { - readonly store: { - range(): { readonly sessionId: string; readonly hasNewer: boolean }; - snapshot(): { readonly messages: readonly Message[] }; - }; - loadLatest(): Promise; - } | undefined; - readonly isCurrent: () => boolean; - readonly setMessages: (messages: Message[]) => void; -}): Promise { - try { - const { controller } = options; - if (!controller || !currentTranscriptRange(controller, options.sessionId)?.hasNewer) return; - await controller.loadLatest(); - if (options.isCurrent()) options.setMessages([...controller.store.snapshot().messages]); - } catch { - // Unopened/offline history must not prevent saving the user's message. - } -} diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index ee44a48d4c..f21e8bf259 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -19,9 +19,11 @@ import { captureTranscriptReadingAnchor, + createTranscriptRestoreLifecycle, currentTranscriptRange, loadTranscriptHistory, newestDurablePromptSequence, + prepareTranscriptForSend, refreshTranscriptTurnLandmarks, restoreSessionTranscriptRange, transcriptRestoreTarget, @@ -29,9 +31,11 @@ import { export const transcriptReadingPosition = { captureAnchor: captureTranscriptReadingAnchor, + createRestoreLifecycle: createTranscriptRestoreLifecycle, currentRange: currentTranscriptRange, loadHistory: loadTranscriptHistory, newestDurablePromptSequence, + prepareForSend: prepareTranscriptForSend, refreshLandmarks: refreshTranscriptTurnLandmarks, restoreRange: restoreSessionTranscriptRange, restoreTarget: transcriptRestoreTarget, @@ -41,6 +45,11 @@ export type { TranscriptHistoryGates, TranscriptHistoryPending, } from './controller/transcript-reading-position.js'; +export { + TranscriptReadingPositionController, + type TranscriptReadingPositionCommands, +} from './controller/transcript-reading-position-controller.js'; + export { deriveTaskReadinessNotice, isTaskSubmissionHardBlocked, @@ -51,4 +60,3 @@ export * from './model/session-ui-state.js'; export type { ConversationServices } from './ports.js'; export { ConversationServicesProvider } from './services.js'; export { SessionLocalMessages } from './controller/session-local-messages.js'; -export { restoreTranscriptTailAfterSend } from './controller/transcript-reading-position.js'; 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 350330deb6..e5cdcaa013 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 @@ -20,7 +20,7 @@ import { useRef } from 'react'; import type { MessageQueueEntryProjection, ShellRunUpdate } from '@maka/core/events'; import type { SessionEventStreamSnapshot } from '@maka/core/session-event-health'; -import { confirmLiveTurn, type InteractionQueues, type LiveTurnProjection } from '@maka/ui'; +import { confirmLiveTurn, createTranscriptViewportNavigation, type InteractionQueues, type LiveTurnProjection } from '@maka/ui'; import { createObservableState } from './observable-state.js'; type StateUpdater = (updater: (current: T) => T) => void; @@ -155,6 +155,7 @@ export function createAppShellSessionUiStateController( // controller still owns the same deletion lifetime as every other Session // UI registry. const transcriptReadingAnchors = createTranscriptReadingAnchorRegistry(); + const transcriptViewportNavigation = createTranscriptViewportNavigation(); // The ref mirrors whatever is about to become current, so it is already // correct when the synchronous notification reaches a listener that reads it. @@ -208,6 +209,7 @@ export function createAppShellSessionUiStateController( liveTurnBySessionRef, sessionEventHealthBySessionRef: sessionEventHealthBySession.ref, transcriptReadingAnchorBySessionRef: transcriptReadingAnchors.ref, + transcriptViewportNavigation, setMessageLoadErrorBySession: createMapSetter('messageLoadErrorBySession'), messageRetryPending: createPendingClaim('messageRetryPendingBySession'), stopPending: createPendingClaim('stopPendingBySession'), diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 4d02b3de02..d1856695b6 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 256 files — blocker 0, reimplementation 0, polish 1, aligned 255. +**Totals:** 257 files — blocker 0, reimplementation 0, polish 1, aligned 256. ## Exclusions (explicit) @@ -49,6 +49,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/connection-settings/onboarding-step-form.tsx` | dialog-overlay | VStack | aligned — uses Astryx (VStack) | aligned | | `apps/desktop/src/renderer/features/connection-settings/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/goals/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, Text, TextArea, TextInput, VStack | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, Text) | aligned | | `apps/desktop/src/renderer/features/goals/ui/goal-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index fd44277143..4140cc46ed 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -20,6 +20,7 @@ apps/desktop/src/renderer/features/connection-settings/generic-provider-mark.tsx apps/desktop/src/renderer/features/connection-settings/onboarding-step-form.tsx apps/desktop/src/renderer/features/connection-settings/services-context.tsx apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx +apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx apps/desktop/src/renderer/features/goals/services-context.tsx apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx apps/desktop/src/renderer/features/goals/ui/goal-host.tsx diff --git a/packages/runtime/src/__tests__/fake-backend.test.ts b/packages/runtime/src/__tests__/fake-backend.test.ts index afe9fac4ad..12328dd9b5 100644 --- a/packages/runtime/src/__tests__/fake-backend.test.ts +++ b/packages/runtime/src/__tests__/fake-backend.test.ts @@ -21,14 +21,61 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { SessionEvent } from '@maka/core/events'; -import type { SessionHeader } from '@maka/core/session'; -import { FAKE_ASK_USER_QUESTION_PROMPT, FakeBackend } from '../test-only/fake-backend.js'; +import { + FAKE_ASK_USER_QUESTION_PROMPT, + FAKE_COMPLETE_HELD_TURN_STEERING, + FAKE_HOLD_OPEN_COMPLETE_PROMPT, + FakeBackend, +} from '../test-only/fake-backend.js'; import { RuntimeInteractionInvariantError, bindRuntimeInteractionRun, type RuntimeUserQuestionContinuation, } from '../interaction-authority.js'; -import type { SessionStore } from '../session-manager.js'; + +test('a completable held answer emits its accumulated live text only after the completion steering latch', async () => { + const backend = new FakeBackend({ sessionId: 'session-1' }); + const pending = [ + { id: 'lease-1', messageId: 'message-1', content: { text: 'background marker' } }, + ]; + const iterator = backend + .send({ + turnId: 'turn-1', + text: FAKE_HOLD_OPEN_COMPLETE_PROMPT, + context: [], + pullSteering: () => pending.splice(0), + }) + [Symbol.asyncIterator](); + const events: SessionEvent[] = []; + while ( + !events.some((event) => event.type === 'text_delta' && event.text.includes('background marker')) + ) { + const next = await iterator.next(); + assert.equal(next.done, false); + events.push(next.value!); + } + assert.equal( + events.some((event) => event.type === 'text_complete' || event.type === 'complete'), + false, + ); + pending.push({ + id: 'lease-2', + messageId: 'message-2', + content: { text: FAKE_COMPLETE_HELD_TURN_STEERING }, + }); + for await (const event of { [Symbol.asyncIterator]: () => iterator }) events.push(event); + assert.equal(events.filter((event) => event.type === 'text_complete').length, 1); + const streamed = events + .flatMap((event) => (event.type === 'text_delta' ? [event.text] : [])) + .join(''); + assert.equal(events.find((event) => event.type === 'text_complete')?.text, streamed); + assert.equal( + events.some((event) => event.type === 'abort'), + false, + ); + const terminal = events.at(-1); + assert.equal(terminal?.type === 'complete' ? terminal.stopReason : undefined, 'end_turn'); +}); test('Fake question publication waits for exact hosted admission', async () => { const admissionStarted = deferred(); diff --git a/packages/runtime/src/test-only/fake-backend.ts b/packages/runtime/src/test-only/fake-backend.ts index 8c41be3681..d0cd466801 100644 --- a/packages/runtime/src/test-only/fake-backend.ts +++ b/packages/runtime/src/test-only/fake-backend.ts @@ -45,6 +45,8 @@ export const FAKE_WAIT_FOR_STEERING_PROMPT = '__e2e_wait_for_steering__'; export const FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT = '__e2e_wait_for_steering_large_response__'; export const FAKE_HOLD_OPEN_PROMPT = '__e2e_hold_open__'; +export const FAKE_HOLD_OPEN_COMPLETE_PROMPT = '__e2e_hold_open_complete__'; +export const FAKE_COMPLETE_HELD_TURN_STEERING = '__e2e_complete_held_turn__'; export const FAKE_HOLD_OPEN_REWRITE_PROMPT = '__e2e_hold_open_rewrite__'; export const FAKE_MERMAID_PROMPT = '__e2e_mermaid__'; export const FAKE_MERMAID_HOSTILE_PROMPT = '__e2e_mermaid_hostile__'; @@ -199,7 +201,12 @@ export class FakeBackend implements AgentBackend { }; try { - if (input.text === FAKE_HOLD_OPEN_PROMPT || input.text === FAKE_HOLD_OPEN_REWRITE_PROMPT) { + if ( + input.text === FAKE_HOLD_OPEN_PROMPT || + input.text === FAKE_HOLD_OPEN_REWRITE_PROMPT || + input.text === FAKE_HOLD_OPEN_COMPLETE_PROMPT + ) { + const canComplete = input.text === FAKE_HOLD_OPEN_COMPLETE_PROMPT; const rewriteTarget = input.text === FAKE_HOLD_OPEN_REWRITE_PROMPT; const waitingPrefix = rewriteTarget ? 'prefix sk-123456789012345' @@ -234,17 +241,26 @@ export class FakeBackend implements AgentBackend { text: delta, }; } + if (canComplete && steered.includes(FAKE_COMPLETE_HELD_TURN_STEERING)) break; await sleep(5); } - yield { type: 'abort', id: randomUUID(), turnId, ts: Date.now(), reason: 'user_stop' }; - yield { - type: 'complete', - id: randomUUID(), - turnId, - ts: Date.now(), - stopReason: 'user_stop', - }; - return; + if (canComplete && !this.stopped) { + // The completion latch exercises the normal durable assistant path + // below after a test has observed a stable live answer and remount. + text = waitingText; + chunks.length = 0; + steered.length = 0; + } else { + yield { type: 'abort', id: randomUUID(), turnId, ts: Date.now(), reason: 'user_stop' }; + yield { + type: 'complete', + id: randomUUID(), + turnId, + ts: Date.now(), + stopReason: 'user_stop', + }; + return; + } } if (isSteeringScenario) { diff --git a/packages/ui/package.json b/packages/ui/package.json index d9f5513126..2ec8ed844f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,6 +12,7 @@ "./assistant-stream": "./dist/assistant-stream.js", "./icons": "./dist/icons.js", "./maka-uri": "./dist/maka-uri.js", + "./testing": "./dist/testing.js", "./styles.css": "./src/styles.css" }, "scripts": { diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index 49c970cad5..188fbd5930 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -29,6 +29,7 @@ import { type TranscriptScrollAuthority, } from '../transcript-scroll-authority.js'; import { useChatScroll } from '../use-chat-scroll.js'; +import { createTranscriptViewportNavigation } from '../transcript-viewport-navigation.js'; const originalGlobals = { CSS: globalThis.CSS, @@ -266,7 +267,10 @@ test('a session switch restores a Turn anchor after async fill and preserves tai const anchors = new Map(); const handledTargets: number[] = []; + const viewportNavigation = createTranscriptViewportNavigation(); const unavailableRestores = new Map(); + const historyRequests: Array<{ direction: 'up' | 'down'; anchor?: string }> = []; + let historyPaging = false; let authority: TranscriptScrollAuthority | undefined; let messageRevision = 0; let target: { turnId: string; nonce: number } | undefined; @@ -285,12 +289,17 @@ test('a session switch restores a Turn anchor after async fill and preserves tai target, restoreTarget, onTargetHandled: (nonce) => handledTargets.push(nonce), + viewportNavigation, onReadingAnchorChange: (turnId) => { unavailableRestores.delete(sessionId); if (turnId) anchors.set(sessionId, turnId); else anchors.delete(sessionId); }, behavior: 'auto', + hasOlderHistory: historyPaging, + hasNewerHistory: historyPaging, + onLoadEarlierHistory: (anchor) => { historyRequests.push({ direction: 'up', anchor }); }, + onLoadLaterHistory: (anchor) => { historyRequests.push({ direction: 'down', anchor }); }, }); return null; } @@ -400,4 +409,65 @@ test('a session switch restores a Turn anchor after async fill and preserves tai 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); + + scroller.scrollTop = 1_000; + scroller.dispatchEvent(new window.Event('scroll')); + assert.equal(anchors.get('session-a'), 'turn-a-latest'); + 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'); + + // Either adjacent-page gesture supersedes an activation's unfinished + // bookmark. A late fill must not move the reader back to that old target. + historyPaging = true; + for (const direction of ['up', 'down'] as const) { + const sessionId = `session-page-${direction}`; + const bookmark = `bookmark-${direction}`; + anchors.set(sessionId, bookmark); + collapseTranscript(); + installTranscript(3_000, [{ id: 'resident', start: 0, height: 3_000 }]); + await renderSession(sessionId); + assert.equal(authority?.getSnapshot().pinned, false); + scroller.scrollTop = direction === 'up' ? 0 : 2_400; + const wheel = new window.Event('wheel', { bubbles: true }); + Object.defineProperty(wheel, 'deltaY', { value: direction === 'up' ? -100 : 100 }); + scroller.dispatchEvent(wheel); + assert.deepEqual(historyRequests.at(-1), { direction, anchor: 'resident' }); + const readerTop: number = scroller.scrollTop; + + installTranscript(3_000, [ + { id: 'resident-before', start: 0, height: 800 }, + { id: bookmark, start: 800, height: 600 }, + { id: 'resident-after', start: 1_400, height: 1_600 }, + ]); + await renderSession(sessionId); + await flushFrames(); + assert.equal(scroller.scrollTop, readerTop, `${direction} paging consumes the pending restore`); + assert.equal(authority?.getSnapshot().pinned, false); + } }); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 1ebb9da9a3..a6d69f683e 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -58,6 +58,7 @@ import { } from './chat-turn.js'; import { useChatScroll } from './use-chat-scroll.js'; import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; +import type { TranscriptViewportNavigation } from './transcript-viewport-navigation.js'; import { placeChatConversationItems } from './chat-conversation-items.js'; import { projectTranscriptRows } from './transcript-row-projection.js'; import { useUiLocale } from './locale-context.js'; @@ -306,6 +307,7 @@ export function ChatView(props: { onScrollTargetHandled?(nonce: number): void; /** Runtime-only reading position restored without search focus or highlight. */ restoreTargetTurn?: { turnId: string; unavailable?: boolean }; + viewportNavigation?: TranscriptViewportNavigation; onReadingAnchorChange?(turnId?: string): void; scrollBehavior: ScrollBehavior; hasOlderHistory?: boolean; @@ -613,6 +615,7 @@ export function ChatView(props: { target: scrollTargetTurn, restoreTarget: props.restoreTargetTurn, onTargetHandled: props.onScrollTargetHandled, + viewportNavigation: props.viewportNavigation, onReadingAnchorChange: props.onReadingAnchorChange, behavior: props.scrollBehavior, hasOlderHistory: props.hasOlderHistory, diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4a7b9e90e4..187a1084ae 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -67,6 +67,7 @@ export * from './tool-output-stream.js'; export * from './ui.js'; export * from './utils.js'; export * from './platform-shortcut-text.js'; +export * from './transcript-viewport-navigation.js'; // Maka-owned product assets and compositions remain public only where they do // not duplicate a published Astryx component authority. diff --git a/packages/ui/src/testing.ts b/packages/ui/src/testing.ts new file mode 100644 index 0000000000..a349a7d97f --- /dev/null +++ b/packages/ui/src/testing.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +/** Cross-package integration tests exercise the same hook and authority as ChatView. */ +export { useChatScroll } from './use-chat-scroll.js'; +export { + TranscriptScrollAuthorityProvider, + TranscriptScrollButton, + useTranscriptScrollAuthority, + type TranscriptScrollAuthority, +} from './transcript-scroll-authority.js'; diff --git a/packages/ui/src/transcript-viewport-navigation.ts b/packages/ui/src/transcript-viewport-navigation.ts new file mode 100644 index 0000000000..d25513cdaf --- /dev/null +++ b/packages/ui/src/transcript-viewport-navigation.ts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +/** Explicit viewport commands are consumed once, never replayed on mount or growth. */ +export function createTranscriptViewportNavigation() { + const listeners = new Set<(sessionId: string) => void>(); + return { + followLatest(sessionId: string): void { + for (const listener of [...listeners]) listener(sessionId); + }, + subscribe(listener: (sessionId: string) => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + }; +} + +export type TranscriptViewportNavigation = ReturnType; diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index c95c03c10c..fd49b9678a 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -35,6 +35,7 @@ import { useEffect, useRef, useState, type RefObject } from 'react'; import type { StoredMessage } from '@maka/core/session'; import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; +import type { TranscriptViewportNavigation } from './transcript-viewport-navigation.js'; export function useChatScroll(input: { scrollRef: RefObject; @@ -49,6 +50,7 @@ export function useChatScroll(input: { target?: { turnId: string; nonce: number; align?: 'start' | 'center' }; restoreTarget?: { turnId: string; unavailable?: boolean }; onTargetHandled?(nonce: number): void; + viewportNavigation?: TranscriptViewportNavigation; onReadingAnchorChange?(turnId?: string): void; behavior: ScrollBehavior; hasOlderHistory?: boolean; @@ -79,6 +81,12 @@ export function useChatScroll(input: { restoreTurnId: input.restoreTarget?.turnId, }; } + if (activation.current?.restoreTurnId + && activation.current.restoreTurnId !== input.restoreTarget?.turnId) { + // Clearing or replacing a bookmark cancels the captured command. A new + // bookmark within the same activation records reading, not navigation. + activation.current = { sessionId: input.sessionId }; + } const restoreUnavailable = input.restoreTarget?.turnId === activation.current?.restoreTurnId && input.restoreTarget?.unavailable === true; @@ -108,6 +116,16 @@ export function useChatScroll(input: { else authority.pinToTail(); }, [input.sessionId]); + useEffect(() => input.viewportNavigation?.subscribe((sessionId) => { + if (activation.current?.sessionId !== sessionId) return; + // A send supersedes both a captured bookmark and a search frame that has + // not landed yet. Consume that frame before the authority reports the pin. + handledTarget.current = commandTarget.current; + activation.current = { sessionId }; + commandTarget.current = null; + authority.pinToTail(); + }), [authority, input.viewportNavigation]); + useEffect(() => { const report = (): void => { const snapshot = authority.getSnapshot(); @@ -133,7 +151,16 @@ export function useChatScroll(input: { }; reportReadingAnchor.current = report; report(); - const stopWatchingPolicy = authority.subscribe(report); + let previousPin = authority.getSnapshot().pinned; + const stopWatchingPolicy = authority.subscribe(() => { + const pinned = authority.getSnapshot().pinned; + // Geometry can change the return-to-tail affordance without changing + // reading intent. Reporting its visible Turn would turn an arriving + // range into a new history command and cancel the range's own sender. + if (pinned === previousPin) return; + previousPin = pinned; + report(); + }); const stopWatchingReader = authority.subscribeToReaderScroll(report); return () => { if (reportReadingAnchor.current === report) reportReadingAnchor.current = undefined; @@ -152,6 +179,8 @@ export function useChatScroll(input: { // request while one is in flight, and asking for history the reader // already has is idempotent anyway. const requestHistory = (direction: 'up' | 'down'): void => { + activation.current = { sessionId: input.sessionId }; + commandTarget.current = null; authority.releasePin(); const anchorTurnId = direction === 'up' ? firstVisibleTurnId(root) @@ -236,6 +265,7 @@ export function useChatScroll(input: { if (handledTarget.current === chosen) return; authority.releasePin(); const frame = window.requestAnimationFrame(() => { + if (commandTarget.current !== chosen) return; const root = input.scrollRef.current; if (!root) return; const element = root.querySelector(`[data-turn-id="${CSS.escape(target.turnId)}"]`); diff --git a/scripts/check-app-shell-hooks.mjs b/scripts/check-app-shell-hooks.mjs index 14a86c0693..45c60058c1 100644 --- a/scripts/check-app-shell-hooks.mjs +++ b/scripts/check-app-shell-hooks.mjs @@ -118,7 +118,7 @@ export const ALLOWED = { useAppShellTurnPresentation: 1, useCommandPalette: 1, useComposerAttachments: 1, - useEffect: 10, + useEffect: 8, useKeyboardHelp: 1, useLayoutEffect: 2, useNewTaskChoice: 1, From a2acf155eb828d632f06cf70922ade146c19bca8 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Tue, 8 Sep 2026 11:24:18 +0800 Subject: [PATCH 2/5] fix(desktop): align WorkHub transcript navigation admission Route coordination tail completion through the range controller so request versions and renderer expectations advance together. Ignore stale batches before updating loading state and retain the active guard across a range reset, including fragmented responses and sparse durable watermarks. Cover the consumer handshake through the real preload and verify that tail completion converges after one request. Generated-by: OpenAI Codex --- ...ub-coordination-transcript-preload.test.ts | 163 ++++++++++++++++++ .../__tests__/workhub-session-port.test.ts | 31 ++-- .../desktop-transcript-range-store.ts | 24 +-- .../src/renderer/workhub-coordination-port.ts | 43 +++-- 4 files changed, 218 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts 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 new file mode 100644 index 0000000000..8d3220cfb8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -0,0 +1,163 @@ +/* + * 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 { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import test from 'node:test'; +import { build } from 'esbuild'; +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 { createDesktopWorkHubCoordinationPort } from '../../renderer/workhub-coordination-port.js'; +import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; +import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; + +// Keep the real preload's navigation defaults and filtering in this consumer +// regression; the IPC stub models the observer's authoritative reset reply. +test('Coordination tail recovery converges through the preload with a fragmented sparse tail', { timeout: 5_000 }, async () => { + 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 message: StoredMessage = { + type: 'user', id: 'latest-message', turnId: 'latest-turn', ts: 7, + text: 'Latest coordination record '.repeat(8_000), + }; + const requests: DesktopTranscriptRangeRequest[] = []; + const projections: string[][] = []; + const partialProjectionCounts: number[] = []; + const errors: unknown[] = []; + 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 deliver = (batch: Omit) => { + listeners.get(`sessions:transcript:${consumerId}`)?.({}, owner, { + ...batch, deliverySequence: ++deliverySequence, + }); + }; + const ipcRenderer = { + on(channel: string, listener: (...args: unknown[]) => void) { listeners.set(channel, listener); }, + off(channel: string) { listeners.delete(channel); }, + send() {}, + async invoke(channel: string, ...args: unknown[]): Promise { + if (channel === 'runtime-host:activeIdentity') return owner; + if (channel === 'runtime-host:identities') return [owner]; + if (channel === 'session-local:transcript') return null; + if (channel === 'sessions:transcript:open') { + consumerId = args[2] as string; + for (const batch of encodeDesktopTranscriptSnapshot({ ...snapshot, navigationVersion: 0, durable: [] })) { + deliver(batch); + } + return { ...snapshot, readThroughMessageId: null }; + } + if (channel === 'sessions:transcript:load-around') { + const request = args[1] as DesktopTranscriptRangeRequest; + requests.push(request); + // Bound a regressed request loop so the test reports its cause. + if (requests.length >= 3) return new Promise(() => {}); + await new Promise((resolve) => setImmediate(resolve)); + try { + for (const batch of encodeDesktopTranscriptSnapshot({ + ...snapshot, navigationVersion: request.navigationVersion, + durable: [{ sequence: 7, message }], + })) { + deliver(batch); + if (!batch.ready) { + partialProjectionCounts.push(projections.length); + // A rejected ready/reset must not publish a partial valid snapshot + // or clear the load guard, even if a caller bypasses preload filtering. + deliverDirect?.({ + ...batch, navigationVersion: 0, fragments: [], ready: true, + deliverySequence: ++deliverySequence, + }); + deliverDirect?.({ + ...batch, generation: 'unrelated-generation', reset: false, fragments: [], ready: true, + deliverySequence: ++deliverySequence, + }); + partialProjectionCounts.push(projections.length); + } + } + } finally { + finishResponse(); + } + return; + } + if (channel === 'sessions:transcript:ack' || channel === 'sessions:transcript:close') return; + throw new Error(`Unexpected channel: ${channel}`); + }, + }; + const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../../../src/preload/preload.ts', import.meta.url))], + bundle: true, write: false, platform: 'node', format: 'cjs', external: ['electron'], + }); + const require = createRequire(import.meta.url); + runInNewContext(bundle.outputFiles[0]!.text, { + require: (id: string) => id === 'electron' ? { + ipcRenderer, + contextBridge: { exposeInMainWorld(name: string, value: MakaBridge) { + if (name === 'maka') bridge = value; + } }, + } : require(id), + process: { env: {} }, Buffer, console, setTimeout, clearTimeout, TextEncoder, TextDecoder, + Uint8Array, crypto: globalThis.crypto, + }); + assert.ok(bridge); + const port = createDesktopWorkHubCoordinationPort({ + sessionId, + transcripts: { + open(requestedSessionId, handler, registerCancellation) { + deliverDirect = handler; + return bridge!.transcripts.open(requestedSessionId, handler, registerCancellation); + }, + }, + record: async (input) => ({ turnId: input.turnId }), + candidates: async () => assert.fail('unused'), + act: async () => assert.fail('unused'), + }); + const handle = await port.open( + (turns) => projections.push(turns.map((turn) => turn.messageId)), + (error) => errors.push(error), + ); + try { + await responseDelivered; + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(errors, []); + assert.equal(requests.length, 1); + assert.equal(requests[0]!.navigationVersion, 1); + assert.equal(requests[0]!.intent, 'followTail'); + assert.equal(requests[0]!.anchorSequence, null); + assert.equal(requests[0]!.maxBytes, 512 * 1024); + assert.deepEqual(partialProjectionCounts, [0, 0]); + assert.deepEqual(projections, [['latest-message']]); + } finally { + await handle.close(); + } +}); diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index 77331b68e3..e5b668ca78 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -380,7 +380,7 @@ test('Coordination transcript adapter never replays history and completes only t const sessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'coordination' }); const snapshots: unknown[] = []; let closes = 0; - const latestLoads: Array<{ sequence: number | null; maxBytes: number | undefined }> = []; + const latestLoads: Array<{ sequence: number | null; maxBytes: number | undefined; intent: string | undefined }> = []; let deliver: ((batch: DesktopTranscriptBatch) => void) | undefined; const adapter = createDesktopWorkHubCoordinationPort({ sessionId, @@ -409,8 +409,8 @@ test('Coordination transcript adapter never replays history and completes only t readThroughMessageId: null, loadBefore: async () => assert.fail('conversation open must not replay older history'), loadAfter: async () => assert.fail('conversation open must not replay newer history'), - loadAround: async (sequence, maxBytes) => { - latestLoads.push({ sequence, maxBytes }); + loadAround: async (sequence, maxBytes, navigation) => { + latestLoads.push({ sequence, maxBytes, intent: navigation?.intent }); const message: StoredMessage = { type: 'user', id: 'latest-message', @@ -421,6 +421,7 @@ test('Coordination transcript adapter never replays history and completes only t const data = new TextEncoder().encode(JSON.stringify(message)); handler({ sessionId: 'coordination', + navigationVersion: navigation?.navigationVersion, deliverySequence: 3, generation: 'generation-2', hostEpoch: 'epoch-1', @@ -439,7 +440,7 @@ test('Coordination transcript adapter never replays history and completes only t completedOverlayMessageIds: [], hasOlder: true, hasNewer: false, - reset: false, + reset: true, ready: true, }); }, @@ -477,8 +478,8 @@ test('Coordination transcript adapter never replays history and completes only t reset: true, ready: true, }); - await Promise.resolve(); - assert.deepEqual(latestLoads, [{ sequence: 7, maxBytes: 512 * 1024 }]); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(latestLoads, [{ sequence: null, maxBytes: 512 * 1024, intent: 'followTail' }]); assert.deepEqual(snapshots, [[], [ { messageId: 'latest-message', @@ -543,14 +544,14 @@ test('Coordination transcript adapter retries latest-record completion in the sa }); const handle = await adapter.open(() => {}, (error) => errors.push(error)); - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(latestLoads, 1); assert.equal(errors.length, 1); deliver?.({ sessionId: 'coordination', deliverySequence: 2, + navigationVersion: 1, generation: 'generation-1', hostEpoch: 'epoch-1', durableThrough: 7, @@ -562,12 +563,13 @@ test('Coordination transcript adapter retries latest-record completion in the sa reset: false, ready: true, }); - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(latestLoads, 2); deliver?.({ sessionId: 'coordination', deliverySequence: 3, + navigationVersion: 2, generation: 'generation-1', hostEpoch: 'epoch-1', durableThrough: 7, @@ -579,7 +581,7 @@ test('Coordination transcript adapter retries latest-record completion in the sa reset: true, ready: true, }); - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(latestLoads, 3); await handle.close(); }); @@ -640,11 +642,13 @@ test('Coordination transcript adapter ignores a stale latest-record failure afte }); const handle = await adapter.open(() => {}, (error) => errors.push(error)); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(latestLoads, 1); deliver?.({ sessionId: 'coordination', deliverySequence: 2, - generation: 'generation-1', + navigationVersion: 1, + generation: 'generation-2', hostEpoch: 'epoch-1', durableThrough: 7, fragments: [], @@ -655,11 +659,10 @@ test('Coordination transcript adapter ignores a stale latest-record failure afte reset: true, ready: true, }); - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); assert.equal(latestLoads, 2); rejectStaleLoad?.(new Error('stale latest-record failure')); - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(errors, []); await handle.close(); }); diff --git a/apps/desktop/src/renderer/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/desktop-transcript-range-store.ts index 37220da597..6e0acbc5ee 100644 --- a/apps/desktop/src/renderer/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/desktop-transcript-range-store.ts @@ -36,7 +36,7 @@ export interface DesktopTranscriptRangeController { loadAfter(maxBytes?: number, anchorTurnId?: string): Promise; loadAround(sequence: number): Promise; setReadingAnchor(sequence: number | null, readingTurnId?: string): Promise; - loadLatest(): Promise; + loadLatest(maxBytes?: number): Promise; reload(): Promise; close(): Promise; } @@ -130,8 +130,8 @@ export function createDesktopTranscriptRangeController( } return navigate({ intent: 'history', kind: 'anchor', sequence, readingTurnId, preserveRange: true }); }, - loadLatest() { - return navigate({ intent: 'followTail', kind: 'latest', sequence: null }); + loadLatest(maxBytes) { + return navigate({ intent: 'followTail', kind: 'latest', sequence: null, maxBytes }); }, async reload() { const previous = handle; @@ -350,18 +350,20 @@ export class DesktopTranscriptRangeStore { this.#batchChanged = false; } - accept(batch: DesktopTranscriptBatchPayload): boolean { + accepts(batch: DesktopTranscriptBatchPayload): boolean { // A stale reset must be rejected before it can clear the current range. if ((batch.navigationVersion ?? 0) !== this.#navigationVersion) return false; if (this.#retiredGenerations.has(batch.generation)) return false; + return batch.reset || ( + batch.sessionId === this.#sourceSessionId && + batch.generation === this.#generation && + batch.hostEpoch === this.#hostEpoch + ); + } + + accept(batch: DesktopTranscriptBatchPayload): boolean { + if (!this.accepts(batch)) return false; if (batch.reset) this.#reset(batch); - if ( - batch.sessionId !== this.#sourceSessionId || - batch.generation !== this.#generation || - batch.hostEpoch !== this.#hostEpoch - ) { - return false; - } let changed = batch.reset || batch.durableThrough !== this.#durableThrough || diff --git a/apps/desktop/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 954e68286b..99e811a0d4 100644 --- a/apps/desktop/src/renderer/workhub-coordination-port.ts +++ b/apps/desktop/src/renderer/workhub-coordination-port.ts @@ -23,7 +23,10 @@ import { type StoredMessage, type TurnStatus, } from '@maka/core/session'; -import { DesktopTranscriptRangeStore } from './desktop-transcript-range-store.js'; +import { + createDesktopTranscriptRangeController, + DesktopTranscriptRangeStore, +} from './desktop-transcript-range-store.js'; import type { WorkHubCoordinationPort, WorkHubCoordinationTurn, @@ -74,12 +77,12 @@ export function createDesktopWorkHubCoordinationPort(deps: { let completedLatestGeneration: string | undefined; let loadingLatestGeneration: string | undefined; let latestLoadRevision = 0; - let handle: Awaited> | undefined; + let opened = false; const emit = () => { handler(projectWorkHubCoordinationTurns(store.snapshot().messages)); }; const emitOrCompleteLatest = () => { - if (!handle || !ready) return; + if (!opened || !ready) return; const snapshot = store.snapshot(); const latestRecordIsIncomplete = snapshot.durableThrough !== null && @@ -92,13 +95,10 @@ export function createDesktopWorkHubCoordinationPort(deps: { const generation = snapshot.generation; const revision = latestLoadRevision; loadingLatestGeneration = generation; - void handle - .loadAround( - snapshot.durableThrough, - WORKHUB_COORDINATION_LATEST_RECORD_MAX_BYTES, - ) + void controller + .loadLatest(WORKHUB_COORDINATION_LATEST_RECORD_MAX_BYTES) .then(() => { - if (latestLoadRevision !== revision) return; + if (disposed || latestLoadRevision !== revision) return; completedLatestGeneration = generation; if (loadingLatestGeneration === generation) loadingLatestGeneration = undefined; }) @@ -113,18 +113,23 @@ export function createDesktopWorkHubCoordinationPort(deps: { } emit(); }; - const opened = await deps.transcripts.open( + const controller = createDesktopTranscriptRangeController(store, (signal) => deps.transcripts.open( deps.sessionId, (batch) => { if (disposed) return; try { + if (!store.accepts(batch)) return; + const changed = store.accept(batch); if (batch.reset) { ready = false; - latestLoadRevision += 1; - completedLatestGeneration = undefined; - loadingLatestGeneration = undefined; + // Navigation replies reset the range too. Keep their in-flight + // guard until the read settles, including sparse durable tails. + if (loadingLatestGeneration !== batch.generation) { + latestLoadRevision += 1; + completedLatestGeneration = undefined; + loadingLatestGeneration = undefined; + } } - const changed = store.accept(batch); ready ||= batch.ready; if (changed || batch.ready) emitOrCompleteLatest(); } catch (error) { @@ -132,19 +137,21 @@ export function createDesktopWorkHubCoordinationPort(deps: { } }, (cancel) => { - if (disposed) cancel(); + if (signal.aborted) cancel(); + else signal.addEventListener('abort', cancel, { once: true }); }, - ).catch((error) => { + )); + await controller.ready().catch((error) => { onError(error); throw error; }); - handle = opened; + opened = true; emitOrCompleteLatest(); return { async close() { disposed = true; - await handle?.close(); + await controller.close(); }, }; }, From bcb754b3e6ed702717c4ca4a0ccc82a603afb23c Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Tue, 8 Sep 2026 11:41:39 +0800 Subject: [PATCH 3/5] refactor(desktop): simplify transcript navigation coordination Publish messages only through accepted transcript subscriptions. Remove completion-time projections, unsupported controller fallbacks, and unused navigation exports while preserving restoration and stale-operation guards. Replace the incident-sized fixture with small real SQLite and RuntimeEvent cases using message-ID checkpoints. Verify complete, unique durable answers after reopening, and move send-from-history geometry into the existing Storybook coverage. Remove the redundant Electron scenario and its exclusive fake-backend support. Generated-by: OpenAI Codex --- apps/desktop/e2e-budget.json | 4 - .../transcript-navigation-ownership.spec.ts | 168 ------- apps/desktop/renderer-architecture.json | 2 +- .../app-shell-first-send-cleanup.test.ts | 10 +- .../app-shell-session-ui-state.test.ts | 58 ++- .../transcript-navigation-pager.test.ts | 84 ++-- .../transcript-navigation-regression.test.ts | 45 +- .../transcript-navigation-test-fixture.ts | 433 +----------------- .../transcript-overlay-settlement.test.ts | 47 +- ...script-reading-position-controller.test.ts | 34 +- .../transcript-send-viewport.test.ts | 17 +- apps/desktop/src/renderer/app-shell.tsx | 1 - .../renderer/features/conversation/README.md | 6 +- ...transcript-reading-position-controller.tsx | 18 +- .../controller/transcript-reading-position.ts | 79 +--- .../renderer/features/conversation/index.ts | 15 - .../renderer/features/conversation/testing.ts | 28 ++ apps/desktop/stories/app-shell.stories.tsx | 68 ++- .../src/__tests__/fake-backend.test.ts | 51 +-- .../runtime/src/test-only/fake-backend.ts | 36 +- 20 files changed, 296 insertions(+), 908 deletions(-) delete mode 100644 apps/desktop/e2e/transcript-navigation-ownership.spec.ts create mode 100644 apps/desktop/src/renderer/features/conversation/testing.ts diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index b514b45fb1..930837c812 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -65,10 +65,6 @@ "tests": 4, "electron": "observation seeding, reconnect and settle are Host subscriptions surviving a renderer remount" }, - "transcript-navigation-ownership.spec.ts": { - "tests": 1, - "electron": "reading ownership must survive Host streaming settlement and Session switches through main, preload and renderer" - }, "transcript-scroll-cost.spec.ts": { "tests": 3, "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping" diff --git a/apps/desktop/e2e/transcript-navigation-ownership.spec.ts b/apps/desktop/e2e/transcript-navigation-ownership.spec.ts deleted file mode 100644 index 5b71ecb506..0000000000 --- a/apps/desktop/e2e/transcript-navigation-ownership.spec.ts +++ /dev/null @@ -1,168 +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 { - FAKE_COMPLETE_HELD_TURN_STEERING, - FAKE_HOLD_OPEN_COMPLETE_PROMPT, -} from '@maka/runtime/test-only/fake-backend'; -import { - awaitSendReady, - COMPOSER_INPUT, - ensureSidebarExpanded, - expect, - test, -} from './fixtures'; - -test('sending from history follows the second Turn through Session switches and durable settlement', async ({ - window: page, -}) => { - test.setTimeout(90_000); - const composer = page.locator(COMPOSER_INPUT); - const log = page.getByRole('log'); - const scroller = page.locator('[data-chat-scroll-container="true"]'); - const distanceToTail = () => scroller.evaluate((element) => - element.scrollHeight - element.clientHeight - element.scrollTop); - // Keep both Turns well below the range budget while making the transcript - // tall enough that loading the same latest range cannot reveal B by clamping. - const firstQuestion = 'First question before the live navigation regression.'; - const firstPrompt = [ - firstQuestion, - ...Array.from({ length: 60 }, (_, index) => `History line ${index + 1}.`), - ].join('\n\n'); - await composer.fill(firstPrompt); - await awaitSendReady(page); - await composer.press('Enter'); - await expect(log).toContainText(`Fake backend received: ${firstQuestion}`, { timeout: 20_000 }); - await expect(page.getByRole('button', { name: '停止', exact: true })).toHaveCount(0); - - await composer.fill(FAKE_HOLD_OPEN_COMPLETE_PROMPT); - await awaitSendReady(page); - const scrollBox = await scroller.boundingBox(); - expect(scrollBox).not.toBeNull(); - await page.mouse.move(scrollBox!.x + scrollBox!.width / 2, scrollBox!.y + scrollBox!.height / 2); - await page.mouse.wheel(0, -800); - await expect.poll(distanceToTail).toBeGreaterThan(500); - await composer.press('Enter'); - const liveText = 'Fake backend waiting for the test to stop the Turn.'; - await expect(page.locator('.maka-bubble-streaming')).toContainText(liveText, { timeout: 20_000 }); - await expect(log).toContainText(firstPrompt); - await expect.poll(distanceToTail).toBeLessThanOrEqual(3); - - await ensureSidebarExpanded(page); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - const activeRow = sidebar.locator('[data-session-id]:has([aria-current="page"])'); - const originalSessionId = await activeRow.getAttribute('data-session-id'); - expect(originalSessionId).toBeTruthy(); - await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); - await composer.fill('Temporary conversation while the original Turn streams.'); - await awaitSendReady(page); - await composer.press('Enter'); - await expect(log).toContainText('Fake backend received: Temporary conversation', { timeout: 20_000 }); - await expect(page.getByRole('button', { name: '停止', exact: true })).toHaveCount(0); - const temporarySessionId = await activeRow.getAttribute('data-session-id'); - expect(temporarySessionId).toBeTruthy(); - expect(temporarySessionId).not.toBe(originalSessionId); - - const backgroundText = 'Durable second-turn navigation marker.'; - await page.evaluate(({ sessionId, text }) => new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - unsubscribe(); - reject(new Error('The isolated fake backend did not publish the background text.')); - }, 10_000); - const unsubscribe = window.maka.sessions.subscribeEvents(sessionId, (event) => { - if (event.type !== 'text_delta' || !event.text.includes(text)) return; - window.clearTimeout(timeout); - unsubscribe(); - resolve(); - }); - void window.maka.sessions.submitMessage(sessionId, 'current_turn', { - messageId: crypto.randomUUID(), text, - }).then((result) => { - if (result.ok) return; - window.clearTimeout(timeout); - unsubscribe(); - reject(new Error(`Background fixture steering was refused: ${result.reason}`)); - }, (error) => { - window.clearTimeout(timeout); - unsubscribe(); - reject(error); - }); - }), { sessionId: originalSessionId!, text: backgroundText }); - - const originalRow = sidebar.locator(`[data-session-id=${JSON.stringify(originalSessionId)}]`); - const temporaryRow = sidebar.locator(`[data-session-id=${JSON.stringify(temporarySessionId)}]`); - await originalRow.click(); - await expect(page.locator('.maka-bubble-streaming')).toContainText(backgroundText); - await expect(log).toContainText(firstPrompt); - const completed = await page.evaluate(({ sessionId, text }) => window.maka.sessions.submitMessage( - sessionId, 'current_turn', { messageId: crypto.randomUUID(), text }, - ), { sessionId: originalSessionId!, text: FAKE_COMPLETE_HELD_TURN_STEERING }); - expect(completed.ok).toBe(true); - await expect(page.getByRole('button', { name: '停止', exact: true })).toHaveCount(0, { timeout: 20_000 }); - await expect(log).toContainText(backgroundText); - - // Inspect the actual Desktop transcript transport. A rendered settled bubble - // alone cannot prove that the Host persisted its accumulated overlay. - const readDurableTranscript = () => page.evaluate(async (sessionId) => { - const records = new Map(); - const navigationVersions = new Set(); - let markReady!: () => void; - const ready = new Promise((resolve) => { markReady = resolve; }); - const handle = await window.maka.transcripts.open(sessionId, (batch) => { - // The cached bootstrap uses the contract's implicit version zero. - navigationVersions.add(batch.navigationVersion ?? 0); - for (const fragment of batch.fragments) { - if (fragment.source !== 'durable' || typeof fragment.identity !== 'number') continue; - const bytes = records.get(fragment.identity) ?? new Uint8Array(fragment.totalBytes); - bytes.set(fragment.data, fragment.byteOffset); - records.set(fragment.identity, bytes); - } - if (batch.ready) markReady(); - }); - try { - await ready; - return { - navigationVersions: [...navigationVersions], - messages: [...records.values()].map((bytes) => JSON.parse(new TextDecoder().decode(bytes)) as { - id: string; type: string; text?: string; - }), - }; - } finally { - await handle.close(); - } - }, originalSessionId!); - await expect.poll(async () => (await readDurableTranscript()).messages.filter(({ type, text }) => - type === 'assistant' && text?.includes(backgroundText)).length, { timeout: 20_000 }).toBe(1); - const durableTranscript = await readDurableTranscript(); - expect(durableTranscript.navigationVersions).toEqual([0]); - - await temporaryRow.click(); - await expect(log).toContainText('Temporary conversation'); - await originalRow.click(); - await expect(log).toContainText(firstPrompt); - await expect(log).toContainText(backgroundText); - await expect(page.locator('.maka-bubble-streaming')).toHaveCount(0); - const settledAnswer = log.getByRole('article', { name: 'Maka 的回答', exact: true }) - .filter({ hasText: backgroundText }); - await expect(settledAnswer).toHaveCount(1); - expect((await settledAnswer.textContent())?.split(backgroundText)).toHaveLength(2); - expect((await settledAnswer.textContent())?.split(liveText)).toHaveLength(2); - expect(durableTranscript.messages.filter(({ type, text }) => - type === 'assistant' && text?.includes(backgroundText))).toHaveLength(1); -}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 5cd57c8da1..8264a67fba 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -894,7 +894,7 @@ "react": 1 }, "importSpecifiers": 121, - "nonTriviaTokens": 14433 + "nonTriviaTokens": 14428 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, 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 aed5f421fa..0d6c9bb97b 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 @@ -39,7 +39,7 @@ import { describe, it } from 'node:test'; import type { LiveTurnProjection } from '@maka/ui'; import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; -import { transcriptReadingPosition } from '../../renderer/features/conversation/index.js'; +import { prepareTranscriptForSend } from '../../renderer/features/conversation/testing.js'; import { createActionsDeps, @@ -528,9 +528,9 @@ describe('composer first-send cleanup', () => { ...createActionsDeps(), activeIdRef, transcriptRangeRef, - onFollowLatest: (sessionId) => transcriptReadingPosition.prepareForSend({ + onFollowLatest: (sessionId) => prepareTranscriptForSend({ sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef, - cancel: () => { order.push('cancel-restore'); }, setMessages: () => {}, followLatest: () => {}, + cancel: () => { order.push('cancel-restore'); }, followLatest: () => {}, }), }).send('hello'); await new Promise((resolve) => setImmediate(resolve)); @@ -564,9 +564,9 @@ describe('composer first-send cleanup', () => { ...createActionsDeps(), activeIdRef, transcriptRangeRef, - onFollowLatest: (sessionId) => transcriptReadingPosition.prepareForSend({ + onFollowLatest: (sessionId) => prepareTranscriptForSend({ sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef, - cancel: () => {}, setMessages: () => { assert.fail('the previous range must not project'); }, + cancel: () => {}, followLatest: (sessionId) => { assert.equal(sessionId, 'selected-session'); }, }), setMessages: () => { assert.fail('the previous range must not replace selected messages'); }, 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 8420e98385..23f4192869 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 @@ -32,10 +32,13 @@ import { type AppShellSessionUiState, } from '../../renderer/app-shell-session-ui-state.js'; import { - transcriptReadingPosition, + createTranscriptRestoreLifecycle, + loadTranscriptHistory, + refreshTranscriptTurnLandmarks, + restoreSessionTranscriptRange, type TranscriptHistoryGates, type TranscriptHistoryPending, -} from '../../renderer/features/conversation/index.js'; +} from '../../renderer/features/conversation/testing.js'; function boundaryRequest(requestId: string): SandboxBoundaryRequestEvent { return { @@ -103,7 +106,7 @@ function deferredHistoryController() { } function crossSessionGateScenario() { - type HistoryRequest = Parameters[0]['request']; + type HistoryRequest = Parameters[0]['request']; const gates: TranscriptHistoryGates = new WeakMap(); const sessionIds = { a: 'session', b: 'session:a' } as const; const sides = { @@ -132,7 +135,7 @@ function crossSessionGateScenario() { request: { target: 'earlier' | 'later' | 'latest'; anchorTurnId?: string }, ) { const side = sides[id]; - return transcriptReadingPosition.loadHistory({ + return loadTranscriptHistory({ gates, sessionId: sessionIds[id], request, @@ -377,7 +380,7 @@ describe('app shell session UI state controller', () => { let index: { sessionId: string; throughSequence: number | null; turns: readonly string[] } | undefined = { sessionId: 'owner-session', throughSequence: 0, turns: ['previous-owner-turn'], }; - const dispose = transcriptReadingPosition.refreshLandmarks({ + const dispose = refreshTranscriptTurnLandmarks({ sessionId: 'owner-session', newestDurablePromptSequence: 1, list: () => new Promise<{ throughSequence: number; landmarks: string[] }>((resolve) => { @@ -388,7 +391,7 @@ describe('app shell session UI state controller', () => { }); // The shell cleans up the Owner effect and passes no ownerActiveId for Guests. dispose?.(); - transcriptReadingPosition.refreshLandmarks({ + refreshTranscriptTurnLandmarks({ sessionId: undefined, newestDurablePromptSequence: 1, list: async () => assert.fail('Guests cannot query Owner turn landmarks'), @@ -403,8 +406,8 @@ describe('app shell session UI state controller', () => { it('enriches a Turn-only reading anchor when its range sequence arrives later', async () => { let anchor: { turnId: string; sequence?: number } | undefined; const admitted: Array = []; - transcriptReadingPosition.restoreRange({ - lifecycle: transcriptReadingPosition.createRestoreLifecycle(), + restoreSessionTranscriptRange({ + lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'session', readingAnchor: { turnId: 'turn' }, controller: { @@ -414,12 +417,10 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => 17, snapshot: () => ({ messages: [] }), }, - ready: async () => undefined, setReadingAnchor: async (sequence) => { admitted.push(sequence); }, loadAround: async () => assert.fail('the resident Turn must not load another range'), }, isCurrent: () => true, - setMessages: () => assert.fail('the resident range must not replace messages'), setReadingAnchor: (_sessionId, next) => { anchor = next; }, @@ -434,8 +435,8 @@ describe('app shell session UI state controller', () => { it('does not enrich a reading anchor from another Session range', () => { let sequenceReads = 0; let anchor: { turnId: string; sequence?: number } | undefined; - transcriptReadingPosition.restoreRange({ - lifecycle: transcriptReadingPosition.createRestoreLifecycle(), + restoreSessionTranscriptRange({ + lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'active', readingAnchor: { turnId: 'turn' }, controller: { @@ -448,11 +449,10 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => 17, snapshot: () => ({ messages: [] }), }, - ready: async () => undefined, + setReadingAnchor: async () => {}, loadAround: async () => assert.fail('a stale range must not load'), }, isCurrent: () => true, - setMessages: () => assert.fail('a stale range must not replace messages'), setReadingAnchor: (_sessionId, next) => { anchor = next; }, @@ -464,10 +464,10 @@ describe('app shell session UI state controller', () => { }); it('abandons a Turn-only restore that remains absent after the range is ready', async () => { - const anchorWrites: Array<{ turnId: string; sequence?: number } | undefined> = []; + let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'missing' }; let unavailable: { sessionId: string; turnId: string } | undefined; const options = { - lifecycle: transcriptReadingPosition.createRestoreLifecycle(), + lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'session', readingAnchor: { turnId: 'missing' }, controller: { @@ -477,13 +477,12 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => null, snapshot: () => ({ messages: [] }), }, - ready: async () => undefined, + setReadingAnchor: async () => {}, loadAround: async () => assert.fail('a Turn-only anchor has no load target'), }, isCurrent: () => true, - setMessages: () => assert.fail('an unavailable target must not replace messages'), setReadingAnchor: (_sessionId: string, next: { turnId: string; sequence?: number } | undefined) => { - anchorWrites.push(next); + anchor = next; }, onRestoreUnavailable: (sessionId: string, turnId: string) => { unavailable = { sessionId, turnId }; @@ -491,20 +490,19 @@ describe('app shell session UI state controller', () => { onError: (error: unknown) => assert.fail(String(error)), }; - transcriptReadingPosition.restoreRange(options); + restoreSessionTranscriptRange(options); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(anchorWrites, [undefined]); + 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 messages: Array<{ id: string }> | undefined; - const anchorWrites: Array<{ turnId: string; sequence?: number } | undefined> = []; + let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'removed', sequence: 23 }; const options = { - lifecycle: transcriptReadingPosition.createRestoreLifecycle(), + lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'session', readingAnchor: { turnId: 'removed', sequence: 23 }, controller: { @@ -514,17 +512,14 @@ describe('app shell session UI state controller', () => { newestDurableUserSequence: () => 29, snapshot: () => ({ messages: [{ id: 'latest' }] }), }, - ready: async () => undefined, + setReadingAnchor: async () => assert.fail('a missing durable Turn must load its range'), loadAround: async (sequence: number) => { loadedSequence = sequence; }, }, isCurrent: () => true, - setMessages: (next: Array<{ id: string }>) => { - messages = next; - }, setReadingAnchor: (_sessionId: string, next: { turnId: string; sequence?: number } | undefined) => { - anchorWrites.push(next); + anchor = next; }, onRestoreUnavailable: (sessionId: string, turnId: string) => { unavailable = { sessionId, turnId }; @@ -532,12 +527,11 @@ describe('app shell session UI state controller', () => { onError: (error: unknown) => assert.fail(String(error)), }; - transcriptReadingPosition.restoreRange(options); + restoreSessionTranscriptRange(options); await new Promise((resolve) => setImmediate(resolve)); assert.equal(loadedSequence, 23); - assert.deepEqual(messages, [{ id: 'latest' }]); - assert.deepEqual(anchorWrites, [undefined]); + assert.equal(anchor, undefined); assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'removed' }); }); diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts index f5bc33aec1..1a3ec68814 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts @@ -18,9 +18,10 @@ */ import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; import test from 'node:test'; import { markPersisted } from '@maka/core/persisted-value'; -import { decodeStoredMessage, isRuntimeSystemNoteKind, type StoredMessage } from '@maka/core/session'; +import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; import { SESSION_CONTINUITY_SCHEMA_VERSION } from '@maka/runtime-host/protocol'; import { ClientSessionSubscription } from '../../../../../packages/runtime-host/dist/client/session-subscription.js'; import { @@ -31,25 +32,22 @@ import { import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../../preload/transcript-contract.js'; import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; -import { - createTranscriptNavigationTestFixture, - openTranscriptNavigationLedger, -} from './transcript-navigation-test-fixture.js'; +import { openTranscriptNavigationLedger } from './transcript-navigation-test-fixture.js'; const PAGE_BYTES = 128 * 1024; const HOST_EPOCH = 'transcript-navigation-host'; const SUBSCRIPTION_ID = 'transcript-navigation-subscription'; test('keeps both Turns reachable when an oversized ledger Turn is followed by a new durable tail', async () => { - const source = createTranscriptNavigationTestFixture(); - assert.ok(source.turns.first.encodedBytes > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); - assert.equal(source.records.length, source.checkpoints.completeThrough + 1); - const ledger = await openTranscriptNavigationLedger(source.records.map(({ message }) => message)); + 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]); let opened: Awaited> | undefined; try { - const firstThrough = await ledger.appendThrough(source.checkpoints.firstTurnThrough); + const firstThrough = await ledger.appendThrough('completed-a'); const first = await ledger.durableRecords(); - assertSourcePayloads(first, source.records.slice(0, source.checkpoints.firstTurnThrough + 1)); + assertSourcePayloads(first, source.first); opened = await openReplica(ledger, firstThrough); const { replica, subscription, state } = opened; assertRecords(replica, first); @@ -59,11 +57,11 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a // RuntimeEvent transcripts publish a Turn durably only after it ends. The // running checkpoints below stay in the overlay, then this terminal event // advances the actual Host watermark and exercises live-to-durable eviction. - const completeThrough = await ledger.appendThrough(source.checkpoints.completeThrough); + const completeThrough = await ledger.appendThrough('completed-b'); assert.ok(completeThrough !== null); const complete = await ledger.durableRecords(); - const second = complete.filter(({ message }) => message.turnId === source.turns.second.turnId); - assertSourcePayloads(second, source.records.slice(source.turns.second.firstSequence)); + const second = complete.filter(({ message }) => message.turnId === 'b'); + assertSourcePayloads(second, source.second); assert.ok(first.some((record, index) => index > 0 && record.sequence > first[index - 1]!.sequence + 1)); assert.ok(completeThrough > complete.at(-1)!.sequence, 'the real watermark includes unused event sequence slots'); assert.equal(updateSubscriberTranscriptHighWater(state, completeThrough), true); @@ -103,25 +101,25 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a } }); -for (const checkpoint of ['secondTurnStartedThrough', 'secondTurnFirstToolsThrough'] as const) { +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 () => { - const source = createTranscriptNavigationTestFixture(); - const ledger = await openTranscriptNavigationLedger(source.records.map(({ message }) => message)); + const source = transcriptFixture(); + const ledger = await openTranscriptNavigationLedger([...source.first, ...source.second]); let opened: Awaited> | undefined; try { - const firstThrough = await ledger.appendThrough(source.checkpoints.firstTurnThrough); + const firstThrough = await ledger.appendThrough('completed-a'); const first = await ledger.durableRecords(); - assert.equal(await ledger.appendThrough(source.checkpoints[checkpoint]), firstThrough, + assert.equal(await ledger.appendThrough(checkpoint), firstThrough, 'a running invocation changes its overlay, not the durable watermark'); const rootTurn = { - sessionId: ledger.sessionId, turnId: source.turns.second.turnId, - runId: `run-${source.turns.second.turnId}`, status: 'running' as const, + sessionId: ledger.sessionId, turnId: 'b', + runId: 'run-b', status: 'running' as const, }; opened = await openReplica(ledger, firstThrough, rootTurn); const { replica } = opened; assertRecords(replica, first); - const expected = source.records.slice(source.turns.second.firstSequence, source.checkpoints[checkpoint] + 1) - .filter(({ message }) => message.type !== 'turn_state').map(({ message }) => message.id); + const expected = source.second.slice(0, source.second.findIndex(({ id }) => id === checkpoint) + 1) + .filter((message) => message.type !== 'turn_state').map(({ id }) => id); assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), expected); await replica.readAt(first[0]!.sequence); assertRecords(replica, first); @@ -129,7 +127,7 @@ for (const checkpoint of ['secondTurnStartedThrough', 'secondTurnFirstToolsThrou assertRecords(replica, first); assert.ok(replica.messages().some(({ id }) => id === expected.at(-1)), 'the running Turn remains reachable'); - const throughSequence = await ledger.appendThrough(source.checkpoints.completeThrough); + const throughSequence = await ledger.appendThrough('completed-b'); assert.ok(throughSequence !== null); assert.equal(updateSubscriberTranscriptHighWater(opened.state, throughSequence), true); opened.subscription.accept({ @@ -191,15 +189,12 @@ function assertRecords(replica: DesktopTranscriptReplica, records: readonly { se function assertSourcePayloads( records: readonly { message: StoredMessage }[], - source: readonly { message: StoredMessage }[], + source: readonly StoredMessage[], ) { - // Running status and Session-level notices live outside the durable ledger; - // every content/tool/usage row and invocation-owned note must survive. - const expected = source.filter(({ message }) => - (message.type !== 'turn_state' || message.status !== 'running') && - (message.type !== 'system_note' || isRuntimeSystemNoteKind(message.kind))); - assert.deepEqual(new Set(records.map(({ message }) => message.id)), new Set(expected.map(({ message }) => message.id))); - for (const { message } of expected) { + // Running status is projected separately; every content row survives. + const expected = source.filter((message) => message.type !== 'turn_state' || message.status !== 'running'); + assert.deepEqual(new Set(records.map(({ message }) => message.id)), new Set(expected.map(({ id }) => id))); + for (const message of expected) { const projected = records.find((record) => record.message.id === message.id)!.message; if (message.type === 'tool_result') { assert.equal(projected.type, 'tool_result'); @@ -214,3 +209,28 @@ function assertSourcePayloads( } } } + +function transcriptFixture() { + const turn = (turnId: string, resultBytes: number): StoredMessage[] => [ + { type: 'user', id: `user-${turnId}`, turnId, ts: 1, text: `Question ${turnId}` }, + { type: 'turn_state', id: `running-${turnId}`, turnId, ts: 2, status: 'running' }, + { type: 'assistant', id: `step-${turnId}`, turnId, ts: 3, text: 'Checking the source.', modelId: 'fixture-model' }, + { + type: 'tool_call', id: `tool-${turnId}`, turnId, ts: 4, stepId: `step-${turnId}`, + toolName: 'fixture_lookup', args: { query: turnId }, origin: 'provider', modelVisibility: 'visible', + }, + { + type: 'tool_result', id: `result-${turnId}`, turnId, ts: 5, toolUseId: `tool-${turnId}`, + isError: false, content: { kind: 'json', value: { payload: 'x'.repeat(resultBytes) } }, + origin: 'provider', modelVisibility: 'visible', + }, + { + type: 'assistant', id: `answer-${turnId}`, turnId, ts: 6, text: `Complete answer ${turnId}`, + thinking: { text: 'Retained reasoning.' }, modelId: 'fixture-model', + }, + { type: 'turn_state', id: `completed-${turnId}`, turnId, ts: 7, status: 'completed' }, + ]; + // 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) }; +} diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts index 612f25d436..7e7eadd116 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts @@ -25,8 +25,10 @@ import { type SessionTranscriptPage, } from '@maka/runtime-host/protocol'; import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../../preload/transcript-contract.js'; -import { transcriptReadingPosition } from '../../renderer/features/conversation/index.js'; -const { createRestoreLifecycle: createTranscriptRestoreLifecycle, restoreRange: restoreSessionTranscriptRange } = transcriptReadingPosition; +import { + createTranscriptRestoreLifecycle, + restoreSessionTranscriptRange, +} from '../../renderer/features/conversation/testing.js'; import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; @@ -89,7 +91,6 @@ test('a completed resident bookmark does not reload after streaming settlement e const lifecycle = createTranscriptRestoreLifecycle(); let loaded = 0; const controller = { - ready: async () => {}, // This test isolates restore command lifetime from reader navigation. setReadingAnchor: async () => {}, loadAround: async (sequence: number) => { @@ -110,7 +111,6 @@ test('a completed resident bookmark does not reload after streaming settlement e readingAnchor: { turnId: 'turn-a', sequence: 0 }, controller, isCurrent: () => true, - setMessages: () => {}, setReadingAnchor: () => {}, onError: (error) => assert.fail(String(error)), }); @@ -149,7 +149,6 @@ test('reopening a bookmark at the current Turn retains content persisted later i sessionId: 'session-1', readingAnchor: { turnId: 'turn-b', sequence: 2 }, controller: { - ready: async () => {}, setReadingAnchor: (sequence) => fixture.replica.readAt(sequence), loadAround: (sequence) => fixture.replica.loadAround(sequence, PAGE_BYTES), store: { @@ -161,7 +160,6 @@ test('reopening a bookmark at the current Turn retains content persisted later i }, }, isCurrent: () => true, - setMessages: () => {}, setReadingAnchor: () => {}, onError: (error) => assert.fail(String(error)), }); @@ -224,18 +222,19 @@ test('streaming persistence retains a newly loaded oversized neighbor until the } }); -test('repeated message notifications share one pending restore and cancellation prevents a late UI commit', async () => { +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; - const committed: string[][] = []; + let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'turn-a', sequence: 0 }; + let unavailable: string | undefined; const options = { lifecycle, sessionId: 'session-1', readingAnchor: { turnId: 'turn-a', sequence: 0 }, controller: { - ready: async () => {}, + setReadingAnchor: async () => {}, loadAround: async () => { reads += 1; await loading; }, store: { range: () => ({ sessionId: 'session-1' }), @@ -245,8 +244,8 @@ test('repeated message notifications share one pending restore and cancellation }, }, isCurrent: () => true, - setMessages: (messages: string[]) => { committed.push(messages); }, - setReadingAnchor: () => {}, + setReadingAnchor: (_sessionId: string, next: typeof anchor) => { anchor = next; }, + onRestoreUnavailable: (_sessionId: string, turnId: string) => { unavailable = turnId; }, onError: (error: unknown) => assert.fail(String(error)), }; restoreSessionTranscriptRange(options); @@ -255,13 +254,15 @@ test('repeated message notifications share one pending restore and cancellation assert.equal(reads, 1); lifecycle.cancel('session-1'); + anchor = { turnId: 'turn-b', sequence: 2 }; finishLoad(); await settleRestore(); restoreSessionTranscriptRange(options); await settleRestore(); assert.equal(reads, 1, 'cancellation must not recapture the bookmark in the same activation'); - assert.deepEqual(committed, [], 'a cancelled restore cannot replace the newer visible range'); + assert.deepEqual(anchor, { turnId: 'turn-b', sequence: 2 }); + 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 () => { @@ -273,7 +274,7 @@ test('switching away and back creates a fresh restore while clearing search does profileId: 'profile-1', readingAnchor: { turnId: 'turn-a', sequence: 0 }, controller: { - ready: async () => {}, + setReadingAnchor: async () => {}, loadAround: async (sequence: number) => { reads.push(sequence); }, store: { range: () => ({ sessionId: 'session-1' }), @@ -283,7 +284,6 @@ test('switching away and back creates a fresh restore while clearing search does }, }, isCurrent: () => true, - setMessages: () => {}, setReadingAnchor: () => {}, onError: (error: unknown) => assert.fail(String(error)), }; @@ -307,16 +307,17 @@ test('switching away and back creates a fresh restore while clearing search does assert.deepEqual(reads, [0, 2, 0, 0], 'changing Hosts also creates a fresh activation'); }); -test('effect teardown followed by setup replaces the first Session restore without a late commit', async () => { +test('effect teardown followed by setup lets only the replacement restore settle its bookmark', async () => { const lifecycle = createTranscriptRestoreLifecycle(); const loads: Array<() => void> = []; - const committed: string[][] = []; + let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'turn-a', sequence: 0 }; + let unavailable: string | undefined; const options = { lifecycle, sessionId: 'session-1', readingAnchor: { turnId: 'turn-a', sequence: 0 }, controller: { - ready: async () => {}, + setReadingAnchor: async () => {}, loadAround: () => new Promise((resolve) => { loads.push(resolve); }), store: { range: () => ({ sessionId: 'session-1' }), @@ -326,8 +327,8 @@ test('effect teardown followed by setup replaces the first Session restore witho }, }, isCurrent: () => true, - setMessages: (messages: string[]) => { committed.push(messages); }, - setReadingAnchor: () => {}, + setReadingAnchor: (_sessionId: string, next: typeof anchor) => { anchor = next; }, + onRestoreUnavailable: (_sessionId: string, turnId: string) => { unavailable = turnId; }, onError: (error: unknown) => assert.fail(String(error)), }; restoreSessionTranscriptRange(options); @@ -338,10 +339,12 @@ test('effect teardown followed by setup replaces the first Session restore witho loads[0]!(); await settleRestore(); - assert.deepEqual(committed, [], 'the deactivated command cannot commit after replacement'); + assert.deepEqual(anchor, { turnId: 'turn-a', sequence: 0 }); + assert.equal(unavailable, undefined, 'the deactivated command cannot settle after replacement'); loads[1]!(); await settleRestore(); - assert.deepEqual(committed, [['replacement range']], 'only the replacement restore commits'); + assert.equal(anchor, undefined); + assert.equal(unavailable, 'turn-a', 'only the replacement restore settles its unavailable target'); }); async function settleRestore(): Promise { diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts b/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts index 69cce28913..34b46ea5fd 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts @@ -18,7 +18,6 @@ */ import assert from 'node:assert/strict'; -import { Buffer } from 'node:buffer'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -30,435 +29,7 @@ import { backfillRuntimeEventsFromStoredMessages } from '../../../../../packages import { createSessionTranscriptReader } from '../../../../../packages/runtime-host/dist/server/session-transcript-reader.js'; import { isRuntimeSystemNoteKind, type StoredMessage } from '@maka/core/session'; -export const TRANSCRIPT_NAVIGATION_SESSION_ID = 'transcript-navigation-fixture'; -export const TRANSCRIPT_NAVIGATION_FIRST_TURN_ID = 'transcript-navigation-turn-a'; -export const TRANSCRIPT_NAVIGATION_SECOND_TURN_ID = 'transcript-navigation-turn-b'; - -/** - * Source-row checkpoints from the production-shaped two-Turn failure. - * - * These identify the original content boundaries. The ledger fixture reads - * its sparse durable sequences and watermarks from the production reader; - * running checkpoints now belong to the active overlay. - */ -export const TRANSCRIPT_NAVIGATION_CHECKPOINTS = { - firstTurnThrough: 194, - secondTurnStartedThrough: 196, - secondTurnFirstToolsThrough: 204, - completeThrough: 232, -} as const; - -export interface TranscriptNavigationRecord { - readonly identity: number; - readonly message: StoredMessage; -} - -export interface TranscriptNavigationTurnFixture { - readonly turnId: string; - readonly firstSequence: number; - readonly lastSequence: number; - readonly encodedBytes: number; -} - -export interface TranscriptNavigationTestFixture { - readonly sessionId: string; - readonly records: readonly TranscriptNavigationRecord[]; - readonly checkpoints: typeof TRANSCRIPT_NAVIGATION_CHECKPOINTS; - readonly turns: { - readonly first: TranscriptNavigationTurnFixture; - readonly second: TranscriptNavigationTurnFixture; - }; -} - -interface ToolSeries { - ordinal: number; -} - -interface ToolBatchOptions { - readonly resultKind: 'json' | 'terminal'; - readonly paddingBytes?: (ordinal: number) => number; -} - const FIXTURE_EPOCH = Date.UTC(2026, 0, 2, 3, 4, 5); -const FIRST_TURN_STANDARD_RESULT_PADDING_BYTES = 4 * 1024; -const FIRST_TURN_LARGE_RESULT_PADDING_BYTES = new Map([ - [12, 425 * 1024], - [17, 935 * 1024], -]); -const SECOND_TURN_ASSISTANT_PADDING_BYTES = 12 * 1024; - -/** - * A privacy-safe transcript with the same useful boundaries as the diagnosed - * failure: Turn A owns sequences 0..194 and is larger than the Desktop range; - * Turn B starts at 195, first advances at 196 and 204, then settles at 232. - * - * Every record is a real StoredMessage. Tool call/result ids are paired and - * every record in a Turn carries that Turn's stable id. Large payloads are - * deterministic filler rather than copied command output or user text. - */ -export function createTranscriptNavigationTestFixture(): TranscriptNavigationTestFixture { - const records: TranscriptNavigationRecord[] = []; - const append = (message: StoredMessage): number => { - const identity = records.length; - records.push({ identity, message }); - return identity; - }; - - append(userMessage(TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 'first')); - append(turnStateMessage(TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 'running')); - let firstStepId = appendAssistant( - append, - TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, - 'first-step-1', - ); - const firstTools: ToolSeries = { ordinal: 0 }; - - // 30 two-tool steps plus 13 one-tool steps produce the diagnosed Turn's - // 73 call/result pairs and leave its final assistant at sequence 191. - for (let step = 0; step < 43; step += 1) { - appendToolBatch( - append, - TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, - firstStepId, - step < 30 ? 2 : 1, - firstTools, - { - resultKind: 'json', - paddingBytes: (ordinal) => - FIRST_TURN_LARGE_RESULT_PADDING_BYTES.get(ordinal) - ?? FIRST_TURN_STANDARD_RESULT_PADDING_BYTES, - }, - ); - firstStepId = appendAssistant( - append, - TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, - `first-step-${step + 2}`, - ); - } - - append(tokenUsageMessage(TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 'first')); - append(turnStateMessage(TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 'completed')); - append(sessionResumeMessage(TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 'first')); - - append(userMessage(TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 'second')); - append(turnStateMessage(TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 'running')); - const secondTools: ToolSeries = { ordinal: 0 }; - const secondToolOptions: ToolBatchOptions = { - resultKind: 'terminal', - paddingBytes: () => 256, - }; - - const secondStep1 = appendAssistant( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - 'second-step-1', - SECOND_TURN_ASSISTANT_PADDING_BYTES, - ); - appendToolBatch( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - secondStep1, - 2, - secondTools, - secondToolOptions, - ); - - const secondStep2 = appendAssistant( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - 'second-step-2', - SECOND_TURN_ASSISTANT_PADDING_BYTES, - ); - appendToolBatch( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - secondStep2, - 2, - secondTools, - secondToolOptions, - ); - - const secondStep3 = appendAssistant( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - 'second-step-3', - SECOND_TURN_ASSISTANT_PADDING_BYTES, - ); - appendToolBatch( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - secondStep3, - 2, - secondTools, - secondToolOptions, - ); - appendToolBatch( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - secondStep3, - 2, - secondTools, - secondToolOptions, - ); - - const secondStep4 = appendAssistant( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - 'second-step-4', - SECOND_TURN_ASSISTANT_PADDING_BYTES, - ); - appendToolBatch( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - secondStep4, - 2, - secondTools, - secondToolOptions, - ); - - const secondStep5 = appendAssistant( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - 'second-step-5', - SECOND_TURN_ASSISTANT_PADDING_BYTES, - ); - appendToolBatch( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - secondStep5, - 2, - secondTools, - secondToolOptions, - ); - - const secondStep6 = appendAssistant( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - 'second-step-6', - SECOND_TURN_ASSISTANT_PADDING_BYTES, - ); - appendToolBatch( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - secondStep6, - 1, - secondTools, - secondToolOptions, - ); - appendAssistant( - append, - TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, - 'second-step-7', - SECOND_TURN_ASSISTANT_PADDING_BYTES, - ); - append(tokenUsageMessage(TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 'second')); - append(turnStateMessage(TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 'completed')); - append(sessionResumeMessage(TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 'second')); - - assertFixtureBoundary(records, 194, 'system_note', TRANSCRIPT_NAVIGATION_FIRST_TURN_ID); - assertFixtureBoundary(records, 195, 'user', TRANSCRIPT_NAVIGATION_SECOND_TURN_ID); - assertFixtureBoundary(records, 196, 'turn_state', TRANSCRIPT_NAVIGATION_SECOND_TURN_ID); - assertFixtureBoundary(records, 204, 'tool_call', TRANSCRIPT_NAVIGATION_SECOND_TURN_ID); - assertFixtureBoundary(records, 232, 'system_note', TRANSCRIPT_NAVIGATION_SECOND_TURN_ID); - - return { - sessionId: TRANSCRIPT_NAVIGATION_SESSION_ID, - records, - checkpoints: TRANSCRIPT_NAVIGATION_CHECKPOINTS, - turns: { - first: turnFixture(records, TRANSCRIPT_NAVIGATION_FIRST_TURN_ID, 0, 194), - second: turnFixture(records, TRANSCRIPT_NAVIGATION_SECOND_TURN_ID, 195, 232), - }, - }; -} - -function appendAssistant( - append: (message: StoredMessage) => number, - turnId: string, - label: string, - thinkingPaddingBytes = 0, -): string { - const id = `assistant-${label}`; - append({ - type: 'assistant', - id, - turnId, - ts: timestampForNextId(id), - text: `Synthetic assistant checkpoint ${label}.`, - ...(thinkingPaddingBytes > 0 - ? { thinking: { text: `Synthetic reasoning ${label}.\n${'r'.repeat(thinkingPaddingBytes)}` } } - : {}), - modelId: 'fixture-model', - }); - return id; -} - -function appendToolBatch( - append: (message: StoredMessage) => number, - turnId: string, - stepId: string, - count: number, - series: ToolSeries, - options: ToolBatchOptions, -): void { - const calls = Array.from({ length: count }, () => { - const ordinal = series.ordinal; - series.ordinal += 1; - const id = `tool-${turnId}-${ordinal}`; - append({ - type: 'tool_call', - id, - turnId, - ts: timestampForNextId(id), - toolName: 'fixture_lookup', - displayName: 'Synthetic fixture lookup', - args: { ordinal, query: `fixture-query-${ordinal}` }, - stepId, - origin: 'provider', - modelVisibility: 'visible', - }); - return { id, ordinal }; - }); - - for (const call of calls) { - const padding = 'x'.repeat(options.paddingBytes?.(call.ordinal) ?? 0); - append({ - type: 'tool_result', - id: `result-${turnId}-${call.ordinal}`, - turnId, - ts: timestampForNextId(`result-${turnId}-${call.ordinal}`), - toolUseId: call.id, - isError: false, - content: options.resultKind === 'json' - ? { - kind: 'json', - value: { - ordinal: call.ordinal, - summary: 'Deterministic synthetic fixture result.', - payload: padding, - }, - } - : { - kind: 'terminal', - cwd: '/workspace/fixture', - cmd: `fixture-command-${call.ordinal}`, - status: 'completed', - exitCode: 0, - output: { - mode: 'pipes', - stdout: `Synthetic terminal result ${call.ordinal}.\n${padding}`, - stderr: '', - stdoutTruncated: false, - stderrTruncated: false, - redacted: false, - }, - }, - durationMs: call.ordinal + 1, - origin: 'provider', - modelVisibility: 'visible', - }); - } -} - -function userMessage( - turnId: string, - label: string, -): Extract { - const id = `user-${label}`; - return { - type: 'user', - id, - turnId, - ts: timestampForNextId(id), - text: `Synthetic ${label} prompt.`, - inlineReferences: [], - }; -} - -function turnStateMessage( - turnId: string, - status: 'running' | 'completed', -): Extract { - const id = `state-${turnId}-${status}`; - return { - type: 'turn_state', - id, - turnId, - ts: timestampForNextId(id), - status, - }; -} - -function tokenUsageMessage( - turnId: string, - label: string, -): Extract { - const id = `usage-${label}`; - return { - type: 'token_usage', - id, - turnId, - ts: timestampForNextId(id), - input: 1_000, - output: 200, - cacheRead: 100, - }; -} - -function sessionResumeMessage( - turnId: string, - label: string, -): Extract { - const id = `session-resume-${label}`; - return { - type: 'system_note', - id, - turnId, - ts: timestampForNextId(id), - kind: 'session_resume', - }; -} - -function timestampForNextId(id: string): number { - let checksum = 0; - for (let index = 0; index < id.length; index += 1) checksum += id.charCodeAt(index); - return FIXTURE_EPOCH + checksum; -} - -function turnFixture( - records: readonly TranscriptNavigationRecord[], - turnId: string, - firstSequence: number, - lastSequence: number, -): TranscriptNavigationTurnFixture { - return { - turnId, - firstSequence, - lastSequence, - encodedBytes: records - .slice(firstSequence, lastSequence + 1) - .reduce( - (total, { message }) => total + Buffer.byteLength(JSON.stringify(message), 'utf8'), - 0, - ), - }; -} - -function assertFixtureBoundary( - records: readonly TranscriptNavigationRecord[], - sequence: number, - type: StoredMessage['type'], - turnId: string, -): void { - const record = records[sequence]; - if ( - record?.identity !== sequence - || record.message.type !== type - || record.message.turnId !== turnId - ) { - throw new Error(`Transcript navigation fixture boundary ${sequence} is invalid`); - } -} /** * The real SQLite ledger and Host reader used by the navigation regressions. @@ -518,7 +89,9 @@ export async function openTranscriptNavigationLedger(messages: readonly StoredMe }); return { sessionId, reader, - async appendThrough(index: number) { + async appendThrough(messageId: string) { + const index = messages.findIndex((message) => message.id === messageId); + assert.ok(index >= 0, `Unknown transcript fixture checkpoint: ${messageId}`); assert.ok(index >= appendedThrough, 'fixture writes advance monotonically'); for (const pendingEvent of pending) { if (pendingEvent.index <= appendedThrough || pendingEvent.index > index) continue; 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 05f393fd02..d664187d80 100644 --- a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts @@ -42,10 +42,10 @@ import { openTranscriptNavigationLedger } from './transcript-navigation-test-fix const HOST_EPOCH = 'host-1'; const SUBSCRIPTION_ID = 'overlay-settlement-subscription'; const PAGE_BYTES = 128 * 1024; -const BOOTSTRAP_THROUGH = 4; -const B_STEERING_THROUGH = 5; -const B_COMPLETED_THROUGH = 7; -const C_COMPLETED_THROUGH = 11; +const BOOTSTRAP_THROUGH = 'running-b'; +const B_STEERING_THROUGH = 'steering-b'; +const B_COMPLETED_THROUGH = 'completed-b'; +const C_COMPLETED_THROUGH = 'completed-c'; for (const coalesced of [false, true]) { test(`settles a bootstrap overlay outside history through ${coalesced ? 'a coalesced B+C watermark' : 'separate B and C watermarks'}`, async () => { @@ -80,6 +80,33 @@ for (const coalesced of [false, true]) { }); } +test('a completed live answer remains unique after a fresh transcript subscription', async () => { + const fixture = await openFixture(); + let reopened: Awaited> | undefined; + const assertAnswer = (messages: readonly StoredMessage[]) => { + const answers = messages.flatMap((message) => message.type === 'assistant' && message.turnId === 'b' + ? [{ id: message.id, text: message.text }] : []); + assert.deepEqual(answers, [ + { id: 'answer-b', text: 'B partial and completed answer' }, + ]); + }; + try { + await fixture.advance(B_COMPLETED_THROUGH); + assert.deepEqual(fixture.replica.snapshot().overlay, []); + assertAnswer(fixture.renderer.snapshot().messages); + assertAnswer((await fixture.ledger.durableRecords()).map(({ message }) => message)); + + reopened = await openSettledReplica(fixture.ledger); + const renderer = new DesktopTranscriptRangeStore(JSON.stringify(['local', fixture.ledger.sessionId])); + for (const batch of encodeDesktopTranscriptSnapshot(reopened.replica.snapshot())) renderer.accept(batch); + assert.deepEqual(reopened.replica.snapshot().overlay, []); + assertAnswer(renderer.snapshot().messages); + } finally { + await reopened?.close(); + await fixture.close(); + } +}); + test('retains an unfinished overlay through runtime checkpoints and skips scans after settlement', async () => { const fixture = await openFixture(); try { @@ -352,9 +379,9 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => }, }); for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) renderer.accept(batch); - const watermarks = new Map(); + const watermarks = new Map(); let frameSequence = 0; - const announce = async (checkpoint: number) => { + const announce = async (checkpoint: string) => { const throughSequence = await ledger.appendThrough(checkpoint); assert.ok(throughSequence !== null); watermarks.set(checkpoint, throughSequence); @@ -375,14 +402,14 @@ async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => return { replica, renderer, changes, requests, announce, history, bootstrapThrough, ledger, acceptNavigation: (navigation: DesktopTranscriptNavigation) => { navigationVersion = navigation.navigationVersion; }, - watermark: (checkpoint: number) => { + watermark: (checkpoint: string) => { const value = watermarks.get(checkpoint); assert.notEqual(value, undefined); return value!; }, - async advance(throughSequence: number) { - await announce(throughSequence); - await replica.advance(watermarks.get(throughSequence)!); + async advance(messageId: string) { + await announce(messageId); + await replica.advance(watermarks.get(messageId)!); }, async close() { replica.close(); diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index 23457a8956..b66ca34509 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 @@ -29,16 +29,21 @@ import { TranscriptReadingPositionController, type TranscriptReadingPositionCommands, type TranscriptHistoryPending, - transcriptReadingPosition, } from '../../renderer/features/conversation/index.js'; +import { + createTranscriptRestoreLifecycle, + prepareTranscriptForSend, + restoreSessionTranscriptRange, +} from '../../renderer/features/conversation/testing.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; afterEach(cleanupFakeDom); -test('a resident search admits history before an older latest response can evict its target', async () => { +test('a resident search supersedes send catch-up before its older latest response can evict the target', async () => { const sessionId = JSON.stringify(['host-1', 'session-1']); const store = new DesktopTranscriptRangeStore(sessionId); const latestStarted = deferred(); + const latestFinished = deferred(); const releaseLatest = deferred(); const readingAdmitted = deferred(); const admissions: Array<{ sequence: number | null; version: number; preserveRange?: boolean }> = []; @@ -62,6 +67,7 @@ test('a resident search admits history before an older latest response can evict latestStarted.resolve(); await releaseLatest.promise; publish('b', 20, version); + latestFinished.resolve(); } else { publish('a', 10, version); readingAdmitted.resolve(); @@ -70,19 +76,24 @@ test('a resident search admits history before an older latest response can evict close: async () => {}, })); try { - const latest = controller.loadLatest(); + const lifecycle = createTranscriptRestoreLifecycle(); + const sessionUi = createAppShellSessionUiStateController(); + assert.equal(await prepareTranscriptForSend({ + sessionId, currentSessionId: { current: sessionId }, controller: { current: controller }, + cancel: (sessionId) => lifecycle.cancel(sessionId), + followLatest: sessionUi.transcriptViewportNavigation.followLatest, + }), true, 'local admission must not wait for the latest range'); await latestStarted.promise; - const lifecycle = transcriptReadingPosition.createRestoreLifecycle(); - const restore = () => transcriptReadingPosition.restoreRange({ + const restore = () => restoreSessionTranscriptRange({ lifecycle, sessionId, controller, searchTarget: { sessionId, turnId: 'a', sequence: 10, nonce: 1 }, - isCurrent: () => true, setMessages: () => {}, setReadingAnchor: () => {}, + isCurrent: () => true, setReadingAnchor: () => {}, onError: (error) => assert.fail(String(error)), }); restore(); await readingAdmitted.promise; releaseLatest.resolve(); - await latest; + await latestFinished.promise; await new Promise((resolve) => setImmediate(resolve)); restore(); assert.deepEqual(admissions, [ @@ -91,6 +102,7 @@ test('a resident search admits history before an older latest response can evict ]); assert.equal(store.sequenceForTurn('a'), 10); assert.equal(store.sequenceForTurn('b'), null); + assert.deepEqual(store.snapshot().messages.map((message) => message.turnId), ['a']); } finally { releaseLatest.resolve(); await controller.close(); @@ -122,12 +134,12 @@ test(`a ${source} bookmark admits its Turn once and retains it across range relo publish(navigation!.navigationVersion); }, })); - const lifecycle = transcriptReadingPosition.createRestoreLifecycle(); + const lifecycle = createTranscriptRestoreLifecycle(); let unavailable = 0; let cleared = 0; - const restore = () => transcriptReadingPosition.restoreRange({ + const restore = () => restoreSessionTranscriptRange({ lifecycle, sessionId, controller, readingAnchor: { turnId: 'b' }, - isCurrent: () => true, setMessages: () => {}, + isCurrent: () => true, isLiveTurn: (candidateSessionId, turnId) => source === 'live projection' && candidateSessionId === sessionId && turnId === 'b', setReadingAnchor: (_sessionId, anchor) => { if (!anchor) cleared += 1; }, @@ -293,7 +305,6 @@ function controllerFixture() { const { root } = installReactRenderer(); const commands = createRef(); const controller = { - ready: async () => {}, loadAround: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, @@ -319,7 +330,6 @@ function controllerFixture() { turnIndex: undefined, setTurnIndex: () => {}, listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), - setMessages: () => {}, setHistoryPending: (next) => { pending = typeof next === 'function' ? next(pending) : next; }, historyPageBytes: 512 * 1024, onRestoreError: (error) => assert.fail(String(error)), 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 d8eb5bb8ca..47453b53a6 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -91,13 +91,11 @@ test('a send is accepted before latest history loads and its old completion cann 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'); - const projections = fixture.projections(); await fixture.switchSession('session-b'); await fixture.readAt(900); await act(async () => { latest.resolve(); }); - assert.equal(fixture.projections(), projections, 'the old range must not replace the new Session'); assert.equal(fixture.pinned(), false); assert.equal(fixture.scroller.scrollTop, 900); await fixture.append('session-b-growth', 600); @@ -112,12 +110,10 @@ for (const direction of ['earlier', 'later'] as const) { await fixture.render(); await fixture.readAt(1000); await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - const projections = fixture.projections(); await fixture.activateHistoryGap(direction); const readerTop = fixture.scroller.scrollTop; await act(async () => { latest.resolve(); }); - assert.equal(fixture.projections(), projections, 'history intent must discard the stale latest projection'); assert.equal(fixture.pinned(), false); assert.equal(fixture.scroller.scrollTop, readerTop); }); @@ -167,12 +163,11 @@ test('geometry changes from the latest range do not cancel the background load o const readingCalls: Array = []; fixture.controller.setReadingAnchor = async (sequence: number | null) => { readingCalls.push(sequence); }; await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - const projections = fixture.projections(); await fixture.replaceRangeFromHost(); await act(async () => { latest.resolve(); }); - assert.equal(fixture.projections(), projections + 1, 'Host range replacement must not cancel its own latest load'); + assert.deepEqual(fixture.visibleTurns(), ['latest-b']); assert.deepEqual(readingCalls, [], 'content geometry must not install a new history reading intent'); assert.equal(fixture.pinned(), true); await fixture.append('new-question', 200); @@ -186,12 +181,10 @@ test('the reader can leave an accepted send while its latest range is still load await fixture.render(); await fixture.readAt(1900); await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); - const projections = fixture.projections(); await fixture.readAt(1000); await act(async () => { latest.resolve(); }); - assert.equal(fixture.projections(), projections, 'reader navigation must discard the stale latest projection'); assert.equal(fixture.pinned(), false); assert.equal(fixture.scroller.scrollTop, 1000); }); @@ -253,9 +246,8 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { addTurn('history', 0, 1800); addTurn('latest', 1800, 1200); let reads = 0; - let projections = 0; const controller = { - ready: async () => {}, loadAround: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, + loadAround: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, loadLatest: async () => { reads += 1; }, setReadingAnchor: async (_sequence: number | null, _turnId?: string) => {}, store: { range: () => ({ sessionId: 'session-a' }), @@ -275,7 +267,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { searchTarget: undefined, clearSearchTarget: () => {}, turnIndex: undefined, setTurnIndex: () => {}, listTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), - setMessages: (next) => { projections += 1; props.messages = next; }, setHistoryPending: () => {}, historyPageBytes: 512 * 1024, + setHistoryPending: () => {}, historyPageBytes: 512 * 1024, onRestoreError: (error) => assert.fail(String(error)), onNavigationError: (error) => assert.fail(String(error)), }; let authority: TranscriptScrollAuthority | undefined; @@ -302,7 +294,8 @@ 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, projections: () => projections, + pinned: () => authority!.getSnapshot().pinned, latestReads: () => reads, + visibleTurns: () => props.messages.map((message) => message.turnId), async clickReturnToLatest() { const button = mount.querySelector('button'); assert.ok(button); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index c851b3b158..49dd04cc1d 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2450,7 +2450,6 @@ function AppShellContent({ turnIndex={transcriptTurnIndex} setTurnIndex={setTranscriptTurnIndex} listTurnLandmarks={(sessionId) => window.maka.sessions.listTurnLandmarks(sessionId)} - setMessages={setMessages} setHistoryPending={setHistoryLoadPending} historyPageBytes={DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES} onRestoreError={(error, sessionId) => sessionUiController.setMessageLoadErrorBySession((current) => ({ diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index 039eca4892..7f42e76805 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -24,13 +24,15 @@ that connect transcript identity to the Desktop bounded-range controller. Its public API includes task-readiness presentation and a headless `TranscriptReadingPositionController` component. That component owns bookmark restoration, landmark refresh, and history navigation, while exposing explicit -cancel, capture, and load commands to AppShell. +capture, send preparation, and history commands to AppShell. Successful send preparation publishes a one-shot viewport command through the Session UI controller. The message surface forwards that port to ChatView, where the scroll authority follows the tail. History catches up in the background so local Message admission does not wait for it. Message growth and bookmark -updates do not replay the command; newer navigation discards stale catch-up results. +updates do not replay the command; the range controller rejects stale catch-up results. +Accepted store updates reach the message surface through the existing transcript +projection; navigation completion only settles bookmark state. Running Turns have no durable sequence in the RuntimeEvent transcript. Reading intent therefore carries the Turn ID until persistence supplies its sequence; 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 78a8f00c5e..2f6dd72740 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 @@ -39,7 +39,6 @@ type RangeController = NonNullable; loadAfter(maxBytes?: number, anchorTurnId?: string): Promise; loadLatest(): Promise; - setReadingAnchor(sequence: number | null, readingTurnId?: string): Promise; }; interface TurnIndex { @@ -49,7 +48,6 @@ interface TurnIndex { } export interface TranscriptReadingPositionCommands { - cancel(sessionId: string, clearAnchor?: boolean): void; prepareSend(sessionId: string): Promise; captureAnchor(turnId?: string): void; loadHistory(target: TranscriptHistoryRequest['target'], anchorTurnId?: string): Promise; @@ -70,14 +68,12 @@ export function TranscriptReadingPositionController(props: { turnIndex: TurnIndex | undefined; setTurnIndex: Dispatch>; listTurnLandmarks: Parameters>[0]['list']; - setMessages(messages: StoredMessage[]): void; setHistoryPending: Dispatch>; historyPageBytes: number; onRestoreError(error: unknown, sessionId: string): void; onNavigationError(error: unknown, sessionId: string): void; }) { const [lifecycle] = useState(createTranscriptRestoreLifecycle); - const navigationRevision = useRef(0); const historyGates = useRef(new WeakMap()); const isCurrent = (sessionId: string, controller: object) => props.currentSessionId.current === sessionId && props.rangeController.current === controller; @@ -96,19 +92,12 @@ export function TranscriptReadingPositionController(props: { } }; useImperativeHandle(props.commands, () => ({ - cancel(sessionId, clearAnchor) { - navigationRevision.current += 1; - cancelHistory(sessionId); - cancel(sessionId, clearAnchor); - }, prepareSend(sessionId) { - const revision = ++navigationRevision.current; cancelHistory(sessionId); return prepareTranscriptForSend({ sessionId, currentSessionId: props.currentSessionId, - controller: props.rangeController, cancel, setMessages: props.setMessages, + controller: props.rangeController, cancel, followLatest: props.sessionUi.transcriptViewportNavigation.followLatest, - isCurrent: () => navigationRevision.current === revision, }); }, captureAnchor(turnId) { @@ -128,7 +117,6 @@ export function TranscriptReadingPositionController(props: { // pin. Its empty-anchor acknowledgement is not another reader intent. if (previous?.turnId === turnId && previous?.sequence === (sequence ?? undefined)) return; let navigation: Promise | undefined; - navigationRevision.current += 1; cancelHistory(sessionId); if (turnId) navigation = controller?.setReadingAnchor(sequence ?? null, turnId); else if (!turnId && previous && !range.hasNewer) { @@ -143,7 +131,6 @@ export function TranscriptReadingPositionController(props: { const controller = props.rangeController.current; const { sessionId } = props; if (!controller || !sessionId || !isCurrent(sessionId, controller)) return; - navigationRevision.current += 1; cancel(sessionId, target === 'latest'); // A direct latest command must enter the range controller now, so it // invalidates older pages rather than waiting behind a paging gate. @@ -176,12 +163,10 @@ export function TranscriptReadingPositionController(props: { setIndex: props.setTurnIndex, }), [props.sessionId, landmarkSessionId, newestPrompt, props.turnIndex]); useEffect(() => () => { - navigationRevision.current += 1; lifecycle.deactivate(); }, [props.sessionId, props.profileId, lifecycle]); useEffect(() => { if (props.searchTarget) { - navigationRevision.current += 1; cancelHistory(props.searchTarget.sessionId); } }, [props.searchTarget?.nonce]); @@ -196,7 +181,6 @@ export function TranscriptReadingPositionController(props: { controller: props.rangeController.current, isCurrent, isLiveTurn: (sessionId, turnId) => props.sessionUi.liveTurnBySessionRef.current[sessionId]?.turnId === turnId, - setMessages: props.setMessages, setReadingAnchor: props.sessionUi.setTranscriptReadingAnchor, onRestoreUnavailable: props.sessionUi.setTranscriptRestoreUnavailable, onError: props.onRestoreError, 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 fb88a3be72..55c7f568a4 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 @@ -28,9 +28,8 @@ interface TranscriptRangeStore { interface TranscriptRangeController { readonly store: TranscriptRangeStore; - ready(): Promise; loadAround(sequence: number): Promise; - setReadingAnchor?(sequence: number | null, readingTurnId?: string): Promise; + setReadingAnchor(sequence: number | null, readingTurnId?: string): Promise; } interface SearchTarget { @@ -116,13 +115,10 @@ export async function prepareTranscriptForSend(options: { currentSessionId: { current: string | undefined }; controller: { current: (TranscriptRangeController & { loadLatest(): Promise }) | undefined }; cancel(sessionId: string, clearAnchor: boolean): void; - setMessages(messages: Message[]): void; followLatest(sessionId: string): void; - isCurrent?(): boolean; }): Promise { const { sessionId } = options; - const current = () => options.currentSessionId.current === sessionId && options.isCurrent?.() !== false; - if (!current()) return false; + if (options.currentSessionId.current !== sessionId) return false; options.cancel(sessionId, true); const controller = options.controller.current; options.followLatest(sessionId); @@ -133,9 +129,6 @@ export async function prepareTranscriptForSend(options: { void (async () => { try { await controller.loadLatest(); - if (!current() || options.controller.current !== controller - || !currentTranscriptRange(controller, sessionId)) return; - options.setMessages([...controller.store.snapshot().messages]); } catch { // Catch-up failure must not prevent the Message from being saved locally. } @@ -311,7 +304,6 @@ export function restoreSessionTranscriptRange(options: { readonly controller?: TranscriptRangeController; readonly isCurrent: (sessionId: string, controller: TranscriptRangeController) => boolean; readonly isLiveTurn?: (sessionId: string, turnId: string) => boolean; - readonly setMessages: (messages: Message[]) => void; readonly setReadingAnchor: ( sessionId: string, anchor: TranscriptReadingAnchor | undefined, @@ -321,7 +313,7 @@ export function restoreSessionTranscriptRange(options: { }): void { const { controller, sessionId } = options; const command = options.lifecycle.request(options); - if (!command || !controller || !sessionId) return; + 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; @@ -345,45 +337,33 @@ export function restoreSessionTranscriptRange(options: { command.attempt = attempt; const current = (): boolean => options.lifecycle.isCurrent(command) && command.attempt === attempt && options.isCurrent(sessionId, controller); - let admitted: Promise | undefined; - let loadedAtAdmission = false; - if (current()) { + if (!current()) { + command.attempt = undefined; + return; + } + let admitted: Promise; + try { const residentSequence = currentTranscriptRange(controller, sessionId) ? controller.store.sequenceForTurn(target.turnId) : null; const sequence = residentSequence ?? target.sequence; - if (sequence !== undefined || controller.setReadingAnchor) { - // The range controller admits intent before awaiting its open handle. - // A resident search/bookmark must invalidate older pages just as a - // missing target does, without needlessly replacing its current range. - try { - loadedAtAdmission = residentSequence === null || !controller.setReadingAnchor; - admitted = controller.setReadingAnchor && (residentSequence !== null || sequence === undefined) - ? controller.setReadingAnchor(sequence ?? null, target.turnId) - : controller.loadAround(sequence!); - } catch (error) { - admitted = Promise.reject(error); - } - } + // Admit intent before awaiting the open handle. Resident and live-only + // targets retain their range while invalidating older navigation requests. + admitted = residentSequence !== null || sequence === undefined + ? controller.setReadingAnchor(sequence ?? null, target.turnId) + : controller.loadAround(sequence); + } catch (error) { + admitted = Promise.reject(error); } - void (admitted ?? controller.ready()) - .then(async () => { - if (!current() || controller.store.range().sessionId !== sessionId) { - return { loaded: false, unavailable: false }; - } + 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 }); } - if (!admitted) { - if (controller.setReadingAnchor) await controller.setReadingAnchor(residentSequence, target.turnId); - else { - await controller.loadAround(residentSequence); - loadedAtAdmission = true; - } - } - return { loaded: loadedAtAdmission, unavailable: false }; + return false; } if (options.isLiveTurn?.(sessionId, target.turnId) || controller.store.snapshot().messages.some((message) => message !== null && typeof message === 'object' && @@ -391,26 +371,13 @@ export function restoreSessionTranscriptRange(options: { )) { // Active Turns are overlay-only in the RuntimeEvent projection. Their // bookmark is already visible even though no durable sequence exists. - return { loaded: loadedAtAdmission, unavailable: false }; - } - if (admitted) return { loaded: loadedAtAdmission, unavailable: restoringReadingAnchor }; - if (target.sequence === undefined) { - return { loaded: false, unavailable: restoringReadingAnchor }; - } - await controller.loadAround(target.sequence); - if (!current() || controller.store.range().sessionId !== sessionId) { - return { loaded: false, unavailable: false }; + return false; } - return { - loaded: true, - unavailable: restoringReadingAnchor - && controller.store.sequenceForTurn(target.turnId) === null, - }; + return restoringReadingAnchor; }) - .then(({ loaded, unavailable }) => { + .then((unavailable) => { if (!current()) return; command.completed = true; - if (loaded) options.setMessages([...controller.store.snapshot().messages]); if (unavailable) { options.setReadingAnchor(sessionId, undefined); options.onRestoreUnavailable?.(sessionId, target.turnId); diff --git a/apps/desktop/src/renderer/features/conversation/index.ts b/apps/desktop/src/renderer/features/conversation/index.ts index f21e8bf259..5382b92656 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -18,31 +18,16 @@ */ import { - captureTranscriptReadingAnchor, - createTranscriptRestoreLifecycle, currentTranscriptRange, - loadTranscriptHistory, - newestDurablePromptSequence, - prepareTranscriptForSend, - refreshTranscriptTurnLandmarks, - restoreSessionTranscriptRange, transcriptRestoreTarget, } from './controller/transcript-reading-position.js'; export const transcriptReadingPosition = { - captureAnchor: captureTranscriptReadingAnchor, - createRestoreLifecycle: createTranscriptRestoreLifecycle, currentRange: currentTranscriptRange, - loadHistory: loadTranscriptHistory, - newestDurablePromptSequence, - prepareForSend: prepareTranscriptForSend, - refreshLandmarks: refreshTranscriptTurnLandmarks, - restoreRange: restoreSessionTranscriptRange, restoreTarget: transcriptRestoreTarget, }; export type { - TranscriptHistoryGates, TranscriptHistoryPending, } from './controller/transcript-reading-position.js'; export { diff --git a/apps/desktop/src/renderer/features/conversation/testing.ts b/apps/desktop/src/renderer/features/conversation/testing.ts new file mode 100644 index 0000000000..a095efb448 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/testing.ts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +export { + createTranscriptRestoreLifecycle, + loadTranscriptHistory, + prepareTranscriptForSend, + refreshTranscriptTurnLandmarks, + restoreSessionTranscriptRange, + type TranscriptHistoryGates, + type TranscriptHistoryPending, +} from './controller/transcript-reading-position.js'; diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 87f9c40f18..c170c106d2 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -27,6 +27,7 @@ import { ChatSurfaceLayout, ChatView, Composer, + createTranscriptViewportNavigation, deriveTitlebarProjectName, TitlebarSessionIdentity, ToastProvider, @@ -2075,11 +2076,20 @@ export const PartialHistoryNotice: Story = { /** Stops the harness below, so the tail can be read against a settled transcript. */ let stopTailStream: (() => void) | undefined; +let startTailStream: (() => void) | undefined; /** Streams one line per frame into a live Turn. */ function StreamingTailHarness() { + const [question, setQuestion] = useState(); + const [streaming, setStreaming] = useState(false); + const [viewportNavigation] = useState(createTranscriptViewportNavigation); const [lines, setLines] = useState(1); useEffect(() => { + startTailStream = () => setStreaming(true); + return () => { startTailStream = undefined; }; + }, []); + useEffect(() => { + if (!streaming) return; // Paced by frames, not by the clock, so it stays in step with the // per-frame sampler on a slow runner. let frame = 0; @@ -2099,23 +2109,37 @@ function StreamingTailHarness() { stop(); stopTailStream = undefined; }; - }, []); + }, [streaming]); return ( { + // Production publishes this once before admitting the sent Message. + // Controller/store races are covered by the Desktop integration suite; + // this story measures what the real ChatView does with that command. + viewportNavigation.followLatest(activeSession.id); + setQuestion(text); + }, + }} chat={{ - runningStatus: true, + runningStatus: Boolean(question), + viewportNavigation, messages: [ - user('msg-tail-1', 'turn-tail', 3, '把转录推过一屏,看看尾巴还跟不跟得住。'), - { - type: 'turn_state', - id: 'state-tail', - turnId: 'turn-tail', - ts: NOW - 30_000, - status: 'running', - }, + user('history-question', 'history-turn', 6, '已有问题。'), + assistant('history-answer', 'history-turn', 5, TAIL_LINES.slice(0, 40).join('\n\n')), + ...(question ? [ + user('msg-tail-1', 'turn-tail', 3, question), + { + type: 'turn_state' as const, + id: 'state-tail', + turnId: 'turn-tail', + ts: NOW - 30_000, + status: 'running' as const, + }, + ] : []), ], - liveTurn: { + liveTurn: question ? { turnId: 'turn-tail', phase: 'streamed', steps: [{ @@ -2129,15 +2153,31 @@ function StreamingTailHarness() { }, tools: [], }], - }, + } : undefined, }} /> ); } +// Real path: read earlier content in a Session → send another question → +// follow the growing answer, through the production viewport command port. export const StreamingTailFollow: Story = { render: () => , - play: async () => { + play: async ({ canvasElement }) => { + await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); + const input = canvasElement.querySelector('.maka-composer-editor [contenteditable="true"]'); + if (!input) throw new Error('The composer input is missing'); + await userEvent.type(input, 'Second question after reading history.'); + tailScroller().scrollTop = 0; + await painted(6); + expect(tailMetrics().distance).toBeGreaterThan(500); + expect(dockOffered()).toBe(true); + await userEvent.keyboard('{Enter}'); + await waitFor(() => { + expect(canvasElement.querySelector('[data-turn-id="turn-tail"]')).not.toBeNull(); + expect(tailMetrics().distance).toBeLessThanOrEqual(4); + }); + startTailStream?.(); // The fuse runs out inside the smoke's per-story budget, so a stalled // stream fails saying so instead of timing the story out. const lag = await measureTailLag(600); diff --git a/packages/runtime/src/__tests__/fake-backend.test.ts b/packages/runtime/src/__tests__/fake-backend.test.ts index 12328dd9b5..553ac67681 100644 --- a/packages/runtime/src/__tests__/fake-backend.test.ts +++ b/packages/runtime/src/__tests__/fake-backend.test.ts @@ -21,62 +21,13 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { SessionEvent } from '@maka/core/events'; -import { - FAKE_ASK_USER_QUESTION_PROMPT, - FAKE_COMPLETE_HELD_TURN_STEERING, - FAKE_HOLD_OPEN_COMPLETE_PROMPT, - FakeBackend, -} from '../test-only/fake-backend.js'; +import { FAKE_ASK_USER_QUESTION_PROMPT, FakeBackend } from '../test-only/fake-backend.js'; import { RuntimeInteractionInvariantError, bindRuntimeInteractionRun, type RuntimeUserQuestionContinuation, } from '../interaction-authority.js'; -test('a completable held answer emits its accumulated live text only after the completion steering latch', async () => { - const backend = new FakeBackend({ sessionId: 'session-1' }); - const pending = [ - { id: 'lease-1', messageId: 'message-1', content: { text: 'background marker' } }, - ]; - const iterator = backend - .send({ - turnId: 'turn-1', - text: FAKE_HOLD_OPEN_COMPLETE_PROMPT, - context: [], - pullSteering: () => pending.splice(0), - }) - [Symbol.asyncIterator](); - const events: SessionEvent[] = []; - while ( - !events.some((event) => event.type === 'text_delta' && event.text.includes('background marker')) - ) { - const next = await iterator.next(); - assert.equal(next.done, false); - events.push(next.value!); - } - assert.equal( - events.some((event) => event.type === 'text_complete' || event.type === 'complete'), - false, - ); - pending.push({ - id: 'lease-2', - messageId: 'message-2', - content: { text: FAKE_COMPLETE_HELD_TURN_STEERING }, - }); - for await (const event of { [Symbol.asyncIterator]: () => iterator }) events.push(event); - assert.equal(events.filter((event) => event.type === 'text_complete').length, 1); - const streamed = events - .flatMap((event) => (event.type === 'text_delta' ? [event.text] : [])) - .join(''); - assert.equal(events.find((event) => event.type === 'text_complete')?.text, streamed); - assert.equal( - events.some((event) => event.type === 'abort'), - false, - ); - const terminal = events.at(-1); - assert.equal(terminal?.type === 'complete' ? terminal.stopReason : undefined, 'end_turn'); -}); - test('Fake question publication waits for exact hosted admission', async () => { const admissionStarted = deferred(); const allowAdmission = deferred(); diff --git a/packages/runtime/src/test-only/fake-backend.ts b/packages/runtime/src/test-only/fake-backend.ts index d0cd466801..8c41be3681 100644 --- a/packages/runtime/src/test-only/fake-backend.ts +++ b/packages/runtime/src/test-only/fake-backend.ts @@ -45,8 +45,6 @@ export const FAKE_WAIT_FOR_STEERING_PROMPT = '__e2e_wait_for_steering__'; export const FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT = '__e2e_wait_for_steering_large_response__'; export const FAKE_HOLD_OPEN_PROMPT = '__e2e_hold_open__'; -export const FAKE_HOLD_OPEN_COMPLETE_PROMPT = '__e2e_hold_open_complete__'; -export const FAKE_COMPLETE_HELD_TURN_STEERING = '__e2e_complete_held_turn__'; export const FAKE_HOLD_OPEN_REWRITE_PROMPT = '__e2e_hold_open_rewrite__'; export const FAKE_MERMAID_PROMPT = '__e2e_mermaid__'; export const FAKE_MERMAID_HOSTILE_PROMPT = '__e2e_mermaid_hostile__'; @@ -201,12 +199,7 @@ export class FakeBackend implements AgentBackend { }; try { - if ( - input.text === FAKE_HOLD_OPEN_PROMPT || - input.text === FAKE_HOLD_OPEN_REWRITE_PROMPT || - input.text === FAKE_HOLD_OPEN_COMPLETE_PROMPT - ) { - const canComplete = input.text === FAKE_HOLD_OPEN_COMPLETE_PROMPT; + if (input.text === FAKE_HOLD_OPEN_PROMPT || input.text === FAKE_HOLD_OPEN_REWRITE_PROMPT) { const rewriteTarget = input.text === FAKE_HOLD_OPEN_REWRITE_PROMPT; const waitingPrefix = rewriteTarget ? 'prefix sk-123456789012345' @@ -241,26 +234,17 @@ export class FakeBackend implements AgentBackend { text: delta, }; } - if (canComplete && steered.includes(FAKE_COMPLETE_HELD_TURN_STEERING)) break; await sleep(5); } - if (canComplete && !this.stopped) { - // The completion latch exercises the normal durable assistant path - // below after a test has observed a stable live answer and remount. - text = waitingText; - chunks.length = 0; - steered.length = 0; - } else { - yield { type: 'abort', id: randomUUID(), turnId, ts: Date.now(), reason: 'user_stop' }; - yield { - type: 'complete', - id: randomUUID(), - turnId, - ts: Date.now(), - stopReason: 'user_stop', - }; - return; - } + yield { type: 'abort', id: randomUUID(), turnId, ts: Date.now(), reason: 'user_stop' }; + yield { + type: 'complete', + id: randomUUID(), + turnId, + ts: Date.now(), + stopReason: 'user_stop', + }; + return; } if (isSteeringScenario) { From e4416aadac9199e326406a8bdc50c01e52c5f8ff Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Tue, 8 Sep 2026 12:49:33 +0800 Subject: [PATCH 4/5] fix(desktop): supersede pending transcript restore on send Use the store's stable Session identity when preparing a send so the latest navigation supersedes queued bookmark restoration before transcript open finishes. Keep message admission independent of background range loading. Cover sending during initial open, rejection of late history frames, and previous-Session controllers both before and after initialization. Generated-by: OpenAI Codex --- .../app-shell-first-send-cleanup.test.ts | 17 ++++- .../app-shell-session-ui-state.test.ts | 4 ++ .../transcript-navigation-regression.test.ts | 5 ++ ...script-reading-position-controller.test.ts | 66 ++++++++++++++++++- .../transcript-send-viewport.test.ts | 3 +- .../desktop-transcript-range-store.ts | 6 +- .../controller/transcript-reading-position.ts | 3 +- 7 files changed, 94 insertions(+), 10 deletions(-) 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 0d6c9bb97b..d851594a3f 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 @@ -505,6 +505,7 @@ describe('composer first-send cleanup', () => { const activeIdRef = { current: 'existing-session' as string | undefined }; const transcript = { store: { + sessionId: 'existing-session', range: () => ({ sessionId: 'existing-session', hasNewer: false }), snapshot: () => ({ messages: [] }), }, @@ -543,11 +544,19 @@ describe('composer first-send cleanup', () => { } }); - it('does not load or project the previous Session controller while sending into the selected Session', async () => { + 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: { range: () => ({ sessionId: 'previous-session' }) }, - loadLatest: async () => { assert.fail('the previous Session must not be navigated'); }, + 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: { @@ -573,10 +582,12 @@ describe('composer first-send cleanup', () => { }).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 23f4192869..4758d5ad67 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 @@ -412,6 +412,7 @@ describe('app shell session UI state controller', () => { readingAnchor: { turnId: 'turn' }, controller: { store: { + sessionId: 'session', range: () => ({ sessionId: 'session' }), sequenceForTurn: () => 17, newestDurableUserSequence: () => 17, @@ -441,6 +442,7 @@ describe('app shell session UI state controller', () => { readingAnchor: { turnId: 'turn' }, controller: { store: { + sessionId: 'stale', range: () => ({ sessionId: 'stale' }), sequenceForTurn: () => { sequenceReads += 1; @@ -472,6 +474,7 @@ describe('app shell session UI state controller', () => { readingAnchor: { turnId: 'missing' }, controller: { store: { + sessionId: 'session', range: () => ({ sessionId: 'session' }), sequenceForTurn: () => null, newestDurableUserSequence: () => null, @@ -507,6 +510,7 @@ describe('app shell session UI state controller', () => { readingAnchor: { turnId: 'removed', sequence: 23 }, controller: { store: { + sessionId: 'session', range: () => ({ sessionId: 'session' }), sequenceForTurn: () => null, newestDurableUserSequence: () => 29, diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts index 7e7eadd116..b56014c5d2 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts @@ -98,6 +98,7 @@ test('a completed resident bookmark does not reload after streaming settlement e await fixture.replica.loadAround(sequence, PAGE_BYTES); }, store: { + sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), sequenceForTurn: (turnId: string) => fixture.replica.snapshot().durable .find(({ message }) => message.turnId === turnId)?.sequence ?? null, @@ -152,6 +153,7 @@ test('reopening a bookmark at the current Turn retains content persisted later i setReadingAnchor: (sequence) => fixture.replica.readAt(sequence), loadAround: (sequence) => fixture.replica.loadAround(sequence, PAGE_BYTES), store: { + sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), sequenceForTurn: (turnId) => fixture.replica.snapshot().durable .find(({ message }) => message.turnId === turnId)?.sequence ?? null, @@ -237,6 +239,7 @@ test('repeated message notifications share one pending restore and cancellation setReadingAnchor: async () => {}, loadAround: async () => { reads += 1; await loading; }, store: { + sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), sequenceForTurn: () => null, newestDurableUserSequence: () => 2, @@ -277,6 +280,7 @@ test('switching away and back creates a fresh restore while clearing search does setReadingAnchor: async () => {}, loadAround: async (sequence: number) => { reads.push(sequence); }, store: { + sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), sequenceForTurn: () => null, newestDurableUserSequence: () => 2, @@ -320,6 +324,7 @@ test('effect teardown followed by setup lets only the replacement restore settle setReadingAnchor: async () => {}, loadAround: () => new Promise((resolve) => { loads.push(resolve); }), store: { + sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), sequenceForTurn: () => null, newestDurableUserSequence: () => 2, 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 b66ca34509..f538a08e9e 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -22,6 +22,7 @@ import { afterEach, test } from 'node:test'; import { act, createElement, createRef, type ComponentProps } from 'react'; import { deferred } from '@maka/core/test-only/async-primitives'; import type { StoredMessage } from '@maka/core/session'; +import type { DesktopTranscriptHandle, DesktopTranscriptNavigation } from '../../preload/transcript-contract.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/desktop-transcript-range-store.js'; import { @@ -39,6 +40,66 @@ import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; afterEach(cleanupFakeDom); +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); + const opening = deferred(); + const controller = createDesktopTranscriptRangeController(store, () => opening.promise); + const lifecycle = createTranscriptRestoreLifecycle(); + const requests: Array<{ sequence: number | null; navigation?: DesktopTranscriptNavigation }> = []; + const handle: DesktopTranscriptHandle = { + sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, + loadBefore: async () => {}, loadAfter: async () => {}, close: async () => {}, + async loadAround(sequence, _maxBytes, navigation) { + requests.push({ sequence, navigation }); + const turnId = sequence === null ? 'b' : 'a'; + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', + navigationVersion: navigation!.navigationVersion, durableThrough: 20, + durable: [{ sequence: sequence ?? 20, message: { + type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', + } }], overlay: [], hasOlder: true, hasNewer: sequence !== null, + })) store.accept(batch); + }, + }; + const restore = () => restoreSessionTranscriptRange({ + lifecycle, sessionId, controller, readingAnchor: { turnId: 'a', sequence: 10 }, + isCurrent: () => true, + setReadingAnchor: () => assert.fail('the cancelled bookmark must not be restored'), + 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 }, + 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?.intent, navigation?.navigationVersion]), [[null, 'followTail', 2]]); + assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['answer-b']); + const latest = store.snapshot(); + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', + navigationVersion: 1, durableThrough: 20, + durable: [{ sequence: 10, message: { + type: 'assistant', id: 'answer-a', turnId: 'a', text: 'a', ts: 1, modelId: 'fixture', + } }], overlay: [], hasOlder: false, hasNewer: true, + })) assert.equal(store.accept(batch), false); + assert.strictEqual(store.snapshot(), latest, 'a late history response must not replace the latest range'); + } finally { + opening.resolve(handle); + await controller.close(); + } +}); + test('a resident search supersedes send catch-up before its older latest response can evict the target', async () => { const sessionId = JSON.stringify(['host-1', 'session-1']); const store = new DesktopTranscriptRangeStore(sessionId); @@ -230,7 +291,7 @@ test('a new Session can load history while the previous Session request is still const loadingFirst = fixture.commands.current!.loadHistory('earlier'); const secondController = { ...fixture.controller, - store: { ...fixture.controller.store, range: () => ({ sessionId: 'session-2' }) }, + store: { ...fixture.controller.store, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, loadBefore: () => second.promise, }; fixture.props.currentSessionId.current = 'session-2'; @@ -280,7 +341,7 @@ test('an old Session controller cannot clear pending history after returning to fixture.props.sessionId = 'session-2'; fixture.props.rangeController.current = { ...fixture.controller, - store: { ...fixture.controller.store, range: () => ({ sessionId: 'session-2' }) }, + store: { ...fixture.controller.store, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, }; await fixture.render(); fixture.props.currentSessionId.current = 'session-1'; @@ -311,6 +372,7 @@ function controllerFixture() { loadLatest: async () => {}, setReadingAnchor: async () => {}, store: { + sessionId: 'session-1', range: () => ({ sessionId: 'session-1' }), sequenceForTurn: () => null, newestDurableUserSequence: () => null, 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 47453b53a6..b4829000eb 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -250,6 +250,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { loadAround: async () => {}, loadBefore: async () => {}, loadAfter: async () => {}, loadLatest: async () => { reads += 1; }, setReadingAnchor: async (_sequence: number | null, _turnId?: string) => {}, store: { + sessionId: 'session-a', range: () => ({ sessionId: 'session-a' }), sequenceForTurn: (turnId: string) => { const sequence = messages.findIndex((message) => message.turnId === turnId); @@ -330,7 +331,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { async switchSession(sessionId: string) { props.sessionId = sessionId; props.currentSessionId.current = sessionId; - props.rangeController.current = { ...controller, store: { ...controller.store, range: () => ({ sessionId }) } }; + props.rangeController.current = { ...controller, store: { ...controller.store, sessionId, range: () => ({ sessionId }) } }; await render(); }, async flushFrames() { diff --git a/apps/desktop/src/renderer/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/desktop-transcript-range-store.ts index 6e0acbc5ee..9acd42900c 100644 --- a/apps/desktop/src/renderer/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/desktop-transcript-range-store.ts @@ -312,7 +312,7 @@ export interface DesktopTranscriptRangeSnapshot extends DesktopTranscriptRangeSt } export class DesktopTranscriptRangeStore { - readonly #sessionKey: string; + readonly sessionId: string; readonly #hostId: string; readonly #expectedSessionId: string; readonly #durable = new Map(); @@ -338,7 +338,7 @@ export class DesktopTranscriptRangeStore { constructor(sessionKey: string) { const { hostId, sessionId } = parseDesktopSessionKey(sessionKey); - this.#sessionKey = sessionKey; + this.sessionId = sessionKey; this.#hostId = hostId; this.#expectedSessionId = sessionId; } @@ -420,7 +420,7 @@ export class DesktopTranscriptRangeStore { throw new Error('Desktop transcript range is not initialized'); } return { - sessionId: this.#sessionKey, + sessionId: this.sessionId, generation: this.#generation, hostEpoch: this.#hostEpoch, durableThrough: this.#durableThrough, 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 55c7f568a4..7c12abf6bb 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 @@ -20,6 +20,7 @@ import type { TranscriptReadingAnchor } from '../model/session-ui-state.js'; interface TranscriptRangeStore { + readonly sessionId: string; range(): { readonly sessionId: string; readonly hasNewer?: boolean }; sequenceForTurn(turnId: string): number | null; newestDurableUserSequence(): number | null; @@ -122,7 +123,7 @@ export async function prepareTranscriptForSend(options: { options.cancel(sessionId, true); const controller = options.controller.current; options.followLatest(sessionId); - if (!controller || !currentTranscriptRange(controller, sessionId)) return true; + 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. From c6403e0c473dd63f285c28a9ed07f1a91c524fa7 Mon Sep 17 00:00:00 2001 From: chinawch007 Date: Tue, 8 Sep 2026 12:53:56 +0800 Subject: [PATCH 5/5] fix(desktop): preserve live transcript identity across cached reloads Track the last live generation separately from the displayed cached snapshot so reopening a surviving replica can resume live delivery. Retire that generation only when a different live replica replaces it, while continuing to reject departed cache generations and stale navigation frames. Cover repeated cached reloads into the same live generation, subsequent live updates, and rejection of old resets and deltas after a real replacement. Generated-by: OpenAI Codex --- .../desktop-transcript-range-store.test.ts | 87 +++++++++++++++++++ .../desktop-transcript-range-store.ts | 10 ++- 2 files changed, 96 insertions(+), 1 deletion(-) 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 3aaf0eb6c8..a0284159f0 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 @@ -209,6 +209,93 @@ test('drops stale transcript batches after a generation reset', () => { assert.deepEqual(store.snapshot().messages, [nextMessage]); }); +test('cached reload snapshots allow the same live transcript generation to resume', async () => { + const store = transcriptStore(); + const identity = { + sessionId: 'session-1', + generation: 'live-generation', + hostEpoch: 'host-1', + }; + let opens = 0; + const deliveries: Array<{ generation: string; accepted: boolean }> = []; + const publish = (generation: string, text: string, navigationVersion = 0) => { + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, generation, navigationVersion, durableThrough: 1, + durable: [{ sequence: 1, message: assistantMessage(text) }], + overlay: [], hasOlder: false, hasNewer: false, + })) deliveries.push({ generation, accepted: store.accept(batch) }); + }; + const controller = createDesktopTranscriptRangeController(store, async () => { + opens += 1; + if (opens > 1) { + publish(`cached:reload-${opens}`, 'cached'); + assert.deepEqual(store.snapshot().messages, [assistantMessage('cached')]); + } + // The event subscription keeps the main-process replica alive between opens. + publish(identity.generation, `live-${opens}`); + return { + ...identity, readThroughMessageId: null, + async loadBefore() {}, + async loadAfter() {}, + async loadAround(_sequence, _maxBytes, navigation) { + publish(identity.generation, `live-${opens}`, navigation?.navigationVersion); + }, + async close() {}, + }; + }); + + try { + await controller.ready(); + for (let reload = 1; reload <= 2; reload += 1) { + await controller.reload(); + assert.equal(store.range().generation, identity.generation); + assert.deepEqual(store.snapshot().messages, [assistantMessage(`live-${reload + 1}`)]); + } + assert.equal(opens, 3); + assert.ok(deliveries.every(({ accepted }) => accepted)); + + const updated = assistantMessage('live update', 'assistant-2'); + for (const batch of encodeDesktopTranscriptChange(identity, { + durableThrough: 2, + durableUpserts: [{ sequence: 2, message: updated }], + evictedDurableSequences: [], completedOverlayMessageIds: [], + hasOlder: false, hasNewer: false, + })) assert.equal(store.accept(batch), true); + assert.deepEqual(store.snapshot().messages, [assistantMessage('live-3'), updated]); + } finally { + await controller.close(); + } +}); + +test('a replacement live generation retires the previous replica through cached snapshots', () => { + for (const cachedGenerations of [[], ['cached:first', 'cached:second']]) { + const store = transcriptStore(); + 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, + })]; + const generations = ['previous-live', ...cachedGenerations, 'replacement-live']; + for (const generation of generations) { + for (const batch of snapshot(generation)) assert.equal(store.accept(batch), true); + } + const replacement = store.snapshot(); + for (const generation of generations.slice(0, -1)) { + for (const batch of snapshot(generation)) assert.equal(store.accept(batch), false); + for (const batch of encodeDesktopTranscriptChange({ + sessionId: 'session-1', generation, hostEpoch: 'host-1', + }, { + durableThrough: 2, + durableUpserts: [{ sequence: 2, message: assistantMessage('stale', 'stale') }], + evictedDurableSequences: [], completedOverlayMessageIds: [], + hasOlder: false, hasNewer: false, + })) assert.equal(store.accept(batch), false); + } + assert.strictEqual(store.snapshot(), replacement); + assert.deepEqual(store.snapshot().messages, [assistantMessage('replacement-live')]); + } +}); + test('keeps unchanged message references stable across immutable range snapshots', () => { const identity = { sessionId: 'session-1', diff --git a/apps/desktop/src/renderer/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/desktop-transcript-range-store.ts index 9acd42900c..dca469634e 100644 --- a/apps/desktop/src/renderer/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/desktop-transcript-range-store.ts @@ -324,6 +324,7 @@ export class DesktopTranscriptRangeStore { readonly #retiredGenerations = new Set(); #sourceSessionId: string | undefined; #generation: string | undefined; + #liveGeneration: string | undefined; #hostEpoch: string | undefined; #durableThrough: number | null = null; #oldestSequence: number | null = null; @@ -469,9 +470,16 @@ export class DesktopTranscriptRangeStore { if (batch.sessionId !== this.#expectedSessionId) { throw new Error('Desktop transcript belongs to a different Session'); } - if (this.#generation && this.#generation !== batch.generation) { + if (this.#generation?.startsWith('cached:') && this.#generation !== batch.generation) { this.#retiredGenerations.add(this.#generation); } + // Cached resets are provisional; only a new live replica retires the previous one. + if (!batch.generation.startsWith('cached:')) { + if (this.#liveGeneration && this.#liveGeneration !== batch.generation) { + this.#retiredGenerations.add(this.#liveGeneration); + } + this.#liveGeneration = batch.generation; + } this.#durable.clear(); this.#overlay.clear(); this.#durableOrder.length = 0;