diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 9a6a332a4b..44e23872fa 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -332,7 +332,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, @@ -341,7 +340,7 @@ "@maka/ui": 1 }, "importSpecifiers": 13, - "nonTriviaTokens": 4076 + "nonTriviaTokens": 4057 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 4, @@ -768,7 +767,7 @@ "useAppShellTurnPresentation": 1, "useCommandPalette": 1, "useComposerAttachments": 1, - "useEffect": 10, + "useEffect": 8, "useKeyboardHelp": 1, "useLayoutEffect": 2, "useNewTaskChoice": 1, @@ -892,7 +891,7 @@ "react": 1 }, "importSpecifiers": 116, - "nonTriviaTokens": 14544 + "nonTriviaTokens": 14342 }, "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..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 @@ -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 { prepareTranscriptForSend } from '../../renderer/features/conversation/testing.js'; import { createActionsDeps, @@ -498,13 +499,14 @@ 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 }), + sessionId: 'existing-session', + range: () => ({ sessionId: 'existing-session', hasNewer: false }), snapshot: () => ({ messages: [] }), }, async loadLatest() { @@ -527,16 +529,65 @@ describe('composer first-send cleanup', () => { ...createActionsDeps(), activeIdRef, transcriptRangeRef, + onFollowLatest: (sessionId) => prepareTranscriptForSend({ + sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef, + cancel: () => { order.push('cancel-restore'); }, 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(); + } + }); + + for (const initialized of [false, true]) { + it(`does not navigate the previous Session controller (${initialized ? 'initialized' : 'opening'}) while sending`, async () => { + const submissions: string[] = []; + let latestReads = 0; + const transcript = { + store: { + sessionId: 'previous-session', + range: () => { + if (!initialized) throw new Error('Desktop transcript range is not initialized'); + return { sessionId: 'previous-session' }; + }, + }, + loadLatest: async () => { latestReads += 1; }, + } as unknown as DesktopTranscriptRangeController; + const restoreWindow = installWindow({ + sessions: { + submitMessage: async (sessionId: string) => { + submissions.push(sessionId); + return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; + }, + }, + }); + const activeIdRef = { current: 'selected-session' }; + const transcriptRangeRef = { current: transcript }; + try { + const result = await createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef, + transcriptRangeRef, + onFollowLatest: (sessionId) => prepareTranscriptForSend({ + sessionId, currentSessionId: activeIdRef, controller: transcriptRangeRef, + cancel: () => {}, + followLatest: (sessionId) => { assert.equal(sessionId, 'selected-session'); }, + }), + setMessages: () => { assert.fail('the previous range must not replace selected messages'); }, + }).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 3a2e76ebd8..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 @@ -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'), @@ -400,23 +403,25 @@ 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; - transcriptReadingPosition.restoreRange({ + const admitted: Array = []; + restoreSessionTranscriptRange({ + lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'session', readingAnchor: { turnId: 'turn' }, controller: { store: { + sessionId: 'session', range: () => ({ sessionId: 'session' }), sequenceForTurn: () => 17, 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; }, @@ -424,16 +429,20 @@ 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({ + restoreSessionTranscriptRange({ + lifecycle: createTranscriptRestoreLifecycle(), sessionId: 'active', readingAnchor: { turnId: 'turn' }, controller: { store: { + sessionId: 'stale', range: () => ({ sessionId: 'stale' }), sequenceForTurn: () => { sequenceReads += 1; @@ -442,11 +451,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; }, @@ -458,25 +466,26 @@ 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: createTranscriptRestoreLifecycle(), sessionId: 'session', readingAnchor: { turnId: 'missing' }, controller: { store: { + sessionId: 'session', range: () => ({ sessionId: 'session' }), sequenceForTurn: () => null, 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 }; @@ -484,39 +493,37 @@ 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: createTranscriptRestoreLifecycle(), sessionId: 'session', readingAnchor: { turnId: 'removed', sequence: 23 }, controller: { store: { + sessionId: 'session', range: () => ({ sessionId: 'session' }), sequenceForTurn: () => null, 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 }; @@ -524,12 +531,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' }); }); @@ -580,7 +586,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 +663,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..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', @@ -611,18 +698,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 +753,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 +813,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 +1044,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..1a3ec68814 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts @@ -0,0 +1,236 @@ +/* + * 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 test from 'node:test'; +import { markPersisted } from '@maka/core/persisted-value'; +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 { + 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 { 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 = 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('completed-a'); + const first = await ledger.durableRecords(); + assertSourcePayloads(first, source.first); + opened = await openReplica(ledger, firstThrough); + const { replica, subscription, state } = opened; + assertRecords(replica, first); + assert.equal(replica.snapshot().hasOlder, false, 'sparse first-row sequence does not imply older history'); + assert.equal(replica.snapshot().hasNewer, false, 'unused low watermark bits do not imply a newer row'); + + // 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('completed-b'); + assert.ok(completeThrough !== null); + const complete = await ledger.durableRecords(); + 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); + 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 ['running-b', 'result-b'] as const) { + test(`retains the full oversized durable Turn while the running second Turn reaches ${checkpoint}`, async () => { + const source = transcriptFixture(); + const ledger = await openTranscriptNavigationLedger([...source.first, ...source.second]); + let opened: Awaited> | undefined; + try { + const firstThrough = await ledger.appendThrough('completed-a'); + const first = await ledger.durableRecords(); + assert.equal(await ledger.appendThrough(checkpoint), firstThrough, + 'a running invocation changes its overlay, not the durable watermark'); + const rootTurn = { + 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.second.slice(0, source.second.findIndex(({ id }) => id === checkpoint) + 1) + .filter((message) => message.type !== 'turn_state').map(({ id }) => id); + assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), expected); + await replica.readAt(first[0]!.sequence); + assertRecords(replica, first); + await replica.followLatest(PAGE_BYTES); + assertRecords(replica, first); + assert.ok(replica.messages().some(({ id }) => id === expected.at(-1)), 'the running Turn remains reachable'); + + const throughSequence = await ledger.appendThrough('completed-b'); + 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 StoredMessage[], +) { + // 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'); + 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); + } + } + } +} + +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-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..b56014c5d2 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts @@ -0,0 +1,482 @@ +/* + * 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 { + createTranscriptRestoreLifecycle, + restoreSessionTranscriptRange, +} from '../../renderer/features/conversation/testing.js'; +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 = { + // 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: { + sessionId: 'session-1', + 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, + 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: { + 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, + newestDurableUserSequence: () => 2, + snapshot: () => ({ messages: fixture.replica.messages() }), + }, + }, + isCurrent: () => true, + setReadingAnchor: () => {}, + onError: (error) => assert.fail(String(error)), + }); + await settleRestore(); + + assert.equal( + fixture.replica.messages().some(({ id }) => id === 'assistant-b-later'), + true, + 'restoring the visible Turn must not silently omit its later persisted answer segment', + ); + } finally { + fixture.replica.close(); + } +}); + +test('an oversized new Turn cannot evict the current Turn being read while its own answer finishes', async () => { + const fixture = await oversizedHistoryFixture(); + try { + await fixture.replica.readAt(2); + await fixture.replica.advance(4); + await fixture.replica.advance(6); + + assert.deepEqual( + sequences(fixture.replica), + [2, 3, 4], + 'the reader keeps the complete selected Turn B when a new oversized Turn C is persisted', + ); + assert.equal(fixture.replica.durableThrough, 6); + assert.equal(fixture.replica.snapshot().hasNewer, true); + assert.equal(fixture.replica.messages().some(({ id }) => id === 'assistant-c'), false); + + await fixture.replica.followLatest(PAGE_BYTES); + + assert.deepEqual(sequences(fixture.replica), [5, 6]); + assert.equal(fixture.replica.messages().at(-1)?.id, 'assistant-c'); + assert.equal(fixture.replica.snapshot().hasNewer, false); + } finally { + fixture.replica.close(); + } +}); + +test('streaming persistence retains a newly loaded oversized neighbor until the reader chooses an anchor', async () => { + const fixture = await oversizedHistoryFixture(); + try { + await fixture.replica.loadBefore(2, PAGE_BYTES); + assert.deepEqual(sequences(fixture.replica), [0, 1, 2, 3]); + + await fixture.replica.advance(4); + + assert.deepEqual( + sequences(fixture.replica), + [0, 1, 2, 3, 4], + 'new durable text in B must not erase the older A that the reader just requested', + ); + await fixture.replica.readAt(2); + assert.deepEqual(sequences(fixture.replica), [2, 3, 4]); + assert.equal(fixture.replica.snapshot().hasOlder, true); + } finally { + fixture.replica.close(); + } +}); + +test('repeated message notifications share one pending restore and cancellation preserves the newer bookmark', async () => { + const lifecycle = createTranscriptRestoreLifecycle(); + let finishLoad!: () => void; + const loading = new Promise((resolve) => { finishLoad = resolve; }); + let reads = 0; + 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: { + setReadingAnchor: async () => {}, + loadAround: async () => { reads += 1; await loading; }, + store: { + sessionId: 'session-1', + range: () => ({ sessionId: 'session-1' }), + sequenceForTurn: () => null, + newestDurableUserSequence: () => 2, + snapshot: () => ({ messages: ['old restored range'] }), + }, + }, + isCurrent: () => true, + setReadingAnchor: (_sessionId: string, next: typeof anchor) => { anchor = next; }, + onRestoreUnavailable: (_sessionId: string, turnId: string) => { unavailable = turnId; }, + onError: (error: unknown) => assert.fail(String(error)), + }; + restoreSessionTranscriptRange(options); + restoreSessionTranscriptRange(options); + await settleRestore(); + 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(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 () => { + const lifecycle = createTranscriptRestoreLifecycle(); + const reads: number[] = []; + const options = { + lifecycle, + sessionId: 'session-1', + profileId: 'profile-1', + readingAnchor: { turnId: 'turn-a', sequence: 0 }, + controller: { + setReadingAnchor: async () => {}, + loadAround: async (sequence: number) => { reads.push(sequence); }, + store: { + sessionId: 'session-1', + range: () => ({ sessionId: 'session-1' }), + sequenceForTurn: () => null, + newestDurableUserSequence: () => 2, + snapshot: () => ({ messages: [] as string[] }), + }, + }, + isCurrent: () => true, + setReadingAnchor: () => {}, + onError: (error: unknown) => assert.fail(String(error)), + }; + restoreSessionTranscriptRange(options); + await settleRestore(); + restoreSessionTranscriptRange({ ...options, searchTarget: { + sessionId: 'session-1', turnId: 'turn-b', sequence: 2, nonce: 1, + } }); + 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 lets only the replacement restore settle its bookmark', async () => { + const lifecycle = createTranscriptRestoreLifecycle(); + const loads: Array<() => void> = []; + let anchor: { turnId: string; sequence?: number } | undefined = { turnId: 'turn-a', sequence: 0 }; + let unavailable: string | undefined; + const options = { + lifecycle, + sessionId: 'session-1', + readingAnchor: { turnId: 'turn-a', sequence: 0 }, + controller: { + setReadingAnchor: async () => {}, + loadAround: () => new Promise((resolve) => { loads.push(resolve); }), + store: { + sessionId: 'session-1', + range: () => ({ sessionId: 'session-1' }), + sequenceForTurn: () => null, + newestDurableUserSequence: () => 2, + snapshot: () => ({ messages: ['replacement range'] }), + }, + }, + isCurrent: () => true, + setReadingAnchor: (_sessionId: string, next: typeof anchor) => { anchor = next; }, + onRestoreUnavailable: (_sessionId: string, turnId: string) => { unavailable = turnId; }, + onError: (error: unknown) => assert.fail(String(error)), + }; + restoreSessionTranscriptRange(options); + assert.equal(loads.length, 1, 'the first command admits its navigation synchronously'); + lifecycle.deactivate(); + restoreSessionTranscriptRange(options); + assert.equal(loads.length, 2, 'StrictMode replay must admit a replacement navigation'); + + loads[0]!(); + await settleRestore(); + assert.deepEqual(anchor, { turnId: 'turn-a', sequence: 0 }); + assert.equal(unavailable, undefined, 'the deactivated command cannot settle after replacement'); + loads[1]!(); + await settleRestore(); + assert.equal(anchor, undefined); + assert.equal(unavailable, 'turn-a', 'only the replacement restore settles its unavailable target'); +}); + +async function settleRestore(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +test('a live second Turn remains reachable after persistence evicts the oversized first Turn', async () => { + const fixture = await oversizedHistoryFixture({ live: true }); + try { + 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..34b46ea5fd --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts @@ -0,0 +1,139 @@ +/* + * 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 { 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'; + +const FIXTURE_EPOCH = Date.UTC(2026, 0, 2, 3, 4, 5); + +/** + * 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(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; + 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..d664187d80 --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts @@ -0,0 +1,466 @@ +/* + * 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 = '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 () => { + 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('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 { + 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: string) => { + 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: string) => { + const value = watermarks.get(checkpoint); + assert.notEqual(value, undefined); + return value!; + }, + async advance(messageId: string) { + await announce(messageId); + await replica.advance(watermarks.get(messageId)!); + }, + 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..f538a08e9e --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -0,0 +1,404 @@ +/* + * 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 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 { + createAppShellSessionUiStateController, + TranscriptReadingPositionController, + type TranscriptReadingPositionCommands, + type TranscriptHistoryPending, +} 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('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); + const latestStarted = deferred(); + const latestFinished = deferred(); + const releaseLatest = deferred(); + const readingAdmitted = deferred(); + const admissions: Array<{ sequence: number | null; version: number; preserveRange?: boolean }> = []; + const publish = (turnId: string, sequence: number, navigationVersion: number) => { + const message: StoredMessage = { + type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', + }; + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', navigationVersion, + durableThrough: 20, durable: [{ sequence, message }], overlay: [], hasOlder: false, hasNewer: turnId === 'a', + })) store.accept(batch); + }; + publish('a', 10, 0); + const controller = createDesktopTranscriptRangeController(store, async () => ({ + sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, + loadBefore: async () => {}, loadAfter: async () => {}, + async loadAround(sequence, _maxBytes, navigation) { + const version = navigation!.navigationVersion; + admissions.push({ sequence, version, preserveRange: navigation!.preserveRange }); + if (sequence === null) { + latestStarted.resolve(); + await releaseLatest.promise; + publish('b', 20, version); + latestFinished.resolve(); + } else { + publish('a', 10, version); + readingAdmitted.resolve(); + } + }, + close: async () => {}, + })); + try { + const lifecycle = createTranscriptRestoreLifecycle(); + const sessionUi = createAppShellSessionUiStateController(); + assert.equal(await prepareTranscriptForSend({ + sessionId, currentSessionId: { current: sessionId }, controller: { current: controller }, + cancel: (sessionId) => lifecycle.cancel(sessionId), + followLatest: sessionUi.transcriptViewportNavigation.followLatest, + }), true, 'local admission must not wait for the latest range'); + await latestStarted.promise; + const restore = () => restoreSessionTranscriptRange({ + lifecycle, sessionId, controller, + searchTarget: { sessionId, turnId: 'a', sequence: 10, nonce: 1 }, + isCurrent: () => true, setReadingAnchor: () => {}, + onError: (error) => assert.fail(String(error)), + }); + restore(); + await readingAdmitted.promise; + releaseLatest.resolve(); + await latestFinished.promise; + await new Promise((resolve) => setImmediate(resolve)); + restore(); + assert.deepEqual(admissions, [ + { sequence: null, version: 1, preserveRange: undefined }, + { sequence: 10, version: 2, preserveRange: true }, + ]); + assert.equal(store.sequenceForTurn('a'), 10); + assert.equal(store.sequenceForTurn('b'), null); + assert.deepEqual(store.snapshot().messages.map((message) => message.turnId), ['a']); + } finally { + releaseLatest.resolve(); + await controller.close(); + } +}); + +for (const source of ['bootstrap overlay', 'live projection'] as const) { +test(`a ${source} bookmark admits its Turn once and retains it across range reload`, async () => { + 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 = createTranscriptRestoreLifecycle(); + let unavailable = 0; + let cleared = 0; + const restore = () => restoreSessionTranscriptRange({ + lifecycle, sessionId, controller, readingAnchor: { turnId: 'b' }, + isCurrent: () => true, + isLiveTurn: (candidateSessionId, turnId) => source === 'live projection' && + candidateSessionId === sessionId && turnId === 'b', + setReadingAnchor: (_sessionId, anchor) => { if (!anchor) cleared += 1; }, + onRestoreUnavailable: () => { unavailable += 1; }, onError: (error) => assert.fail(String(error)), + }); + 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, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, + loadBefore: () => second.promise, + }; + fixture.props.currentSessionId.current = 'session-2'; + fixture.props.rangeController.current = secondController; + fixture.props.sessionId = 'session-2'; + 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, sessionId: 'session-2', range: () => ({ sessionId: 'session-2' }) }, + }; + await fixture.render(); + fixture.props.currentSessionId.current = 'session-1'; + fixture.props.sessionId = 'session-1'; + fixture.props.rangeController.current = { + ...fixture.controller, + loadBefore: () => replacement.promise, + }; + 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 = { + loadAround: async () => {}, + loadBefore: async () => {}, + loadAfter: async () => {}, + loadLatest: async () => {}, + setReadingAnchor: async () => {}, + store: { + sessionId: 'session-1', + 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: [] }), + 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..b4829000eb --- /dev/null +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -0,0 +1,341 @@ +/* + * 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'); + + await fixture.switchSession('session-b'); + await fixture.readAt(900); + await act(async () => { latest.resolve(); }); + + 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); }); + await fixture.activateHistoryGap(direction); + const readerTop = fixture.scroller.scrollTop; + await act(async () => { latest.resolve(); }); + + assert.equal(fixture.pinned(), false); + assert.equal(fixture.scroller.scrollTop, readerTop); + }); +} + +test('a prepared send cancels an outstanding bookmark frame before it can scroll to history', async () => { + const fixture = viewportFixture(); + fixture.sessionUi.setTranscriptReadingAnchor('session-a', { turnId: 'history', sequence: 0 }); + 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); }); + + await fixture.replaceRangeFromHost(); + await act(async () => { latest.resolve(); }); + + assert.deepEqual(fixture.visibleTurns(), ['latest-b']); + assert.deepEqual(readingCalls, [], 'content geometry must not install a new history reading intent'); + assert.equal(fixture.pinned(), true); + await fixture.append('new-question', 200); + assert.equal(fixture.scroller.scrollTop, 400); +}); + +test('the reader can leave an accepted send while its latest range is still loading', async () => { + const fixture = viewportFixture(); + const latest = deferred(); + fixture.controller.loadLatest = () => latest.promise; + await fixture.render(); + await fixture.readAt(1900); + await act(async () => { assert.equal(await fixture.commands.current!.prepareSend('session-a'), true); }); + + await fixture.readAt(1000); + await act(async () => { latest.resolve(); }); + + assert.equal(fixture.pinned(), false); + assert.equal(fixture.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; + const controller = { + 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); + 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: [] }), + 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, + visibleTurns: () => props.messages.map((message) => message.turnId), + 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, sessionId, 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-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 91022b9777..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; 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/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 ff2eaacdc2..18f47fb6c5 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, @@ -2481,6 +2482,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; @@ -2499,12 +2502,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; @@ -2574,7 +2580,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'); @@ -2585,17 +2598,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 cf79cdd9b4..6c0a35b7ce 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -71,7 +71,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'; @@ -425,7 +425,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; @@ -1580,6 +1580,7 @@ function AppShellContent({ updateTransientMessage, removeTransientMessage, transcriptRangeRef, + onFollowLatest: (sessionId) => transcriptReadingCommands.current?.prepareSend(sessionId) ?? Promise.resolve(true), setLiveTurnBySession: sessionUiController.setLiveTurnBySession, setInteractionBySession: sessionUiController.setInteractionBySession, onInteractionChanged: markInteractionChanged, @@ -2139,43 +2140,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, @@ -2339,41 +2303,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 && @@ -2481,6 +2410,30 @@ function AppShellContent({ } as CSSProperties) } > + setSearchScrollTarget(null)} + sessionUi={sessionUiController} + turnIndex={transcriptTurnIndex} + setTurnIndex={setTranscriptTurnIndex} + listTurnLandmarks={(sessionId) => window.maka.sessions.listTurnLandmarks(sessionId)} + setHistoryPending={setHistoryLoadPending} + historyPageBytes={DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES} + onRestoreError={(error, sessionId) => sessionUiController.setMessageLoadErrorBySession((current) => ({ + ...current, + [sessionId]: localizedShellErrorMessage(error, desktopConversationCopy.actions.operationFailedFallback, uiLocale), + }))} + 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={ @@ -2865,7 +2818,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} @@ -2915,7 +2868,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; - loadLatest(): Promise; + setReadingAnchor(sequence: number | null, readingTurnId?: string): Promise; + loadLatest(maxBytes?: number): 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(maxBytes) { + return navigate({ intent: 'followTail', kind: 'latest', sequence: null, maxBytes }); }, 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; @@ -258,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(); @@ -266,8 +320,11 @@ 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; + #liveGeneration: string | undefined; #hostEpoch: string | undefined; #durableThrough: number | null = null; #oldestSequence: number | null = null; @@ -282,20 +339,32 @@ export class DesktopTranscriptRangeStore { constructor(sessionKey: string) { const { hostId, sessionId } = parseDesktopSessionKey(sessionKey); - this.#sessionKey = sessionKey; + this.sessionId = sessionKey; this.#hostId = hostId; this.#expectedSessionId = sessionId; } + expectNavigation(navigationVersion: number): void { + if (navigationVersion <= this.#navigationVersion) return; + this.#navigationVersion = navigationVersion; + this.#pending.clear(); + this.#batchChanged = false; + } + + 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 || @@ -352,7 +421,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, @@ -401,6 +470,16 @@ export class DesktopTranscriptRangeStore { if (batch.sessionId !== this.#expectedSessionId) { throw new Error('Desktop transcript belongs to a different Session'); } + 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; diff --git a/apps/desktop/src/renderer/features/conversation/README.md b/apps/desktop/src/renderer/features/conversation/README.md index 334b948410..7f42e76805 100644 --- a/apps/desktop/src/renderer/features/conversation/README.md +++ b/apps/desktop/src/renderer/features/conversation/README.md @@ -21,10 +21,24 @@ 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 +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; 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; +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..2f6dd72740 --- /dev/null +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position-controller.tsx @@ -0,0 +1,189 @@ +/* + * 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; +}; + +interface TurnIndex { + sessionId: string; + throughSequence: number | null; + turns: readonly { turnId: string; sequence: number; label: string }[]; +} + +export interface TranscriptReadingPositionCommands { + 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']; + setHistoryPending: Dispatch>; + historyPageBytes: number; + onRestoreError(error: unknown, sessionId: string): void; + onNavigationError(error: unknown, sessionId: string): void; +}) { + const [lifecycle] = useState(createTranscriptRestoreLifecycle); + const historyGates = useRef(new WeakMap()); + const 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, () => ({ + prepareSend(sessionId) { + cancelHistory(sessionId); + return prepareTranscriptForSend({ + sessionId, currentSessionId: props.currentSessionId, + controller: props.rangeController, cancel, + followLatest: props.sessionUi.transcriptViewportNavigation.followLatest, + }); + }, + 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; + 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; + 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(() => () => { + lifecycle.deactivate(); + }, [props.sessionId, props.profileId, lifecycle]); + useEffect(() => { + if (props.searchTarget) { + 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, + 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..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,7 +20,8 @@ import type { TranscriptReadingAnchor } from '../model/session-ui-state.js'; interface TranscriptRangeStore { - range(): { readonly sessionId: string }; + readonly sessionId: string; + range(): { readonly sessionId: string; readonly hasNewer?: boolean }; sequenceForTurn(turnId: string): number | null; newestDurableUserSequence(): number | null; snapshot(): { readonly messages: readonly Message[] }; @@ -28,14 +29,112 @@ interface TranscriptRangeStore { 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; + followLatest(sessionId: string): void; +}): Promise { + const { sessionId } = options; + if (options.currentSessionId.current !== sessionId) return false; + options.cancel(sessionId, true); + const controller = options.controller.current; + options.followLatest(sessionId); + if (!controller || controller.store.sessionId !== sessionId) return true; + // Invalidate pending history immediately, but keep local Message admission + // independent of an unopened, slow or offline transcript. The explicit pin + // happens once; a late page must not reclaim the viewport from the reader. + void (async () => { + try { + await controller.loadLatest(); + } catch { + // Catch-up failure must not prevent the Message from being saved locally. + } + })(); + return true; } export function currentTranscriptRange( @@ -126,6 +225,7 @@ export interface TranscriptHistoryPending { export interface TranscriptHistoryGate { pending: boolean; + active?: TranscriptHistoryRequest; queued?: TranscriptHistoryRequest; } @@ -170,6 +270,7 @@ export async function loadTranscriptHistory(options: { return; } gate.pending = true; + gate.active = request; options.setPending((current) => updateTranscriptHistoryPending(current, options.sessionId, request)); try { @@ -181,81 +282,103 @@ 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 setMessages: (messages: Message[]) => void; + readonly isLiveTurn?: (sessionId: string, turnId: string) => boolean; readonly setReadingAnchor: ( sessionId: string, anchor: TranscriptReadingAnchor | undefined, ) => 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 || !options.isCurrent(sessionId, controller)) return; + if (command.controller === controller && command.attempt) return; + if (!command.fromSearch && command.target.sequence === undefined) { + const { turnId } = command.target; try { const sequence = controller.store.range().sessionId === sessionId ? controller.store.sequenceForTurn(turnId) : null; if (sequence !== null) { - 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() - .then(async () => { - if (!current() || controller.store.range().sessionId !== sessionId) { - return { loaded: false, unavailable: false }; - } + const target = command.target; + if (command.fromSearch && target.sequence === undefined) return; + const restoringReadingAnchor = !command.fromSearch; + const attempt = {}; + command.controller = controller; + command.attempt = attempt; + const current = (): boolean => options.lifecycle.isCurrent(command) + && command.attempt === attempt && options.isCurrent(sessionId, controller); + if (!current()) { + command.attempt = undefined; + return; + } + let admitted: Promise; + try { + const residentSequence = currentTranscriptRange(controller, sessionId) + ? controller.store.sequenceForTurn(target.turnId) + : null; + const sequence = residentSequence ?? target.sequence; + // Admit intent before awaiting the open handle. Resident and live-only + // targets retain their range while invalidating older navigation requests. + admitted = residentSequence !== null || sequence === undefined + ? controller.setReadingAnchor(sequence ?? null, target.turnId) + : controller.loadAround(sequence); + } catch (error) { + admitted = Promise.reject(error); + } + void admitted + .then(() => { + if (!current() || controller.store.range().sessionId !== sessionId) return false; const residentSequence = controller.store.sequenceForTurn(target.turnId); if (residentSequence !== null) { - if (restoringReadingAnchor && readingAnchor?.sequence === undefined) { + if (restoringReadingAnchor && target.sequence === undefined) { options.setReadingAnchor(sessionId, { turnId: target.turnId, sequence: residentSequence }); } - return { loaded: false, unavailable: false }; - } - if (target.sequence === undefined) { - return { loaded: false, unavailable: restoringReadingAnchor }; + return false; } - await controller.loadAround(target.sequence); - if (!current() || controller.store.range().sessionId !== sessionId) { - return { loaded: false, 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 false; } - return { - loaded: true, - unavailable: restoringReadingAnchor - && controller.store.sequenceForTurn(target.turnId) === null, - }; + return restoringReadingAnchor; }) - .then(({ loaded, unavailable }) => { + .then((unavailable) => { if (!current()) return; - if (loaded) options.setMessages([...controller.store.snapshot().messages]); + command.completed = true; if (unavailable) { options.setReadingAnchor(sessionId, undefined); options.onRestoreUnavailable?.(sessionId, target.turnId); @@ -263,10 +386,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 +413,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 95a408f8d2..68435089aa 100644 --- a/apps/desktop/src/renderer/features/conversation/index.ts +++ b/apps/desktop/src/renderer/features/conversation/index.ts @@ -18,29 +18,23 @@ */ import { - captureTranscriptReadingAnchor, currentTranscriptRange, - loadTranscriptHistory, - newestDurablePromptSequence, - refreshTranscriptTurnLandmarks, - restoreSessionTranscriptRange, transcriptRestoreTarget, } from './controller/transcript-reading-position.js'; export const transcriptReadingPosition = { - captureAnchor: captureTranscriptReadingAnchor, currentRange: currentTranscriptRange, - loadHistory: loadTranscriptHistory, - newestDurablePromptSequence, - refreshLandmarks: refreshTranscriptTurnLandmarks, - restoreRange: restoreSessionTranscriptRange, restoreTarget: transcriptRestoreTarget, }; 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,7 +45,6 @@ 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'; export { useComposerAttachments, type ComposerAttachmentService } from './controller/use-composer-attachments.js'; export { toComposerIngestItems, retainedAttachmentRefs, type PendingAttachment } from '@maka/ui/composer-attachments'; 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/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/src/renderer/workhub-coordination-port.ts b/apps/desktop/src/renderer/workhub-coordination-port.ts index 88f9c82fd8..a157a60b3f 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(); }, }; }, 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/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index fe3841cf0f..8e8865ad5f 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:** 259 files — blocker 0, reimplementation 0, polish 1, aligned 258. +**Totals:** 260 files — blocker 0, reimplementation 0, polish 1, aligned 259. ## 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 da07d479ce..8855985d24 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..553ac67681 100644 --- a/packages/runtime/src/__tests__/fake-backend.test.ts +++ b/packages/runtime/src/__tests__/fake-backend.test.ts @@ -21,14 +21,12 @@ 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 { RuntimeInteractionInvariantError, bindRuntimeInteractionRun, type RuntimeUserQuestionContinuation, } from '../interaction-authority.js'; -import type { SessionStore } from '../session-manager.js'; test('Fake question publication waits for exact hosted admission', async () => { const admissionStarted = deferred(); diff --git a/packages/ui/package.json b/packages/ui/package.json index 22e7137cda..d31ad6d9ed 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", "./composer-attachments": "./dist/composer-attachments.js", "./pending-items": "./dist/pending-items.js", diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index 2687a1f7e6..fd8ebd003d 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, @@ -288,7 +289,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; @@ -307,12 +311,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; } @@ -422,6 +431,67 @@ 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 4f0fae2e7e..93bcd9dd73 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(); // A wheel at either edge moves nothing, so no scroll event refreshes the // anchor and the restore effect would load around an evicted Turn. @@ -239,6 +268,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,