diff --git a/apps/desktop/e2e/streaming-remount.spec.ts b/apps/desktop/e2e/streaming-remount.spec.ts index ba86948dd9..81aa04f385 100644 --- a/apps/desktop/e2e/streaming-remount.spec.ts +++ b/apps/desktop/e2e/streaming-remount.spec.ts @@ -21,8 +21,10 @@ test('remounting a live surface leaves accumulated output settled', async ({ const sidebar = page.getByRole('navigation', { name: '对话列表' }); await sidebar.getByRole('button', { name: '扩展' }).click(); await expect(page.locator('[data-module="skills"]')).toBeVisible(); + await expect(liveBubble).toHaveCount(0); await sidebar.getByRole('button', { name: '会话', exact: true }).click(); - await liveBubble.waitFor({ state: 'attached' }); + await expect(liveBubble).toHaveCount(1); + await expect(liveBubble).toContainText(accumulatedOutput); expect((await liveBubble.textContent())?.split(accumulatedOutput)).toHaveLength(2); expect( @@ -46,7 +48,8 @@ test('remounting a live surface leaves accumulated output settled', async ({ const steering = 'trigger rewrite after returning to this conversation'; await composer.fill(steering); await composer.press('Enter'); - await expect(liveBubble).toContainText(' NEW'); + const finalText = 'prefix NEW streamed after the remount'; + await expect(liveBubble).toContainText(finalText); const observed = await page.evaluate(() => ( window as typeof window & { @@ -55,9 +58,8 @@ test('remounting a live surface leaves accumulated output settled', async ({ }; } ).__makaStreamingRemountObserved); - expect(observed?.texts.some((text) => - text.includes('') && !text.includes('NEW') - )).toBe(true); + expect(observed?.texts.some((text) => text.includes('') && !text.includes(finalText))) + .toBe(true); }); test('returning to a live conversation settles output accumulated while away', async ({ 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 45c2cc6a1e..f963f3d3de 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 @@ -18,6 +18,7 @@ import { describe, it } from 'node:test'; import type { SessionSummary } from '@maka/core/session'; 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 { createAppShellSessionUiStateController } from '../../renderer/app-shell-session-ui-state.js'; import { settledSessionTransientIds } from '../../renderer/settled-session-transients.js'; @@ -81,6 +82,7 @@ function createActionsDeps() { setMessageLoadErrorBySession: () => undefined, setMessageRetryPendingBySession: () => undefined, setMessages: () => undefined, + transcriptRangeRef: { current: undefined }, setNavSelection: () => undefined, setLiveTurnBySession: () => undefined, setInteractionBySession: () => undefined, @@ -144,7 +146,6 @@ describe('composer first-send cleanup', () => { attachments: [], skillInvocation: { loaded: [], failed: [] }, }), - readMessages: async () => [], }, }); @@ -181,7 +182,6 @@ describe('composer first-send cleanup', () => { attachments: [], skillInvocation: { loaded: [], failed: [] }, }), - readMessages: async () => [], }, }); @@ -232,7 +232,6 @@ describe('composer first-send cleanup', () => { remove: async (sessionId: string) => { removed.push(sessionId); }, - readMessages: async () => [], }, }); @@ -273,6 +272,46 @@ describe('composer first-send cleanup', () => { assert.deepEqual(removed, []); }); + + it('returns a sparse existing session to latest before sending', async () => { + const latest = deferred(); + const order: string[] = []; + const activeIdRef = { current: 'existing-session' as string | undefined }; + const transcript = { + store: { + range: () => ({ sessionId: 'existing-session', hasNewer: true }), + snapshot: () => ({ messages: [] }), + }, + async loadLatest() { + order.push('latest'); + await latest.promise; + }, + } as unknown as DesktopTranscriptRangeController; + const transcriptRangeRef = { current: transcript as DesktopTranscriptRangeController | undefined }; + const restoreWindow = installWindow({ + sessions: { + send: async () => { + order.push('send'); + return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; + }, + }, + }); + + try { + const sending = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef, + transcriptRangeRef, + }).send('hello'); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(order, ['latest']); + latest.resolve(); + assert.equal(await sending, true); + assert.deepEqual(order, ['latest', 'send']); + } finally { + restoreWindow(); + } + }); }); function deferred() { @@ -389,23 +428,15 @@ describe('composer send failure feedback', () => { describe('a send in flight versus a stale session list', () => { const sessionId = 'session-a'; - // Echoes the sent turn back through `readMessages`, which is what `send()` - // waits on before it reports success — a window that never shows the user - // message would time the send out rather than exercise the race. function sendingWindow() { - let sentTurnId: string | undefined; return { sessions: { create: async () => ({ id: sessionId }), - send: async (_sessionId: string, command: { turnId: string }) => { - sentTurnId = command.turnId; - return { ok: true, attachments: [], skillInvocation: { loaded: [], failed: [] } }; - }, - readMessages: async () => ( - sentTurnId - ? [{ type: 'user', id: `user-${sentTurnId}`, turnId: sentTurnId, ts: 1, text: 'hello' }] - : [] - ), + send: async () => ({ + ok: true, + attachments: [], + skillInvocation: { loaded: [], failed: [] }, + }), }, }; } 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 new file mode 100644 index 0000000000..3586fc3a5f --- /dev/null +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -0,0 +1,563 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { StoredMessage } from '@maka/core/session'; +import { + encodeDesktopTranscriptChange, + encodeDesktopTranscriptSnapshot, +} from '../desktop-transcript-ipc.js'; +import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES } from '../../preload/transcript-contract.js'; +import { + createDesktopTranscriptRangeController, + DesktopTranscriptRangeStore, +} from '../../renderer/desktop-transcript-range-store.js'; +import { + mergeSettledMessages, + readSettledMessages, +} from '../../renderer/session-message-settlement.js'; +import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; +import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; + +test('merges a settled tail without dropping earlier messages', () => { + const earlier = assistantMessage('earlier', 'assistant-earlier'); + const current = assistantMessage('partial', 'assistant-current'); + const settled = assistantMessage('complete', current.id); + const latest = assistantMessage('latest', 'assistant-latest'); + + assert.deepEqual(mergeSettledMessages([earlier, current], [settled, latest]), [ + earlier, + settled, + latest, + ]); +}); + +test('cancels settlement while transcript open is pending', async () => { + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + let cancelled = false; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + maka: { + transcripts: { + open: async ( + _sessionId: string, + _handler: unknown, + registerCancellation: (cancel: () => void) => void, + ) => new Promise((_resolve, reject) => { + registerCancellation(() => { + cancelled = true; + reject(new Error('open cancelled')); + }); + }), + }, + }, + }, + }); + const controller = new AbortController(); + try { + const settling = readSettledMessages('session-1', { signal: controller.signal }); + controller.abort(); + await assert.rejects(settling, /settlement was cancelled/); + assert.equal(cancelled, true); + } finally { + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); + else Reflect.deleteProperty(globalThis, 'window'); + } +}); + +test('moves a fragmented overlay record to durable storage without duplicating it', () => { + const message = assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES * 2)); + const identity = { + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + }; + const store = new DesktopTranscriptRangeStore(); + const snapshot = [...encodeDesktopTranscriptSnapshot({ + ...identity, + durableThrough: null, + durable: [], + overlay: [message], + hasOlder: false, + hasNewer: false, + })]; + + assert.ok(snapshot.length > 1); + for (const [index, batch] of snapshot.entries()) { + assert.ok( + batch.fragments.reduce( + (total, fragment) => total + fragment.data.byteLength, + 0, + ) <= DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + ); + assert.equal(store.accept(batch), index === snapshot.length - 1); + } + assert.deepEqual(store.snapshot().messages, [message]); + assert.equal(store.hasDurableMessage(message.id), false); + + const change = [...encodeDesktopTranscriptChange(identity, { + durableThrough: 4, + durableUpserts: [{ sequence: 4, message }], + evictedDurableSequences: [], + completedOverlayMessageIds: [message.id], + hasOlder: true, + hasNewer: false, + })]; + for (const batch of change) store.accept(batch); + assert.deepEqual(store.snapshot().messages, [message]); + assert.equal(store.hasDurableMessage(message.id), true); + + for (const batch of change) assert.equal(store.accept(batch), false); + assert.deepEqual(store.snapshot().messages, [message]); +}); + +test('retains the newest observed durable prompt across eviction', () => { + const identity = { + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + }; + const store = new DesktopTranscriptRangeStore(); + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, + durableThrough: 3, + durable: [ + { sequence: 1, message: userMessage('older', 'user-1') }, + { sequence: 2, message: assistantMessage('answer') }, + { sequence: 3, message: userMessage('newer', 'user-3') }, + ], + overlay: [], + hasOlder: false, + hasNewer: false, + })) store.accept(batch); + assert.equal(store.newestDurableUserSequence(), 3); + + for (const batch of encodeDesktopTranscriptChange(identity, { + durableThrough: 4, + durableUpserts: [{ sequence: 4, message: assistantMessage('latest') }], + evictedDurableSequences: [3], + completedOverlayMessageIds: [], + hasOlder: false, + hasNewer: false, + })) store.accept(batch); + assert.equal(store.newestDurableUserSequence(), 3); +}); + +test('drops stale transcript batches after a generation reset', () => { + const store = new DesktopTranscriptRangeStore(); + const oldBatches = [...encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', + generation: 'old', + hostEpoch: 'host-1', + durableThrough: 1, + durable: [{ sequence: 1, message: assistantMessage('old') }], + overlay: [], + hasOlder: false, + hasNewer: false, + })]; + const nextMessage = assistantMessage('new'); + const nextBatches = [...encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', + generation: 'next', + hostEpoch: 'host-2', + durableThrough: 2, + durable: [{ sequence: 2, message: nextMessage }], + overlay: [], + hasOlder: true, + hasNewer: false, + })]; + + for (const batch of oldBatches) store.accept(batch); + for (const batch of nextBatches) store.accept(batch); + const staleChange = [...encodeDesktopTranscriptChange( + { sessionId: 'session-1', generation: 'old', hostEpoch: 'host-1' }, + { + durableThrough: 3, + durableUpserts: [{ sequence: 3, message: assistantMessage('stale') }], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: false, + hasNewer: false, + }, + )]; + for (const batch of staleChange) assert.equal(store.accept(batch), false); + assert.deepEqual(store.snapshot().messages, [nextMessage]); +}); + +test('keeps a bounded contiguous window while moving between history and the tail', async () => { + const messages = [0, 1, 2, 3, 4].map((sequence) => ({ + identity: sequence, + message: assistantMessage(String(sequence), `assistant-${sequence}`), + })); + const page = (nextCursor: string | null) => ({ + kind: 'page' as const, + sessionId: 'session-1', + source: 'durable' as const, + direction: 'older' as const, + throughSequence: 4, + rawBytes: 1, + fragments: [], + nextCursor, + }); + const bootstrapPage = page('older'); + const olderPage = page('older'); + const latestPage = page(null); + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 4, + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...page(null), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (candidate) => candidate === bootstrapPage + ? { messages: messages.slice(3), nextCursor: 'older' } + : candidate === olderPage + ? { messages: messages.slice(2, 4), nextCursor: 'older' } + : { messages: messages.slice(4), nextCursor: null }, + loadTranscriptPage: async (input) => input.anchorSequence === 4 ? olderPage : latestPage, + async close() {}, + }); + const maxResidentBytes = Buffer.byteLength(JSON.stringify(messages[0]!.message), 'utf8') + 1; + const replica = await DesktopTranscriptReplica.prepare(handle, { + maxResidentBytes, + }); + + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [4]); + await replica.loadBefore(4, 128 * 1024); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2]); + assert.equal(replica.snapshot().hasNewer, true); + + await replica.loadAround(4, 128 * 1024); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [4]); + assert.equal(replica.snapshot().hasNewer, false); + assert.ok(replica.residentBytes <= maxResidentBytes); +}); + +test('loads a history target with newer messages available below it', async () => { + const messages = [0, 1, 2, 3, 4].map((sequence) => ({ + identity: sequence, + message: assistantMessage(String(sequence), `assistant-${sequence}`), + })); + const bootstrapPage = transcriptPage('older', null, 4); + const aroundPage = transcriptPage('newer', 'newer', 4); + let aroundInput: { direction: string; anchorSequence: number | null } | undefined; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 4, + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, 4), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (page) => page === bootstrapPage + ? { messages: messages.slice(4), nextCursor: null } + : { messages: messages.slice(0, 3), nextCursor: 'newer' }, + loadTranscriptPage: async (input) => { + aroundInput = input; + return aroundPage; + }, + async close() {}, + }); + const replica = await DesktopTranscriptReplica.prepare(handle, { + maxResidentBytes: 128 * 1024, + }); + + await replica.loadAround(0, 128 * 1024); + + assert.equal(aroundInput?.direction, 'newer'); + assert.equal(aroundInput?.anchorSequence, null); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [0, 1, 2]); + assert.equal(replica.snapshot().hasOlder, false); + assert.equal(replica.snapshot().hasNewer, true); +}); + +test('rejects an overlay that exceeds its cache budget', async () => { + const messages = [ + assistantMessage('x'.repeat(700), 'overlay-1'), + assistantMessage('y'.repeat(700), 'overlay-2'), + ]; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + loadTranscriptOverlay: async () => messages, + async close() {}, + }); + + await assert.rejects( + DesktopTranscriptReplica.prepare(handle, { + maxResidentBytes: 1_024, + maxOverlayBytes: 1_024, + maxMessageBytes: 1_024, + }), + /overlay exceeds the session cache limit/, + ); +}); + +test('keeps history resident when an active overlay uses its own cache budget', async () => { + const overlay = assistantMessage('o'.repeat(700), 'overlay-1'); + const historical = assistantMessage('h'.repeat(700), 'history-1'); + const bootstrapPage = transcriptPage('older', 'older', 1); + const olderPage = transcriptPage('older', null, 1); + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 1, + overlayMessageCount: 1, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, 1), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [overlay], + decodeTranscriptPage: async (page) => page === olderPage + ? { messages: [{ identity: 0, message: historical }], nextCursor: null } + : { messages: [], nextCursor: 'older' }, + loadTranscriptPage: async () => olderPage, + async close() {}, + }); + const replica = await DesktopTranscriptReplica.prepare(handle, { + maxResidentBytes: 1_024, + maxOverlayBytes: 1_024, + maxMessageBytes: 1_024, + }); + + await replica.loadBefore(null, 128 * 1024); + + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [0]); + assert.equal(replica.snapshot().hasOlder, false); +}); + +test('transfers prepared transcript bytes into active replica accounting', async () => { + const message = assistantMessage('prepared', 'overlay-1'); + const messageBytes = Buffer.byteLength(JSON.stringify(message), 'utf8'); + let accountedBytes = 0; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + loadTranscriptOverlay: async (_maxMessageBytes, accountAssemblyBytes) => { + accountAssemblyBytes?.(messageBytes); + accountAssemblyBytes?.(-messageBytes); + return [message]; + }, + async close() {}, + }); + + const replica = await DesktopTranscriptReplica.prepare(handle, { + accountPreparationBytes: (deltaBytes) => { + accountedBytes += deltaBytes; + }, + }); + assert.equal(accountedBytes, messageBytes); + replica.adoptResidentAccounting(); + assert.equal(accountedBytes, 0); + replica.close(); + assert.equal(accountedBytes, 0); +}); + +test('does not release resident bytes when preparation accounting rejects them', async () => { + const message = assistantMessage('prepared', 'overlay-1'); + const deltas: number[] = []; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + loadTranscriptOverlay: async () => [message], + async close() {}, + }); + + await assert.rejects( + DesktopTranscriptReplica.prepare(handle, { + accountPreparationBytes: (deltaBytes) => { + deltas.push(deltaBytes); + if (deltaBytes > 0) throw new RangeError('capacity reached'); + }, + }), + /capacity reached/, + ); + assert.deepEqual(deltas.filter((deltaBytes) => deltaBytes < 0), []); +}); + +test('reopens a failed transcript range with a fresh generation', async () => { + const store = new DesktopTranscriptRangeStore(); + let attempts = 0; + const controller = createDesktopTranscriptRangeController(store, async () => { + attempts += 1; + if (attempts === 1) throw new Error('open failed'); + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', + generation: 'reloaded', + hostEpoch: 'host-2', + durableThrough: null, + durable: [], + overlay: [], + hasOlder: false, + hasNewer: false, + })) + store.accept(batch); + return { + sessionId: 'session-1', + generation: 'reloaded', + hostEpoch: 'host-2', + readThroughMessageId: null, + async loadBefore() {}, + async loadAround() {}, + async close() {}, + }; + }); + + await assert.rejects(() => controller.ready(), /open failed/); + await controller.reload(); + assert.equal(store.range().generation, 'reloaded'); + await controller.close(); +}); + +test('forwards a larger logical history range without changing batch size', async () => { + const store = new DesktopTranscriptRangeStore(); + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 1, + durable: [{ sequence: 1, message: assistantMessage('latest') }], + overlay: [], + hasOlder: true, + hasNewer: false, + })) store.accept(batch); + let request: { anchorSequence: number | null; maxBytes?: number } | undefined; + const controller = createDesktopTranscriptRangeController(store, async () => ({ + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + readThroughMessageId: 'assistant-1', + async loadBefore(anchorSequence, maxBytes) { + request = { anchorSequence, maxBytes }; + }, + async loadAround() {}, + async close() {}, + })); + + await controller.loadBefore(512 * 1024); + + assert.deepEqual(request, { anchorSequence: 1, maxBytes: 512 * 1024 }); + await controller.close(); +}); + +test('waits for the required durable message on the current transcript generation', async () => { + const store = new DesktopTranscriptRangeStore(); + const identity = { + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + }; + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, + durableThrough: null, + durable: [], + overlay: [], + hasOlder: false, + hasNewer: false, + })) store.accept(batch); + const waiting = store.waitForDurableMessage('assistant-1', 100); + for (const batch of encodeDesktopTranscriptChange(identity, { + durableThrough: 0, + durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: false, + hasNewer: false, + })) store.accept(batch); + + assert.equal(await waiting, true); +}); + +test('cancels a transcript open that is still waiting for a Host', async () => { + const store = new DesktopTranscriptRangeStore(); + let openSignal: AbortSignal | undefined; + const controller = createDesktopTranscriptRangeController( + store, + (signal) => + new Promise((_resolve, reject) => { + openSignal = signal; + signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true }); + }), + ); + + await controller.close(); + assert.equal(openSignal?.aborted, true); +}); + +function assistantMessage( + text: string, + id = 'assistant-1', +): Extract { + return { + type: 'assistant', + id, + turnId: 'turn-1', + ts: 1, + text, + modelId: 'model-1', + }; +} + +function userMessage( + text: string, + id: string, +): Extract { + return { + type: 'user', + id, + turnId: id.replace('user-', 'turn-'), + ts: 1, + text, + }; +} + +function transcriptPage( + direction: 'older' | 'newer', + nextCursor: string | null, + throughSequence: number, +) { + return { + kind: 'page' as const, + sessionId: 'session-1', + source: 'durable' as const, + direction, + throughSequence, + rawBytes: 1, + fragments: [], + nextCursor, + }; +} + +function continuitySnapshot() { + return { + schemaVersion: 3 as const, + session: { + sessionId: 'session-1', + metadataRevision: 1, + status: 'running' as const, + createdAt: 1, + lastUsedAt: 1, + isArchived: false, + }, + projectionRevision: 1, + rootTurn: null, + goal: null, + queue: { + hostEpoch: 'host-1', + queueRevision: 0, + steering: [], + followup: [], + }, + interactions: { pending: [] }, + }; +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts index 46e2db4ed3..f99c587e65 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts @@ -12,7 +12,7 @@ import { createRuntimeHostBotSessionAdapter, type RuntimeHostBotSessionAdapterDeps, } from '../runtime-host-bot-session-adapter.js'; -import type { DesktopRuntimeHostSession } from '../runtime-host-client.js'; +import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; type BotClient = RuntimeHostBotSessionAdapterDeps['client']; @@ -114,7 +114,7 @@ test('subscribes before Turn start and settles a fast Host reply without losing const events = new AsyncFrameQueue(); const changes: unknown[] = []; let closeCount = 0; - const handle: DesktopRuntimeHostSession = { + const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(null), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -123,7 +123,7 @@ test('subscribes before Turn start and settles a fast Host reply without losing closeCount += 1; events.end(); }, - }; + }); const client = botClient({ openSession: async () => handle, startTurn: async (input) => { @@ -170,7 +170,7 @@ test('accepts an empty reset delta as the authoritative Bot reply', async () => const events = new AsyncFrameQueue(); const adapter = createRuntimeHostBotSessionAdapter({ client: botClient({ - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(null), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -211,7 +211,7 @@ test('returns blocked Skill feedback without waiting for a Turn that was not cre let closeCount = 0; const adapter = createRuntimeHostBotSessionAdapter({ client: botClient({ - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(null), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -265,7 +265,7 @@ async function runProjectedTurn(rootTurn: TurnSnapshot) { const events = new AsyncFrameQueue(); const adapter = createRuntimeHostBotSessionAdapter({ client: botClient({ - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(null), activeAssistantStreams: [], transcript: Promise.resolve([]), diff --git a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts index ddd733afb2..ea799aaf41 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts @@ -10,7 +10,7 @@ import { } from '@maka/runtime-host/protocol'; import { DesktopRuntimeHostClient } from '../runtime-host-client.js'; -test('starts transcript loading at attach and closes every Session before the connection', async () => { +test('loads a full transcript only on explicit request and closes Sessions before the connection', async () => { const lifecycle: string[] = []; const first = subscription('session-1', lifecycle); const second = subscription('session-2', lifecycle); @@ -29,9 +29,9 @@ test('starts transcript loading at attach and closes every Session before the co const sessionOne = await client.openSession('session-1'); const sessionTwo = await client.openSession('session-2'); - assert.deepEqual(lifecycle, ['session-1:transcript', 'session-2:transcript']); - assert.deepEqual(await sessionOne.transcript, []); - assert.deepEqual(await sessionTwo.transcript, []); + assert.deepEqual(lifecycle, []); + assert.deepEqual(await sessionOne.loadTranscript(), []); + assert.deepEqual(await sessionTwo.loadTranscript(), []); await client.close(); assert.deepEqual(lifecycle, [ @@ -44,6 +44,96 @@ test('starts transcript loading at attach and closes every Session before the co await assert.rejects(() => client.openSession('session-3'), /Client is closed/); }); +test('derives turn records from bounded contribution pages', async () => { + const positions: number[] = []; + const connection = { + request: async (operation: string, input: { position: number }) => { + assert.equal(operation, 'session.turns.query'); + positions.push(input.position); + if (input.position === 0) { + return { + sessionId: 'session-1', + throughSequence: 3, + contributions: [{ + turnId: 'turn-1', + firstSequence: 0, + latestState: null, + userPromptPreview: 'hello', + hasAssistantMessage: true, + hasAssistantOutput: true, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }], + nextPosition: 2, + }; + } + return { + sessionId: 'session-1', + throughSequence: 3, + contributions: [{ + turnId: 'turn-1', + firstSequence: 2, + latestState: { + sequence: 2, + message: { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 3, + status: 'completed', + partialOutputRetained: false, + }, + }, + userPromptPreview: null, + hasAssistantMessage: false, + hasAssistantOutput: false, + hasToolResult: true, + hasFailedToolResult: false, + hasAbortNote: false, + }], + nextPosition: null, + }; + }, + close: async () => undefined, + } as unknown as RuntimeHostConnection; + const client = new DesktopRuntimeHostClient(connection); + + assert.deepEqual(await client.listSessionTurns('session-1'), [{ + turnId: 'turn-1', + firstSequence: 0, + userPromptPreview: 'hello', + status: 'completed', + statusSource: 'recorded', + partialOutputRetained: true, + }]); + assert.deepEqual(positions, [0, 2]); + await client.close(); +}); + +test('reads the bounded prompt rail index without paging every turn', async () => { + const connection = { + request: async (operation: string, input: unknown) => { + assert.equal(operation, 'session.turn_landmarks.query'); + assert.deepEqual(input, { sessionId: 'session-1', maxLandmarks: 64 }); + return { + sessionId: 'session-1', + throughSequence: 100, + landmarks: [{ turnId: 'turn-50', sequence: 50, label: 'middle' }], + }; + }, + close: async () => undefined, + } as unknown as RuntimeHostConnection; + const client = new DesktopRuntimeHostClient(connection); + + assert.deepEqual(await client.listSessionTurnLandmarks('session-1'), { + sessionId: 'session-1', + throughSequence: 100, + landmarks: [{ turnId: 'turn-50', sequence: 50, label: 'middle' }], + }); + await client.close(); +}); + function subscription( sessionId: string, lifecycle: string[], @@ -52,7 +142,12 @@ function subscription( hostEpoch: 'host-1', subscriptionId: `subscription-${sessionId}`, activeAssistantStreams: [], - transcriptBootstrap: null, + transcriptBootstrap: { + throughSequence: null, + overlayMessageCount: 0, + durable: emptyTranscriptPage(sessionId, 'durable'), + overlay: emptyTranscriptPage(sessionId, 'overlay'), + }, snapshot: { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { @@ -73,6 +168,10 @@ function subscription( lifecycle.push(`${sessionId}:transcript`); return [] as T[]; }, + loadTranscriptOverlay: async (_decodeMessage: (value: unknown) => T) => [] as T[], + decodeTranscriptPage: async () => { + throw new Error('Fake subscription does not expose transcript pages'); + }, loadTranscriptPage: async () => { throw new Error('Fake subscription does not expose transcript pages'); }, @@ -82,3 +181,16 @@ function subscription( [Symbol.asyncIterator]: async function* (): AsyncIterator {}, }; } + +function emptyTranscriptPage(sessionId: string, source: 'durable' | 'overlay') { + return { + kind: 'page' as const, + sessionId, + source, + direction: 'older' as const, + throughSequence: null, + rawBytes: 0, + fragments: [], + nextCursor: null, + }; +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index 7f7827b51c..fcd5cf84b4 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -781,6 +781,16 @@ function connectionHarness( activeSubscriptionFrames = subscriptionFrames; const closeSubscription = () => subscriptionFrames.end(); closeSubscriptions.add(closeSubscription); + const emptyPage = { + kind: 'page' as const, + sessionId, + source: 'durable' as const, + direction: 'older' as const, + throughSequence: null, + rawBytes: 0, + fragments: [], + nextCursor: null, + }; return { hostEpoch: `host-${label}`, subscriptionId: `subscription-${label}`, @@ -788,7 +798,17 @@ function connectionHarness( projectionRevision: 1, session: { sessionId }, }, + activeAssistantStreams: [], + transcriptBootstrap: { + throughSequence: null, + overlayMessageCount: 0, + durable: emptyPage, + overlay: { ...emptyPage, source: 'overlay' }, + }, loadTranscript: async () => [], + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async () => ({ messages: [], nextCursor: null }), + loadTranscriptPage: async () => emptyPage, [Symbol.asyncIterator]: () => subscriptionFrames[Symbol.asyncIterator](), close: async () => closeSubscription(), }; 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 7b7e745d70..407a9fb857 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 @@ -15,62 +15,7 @@ import { type RuntimeHostSessionExecutionIpcDeps, } from "../runtime-host-session-execution-ipc-main.js"; import { RuntimeHostSessionObserver } from "../runtime-host-session-observer.js"; - -test("advances the Host read marker through the last visible message", async () => { - const readMarkers: unknown[] = []; - const observer = observerWithTranscript([ - { - type: "user", - id: "user-1", - turnId: "turn-1", - ts: 1, - text: "Hello", - }, - { - type: "assistant", - id: "assistant-1", - turnId: "turn-1", - ts: 2, - text: "Hi", - modelId: "test-model", - }, - { - type: "system_note", - id: "internal-tail", - turnId: "turn-1", - ts: 3, - kind: "session_resume", - }, - ]); - const ipc = ipcHarness(); - registerExecutionIpc( - { - client: executionClient({ - setSessionReadMarker: async (sessionId, readThroughMessageId) => { - readMarkers.push({ sessionId, readThroughMessageId }); - return session(); - }, - }), - observer, - attachmentApprovals: createAttachmentApprovalRegistry(), - emitSessionsChanged() {}, - stat: async () => ({ size: 0 }), - resizeImage: async (bytes) => bytes, - beforeStop() {}, - }, - ipc, - ); - - assert.equal( - ((await ipc.invoke("sessions:readMessages", "session-1")) as unknown[]) - .length, - 3, - ); - assert.deepEqual(readMarkers, [ - { sessionId: "session-1", readThroughMessageId: "assistant-1" }, - ]); - await observer.close(); -}); +import { runtimeHostSessionFixture } from "./runtime-host-session-test-fixture.js"; test("keeps synthetic E2E interactions visible through Host hydration and retires their answer", async () => { const observer = observerWithSnapshot(); @@ -158,7 +103,11 @@ test("retries committed Branch and Revision copies with the renderer-owned ident }); let copy = committed.get(input.targetSessionId); if (!copy) { - copy = { ...session(), id: input.targetSessionId, name: input.targetSessionId }; + copy = { + ...session(), + id: input.targetSessionId, + name: input.targetSessionId, + }; committed.set(input.targetSessionId, copy); } if (lostResponses.delete(input.targetSessionId)) { @@ -630,6 +579,8 @@ function executionClient(overrides: Partial): ExecutionClient { getSession: unavailable, ingestAttachment: unavailable, interruptTurn: unavailable, + listSessionTurnLandmarks: unavailable, + listSessionTurns: unavailable, queryTurnResume: unavailable, readExecutionBoundary: unavailable, regenerateTurn: unavailable, @@ -667,7 +618,7 @@ function observerWithTranscript( }); return new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: { schemaVersion: 3, session: { @@ -714,7 +665,7 @@ type IpcHandler = Parameters["handle"]>[1]; function ipcHarness() { const handlers = new Map(); - const sender = Object.assign(new EventEmitter(), { id: 9 }); + const sender = Object.assign(new EventEmitter(), { id: 9, send() {} }); return { handle(channel: string, handler: IpcHandler) { assert.equal( 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 d6fc4fcfdb..3c9e8776a5 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 @@ -9,18 +9,26 @@ import type { } from "@maka/runtime-host/protocol"; import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; import type { DesktopRuntimeHostSession } from "../runtime-host-client.js"; +import { + DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + type DesktopTranscriptBatch, +} from '../../preload/transcript-contract.js'; import { RuntimeHostSessionObservationRegistry } from "../runtime-host-session-observation-registry.js"; import { RuntimeHostSessionObserver, + type RuntimeHostRendererTarget, type RuntimeHostSessionObserverTarget, + type RuntimeHostTranscriptTarget, } from "../runtime-host-session-observer.js"; +import { RuntimeHostSessionSubscriptionOwner } from '../runtime-host-session-subscription-owner.js'; +import { runtimeHostSessionFixture } from "./runtime-host-session-test-fixture.js"; test("joins an active Turn without losing or replaying assistant text", async () => { const transcript = deferred(); const events = new AsyncFrameQueue(); const finishedTurns: Array<[string, "completed" | "abandoned"]> = []; let closeCount = 0; - const handle: DesktopRuntimeHostSession = { + const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [activeText('message-1')], transcript: transcript.promise, @@ -29,7 +37,7 @@ test("joins an active Turn without losing or replaying assistant text", async () closeCount += 1; events.end(); }, - }; + }); const observer = new RuntimeHostSessionObserver({ client: { openSession: async () => handle }, emitSessionsChanged() {}, @@ -112,7 +120,7 @@ test("restores renderer observation after the Host connection is replaced", asyn const secondEvents = new AsyncFrameQueue(); const firstObserver = new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -126,7 +134,7 @@ test("restores renderer observation after the Host connection is replaced", asyn }); const secondObserver = new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [activeText('message-1')], transcript: Promise.resolve([ @@ -226,7 +234,7 @@ test("rebinds a restored renderer observation to the current target scope", asyn }, async unobserve() {}, }; - const bind = (targetEpoch: string) => (target: RuntimeHostSessionObserverTarget) => ({ + const bind = (targetEpoch: string) => (target: RuntimeHostRendererTarget) => ({ ...target, send: (channel: string, payload: unknown) => (target.send as (channel: string, ...args: unknown[]) => void)( @@ -361,6 +369,640 @@ test("keeps an active observation across a failed Host replacement", async () => await observations.close(); }); +test('restores transcript consumers across Host replacement', async () => { + const observations = new RuntimeHostSessionObservationRegistry(); + const batches: DesktopTranscriptBatch[] = []; + const scopes: string[] = []; + const target: RuntimeHostTranscriptTarget = { + id: 18, + send(_channel, ...args: unknown[]) { + const [scope, batch] = args as [{ targetEpoch: string }, DesktopTranscriptBatch]; + scopes.push(scope.targetEpoch); + batches.push(batch); + }, + once() {}, + off() {}, + }; + const bind = (targetEpoch: string) => (target: RuntimeHostRendererTarget) => ({ + ...target, + send: (channel: string, payload: Payload) => + (target.send as (channel: string, ...args: unknown[]) => void)( + channel, + { targetEpoch }, + payload, + ), + }); + const opens: string[] = []; + const source = (generation: string) => ({ + async observe() {}, + async unobserve() {}, + async openTranscript( + sessionId: string, + consumerId: string, + consumer: RuntimeHostTranscriptTarget, + ) { + opens.push(`${generation}:${sessionId}:${consumerId}`); + consumer.send(`sessions:transcript:${consumerId}`, { + deliverySequence: 1, + sessionId, + generation, + hostEpoch: `host-${generation}`, + durableThrough: null, + fragments: [], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: false, + hasNewer: false, + reset: true, + ready: true, + }); + return { + sessionId, + generation, + hostEpoch: `host-${generation}`, + readThroughMessageId: null, + }; + }, + async loadTranscriptBefore() {}, + async loadTranscriptAround() {}, + async closeTranscript() {}, + }); + const first = source('first'); + await observations.attach(first, bind('first')); + await observations.openTranscript('session-1', 'consumer-1', target); + observations.detach(first); + assert.doesNotThrow(() => observations.acknowledgeTranscript('consumer-1', 'first', 1, 18)); + const second = source('second'); + await observations.attach(second, bind('second')); + observations.detach(second); + await observations.closeTranscript('consumer-1'); + const pending = observations.openTranscript('session-1', 'consumer-2', target); + let pendingSettled = false; + void pending.finally(() => { + pendingSettled = true; + }); + await Promise.resolve(); + assert.equal(pendingSettled, false); + await observations.attach(source('third'), bind('third')); + assert.equal((await pending).generation, 'third'); + + assert.deepEqual(opens, [ + 'first:session-1:consumer-1', + 'second:session-1:consumer-1', + 'third:session-1:consumer-2', + ]); + assert.deepEqual( + batches.map((batch) => batch.generation), + ['first', 'second', 'third'], + ); + assert.deepEqual(scopes, ['first', 'second', 'third']); + await observations.close(); +}); + +test('cancels a transcript consumer while its replica is still preparing', async () => { + const transcript = deferred(); + const events = new AsyncFrameQueue(); + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: transcript.promise, + events, + async close() { + events.end(); + }, + }), + }, + emitSessionsChanged() {}, + }); + const opening = observer.openTranscript('session-1', 'consumer-pending', { + id: 20, + send() {}, + once() {}, + off() {}, + }); + await Promise.resolve(); + + await observer.closeTranscript('consumer-pending', 20); + await assert.rejects(() => opening, /cancelled/); + transcript.resolve([]); + await observer.close(); +}); + +test('broadcasts transcript changes to every consumer and advances the read marker', async () => { + const events = new AsyncFrameQueue(); + const markers: string[] = []; + const message: StoredMessage = { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 2, + text: 'Hi', + modelId: 'test-model', + }; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + loadTranscriptPage: async () => ({ + kind: 'page', + sessionId: 'session-1', + source: 'durable', + direction: 'newer', + throughSequence: 0, + rawBytes: 1, + fragments: [], + nextCursor: null, + }), + decodeTranscriptPage: async () => ({ + messages: [{ identity: 0, message }], + nextCursor: null, + }), + async close() { + events.end(); + }, + }), + setSessionReadMarker: async (_sessionId, messageId) => { + markers.push(messageId); + return undefined as never; + }, + }, + emitSessionsChanged() {}, + }); + const transcriptBatches: DesktopTranscriptBatch[][] = [[], []]; + for (const [index, batches] of transcriptBatches.entries()) { + const consumerId = `consumer-${index}`; + await observer.openTranscript('session-1', consumerId, { + id: 19 + index, + send(_channel, batch) { + batches.push(batch); + queueMicrotask(() => + observer.acknowledgeTranscript( + consumerId, + batch.generation, + batch.deliverySequence!, + 19 + index, + ), + ); + }, + once() {}, + off() {}, + }); + batches.splice(0); + } + markers.splice(0); + events.push({ + kind: 'subscription.transcript_advanced', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sessionId: 'session-1', + sequence: 1, + throughSequence: 0, + }); + await waitFor(() => + markers.length === 1 && transcriptBatches.every((batches) => batches.length > 0), + ); + + assert.deepEqual(markers, ['assistant-1']); + assert.deepEqual(transcriptBatches[1], transcriptBatches[0]); + await observer.close(); +}); + +test('keeps a bounded transcript batch window in flight until the renderer acknowledges it', async () => { + const events = new AsyncFrameQueue(); + const message: StoredMessage = { + type: 'assistant', + id: 'assistant-large', + turnId: 'turn-1', + ts: 2, + text: 'x'.repeat(DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES * 6), + modelId: 'test-model', + }; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([message]), + events, + async close() { + events.end(); + }, + }), + }, + emitSessionsChanged() {}, + }); + const batches: DesktopTranscriptBatch[] = []; + const opening = observer.openTranscript('session-1', 'consumer-ack', { + id: 21, + send(_channel, batch) { + batches.push(batch); + }, + once() {}, + off() {}, + }); + + await waitFor(() => batches.length === 4); + assert.equal(batches.some((batch) => batch.ready), false); + const second = batches[1]!; + observer.acknowledgeTranscript( + 'consumer-ack', + second.generation, + second.deliverySequence, + 21, + ); + await waitFor(() => batches.length === 5); + + const acknowledged = new Set([second.deliverySequence]); + for (let attempt = 0; !batches.some((batch) => batch.ready) && attempt < 100; attempt += 1) { + for (const batch of batches) { + if (acknowledged.has(batch.deliverySequence)) continue; + acknowledged.add(batch.deliverySequence); + observer.acknowledgeTranscript( + 'consumer-ack', + batch.generation, + batch.deliverySequence, + 21, + ); + } + await new Promise((resolve) => setImmediate(resolve)); + } + assert.ok(batches.some((batch) => batch.ready)); + for (const batch of batches) { + if (acknowledged.has(batch.deliverySequence)) continue; + observer.acknowledgeTranscript( + 'consumer-ack', + batch.generation, + batch.deliverySequence, + 21, + ); + } + await opening; + await observer.close(); +}); + +test('finishes transcript open against a replacement that arrives while reset delivery waits', async () => { + const firstEvents = new AsyncFrameQueue(); + const secondEvents = new AsyncFrameQueue(); + const message: StoredMessage = { + type: 'assistant', + id: 'assistant-large', + turnId: 'turn-1', + ts: 2, + text: 'x'.repeat(DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES * 6), + modelId: 'test-model', + }; + let opens = 0; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => { + opens += 1; + const events = opens === 1 ? firstEvents : secondEvents; + return runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([message]), + events, + async close() { + events.end(); + }, + }); + }, + }, + emitSessionsChanged() {}, + }); + const batches: DesktopTranscriptBatch[] = []; + const opening = observer.openTranscript('session-1', 'consumer-recovery', { + id: 22, + send(_channel, batch) { + batches.push(batch); + }, + once() {}, + off() {}, + }); + const result = opening.then( + (value) => ({ value, error: undefined }), + (error: unknown) => ({ value: undefined, error }), + ); + + await waitFor(() => batches.length === 4); + firstEvents.push({ + kind: 'subscription.closed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-session-1', + sequence: 1, + reason: 'slow_consumer', + }); + await waitFor(() => opens === 2); + + const acknowledged = new Set(); + for (let attempt = 0; attempt < 100; attempt += 1) { + for (const batch of batches) { + const key = `${batch.generation}:${batch.deliverySequence}`; + if (acknowledged.has(key)) continue; + acknowledged.add(key); + observer.acknowledgeTranscript( + 'consumer-recovery', + batch.generation, + batch.deliverySequence, + 22, + ); + } + const settled = await Promise.race([ + result.then(() => true), + new Promise((resolve) => setImmediate(() => resolve(false))), + ]); + if (settled) break; + } + + const opened = await result; + assert.equal(opened.error, undefined); + assert.equal(opened.value?.generation, batches.at(-1)?.generation); + await observer.close(); +}); + +test('coalesces transcript changes into one bounded delta while renderer delivery is backpressured', async () => { + const events = new AsyncFrameQueue(); + let decoded = 0; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + loadTranscriptPage: async (input) => ({ + kind: 'page', + sessionId: 'session-1', + source: 'durable', + direction: 'newer', + throughSequence: input.throughSequence, + rawBytes: 1, + fragments: [], + nextCursor: null, + }), + decodeTranscriptPage: async (page) => { + if (page.throughSequence === null) return { messages: [], nextCursor: null }; + decoded += 1; + const identity = page.throughSequence; + return { + messages: [{ + identity, + message: { + type: 'assistant', + id: `a-${identity}`, + turnId: 'turn-1', + ts: identity, + text: String(identity), + modelId: 'test-model', + }, + }], + nextCursor: null, + }; + }, + async close() { + events.end(); + }, + }), + }, + emitSessionsChanged() {}, + }); + const batches: DesktopTranscriptBatch[] = []; + const consumerId = 'consumer-coalesced'; + await observer.openTranscript('session-1', consumerId, { + id: 22, + send(_channel, batch) { + batches.push(batch); + if (batch.reset) { + queueMicrotask(() => + observer.acknowledgeTranscript( + consumerId, + batch.generation, + batch.deliverySequence, + 22, + ), + ); + } + }, + once() {}, + off() {}, + }); + batches.splice(0); + + for (let sequence = 0; sequence < 5; sequence += 1) { + events.push({ + kind: 'subscription.transcript_advanced', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sessionId: 'session-1', + sequence: sequence + 1, + throughSequence: sequence, + }); + await waitFor(() => decoded === sequence + 1); + } + await waitFor(() => batches.length === 1); + assert.equal(batches[0]!.reset, false); + observer.acknowledgeTranscript( + consumerId, + batches[0]!.generation, + batches[0]!.deliverySequence, + 22, + ); + await waitFor(() => batches.length === 2); + assert.equal(batches[1]!.reset, false); + assert.equal(batches[1]!.durableThrough, 4); + assert.equal(batches[1]!.fragments.length, 4); + observer.acknowledgeTranscript( + consumerId, + batches[1]!.generation, + batches[1]!.deliverySequence, + 22, + ); + await observer.close(); +}); + +test('does not let one backpressured transcript consumer block another', async () => { + const events = new AsyncFrameQueue(); + let decoded = 0; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + loadTranscriptPage: async (input) => ({ + kind: 'page', + sessionId: 'session-1', + source: 'durable', + direction: 'newer', + throughSequence: input.throughSequence, + rawBytes: 1, + fragments: [], + nextCursor: null, + }), + decodeTranscriptPage: async (page) => { + if (page.throughSequence === null) return { messages: [], nextCursor: null }; + decoded += 1; + return { + messages: [{ + identity: page.throughSequence, + message: { + type: 'assistant', + id: `a-${page.throughSequence}`, + turnId: 'turn-1', + ts: page.throughSequence, + text: String(page.throughSequence), + modelId: 'test-model', + }, + }], + nextCursor: null, + }; + }, + async close() { + events.end(); + }, + }), + }, + emitSessionsChanged() {}, + }); + const received = new Map([ + ['slow', []], + ['healthy', []], + ]); + let blockSlow = false; + for (const [consumerId, targetId] of [['slow', 23], ['healthy', 24]] as const) { + await observer.openTranscript('session-1', consumerId, { + id: targetId, + send(_channel, batch) { + received.get(consumerId)!.push(batch); + if (consumerId !== 'slow' || !blockSlow) { + queueMicrotask(() => + observer.acknowledgeTranscript( + consumerId, + batch.generation, + batch.deliverySequence, + targetId, + ), + ); + } + }, + once() {}, + off() {}, + }); + received.get(consumerId)!.splice(0); + } + blockSlow = true; + const slow = received.get('slow')!; + const healthy = received.get('healthy')!; + + events.push({ + kind: 'subscription.transcript_advanced', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sessionId: 'session-1', + sequence: 1, + throughSequence: 0, + }); + await waitFor(() => decoded === 1); + await waitFor(() => slow.length === 1 && healthy.length >= 2); + const healthyBeforeSecondAdvance = healthy.length; + events.push({ + kind: 'subscription.transcript_advanced', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sessionId: 'session-1', + sequence: 2, + throughSequence: 1, + }); + await waitFor(() => decoded === 2 && healthy.length > healthyBeforeSecondAdvance); + assert.equal(slow.length, 1); + observer.acknowledgeTranscript( + 'slow', + slow[0]!.generation, + slow[0]!.deliverySequence, + 23, + ); + await observer.close(); +}); + +test('releases an idle session when transcript delivery fails', async () => { + const events = new AsyncFrameQueue(); + let closeCount = 0; + const observer = new RuntimeHostSessionObserver({ + client: { + openSession: async () => + runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + loadTranscriptPage: async (input) => ({ + kind: 'page', + sessionId: 'session-1', + source: 'durable', + direction: 'newer', + throughSequence: input.throughSequence, + rawBytes: 1, + fragments: [], + nextCursor: null, + }), + decodeTranscriptPage: async (page) => ({ + messages: page.throughSequence === null ? [] : [{ + identity: page.throughSequence, + message: { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 1, + text: 'done', + modelId: 'test-model', + }, + }], + nextCursor: null, + }), + async close() { + closeCount += 1; + events.end(); + }, + }), + }, + emitSessionsChanged() {}, + }); + let opened = false; + const consumerId = 'consumer-failing'; + await observer.openTranscript('session-1', consumerId, { + id: 25, + send(_channel, batch) { + if (opened) throw new Error('renderer unavailable'); + queueMicrotask(() => + observer.acknowledgeTranscript( + consumerId, + batch.generation, + batch.deliverySequence, + 25, + ), + ); + }, + once() {}, + off() {}, + }); + opened = true; + events.push({ + kind: 'subscription.transcript_advanced', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sessionId: 'session-1', + sequence: 1, + throughSequence: 0, + }); + + await waitFor(() => closeCount === 1); + await observer.close(); +}); + test("ignores a stale seed failure after its replacement succeeds", async () => { const observations = new RuntimeHostSessionObservationRegistry(); let rejectStale!: (error: Error) => void; @@ -414,7 +1056,7 @@ test("does not publish a terminal error while an owner-managed connection is rep }; const observer = new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -442,7 +1084,7 @@ test("keeps a native Turn watched without a renderer and releases it at terminal let closeCount = 0; const observer = new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -487,7 +1129,7 @@ test("does not let an older terminal projection finish a newer watched Turn", as const finishedTurns: Array<[string, "completed" | "abandoned"]> = []; const observer = new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: transcript.promise, @@ -568,7 +1210,7 @@ test("invalidates the transcript when another client starts a Turn", async () => }> = []; const observer = new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot({ rootTurn: { sessionId: "session-1", @@ -643,7 +1285,7 @@ test("abandons a watched Turn when the Session is removed", async () => { let closeCount = 0; const observer = new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -684,7 +1326,7 @@ test("reopens an evicted active subscription without a renderer resubscribe", as openSession: async () => { openCount += 1; const events = openCount === 1 ? firstEvents : secondEvents; - return { + return runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: openCount === 1 ? [] : [activeText('message-1')], @@ -706,7 +1348,7 @@ test("reopens an evicted active subscription without a renderer resubscribe", as async close() { events.end(); }, - }; + }); }, }, emitSessionsChanged: (reason, sessionId) => @@ -752,6 +1394,150 @@ test("reopens an evicted active subscription without a renderer resubscribe", as await observer.close(); }); +test('does not activate a refresh candidate that fails during commit preparation', async () => { + const firstEvents = new AsyncFrameQueue(); + const secondEvents = new AsyncFrameQueue(); + const activation = deferred<() => void>(); + const accepted: SubscriptionFrame[] = []; + let opens = 0; + let preparations = 0; + let activated = false; + let firstCloses = 0; + const owner = new RuntimeHostSessionSubscriptionOwner({ + client: { + openSession: async () => { + opens += 1; + const first = opens === 1; + return runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: first ? firstEvents : secondEvents, + async close() { + if (first) firstCloses += 1; + (first ? firstEvents : secondEvents).end(); + }, + }); + }, + }, + sessionId: 'session-1', + now: () => 0, + async prepareActivation() { + preparations += 1; + if (preparations === 1) return () => undefined; + return activation.promise; + }, + acceptFrame: (frame) => { + accepted.push(frame); + }, + recoveryStarted() {}, + recoveryCompleted() {}, + recoveryFailed() {}, + terminalFailure(error) { + throw error; + }, + }); + owner.start(); + await owner.waitUntilReady(); + + const refresh = owner.refresh(); + await waitFor(() => preparations === 2); + secondEvents.push({ + kind: 'subscription.closed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-2', + sequence: 1, + reason: 'slow_consumer', + }); + await assert.rejects(refresh, /slow consumer/); + activation.resolve(() => { + activated = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(activated, false); + assert.equal(firstCloses, 0); + firstEvents.push(deltaFrame(1, 0, 'still live')); + await waitFor(() => accepted.length === 1); + await owner.close(); +}); + +test('lets an active recovery supersede a concurrent cold refresh', async () => { + const firstEvents = new AsyncFrameQueue(); + const candidateEvents = new AsyncFrameQueue(); + const recoveredEvents = new AsyncFrameQueue(); + const candidateTranscript = deferred(); + const accepted: SubscriptionFrame[] = []; + let preparationBytes = 0; + let sawCandidateBytes = false; + let opens = 0; + const owner = new RuntimeHostSessionSubscriptionOwner({ + client: { + openSession: async () => { + opens += 1; + const events = + opens === 1 ? firstEvents : opens === 2 ? candidateEvents : recoveredEvents; + return runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: opens === 2 ? candidateTranscript.promise : Promise.resolve([]), + events, + async close() { + events.end(); + }, + }); + }, + }, + sessionId: 'session-1', + now: () => 0, + transcriptReplicaOptions: { + accountPreparationBytes(deltaBytes) { + preparationBytes += deltaBytes; + if (preparationBytes > 0) sawCandidateBytes = true; + }, + }, + async prepareActivation() { + return () => undefined; + }, + acceptFrame: (frame) => { + accepted.push(frame); + }, + recoveryStarted() {}, + recoveryCompleted() {}, + recoveryFailed() {}, + terminalFailure(error) { + throw error; + }, + }); + owner.start(); + await owner.waitUntilReady(); + + const refresh = owner.refresh(); + await waitFor(() => opens === 2); + firstEvents.push({ + kind: 'subscription.closed', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + reason: 'slow_consumer', + }); + await refresh; + + assert.equal(opens, 3); + candidateTranscript.resolve([ + { + type: 'assistant', + id: 'candidate-message', + turnId: 'candidate-turn', + ts: 1, + text: 'late candidate', + modelId: 'test-model', + }, + ]); + await waitFor(() => sawCandidateBytes && preparationBytes === 0); + recoveredEvents.push(deltaFrame(1, 0, 'recovered')); + await waitFor(() => accepted.length === 1); + await owner.close(); +}); + test("retries an initial subscription closed before commit and resyncs once", async () => { const firstEvents = new AsyncFrameQueue(); const secondEvents = new AsyncFrameQueue(); @@ -764,7 +1550,7 @@ test("retries an initial subscription closed before commit and resyncs once", as openSession: async () => { openCount += 1; const first = openCount === 1; - return { + return runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: first ? firstTranscript.promise : secondTranscript.promise, @@ -772,7 +1558,7 @@ test("retries an initial subscription closed before commit and resyncs once", as async close() { (first ? firstEvents : secondEvents).end(); }, - }; + }); }, }, emitSessionsChanged() {}, @@ -790,20 +1576,14 @@ test("retries an initial subscription closed before commit and resyncs once", as observingSettled = true; }, ); - await waitFor(() => firstEvents.nextCount > 0); - - firstTranscript.resolve([]); - queueMicrotask(() => { - queueMicrotask(() => { - firstEvents.push({ - kind: "subscription.closed", - hostEpoch: "host-1", - subscriptionId: "subscription-1", - sequence: 1, - reason: "slow_consumer", - }); - }); + firstEvents.push({ + kind: "subscription.closed", + hostEpoch: "host-1", + subscriptionId: "subscription-1", + sequence: 1, + reason: "slow_consumer", }); + firstTranscript.resolve([]); await waitFor(() => openCount === 2); assert.equal(observingSettled, false); assert.deepEqual(recoveredSessions, []); @@ -826,7 +1606,7 @@ test("finishes a watched predecessor after initial catch-up recovery", async () openSession: async () => { openCount += 1; if (openCount === 1) { - return { + return runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: firstTranscript.promise, @@ -834,9 +1614,9 @@ test("finishes a watched predecessor after initial catch-up recovery", async () async close() { firstEvents.end(); }, - }; + }); } - return { + return runtimeHostSessionFixture({ snapshot: continuitySnapshot({ projectionRevision: 2, rootTurn: { @@ -862,7 +1642,7 @@ test("finishes a watched predecessor after initial catch-up recovery", async () replacementCloseCount += 1; secondEvents.end(); }, - }; + }); }, }, emitSessionsChanged() {}, @@ -871,8 +1651,6 @@ test("finishes a watched predecessor after initial catch-up recovery", async () }, }); const watching = observer.watchTurn("session-1", "turn-1"); - await waitFor(() => firstEvents.nextCount > 0); - firstEvents.push({ kind: "subscription.closed", hostEpoch: "host-1", @@ -880,6 +1658,7 @@ test("finishes a watched predecessor after initial catch-up recovery", async () sequence: 1, reason: "slow_consumer", }); + firstTranscript.resolve([]); await watching; await waitFor(() => replacementCloseCount === 1); @@ -891,6 +1670,7 @@ test("keeps a joining observer pending across repeated catch-up eviction", async const firstEvents = new AsyncFrameQueue(); const replacementEvents = new AsyncFrameQueue(); const finalEvents = new AsyncFrameQueue(); + const replacementTranscript = deferred(); const finalTranscript = deferred(); let openCount = 0; const observer = new RuntimeHostSessionObserver({ @@ -898,7 +1678,7 @@ test("keeps a joining observer pending across repeated catch-up eviction", async openSession: async () => { openCount += 1; if (openCount === 1) { - return { + return runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -906,20 +1686,20 @@ test("keeps a joining observer pending across repeated catch-up eviction", async async close() { firstEvents.end(); }, - }; + }); } if (openCount === 2) { - return { + return runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], - transcript: deferred().promise, + transcript: replacementTranscript.promise, events: replacementEvents, async close() { replacementEvents.end(); }, - }; + }); } - return { + return runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [activeText('message-1')], transcript: finalTranscript.promise, @@ -927,7 +1707,7 @@ test("keeps a joining observer pending across repeated catch-up eviction", async async close() { finalEvents.end(); }, - }; + }); }, }, emitSessionsChanged() {}, @@ -943,7 +1723,7 @@ test("keeps a joining observer pending across repeated catch-up eviction", async sequence: 1, reason: "slow_consumer", }); - await waitFor(() => openCount === 2 && replacementEvents.nextCount > 0); + await waitFor(() => openCount === 2); const joining = observer.observe( "session-1", @@ -966,6 +1746,7 @@ test("keeps a joining observer pending across repeated catch-up eviction", async sequence: 1, reason: "slow_consumer", }); + replacementTranscript.resolve([]); await waitFor(() => openCount === 3); await Promise.resolve(); assert.equal(joiningSettled, false); @@ -1004,10 +1785,16 @@ test("reconciles terminal, Goal, interaction, and sidecar state after subscripti let openCount = 0; const observer = new RuntimeHostSessionObserver({ client: { + listSessionTurns: async () => [{ + turnId: 'turn-1', + status: 'completed' as const, + statusSource: 'recorded' as const, + partialOutputRetained: true, + }], openSession: async () => { openCount += 1; if (openCount === 1) { - return { + return runtimeHostSessionFixture({ snapshot: continuitySnapshot({ interactions: { pending: [firstInteraction] }, }), @@ -1017,9 +1804,9 @@ test("reconciles terminal, Goal, interaction, and sidecar state after subscripti async close() { firstEvents.end(); }, - }; + }); } - return { + return runtimeHostSessionFixture({ snapshot: continuitySnapshot({ projectionRevision: 3, goal: activeGoal(), @@ -1033,22 +1820,6 @@ test("reconciles terminal, Goal, interaction, and sidecar state after subscripti }), activeAssistantStreams: [activeText('message-2', 'turn-2')], transcript: Promise.resolve([ - { - type: "assistant" as const, - id: "message-1", - turnId: "turn-1", - ts: 10, - text: "First answer", - modelId: "test-model", - }, - { - type: "turn_state" as const, - id: "terminal-1", - turnId: "turn-1", - ts: 20, - status: "completed" as const, - partialOutputRetained: true, - }, { type: "assistant" as const, id: "message-2", @@ -1062,7 +1833,7 @@ test("reconciles terminal, Goal, interaction, and sidecar state after subscripti async close() { secondEvents.end(); }, - }; + }); }, }, emitSessionsChanged(reason) { @@ -1129,7 +1900,7 @@ test("shares one Host subscription and one delivery per renderer target", async client: { openSession: async () => { openCount += 1; - return { + return runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -1138,7 +1909,7 @@ test("shares one Host subscription and one delivery per renderer target", async closeCount += 1; events.end(); }, - }; + }); }, }, emitSessionsChanged() {}, @@ -1165,7 +1936,7 @@ test("releases the renderer destroyed listener when its last observer leaves", a const destroyed = new EventEmitter(); const observer = new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -1200,7 +1971,7 @@ test("closes a Host handle that arrives after the observer is closed", async () const observing = observer.observe("session-1", "observer-1", eventTarget(8)); await observer.close(); - opened.resolve({ + opened.resolve(runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -1208,61 +1979,12 @@ test("closes a Host handle that arrives after the observer is closed", async () async close() { closeCount += 1; }, - }); + })); await assert.rejects(observing, /closed while opening/); assert.equal(closeCount, 1); }); -test("drains live frames while refreshing the canonical transcript", async () => { - const initialEvents = new AsyncFrameQueue(); - const refreshEvents = new AsyncFrameQueue(); - const refreshTranscript = deferred(); - let openCount = 0; - const observer = new RuntimeHostSessionObserver({ - client: { - openSession: async () => { - openCount += 1; - if (openCount === 1) { - return { - snapshot: continuitySnapshot(), - activeAssistantStreams: [], - transcript: Promise.resolve([]), - events: initialEvents, - async close() { - initialEvents.end(); - }, - }; - } - return { - snapshot: continuitySnapshot(), - activeAssistantStreams: [], - transcript: refreshTranscript.promise, - events: refreshEvents, - async close() { - refreshEvents.end(); - }, - }; - }, - }, - emitSessionsChanged() {}, - }); - await observer.observe("session-1", "observer-1", eventTarget(9)); - await observer.readMessages("session-1"); - - const refreshing = observer.readMessages("session-1"); - const concurrentRefresh = observer.readMessages("session-1"); - await waitFor(() => refreshEvents.nextCount > 0); - refreshTranscript.resolve([]); - - assert.deepEqual(await Promise.all([refreshing, concurrentRefresh]), [ - [], - [], - ]); - assert.equal(openCount, 2); - await observer.close(); -}); - test("rehydrates pending interactions and publishes answer acknowledgements", async () => { const pending = { schemaVersion: 1 as const, @@ -1287,8 +2009,10 @@ test("rehydrates pending interactions and publishes answer acknowledgements", as const events = new AsyncFrameQueue(); const observer = new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ - snapshot: continuitySnapshot({ interactions: { pending: [pending] } }), + openSession: async () => runtimeHostSessionFixture({ + snapshot: continuitySnapshot({ + interactions: { pending: [pending] }, + }), activeAssistantStreams: [], transcript: Promise.resolve([]), events, @@ -1325,7 +2049,7 @@ test("projects Host queue revisions and newly delivered steering messages", asyn const events = new AsyncFrameQueue(); const observer = new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: Promise.resolve([]), @@ -1404,7 +2128,7 @@ test("publishes Host sidecar and graph invalidations without inventing Session s const graphChanges: unknown[] = []; const observer = new RuntimeHostSessionObserver({ client: { - openSession: async () => ({ + openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], transcript: Promise.resolve([]), diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts new file mode 100644 index 0000000000..32a562dd3b --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts @@ -0,0 +1,59 @@ +import type { StoredMessage } from '@maka/core/session'; +import type { DecodedSessionTranscriptPage } from '@maka/runtime-host/client'; +import type { + SessionAssistantStreamIdentity, + SessionContinuitySnapshot, + SessionTranscriptPage, + SubscriptionFrame, +} from '@maka/runtime-host/protocol'; +import type { DesktopRuntimeHostSession } from '../runtime-host-client.js'; + +export function runtimeHostSessionFixture(input: { + readonly snapshot: SessionContinuitySnapshot; + readonly activeAssistantStreams?: readonly SessionAssistantStreamIdentity[]; + readonly transcript: Promise; + readonly events: AsyncIterable; + readonly transcriptBootstrap?: DesktopRuntimeHostSession['transcriptBootstrap']; + loadTranscriptOverlay?: DesktopRuntimeHostSession['loadTranscriptOverlay']; + decodeTranscriptPage?: DesktopRuntimeHostSession['decodeTranscriptPage']; + loadTranscriptPage?: DesktopRuntimeHostSession['loadTranscriptPage']; + close(): Promise; +}): DesktopRuntimeHostSession { + const sessionId = input.snapshot.session.sessionId; + return { + hostEpoch: 'host-1', + subscriptionId: `subscription-${sessionId}`, + snapshot: input.snapshot, + activeAssistantStreams: input.activeAssistantStreams ?? [], + transcriptBootstrap: input.transcriptBootstrap ?? { + throughSequence: null, + overlayMessageCount: 0, + durable: emptyPage(sessionId, 'durable'), + overlay: emptyPage(sessionId, 'overlay'), + }, + events: input.events, + loadTranscript: () => input.transcript, + loadTranscriptOverlay: input.loadTranscriptOverlay ?? (() => input.transcript), + decodeTranscriptPage: input.decodeTranscriptPage ?? + (async (): Promise> => ({ + messages: [], + nextCursor: null, + })), + loadTranscriptPage: input.loadTranscriptPage ?? + (async () => emptyPage(sessionId, 'durable')), + close: input.close, + }; +} + +function emptyPage(sessionId: string, source: 'durable' | 'overlay'): SessionTranscriptPage { + return { + kind: 'page', + sessionId, + source, + direction: 'older', + throughSequence: null, + rawBytes: 0, + fragments: [], + nextCursor: null, + }; +} diff --git a/apps/desktop/src/main/__tests__/thread-search.test.ts b/apps/desktop/src/main/__tests__/thread-search.test.ts index 66a9824d12..c928debe5f 100644 --- a/apps/desktop/src/main/__tests__/thread-search.test.ts +++ b/apps/desktop/src/main/__tests__/thread-search.test.ts @@ -220,6 +220,7 @@ describe('runThreadSearch', () => { kind: 'thread', sessionId: 's1', turnId: 'turn-user', + sequence: 0, }); assert.equal(messageHit.summary, '用户消息'); assert.equal(messageHit.url, undefined); diff --git a/apps/desktop/src/main/desktop-transcript-ipc.ts b/apps/desktop/src/main/desktop-transcript-ipc.ts new file mode 100644 index 0000000000..2c2e83b0a1 --- /dev/null +++ b/apps/desktop/src/main/desktop-transcript-ipc.ts @@ -0,0 +1,155 @@ +import type { StoredMessage } from '@maka/core/session'; +import { + DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + type DesktopTranscriptBatchPayload, + type DesktopTranscriptFragment, +} from '../preload/transcript-contract.js'; +import type { + DesktopSequencedTranscriptMessage, + DesktopTranscriptReplicaChange, + DesktopTranscriptReplicaSnapshot, +} from './desktop-transcript-replica.js'; + +interface TranscriptBatchIdentity { + readonly sessionId: string; + readonly generation: string; + readonly hostEpoch: string; +} + +interface TranscriptBatchContent { + readonly durableThrough: number | null; + readonly durable: readonly DesktopSequencedTranscriptMessage[]; + readonly overlay: readonly StoredMessage[]; + readonly evictedDurableSequences: readonly number[]; + readonly completedOverlayMessageIds: readonly string[]; + readonly hasOlder: boolean; + readonly hasNewer: boolean; + readonly reset: boolean; +} + +export function encodeDesktopTranscriptSnapshot( + snapshot: DesktopTranscriptReplicaSnapshot, +): Iterable { + return encodeDesktopTranscriptBatches(snapshot, { + durableThrough: snapshot.durableThrough, + durable: snapshot.durable, + overlay: snapshot.overlay, + evictedDurableSequences: [], + completedOverlayMessageIds: [], + hasOlder: snapshot.hasOlder, + hasNewer: snapshot.hasNewer, + reset: true, + }); +} + +export function encodeDesktopTranscriptChange( + identity: TranscriptBatchIdentity, + change: DesktopTranscriptReplicaChange, +): Iterable { + return encodeDesktopTranscriptBatches(identity, { + durableThrough: change.durableThrough, + durable: change.durableUpserts, + overlay: [], + evictedDurableSequences: change.evictedDurableSequences, + completedOverlayMessageIds: change.completedOverlayMessageIds, + hasOlder: change.hasOlder, + hasNewer: change.hasNewer, + reset: false, + }); +} + +function* encodeDesktopTranscriptBatches( + identity: TranscriptBatchIdentity, + content: TranscriptBatchContent, +): Iterable { + const fragments = encodeMessages(content); + let fragment = fragments.next(); + let evictedIndex = 0; + let completedIndex = 0; + let first = true; + while ( + !fragment.done || + evictedIndex < content.evictedDurableSequences.length || + completedIndex < content.completedOverlayMessageIds.length || + first + ) { + const batchFragments: DesktopTranscriptFragment[] = []; + let rawBytes = 0; + while (!fragment.done) { + const bytes = fragment.value.data.byteLength; + if (batchFragments.length > 0 && rawBytes + bytes > DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { + break; + } + batchFragments.push(fragment.value); + rawBytes += bytes; + fragment = fragments.next(); + } + const evictedDurableSequences = content.evictedDurableSequences.slice( + evictedIndex, + evictedIndex + 256, + ); + evictedIndex += evictedDurableSequences.length; + const completedOverlayMessageIds: string[] = []; + let identityBytes = 0; + while (completedIndex < content.completedOverlayMessageIds.length) { + const messageId = content.completedOverlayMessageIds[completedIndex]!; + const bytes = Buffer.byteLength(messageId, 'utf8'); + if ( + completedOverlayMessageIds.length >= 256 || + (completedOverlayMessageIds.length > 0 && + identityBytes + bytes > DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) + ) { + break; + } + completedOverlayMessageIds.push(messageId); + identityBytes += bytes; + completedIndex += 1; + } + const ready = + fragment.done === true && + evictedIndex === content.evictedDurableSequences.length && + completedIndex === content.completedOverlayMessageIds.length; + yield { + ...identity, + durableThrough: content.durableThrough, + fragments: batchFragments, + evictedDurableSequences, + completedOverlayMessageIds, + hasOlder: content.hasOlder, + hasNewer: content.hasNewer, + reset: content.reset && first, + ready, + }; + first = false; + } +} + +function* encodeMessages(content: TranscriptBatchContent): Generator { + for (const entry of content.durable) { + yield* encodeMessage('durable', entry.sequence, null, entry.message); + } + for (const [order, message] of content.overlay.entries()) { + yield* encodeMessage('overlay', message.id, order, message); + } +} + +function* encodeMessage( + source: 'durable' | 'overlay', + identity: number | string, + order: number | null, + message: StoredMessage, +): Generator { + const bytes = Buffer.from(JSON.stringify(message), 'utf8'); + for (let byteOffset = 0; byteOffset < bytes.byteLength; ) { + const end = Math.min(byteOffset + DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, bytes.byteLength); + yield { + source, + identity, + order, + byteOffset, + totalBytes: bytes.byteLength, + data: Uint8Array.from(bytes.subarray(byteOffset, end)), + }; + byteOffset = end; + } +} diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts new file mode 100644 index 0000000000..208148e681 --- /dev/null +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -0,0 +1,592 @@ +import { randomUUID } from 'node:crypto'; +import type { StoredMessage } from '@maka/core/session'; +import { + createRuntimeHostSessionProjectionSeed, + type RuntimeHostSessionProjectionSeed, +} from '@maka/runtime-host/adapter'; +import { RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; +import type { SessionTranscriptPage } from '@maka/runtime-host/protocol'; +import { + DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES, + DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES, +} from '../preload/transcript-contract.js'; +import type { DesktopRuntimeHostSession } from './runtime-host-client.js'; + +export interface DesktopTranscriptReplicaOptions { + readonly generation?: string; + readonly maxMessageBytes?: number; + readonly maxResidentBytes?: number; + readonly maxOverlayBytes?: number; + readonly accountPreparationBytes?: (deltaBytes: number) => void; + readonly onChange?: ( + replica: DesktopTranscriptReplica, + change: DesktopTranscriptReplicaChange, + ) => void; +} + +export interface DesktopSequencedTranscriptMessage { + readonly sequence: number; + readonly message: StoredMessage; +} + +export interface DesktopTranscriptReplicaSnapshot { + readonly sessionId: string; + readonly generation: string; + readonly hostEpoch: string; + readonly durableThrough: number | null; + readonly durable: readonly DesktopSequencedTranscriptMessage[]; + readonly overlay: readonly StoredMessage[]; + readonly hasOlder: boolean; + readonly hasNewer: boolean; +} + +export interface DesktopTranscriptReplicaChange { + readonly durableThrough: number | null; + readonly durableUpserts: readonly DesktopSequencedTranscriptMessage[]; + readonly evictedDurableSequences: readonly number[]; + readonly completedOverlayMessageIds: readonly string[]; + readonly hasOlder: boolean; + readonly hasNewer: boolean; +} + +interface ResidentMessage extends DesktopSequencedTranscriptMessage { + readonly encodedBytes: number; +} + +export class DesktopTranscriptReplica { + readonly sessionId: string; + readonly generation: string; + readonly hostEpoch: string; + readonly #handle: DesktopRuntimeHostSession; + readonly #maxResidentBytes: number; + readonly #maxOverlayBytes: number; + readonly #maxMessageBytes: number; + readonly #accountPreparationBytes: (deltaBytes: number) => void; + readonly #onChange: ( + replica: DesktopTranscriptReplica, + change: DesktopTranscriptReplicaChange, + ) => void; + readonly #durable = new Map(); + readonly #overlay = new Map(); + #residentBytes = 0; + #overlayBytes = 0; + #durableThrough: number | null; + #targetThrough: number | null; + #hasOlder: boolean; + #hasNewer = false; + #resident = true; + #residentExternallyAccounted = true; + #closed = false; + #catchUpTask: Promise | undefined; + #operationTail = Promise.resolve(); + + private constructor( + handle: DesktopRuntimeHostSession, + options: DesktopTranscriptReplicaOptions, + ) { + this.#handle = handle; + this.sessionId = handle.snapshot.session.sessionId; + this.generation = options.generation ?? randomUUID(); + this.hostEpoch = handle.hostEpoch; + this.#maxResidentBytes = + options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES; + this.#maxOverlayBytes = + options.maxOverlayBytes ?? DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES; + this.#maxMessageBytes = options.maxMessageBytes ?? DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES; + this.#accountPreparationBytes = options.accountPreparationBytes ?? (() => undefined); + this.#onChange = options.onChange ?? (() => undefined); + this.#durableThrough = handle.transcriptBootstrap.throughSequence; + this.#targetThrough = this.#durableThrough; + this.#hasOlder = handle.transcriptBootstrap.durable.nextCursor !== null; + } + + static async prepare( + handle: DesktopRuntimeHostSession, + options: DesktopTranscriptReplicaOptions = {}, + ): Promise { + const replica = new DesktopTranscriptReplica(handle, options); + try { + await replica.#withAssembly(async (accountAssemblyBytes) => { + replica.#installOverlay( + await handle.loadTranscriptOverlay(replica.#maxMessageBytes, accountAssemblyBytes), + ); + }); + await replica.#withDecodedPage(handle.transcriptBootstrap.durable, (durable) => { + replica.#installDurable(durable.messages); + replica.#hasOlder = durable.nextCursor !== null; + }); + replica.#evictToBudget(); + if (replica.#overlayBytes > replica.#maxOverlayBytes) { + throw new RangeError('Desktop transcript overlay exceeds the session cache limit'); + } + return replica; + } catch (error) { + replica.close(); + throw error; + } + } + + get residentBytes(): number { + return this.#residentBytes; + } + + get resident(): boolean { + return this.#resident; + } + + adoptResidentAccounting(): void { + if (!this.#residentExternallyAccounted) return; + this.#residentExternallyAccounted = false; + this.#accountPreparationBytes(-this.#residentBytes); + } + + get durableThrough(): number | null { + return this.#durableThrough; + } + + get projectionSeed(): RuntimeHostSessionProjectionSeed { + this.#assertResident(); + return createRuntimeHostSessionProjectionSeed(this.messages(), this.#handle.snapshot); + } + + snapshot(): DesktopTranscriptReplicaSnapshot { + this.#assertOpen(); + this.#assertResident(); + return { + sessionId: this.sessionId, + generation: this.generation, + hostEpoch: this.hostEpoch, + durableThrough: this.#durableThrough, + durable: this.#orderedDurable(false), + overlay: [...this.#overlay.values()], + hasOlder: this.#hasOlder, + hasNewer: this.#hasNewer, + }; + } + + messages(): StoredMessage[] { + this.#assertOpen(); + this.#assertResident(); + return this.#orderedDurable() + .map((entry) => entry.message) + .concat([...this.#overlay.values()].map((message) => structuredClone(message))); + } + + messagesForTurn(turnId: string): StoredMessage[] { + return this.messages().filter((message) => message.turnId === turnId); + } + + latestDurableVisibleMessageId(): string | null { + this.#assertOpen(); + this.#assertResident(); + let latest: ResidentMessage | undefined; + for (const entry of this.#durable.values()) { + if ( + (entry.message.type === 'user' || entry.message.type === 'assistant') && + (!latest || entry.sequence > latest.sequence) + ) { + latest = entry; + } + } + return latest?.message.id ?? null; + } + + async loadBefore( + anchorSequence: number | null, + maxBytes: number, + ): Promise { + return this.#enqueue(() => this.#loadBefore(anchorSequence, maxBytes)); + } + + async #loadBefore(anchorSequence: number | null, maxBytes: number): Promise { + this.#assertOpen(); + const throughSequence = this.#durableThrough; + if (throughSequence === null) return; + const anchor = anchorSequence ?? this.#oldestSequence(); + const page = await this.#handle.loadTranscriptPage({ + source: 'durable', + direction: 'older', + throughSequence, + cursor: null, + anchorSequence: anchor, + maxBytes, + }); + await this.#withDecodedPage(page, (decoded) => { + this.#assertOpen(); + this.#acceptRange(decoded.messages); + if ( + anchor !== null && + decoded.messages.length > 0 && + decoded.messages.at(-1)!.identity !== anchor - 1 + ) { + throw correlationError('Desktop transcript older page did not meet its anchor'); + } + const completedOverlayMessageIds = this.#installDurable(decoded.messages); + this.#hasOlder = decoded.nextCursor !== null; + const evictedDurableSequences = this.#evictToBudget(undefined, 'newest'); + this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); + }); + } + + async loadAround(sequence: number, maxBytes: number): Promise { + return this.#enqueue(() => this.#loadAround(sequence, maxBytes)); + } + + async #loadAround(sequence: number, maxBytes: number): Promise { + this.#assertOpen(); + const throughSequence = this.#durableThrough; + if (throughSequence === null || sequence > throughSequence) return; + await this.#replaceWithRange(throughSequence, sequence, maxBytes); + } + + async #replaceWithRange( + throughSequence: number, + sequence: number, + maxBytes: number, + ): Promise { + const loadTail = sequence === throughSequence; + const page = await this.#handle.loadTranscriptPage({ + source: 'durable', + direction: loadTail ? 'older' : 'newer', + throughSequence, + cursor: null, + anchorSequence: loadTail ? sequence + 1 : sequence === 0 ? null : sequence - 1, + maxBytes, + }); + await this.#withDecodedPage(page, (decoded) => { + this.#assertOpen(); + this.#acceptRange(decoded.messages); + if ( + decoded.messages.length > 0 && + (loadTail + ? decoded.messages.at(-1)!.identity !== sequence + : decoded.messages[0]!.identity !== sequence) + ) { + throw correlationError('Desktop transcript range did not meet its anchor'); + } + const evictedDurableSequences = [...this.#durable.keys()]; + this.#clearDurable(); + const completedOverlayMessageIds = this.#installDurable(decoded.messages); + this.#durableThrough = throughSequence; + this.#hasOlder = loadTail ? decoded.nextCursor !== null : sequence > 0; + this.#hasNewer = loadTail ? false : decoded.nextCursor !== null; + evictedDurableSequences.push( + ...this.#evictToBudget(undefined, loadTail ? 'oldest' : 'newest'), + ); + this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); + }); + } + + advance(throughSequence: number): Promise { + this.#assertOpen(); + if (this.#targetThrough === null || throughSequence > this.#targetThrough) { + this.#targetThrough = throughSequence; + } + if (!this.#resident) { + this.#durableThrough = this.#targetThrough; + return Promise.resolve(); + } + this.#catchUpTask ??= this.#enqueue(() => this.#catchUp()).finally(() => { + this.#catchUpTask = undefined; + if ( + !this.#closed && + this.#targetThrough !== null && + (this.#durableThrough === null || this.#targetThrough > this.#durableThrough) + ) { + void this.advance(this.#targetThrough).catch(() => undefined); + } + }); + return this.#catchUpTask; + } + + trimDurable(targetResidentBytes: number): DesktopTranscriptReplicaChange | undefined { + this.#assertOpen(); + if (!this.#resident) return undefined; + const evictedDurableSequences = this.#evictToBudget(targetResidentBytes); + return evictedDurableSequences.length === 0 + ? undefined + : this.#change([], [], evictedDurableSequences); + } + + discard(): void { + this.#assertOpen(); + if (!this.#resident) return; + this.#resident = false; + this.#clearDurable(); + for (const message of this.#overlay.values()) { + this.#adjustOverlayBytes(-encodedMessageBytes(message)); + } + this.#overlay.clear(); + this.#overlayBytes = 0; + } + + close(): void { + this.#closed = true; + this.#resident = false; + this.#durable.clear(); + this.#overlay.clear(); + this.#overlayBytes = 0; + if (this.#residentExternallyAccounted) { + this.#accountPreparationBytes(-this.#residentBytes); + } + this.#residentBytes = 0; + } + + async #catchUp(): Promise { + while (!this.#closed && this.#resident) { + const target = this.#targetThrough; + if (target === null || (this.#durableThrough !== null && target <= this.#durableThrough)) { + return; + } + if (this.#hasNewer) { + this.#durableThrough = target; + this.#publish([], [], []); + return; + } + let cursor: string | null = null; + const anchorSequence = this.#durableThrough; + let expectedSequence = (anchorSequence ?? -1) + 1; + do { + if (!this.#resident) return; + const page: SessionTranscriptPage = await this.#handle.loadTranscriptPage({ + source: 'durable', + direction: 'newer', + throughSequence: target, + cursor, + anchorSequence: cursor === null ? anchorSequence : null, + maxBytes: 512 * 1024, + }); + await this.#withDecodedPage(page, (decoded) => { + this.#assertOpen(); + if (!this.#resident) return; + if (decoded.messages.length === 0 && decoded.nextCursor !== null) { + throw correlationError('Desktop transcript catch-up returned an empty continuation'); + } + this.#acceptRange(decoded.messages); + if ( + decoded.messages.length > 0 && + decoded.messages[0]!.identity !== expectedSequence + ) { + throw correlationError('Desktop transcript catch-up has a sequence gap'); + } + if (decoded.messages.length > 0) { + expectedSequence = decoded.messages.at(-1)!.identity + 1; + } + const completedOverlayMessageIds = this.#installDurable(decoded.messages); + const evictedDurableSequences = this.#evictToBudget(); + this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); + cursor = decoded.nextCursor; + }); + } while (cursor !== null); + if (expectedSequence !== target + 1) { + throw correlationError('Desktop transcript catch-up ended before its watermark'); + } + this.#durableThrough = target; + this.#publish([], [], []); + } + } + + #installOverlay(messages: readonly StoredMessage[]): void { + for (const message of messages) { + const previous = this.#overlay.get(message.id); + if (previous) this.#adjustOverlayBytes(-encodedMessageBytes(previous)); + this.#overlay.set(message.id, message); + this.#adjustOverlayBytes(encodedMessageBytes(message)); + } + } + + #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) { + throw correlationError(`Desktop transcript sequence ${item.identity} changed identity`); + } + if (previous) this.#adjustResidentBytes(-previous.encodedBytes); + const message = item.message; + const encodedBytes = encodedMessageBytes(message); + this.#durable.set(item.identity, { + sequence: item.identity, + message, + encodedBytes, + }); + this.#adjustResidentBytes(encodedBytes); + const overlay = this.#overlay.get(message.id); + if (overlay) { + this.#overlay.delete(message.id); + this.#adjustOverlayBytes(-encodedMessageBytes(overlay)); + completedOverlayMessageIds.push(message.id); + } + } + return completedOverlayMessageIds; + } + + #acceptRange( + messages: readonly { readonly identity: number }[], + ): void { + for (let index = 1; index < messages.length; index += 1) { + const previous = messages[index - 1]!.identity; + const current = messages[index]!.identity; + if (current !== previous + 1) { + throw correlationError('Desktop transcript page has a sequence gap'); + } + } + } + + #publish( + messages: readonly { + readonly identity: number; + readonly message: StoredMessage; + }[], + completedOverlayMessageIds: readonly string[], + evictedDurableSequences: readonly number[], + ): void { + this.#onChange(this, this.#change(messages, completedOverlayMessageIds, evictedDurableSequences)); + } + + #change( + messages: readonly { + readonly identity: number; + readonly message: StoredMessage; + }[], + completedOverlayMessageIds: readonly string[], + evictedDurableSequences: readonly number[], + ): DesktopTranscriptReplicaChange { + return { + durableThrough: this.#durableThrough, + durableUpserts: messages.flatMap((entry) => { + const resident = this.#durable.get(entry.identity); + return resident?.message.id === entry.message.id + ? [{ sequence: entry.identity, message: resident.message }] + : []; + }), + evictedDurableSequences: [...new Set(evictedDurableSequences)].filter( + (sequence) => !this.#durable.has(sequence), + ), + completedOverlayMessageIds, + hasOlder: this.#hasOlder, + hasNewer: this.#hasNewer, + }; + } + + #evictToBudget( + budget: number | undefined = undefined, + edge: 'oldest' | 'newest' = 'oldest', + ): number[] { + const residentBudget = budget ?? this.#maxResidentBytes + this.#overlayBytes; + const evicted: number[] = []; + const direction = edge === 'oldest' ? 1 : -1; + for (const sequence of [...this.#durable.keys()].sort((left, right) => direction * (left - right))) { + if (this.#residentBytes <= residentBudget) break; + const entry = this.#durable.get(sequence); + if (!entry) continue; + this.#durable.delete(sequence); + this.#adjustResidentBytes(-entry.encodedBytes); + if (edge === 'oldest') this.#hasOlder = true; + else this.#hasNewer = true; + evicted.push(sequence); + } + return evicted; + } + + #orderedDurable(cloneMessages = true): DesktopSequencedTranscriptMessage[] { + return [...this.#durable.values()] + .sort((left, right) => left.sequence - right.sequence) + .map((entry) => ({ + sequence: entry.sequence, + message: cloneMessages ? structuredClone(entry.message) : entry.message, + })); + } + + #oldestSequence(): number | null { + let oldest: number | null = null; + for (const sequence of this.#durable.keys()) { + if (oldest === null || sequence < oldest) oldest = sequence; + } + return oldest; + } + + #clearDurable(): void { + for (const entry of this.#durable.values()) this.#adjustResidentBytes(-entry.encodedBytes); + this.#durable.clear(); + } + + #adjustResidentBytes(deltaBytes: number): void { + if (this.#residentExternallyAccounted) this.#accountPreparationBytes(deltaBytes); + this.#residentBytes += deltaBytes; + } + + #adjustOverlayBytes(deltaBytes: number): void { + this.#adjustResidentBytes(deltaBytes); + this.#overlayBytes += deltaBytes; + } + + async #withDecodedPage( + page: SessionTranscriptPage, + accept: ( + decoded: Awaited>, + ) => T | Promise, + ): Promise { + return this.#withAssembly(async (accountAssemblyBytes) => + accept( + await this.#handle.decodeTranscriptPage( + page, + this.#maxMessageBytes, + accountAssemblyBytes, + ), + ), + ); + } + + async #withAssembly( + operation: (accountAssemblyBytes: (deltaBytes: number) => void) => Promise, + ): Promise { + let acquiredBytes = 0; + let balance = 0; + const accountAssemblyBytes = (deltaBytes: number) => { + const next = balance + deltaBytes; + if (!Number.isSafeInteger(next) || next < 0) { + throw new RangeError('Invalid Desktop transcript assembly accounting'); + } + balance = next; + if (deltaBytes <= 0) return; + this.#accountPreparationBytes(deltaBytes); + acquiredBytes += deltaBytes; + }; + try { + return await operation(accountAssemblyBytes); + } finally { + if (acquiredBytes > 0) this.#accountPreparationBytes(-acquiredBytes); + } + } + + #assertOpen(): void { + if (this.#closed) throw new Error('Desktop transcript replica is closed'); + } + + #assertResident(): void { + if (!this.#resident) { + throw new Error('Desktop transcript replica was evicted'); + } + } + + #enqueue(operation: () => Promise): Promise { + const task = this.#operationTail.then(operation); + this.#operationTail = task.catch(() => undefined); + return task; + } +} + +function encodedMessageBytes(message: StoredMessage): number { + return Buffer.byteLength(JSON.stringify(message), 'utf8'); +} + +function correlationError(message: string): RuntimeHostSubscriptionError { + return new RuntimeHostSubscriptionError('correlation_changed', message); +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index e7fbf1d966..cf058b5278 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -143,6 +143,7 @@ import { startupStep, whileAwaitingPerson } from "./startup-step.js"; import { registerWorkspaceSearchIpc } from "./workspace-search-ipc-main.js"; import { parseDesktopSessionResourceKey, + requireDesktopHostRef, type DesktopHostRef, } from "../preload/runtime-host-identity.js"; @@ -1081,6 +1082,35 @@ function registerPersistentClientIpc(): void { } await owner?.unobserveSession(observerId); }); + ipcMain.handle('sessions:transcript:close', async (event, consumerId: unknown) => { + if (typeof consumerId !== 'string' || consumerId.length === 0 || consumerId.length > 256) { + throw new Error('Invalid transcript consumer identity'); + } + await owner?.closeTranscript(consumerId, event.sender.id); + }); + ipcMain.handle( + 'sessions:transcript:ack', + (event, scope: unknown, consumerId: unknown, generation: unknown, deliverySequence: unknown) => { + const active = activeRuntimeHostRef(); + if (!active) throw new Error('Desktop Runtime Host identity is unavailable'); + requireDesktopHostRef(scope, active); + if (typeof consumerId !== 'string' || consumerId.length === 0 || consumerId.length > 256) { + throw new Error('Invalid transcript consumer identity'); + } + if (typeof generation !== 'string' || generation.length === 0 || generation.length > 256) { + throw new Error('Invalid transcript generation'); + } + if (!Number.isSafeInteger(deliverySequence) || Number(deliverySequence) < 0) { + throw new Error('Invalid transcript delivery'); + } + owner?.acknowledgeTranscript( + consumerId, + generation, + Number(deliverySequence), + event.sender.id, + ); + }, + ); ipcMain.handle("runtime-host:activeIdentity", () => { const scope = activeRuntimeHostRef(); if (!scope) throw new Error("Desktop Runtime Host identity is unavailable"); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 89b46c1e03..47ba8a108e 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -4,6 +4,7 @@ import type { PlanSessionState, PlanUserControlInput } from "@maka/core/plan"; import { decodeStoredMessage, type StoredMessage, + type TurnRecord, } from "@maka/core/session"; import type { Task } from "@maka/core/task-ledger"; import type { @@ -21,6 +22,7 @@ import { import type { PricingConfig } from "@maka/core/usage-stats/types"; import { type ClientCapabilityProvider, + type DecodedSessionTranscriptPage, type DirectRequestOperationKey, type RuntimeHostConnection, type RuntimeHostSessionSubscription, @@ -75,6 +77,11 @@ import { type SessionConfiguration, type SessionAssistantStreamIdentity, type SessionContinuitySnapshot, + type SessionTranscriptBootstrap, + type SessionTranscriptPage, + type SessionTranscriptPageInput, + mergeSessionTurnContributions, + projectSessionTurnContribution, type SessionConversationCopyInput, type SessionConversationCopyResult, type SessionCreateInput, @@ -128,10 +135,25 @@ export class DesktopRuntimeHostClientError extends Error { } export interface DesktopRuntimeHostSession { + readonly hostEpoch: string; + readonly subscriptionId: string; readonly snapshot: SessionContinuitySnapshot; readonly activeAssistantStreams: readonly SessionAssistantStreamIdentity[]; - readonly transcript: Promise; + readonly transcriptBootstrap: SessionTranscriptBootstrap; readonly events: AsyncIterable; + loadTranscript(): Promise; + loadTranscriptOverlay( + maxMessageBytes?: number, + accountAssemblyBytes?: (deltaBytes: number) => void, + ): Promise; + decodeTranscriptPage( + page: SessionTranscriptPage, + maxMessageBytes?: number, + accountAssemblyBytes?: (deltaBytes: number) => void, + ): Promise>; + loadTranscriptPage( + input: Omit, + ): Promise; close(): Promise; } @@ -1276,6 +1298,57 @@ export class DesktopRuntimeHostClient { return session; } + async listSessionTurns(sessionId: string): Promise { + this.#assertOpen(); + const contributions = new Map< + string, + OperationOutput<'session.turns.query'>['contributions'][number] + >(); + let throughSequence: number | null = null; + let position = 0; + const positions = new Set(); + while (true) { + if (positions.has(position)) throw invalidProjection('Session turns'); + positions.add(position); + const page: OperationOutput<'session.turns.query'> = await this.request( + 'session.turns.query', { + sessionId, + throughSequence, + position, + maxContributions: 128, + }, + ); + throughSequence = page.throughSequence; + for (const contribution of page.contributions) { + const current = contributions.get(contribution.turnId); + if (!current) { + contributions.set(contribution.turnId, contribution); + continue; + } + contributions.set( + contribution.turnId, + mergeSessionTurnContributions(current, contribution), + ); + } + if (page.nextPosition === null) break; + if (page.nextPosition <= position) throw invalidProjection('Session turns'); + position = page.nextPosition; + } + return [...contributions.values()] + .sort((left, right) => left.firstSequence - right.firstSequence) + .map(projectSessionTurnContribution); + } + + async listSessionTurnLandmarks( + sessionId: string, + ): Promise> { + this.#assertOpen(); + return this.request('session.turn_landmarks.query', { + sessionId, + maxLandmarks: 64, + }); + } + close(): Promise { this.#closeTask ??= this.#close(); return this.#closeTask; @@ -1393,21 +1466,63 @@ export class DesktopRuntimeHostClient { } class DesktopSessionHandle implements DesktopRuntimeHostSession { + readonly hostEpoch: string; + readonly subscriptionId: string; readonly snapshot: SessionContinuitySnapshot; readonly activeAssistantStreams: readonly SessionAssistantStreamIdentity[]; - readonly transcript: Promise; + readonly transcriptBootstrap: SessionTranscriptBootstrap; readonly events: AsyncIterable; #closeTask: Promise | undefined; + #transcriptTask: Promise | undefined; constructor( private readonly subscription: RuntimeHostSessionSubscription, private readonly onClose: () => void, ) { + if (!subscription.transcriptBootstrap) { + throw new Error("Desktop Session subscription omitted its transcript bootstrap"); + } + this.hostEpoch = subscription.hostEpoch; + this.subscriptionId = subscription.subscriptionId; this.snapshot = subscription.snapshot; this.activeAssistantStreams = subscription.activeAssistantStreams; + this.transcriptBootstrap = subscription.transcriptBootstrap; this.events = subscription; - this.transcript = subscription.loadTranscript(decodeStoredMessage); - void this.transcript.catch(() => undefined); + } + + loadTranscript(): Promise { + this.#transcriptTask ??= this.subscription.loadTranscript(decodeStoredMessage); + return this.#transcriptTask; + } + + loadTranscriptOverlay( + maxMessageBytes?: number, + accountAssemblyBytes?: (deltaBytes: number) => void, + ): Promise { + return this.subscription.loadTranscriptOverlay( + decodeStoredMessage, + maxMessageBytes, + accountAssemblyBytes, + ); + } + + decodeTranscriptPage( + page: SessionTranscriptPage, + maxMessageBytes?: number, + accountAssemblyBytes?: (deltaBytes: number) => void, + ): Promise> { + return this.subscription.decodeTranscriptPage( + page, + decodeStoredMessage, + maxMessageBytes, + accountAssemblyBytes, + ); + } + + loadTranscriptPage( + input: Omit, + ): Promise { + return this.subscription.loadTranscriptPage(input); } close(): Promise { diff --git a/apps/desktop/src/main/runtime-host-desktop-owner.ts b/apps/desktop/src/main/runtime-host-desktop-owner.ts index b7af6a5b9f..12cd5557a7 100644 --- a/apps/desktop/src/main/runtime-host-desktop-owner.ts +++ b/apps/desktop/src/main/runtime-host-desktop-owner.ts @@ -26,6 +26,13 @@ export interface RuntimeHostDesktopOwner { current(): RuntimeHostDesktopTargetSnapshot | undefined; handleBotIncomingMessage(message: BotIncomingMessage): Promise; stopSession(ref: DesktopSessionRef): Promise; + closeTranscript(consumerId: string, targetId: number): Promise; + acknowledgeTranscript( + consumerId: string, + generation: string, + deliverySequence: number, + targetId: number, + ): void; unobserveSession(observerId: string): Promise; switchTarget( remote: DesktopRuntimeHostCandidateStartInput['remote'], @@ -242,6 +249,28 @@ class RuntimeHostDesktopOwnerImpl implements RuntimeHostDesktopOwner { ); } + async closeTranscript(consumerId: string, targetId: number): Promise { + await Promise.all( + [...this.#observationRegistries].map((observations) => + observations.closeTranscript(consumerId, targetId), + ), + ); + } + + acknowledgeTranscript( + consumerId: string, + generation: string, + deliverySequence: number, + targetId: number, + ): void { + this.#activeTarget?.observations.acknowledgeTranscript( + consumerId, + generation, + deliverySequence, + targetId, + ); + } + switchTarget( remote: DesktopRuntimeHostCandidateStartInput['remote'], ): Promise { diff --git a/apps/desktop/src/main/runtime-host-search-ipc-main.ts b/apps/desktop/src/main/runtime-host-search-ipc-main.ts index 8bdce4eed4..d8290e0651 100644 --- a/apps/desktop/src/main/runtime-host-search-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-search-ipc-main.ts @@ -26,7 +26,7 @@ export function registerRuntimeHostSearchIpc( readWithFallback(async () => { const session = await deps.client.openSession(sessionId); try { - return await session.transcript; + return await session.loadTranscript(); } finally { await session.close(); } 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 1e8d16674a..5182a20c4d 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 @@ -1,10 +1,8 @@ import { randomUUID } from "node:crypto"; import type { IpcMainInvokeEvent } from "electron"; import { - deriveTurnRecords, type SessionChangedEvent, type SessionChangedReason, - type StoredMessage, } from '@maka/core/session'; import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; import { type ActiveInteractionRequestEvent, type AttachmentRef } from '@maka/core/events'; @@ -33,7 +31,9 @@ import type { RuntimeHostSessionObservationRegistry } from "./runtime-host-sessi import { RuntimeHostSessionObserver, type RuntimeHostSessionObserverTarget, + type RuntimeHostTranscriptTarget, } from "./runtime-host-session-observer.js"; +import type { DesktopTranscriptRangeRequest } from '../preload/transcript-contract.js'; import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; import { mergeWorkspaceFileInlineReferences } from "./session-workspace-inline-references.js"; @@ -45,6 +45,8 @@ type RuntimeHostSessionExecutionClient = Pick< | "getSession" | "ingestAttachment" | "interruptTurn" + | 'listSessionTurns' + | 'listSessionTurnLandmarks' | "queryTurnResume" | "readExecutionBoundary" | "regenerateTurn" @@ -61,7 +63,10 @@ export interface RuntimeHostSessionExecutionIpcDeps { observer: RuntimeHostSessionObserver; observations: Pick< RuntimeHostSessionObservationRegistry, - "observe" + | 'loadTranscriptAround' + | 'loadTranscriptBefore' + | 'observe' + | 'openTranscript' >; attachmentApprovals: AttachmentApprovalRegistry; emitSessionsChanged: ( @@ -127,18 +132,37 @@ export function registerRuntimeHostSessionExecutionIpc( ); }, ); - ipcMain.handle("sessions:readMessages", async (_event, sessionId: string) => { - const messages = await deps.observer.readMessages(sessionId); - const readThroughMessageId = latestVisibleMessageId(messages); - if (readThroughMessageId) { - await deps.client - .setSessionReadMarker(sessionId, readThroughMessageId) - .catch(() => undefined); - } - return messages; + ipcMain.handle( + 'sessions:transcript:open', + async (event, sessionId: unknown, consumerId: unknown) => { + const result = await deps.observations.openTranscript( + requiredId(sessionId, 'Session'), + requiredId(consumerId, 'Transcript consumer'), + event.sender as RuntimeHostTranscriptTarget, + ); + return result; + }, + ); + ipcMain.handle('sessions:transcript:load-before', async (event, input: unknown) => { + await deps.observations.loadTranscriptBefore( + normalizeTranscriptRangeRequest(input), + event.sender.id, + ); + }); + ipcMain.handle('sessions:transcript:load-around', async (event, input: unknown) => { + await deps.observations.loadTranscriptAround( + normalizeTranscriptRangeRequest(input), + event.sender.id, + ); }); - handleReconnectableRead(ipcMain, "sessions:listTurns", async (_event, sessionId: string) => - deriveTurnRecords(await deps.observer.readMessages(sessionId)), + handleReconnectableRead(ipcMain, 'sessions:listTurns', async (_event, sessionId: unknown) => + deps.client.listSessionTurns(requiredId(sessionId, 'Session')), + ); + handleReconnectableRead( + ipcMain, + 'sessions:listTurnLandmarks', + async (_event, sessionId: unknown) => + deps.client.listSessionTurnLandmarks(requiredId(sessionId, 'Session')), ); handleReconnectableRead( ipcMain, @@ -411,6 +435,30 @@ export function registerRuntimeHostSessionExecutionIpc( return stopSession; } +function normalizeTranscriptRangeRequest(input: unknown): DesktopTranscriptRangeRequest { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Invalid Desktop transcript range request'); + } + const value = input as Record; + const anchorSequence = value.anchorSequence; + const maxBytes = value.maxBytes; + if ( + anchorSequence !== null && + (!Number.isSafeInteger(anchorSequence) || (anchorSequence as number) < 0) + ) { + throw new Error('Invalid Desktop transcript range anchor'); + } + if (!Number.isSafeInteger(maxBytes)) { + throw new Error('Invalid Desktop transcript range byte limit'); + } + return { + consumerId: requiredId(value.consumerId, 'Transcript consumer'), + generation: requiredId(value.generation, 'Transcript generation'), + anchorSequence: anchorSequence as number | null, + maxBytes: maxBytes as number, + }; +} + function createRuntimeHostSessionStop( deps: Pick< RuntimeHostSessionExecutionIpcDeps, @@ -434,17 +482,6 @@ function createRuntimeHostSessionStop( }; } -function latestVisibleMessageId( - messages: readonly StoredMessage[], -): string | undefined { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]!; - if (message.type === "user" || message.type === "assistant") - return message.id; - } - return undefined; -} - async function requireInteraction( observer: RuntimeHostSessionObserver, sessionId: string, @@ -463,6 +500,13 @@ function requiredId(value: unknown, label: string): string { return value; } +function requiredSequence(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`Invalid ${label} sequence`); + } + return value as number; +} + function steeringContent(value: unknown): string { if ( typeof value !== "string" || 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 853cb1aeeb..6dae077cc1 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -1,16 +1,39 @@ import type { RuntimeHostSessionObserver, + RuntimeHostRendererTarget, RuntimeHostSessionObserverTarget, + RuntimeHostTranscriptTarget, } from "./runtime-host-session-observer.js"; +import type { + DesktopTranscriptOpenResult, + DesktopTranscriptRangeRequest, +} from '../preload/transcript-contract.js'; -type SessionObservationSource = Pick< - RuntimeHostSessionObserver, - "observe" | "unobserve" +type SessionObservationSource = Pick & + Partial< + Pick< + RuntimeHostSessionObserver, + | 'acknowledgeTranscript' + | 'closeTranscript' + | 'loadTranscriptAround' + | 'loadTranscriptBefore' + | 'openTranscript' + > + >; + +type TranscriptSource = Required< + Pick< + RuntimeHostSessionObserver, + | 'closeTranscript' + | 'loadTranscriptAround' + | 'loadTranscriptBefore' + | 'openTranscript' + > >; -type ObservationTargetBinding = ( - target: RuntimeHostSessionObserverTarget, -) => RuntimeHostSessionObserverTarget; +type ObservationTargetBinding = ( + target: RuntimeHostRendererTarget, +) => RuntimeHostRendererTarget; interface ObservationReadiness { readonly promise: Promise; @@ -18,6 +41,20 @@ interface ObservationReadiness { reject(error: Error): void; } +function requireTranscriptSource( + source: SessionObservationSource | undefined, +): SessionObservationSource & TranscriptSource { + if ( + !source?.openTranscript || + !source.loadTranscriptBefore || + !source.loadTranscriptAround || + !source.closeTranscript + ) { + throw new Error('Runtime Host transcript source is unavailable'); + } + return source as SessionObservationSource & TranscriptSource; +} + interface SessionObservationRegistration { readonly sessionId: string; readonly target: RuntimeHostSessionObserverTarget; @@ -26,6 +63,20 @@ interface SessionObservationRegistration { lifecycle: "pending" | "active"; } +interface TranscriptRegistration { + readonly sessionId: string; + readonly target: RuntimeHostTranscriptTarget; + readonly destroyedListener: () => void; + readonly ready: TranscriptReadiness; + lifecycle: 'pending' | 'active'; +} + +interface TranscriptReadiness { + readonly promise: Promise; + resolve(result: DesktopTranscriptOpenResult): void; + reject(error: Error): void; +} + function observationReadiness(): ObservationReadiness { let resolve!: () => void; let reject!: (error: Error) => void; @@ -36,6 +87,16 @@ function observationReadiness(): ObservationReadiness { return { promise, resolve, reject }; } +function transcriptReadiness(): TranscriptReadiness { + let resolve!: (result: DesktopTranscriptOpenResult) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + /** * Keeps renderer observation intent alive while the Host connection is replaced. */ @@ -44,6 +105,7 @@ export class RuntimeHostSessionObservationRegistry { string, SessionObservationRegistration >(); + readonly #transcripts = new Map(); readonly #onError: (error: unknown) => void; #source: SessionObservationSource | undefined; #bindTarget: ObservationTargetBinding = (target) => target; @@ -94,6 +156,31 @@ export class RuntimeHostSessionObservationRegistry { } }), ); + await Promise.all( + [...this.#transcripts].map(async ([consumerId, registration]) => { + try { + const transcriptSource = requireTranscriptSource(source); + const result = await transcriptSource.openTranscript( + registration.sessionId, + consumerId, + this.#bindTranscriptTarget(registration.target), + ); + if (this.#source === source && this.#transcripts.get(consumerId) === registration) { + registration.lifecycle = 'active'; + registration.ready.resolve(result); + } else { + await transcriptSource.closeTranscript(consumerId); + } + } catch (error) { + if (this.#source === source && this.#transcripts.get(consumerId) === registration) { + if (registration.lifecycle === 'pending') { + this.#deleteTranscript(consumerId, registration); + } + this.#onError(error); + } + } + }), + ); return [...new Set(restored.filter((sessionId): sessionId is string => !!sessionId))]; } @@ -161,6 +248,102 @@ export class RuntimeHostSessionObservationRegistry { await this.#remove(observerId); } + async openTranscript( + sessionId: string, + consumerId: string, + target: RuntimeHostTranscriptTarget, + ): Promise { + this.#assertOpen(); + if (this.#transcripts.has(consumerId)) { + throw new Error('Desktop transcript consumer identity was reused'); + } + const destroyedListener = () => { + void this.closeTranscript(consumerId).catch(this.#onError); + }; + const ready = transcriptReadiness(); + void ready.promise.catch(() => undefined); + const registration: TranscriptRegistration = { + sessionId, + target, + destroyedListener, + ready, + lifecycle: 'pending', + }; + this.#transcripts.set(consumerId, registration); + target.once('destroyed', destroyedListener); + const source = this.#source; + if (!source) return ready.promise; + const transcriptSource = requireTranscriptSource(source); + try { + const result = await transcriptSource.openTranscript( + sessionId, + consumerId, + this.#bindTranscriptTarget(target), + ); + if (this.#source === source && this.#transcripts.get(consumerId) === registration) { + registration.lifecycle = 'active'; + registration.ready.resolve(result); + } else { + await transcriptSource.closeTranscript(consumerId); + } + } catch (error) { + if (this.#source === source && this.#transcripts.get(consumerId) === registration) { + this.#deleteTranscript(consumerId, registration); + throw error; + } + return registration.ready.promise; + } + return registration.ready.promise; + } + + loadTranscriptBefore(request: DesktopTranscriptRangeRequest, targetId?: number): Promise { + return requireTranscriptSource(this.#transcriptSource(request.consumerId)).loadTranscriptBefore( + request, + targetId, + ); + } + + loadTranscriptAround(request: DesktopTranscriptRangeRequest, targetId?: number): Promise { + return requireTranscriptSource(this.#transcriptSource(request.consumerId)).loadTranscriptAround( + request, + targetId, + ); + } + + acknowledgeTranscript( + consumerId: string, + generation: string, + deliverySequence: number, + targetId?: number, + ): void { + const registration = this.#transcripts.get(consumerId); + if (!registration) throw new Error('Desktop transcript consumer does not exist'); + if (targetId !== undefined && registration.target.id !== targetId) { + throw new Error('Desktop transcript consumer belongs to another renderer'); + } + const source = this.#source; + if (!source) return; + if (!source.acknowledgeTranscript) { + throw new Error('Runtime Host transcript acknowledgement source is unavailable'); + } + source.acknowledgeTranscript( + consumerId, + generation, + deliverySequence, + targetId, + ); + } + + async closeTranscript(consumerId: string, targetId?: number): Promise { + const registration = this.#transcripts.get(consumerId); + if (!registration) return; + if (targetId !== undefined && registration.target.id !== targetId) { + throw new Error('Desktop transcript consumer belongs to another renderer'); + } + this.#deleteTranscript(consumerId, registration); + await this.#source?.closeTranscript?.(consumerId, targetId); + } + async close(): Promise { if (this.#closed) return; this.#closed = true; @@ -168,17 +351,24 @@ export class RuntimeHostSessionObservationRegistry { this.#source = undefined; this.#bindTarget = (target) => target; const registrations = [...this.#registrations]; + const transcripts = [...this.#transcripts]; this.#registrations.clear(); + this.#transcripts.clear(); for (const [, registration] of registrations) { registration.target.off("destroyed", registration.destroyedListener); registration.ready.reject( new Error("Session observation ended before it became ready"), ); } + for (const [, registration] of transcripts) { + registration.target.off('destroyed', registration.destroyedListener); + registration.ready.reject(new Error('Transcript observation ended before it became ready')); + } if (source) { - await Promise.allSettled( - registrations.map(([observerId]) => source.unobserve(observerId)), - ); + await Promise.allSettled([ + ...registrations.map(([observerId]) => source.unobserve(observerId)), + ...transcripts.map(([consumerId]) => source.closeTranscript?.(consumerId)), + ]); } } @@ -201,6 +391,25 @@ export class RuntimeHostSessionObservationRegistry { ); } + #transcriptSource(consumerId: string): SessionObservationSource { + if (!this.#transcripts.has(consumerId)) { + throw new Error('Desktop transcript consumer does not exist'); + } + if (!this.#source) throw new Error('Runtime Host transcript source is unavailable'); + return this.#source; + } + + #deleteTranscript(consumerId: string, registration: TranscriptRegistration): void { + if (this.#transcripts.get(consumerId) !== registration) return; + this.#transcripts.delete(consumerId); + registration.target.off('destroyed', registration.destroyedListener); + registration.ready.reject(new Error('Transcript observation ended before it became ready')); + } + + #bindTranscriptTarget(target: RuntimeHostTranscriptTarget): RuntimeHostTranscriptTarget { + return this.#bindTarget(target); + } + #assertOpen(): void { if (this.#closed) { throw new Error("Runtime Host Session observation registry is closed"); diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index d26c3747f1..a3ce7e7d6c 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -1,5 +1,5 @@ import type { ActiveInteractionRequestEvent, SessionEvent } from '@maka/core/events'; -import type { SessionChangedReason, StoredMessage } from '@maka/core/session'; +import type { SessionChangedReason, StoredMessage, TurnRecord } from '@maka/core/session'; import type { AgentGraphClientChangedEvent } from '@maka/runtime/stream-graph-coordinator'; import type { ShellRunPtyDataEvent } from '@maka/runtime/shell-run-contract'; import { @@ -16,21 +16,47 @@ import type { } from "@maka/runtime-host/protocol"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; +import { + DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES, + DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES, + DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + type DesktopTranscriptBatch, + type DesktopTranscriptBatchPayload, + type DesktopTranscriptOpenResult, + type DesktopTranscriptRangeRequest, +} from '../preload/transcript-contract.js'; import { type PreparedSessionSubscription, RuntimeHostSessionSubscriptionOwner, SessionRemovedSubscriptionError, } from "./runtime-host-session-subscription-owner.js"; +import { + type DesktopSequencedTranscriptMessage, + type DesktopTranscriptReplica, + type DesktopTranscriptReplicaChange, +} from './desktop-transcript-replica.js'; +import { + encodeDesktopTranscriptChange, + encodeDesktopTranscriptSnapshot, +} from './desktop-transcript-ipc.js'; + +type SessionObserverClient = Pick & + Partial>; -type SessionObserverClient = Pick; +const TRANSCRIPT_DELIVERY_TIMEOUT_MS = 30_000; +const TRANSCRIPT_DELIVERY_WINDOW = 4; -export interface RuntimeHostSessionObserverTarget { +export interface RuntimeHostRendererTarget { readonly id: number; - send(channel: string, event: SessionEvent): void; + send(channel: string, payload: Payload): void; once(event: "destroyed", listener: () => void): void; off(event: "destroyed", listener: () => void): void; } +export type RuntimeHostSessionObserverTarget = RuntimeHostRendererTarget; +export type RuntimeHostTranscriptTarget = RuntimeHostRendererTarget; + export interface RuntimeHostSessionObserverDeps { client: SessionObserverClient; emitSessionsChanged: ( @@ -65,14 +91,55 @@ interface ObservedSessionState { readonly sessionId: string; readonly targets: Map; readonly watchedTurnIds: Set; + readonly transcriptConsumers: Map; readonly subscriptionOwner: RuntimeHostSessionSubscriptionOwner; - transcript?: StoredMessage[]; - transcriptConsumed: boolean; + pendingTranscriptConsumers: number; + replica?: DesktopTranscriptReplica; snapshot?: SessionContinuitySnapshot; projector?: RuntimeHostSessionProjector; + transcriptAccess: number; closing: boolean; } +interface TranscriptConsumer { + readonly consumerId: string; + readonly target: RuntimeHostTranscriptTarget; + readonly destroyedListener: () => void; + generation: string; + deliverySequence: number; + deliveryBytes: number; + deliveryTask?: Promise; + resetRequested: boolean; + pendingChange?: PendingTranscriptChange; + readonly pendingDeliveries: Map; +} + +interface PendingTranscriptChange { + durableThrough: number | null; + readonly durableUpserts: Map; + readonly evictedDurableSequences: Set; + readonly completedOverlayMessageIds: Set; + hasOlder: boolean; + hasNewer: boolean; + encodedBytes: number; +} + +interface PendingTranscriptUpsert { + readonly entry: DesktopSequencedTranscriptMessage; + readonly encodedBytes: number; +} + +interface PendingTranscriptConsumer { + readonly targetId: number; + readonly cancelled: Promise; + cancel(): void; +} + interface ObserverRegistration { readonly state: ObservedSessionState; readonly group: ObserverTargetGroup; @@ -95,7 +162,8 @@ interface SubscriptionFailureIdentity { export class RuntimeHostSessionObserver { readonly #states = new Map(); readonly #observers = new Map(); - readonly #transcriptRefreshes = new Map>(); + readonly #transcriptConsumers = new Map(); + readonly #pendingTranscriptConsumers = new Map(); readonly #client: SessionObserverClient; readonly #emitSessionsChanged: RuntimeHostSessionObserverDeps["emitSessionsChanged"]; readonly #emitSessionDomainChanged: (change: SessionDomainChange) => void; @@ -115,6 +183,8 @@ export class RuntimeHostSessionObserver { readonly #recoverConnectionClosed: boolean; readonly #now: () => number; #closed = false; + #transcriptAccessClock = 0; + #transcriptPreparationBytes = 0; constructor(deps: RuntimeHostSessionObserverDeps) { this.#client = deps.client; @@ -135,25 +205,164 @@ export class RuntimeHostSessionObserver { this.#now = deps.now ?? Date.now; } - async readMessages(sessionId: string): Promise { + async openTranscript( + sessionId: string, + consumerId: string, + target: RuntimeHostTranscriptTarget, + ): Promise { this.#assertOpen(); - const existing = this.#states.get(sessionId); - if (existing) { - await existing.subscriptionOwner.waitUntilReady(); - if (!existing.transcriptConsumed) { - existing.transcriptConsumed = true; - return cloneMessages(existing.transcript ?? []); + if ( + this.#transcriptConsumers.has(consumerId) || + this.#pendingTranscriptConsumers.has(consumerId) + ) { + throw new Error('Desktop transcript consumer identity was reused'); + } + const state = this.#state(sessionId); + let cancel!: () => void; + const cancelled = new Promise((_resolve, reject) => { + cancel = () => reject(new Error('Desktop transcript open was cancelled')); + }); + void cancelled.catch(() => undefined); + const pending: PendingTranscriptConsumer = { + targetId: target.id, + cancelled, + cancel, + }; + this.#pendingTranscriptConsumers.set(consumerId, pending); + state.pendingTranscriptConsumers += 1; + let replica: DesktopTranscriptReplica; + let admitted = false; + try { + await Promise.race([state.subscriptionOwner.waitUntilReady(), cancelled]); + if (!state.replica?.resident) { + await Promise.race([state.subscriptionOwner.refresh(), cancelled]); + } + if (this.#pendingTranscriptConsumers.get(consumerId) !== pending) await cancelled; + replica = state.replica!; + if (!replica?.resident) { + throw new Error('Desktop transcript replica is unavailable'); + } + if (!this.#touchReplica(state, state)) { + throw new Error('Desktop transcript cache capacity was reached'); + } + admitted = true; + } finally { + if (this.#pendingTranscriptConsumers.get(consumerId) === pending) { + this.#pendingTranscriptConsumers.delete(consumerId); + } + state.pendingTranscriptConsumers -= 1; + if (!admitted) { + this.#touchReplica(state); + void this.#closeIfIdle(state); } } - if (!existing) { - const state = this.#state(sessionId); + const destroyedListener = () => { + void this.closeTranscript(consumerId); + }; + const consumer: TranscriptConsumer = { + consumerId, + target, + destroyedListener, + generation: replica.generation, + deliverySequence: 0, + deliveryBytes: 0, + resetRequested: false, + pendingDeliveries: new Map(), + }; + state.transcriptConsumers.set(consumerId, consumer); + this.#transcriptConsumers.set(consumerId, state); + target.once('destroyed', destroyedListener); + try { + consumer.resetRequested = true; + await this.#scheduleTranscriptDelivery(state, consumer); await state.subscriptionOwner.waitUntilReady(); - state.transcriptConsumed = true; - const transcript = cloneMessages(state.transcript ?? []); - void this.#closeIfIdle(state); - return transcript; + if (state.replica?.generation !== consumer.generation) { + consumer.resetRequested = true; + await this.#scheduleTranscriptDelivery(state, consumer); + } + const currentReplica = state.replica; + if (!currentReplica?.resident || currentReplica.generation !== consumer.generation) { + throw new Error('Desktop transcript replica changed while opening'); + } + this.#touchReplica(state); + this.#markTranscriptRead(state, currentReplica); + const readThroughMessageId = currentReplica.latestDurableVisibleMessageId(); + return { + sessionId, + generation: currentReplica.generation, + hostEpoch: currentReplica.hostEpoch, + readThroughMessageId, + }; + } catch (error) { + this.#detachTranscriptConsumer(state, consumer); + await this.#closeIfIdle(state); + throw error; } - return this.#loadCurrentTranscript(sessionId); + } + + async loadTranscriptBefore( + request: DesktopTranscriptRangeRequest, + targetId?: number, + ): Promise { + const { state, replica, consumer } = this.#requireTranscriptConsumer(request, targetId); + await replica.loadBefore(request.anchorSequence, requireTranscriptRangeBytes(request.maxBytes)); + await consumer.deliveryTask; + this.#touchReplica(state); + } + + async loadTranscriptAround( + request: DesktopTranscriptRangeRequest, + targetId?: number, + ): Promise { + const { state, replica, consumer } = this.#requireTranscriptConsumer(request, targetId); + if (request.anchorSequence === null) { + throw new Error('Desktop transcript around request requires an anchor'); + } + await replica.loadAround( + request.anchorSequence, + requireTranscriptRangeBytes(request.maxBytes), + ); + await consumer.deliveryTask; + this.#touchReplica(state); + } + + async closeTranscript(consumerId: string, targetId?: number): Promise { + const pending = this.#pendingTranscriptConsumers.get(consumerId); + if (pending) { + if (targetId !== undefined && pending.targetId !== targetId) { + throw new Error('Desktop transcript consumer belongs to another renderer'); + } + this.#pendingTranscriptConsumers.delete(consumerId); + pending.cancel(); + return; + } + const state = this.#transcriptConsumers.get(consumerId); + const consumer = state?.transcriptConsumers.get(consumerId); + if (!state || !consumer) return; + if (targetId !== undefined && consumer.target.id !== targetId) { + throw new Error('Desktop transcript consumer belongs to another renderer'); + } + this.#detachTranscriptConsumer(state, consumer); + this.#touchReplica(state); + await this.#closeIfIdle(state); + } + + acknowledgeTranscript( + consumerId: string, + generation: string, + deliverySequence: number, + targetId?: number, + ): void { + const state = this.#transcriptConsumers.get(consumerId); + const consumer = state?.transcriptConsumers.get(consumerId); + if (!state || !consumer) return; + if (targetId !== undefined && consumer.target.id !== targetId) { + throw new Error('Desktop transcript consumer belongs to another renderer'); + } + const pending = consumer.pendingDeliveries.get(deliverySequence); + if (!pending || pending.generation !== generation) return; + consumer.pendingDeliveries.delete(deliverySequence); + pending.resolve(); } async snapshot(sessionId: string): Promise { @@ -226,8 +435,8 @@ export class RuntimeHostSessionObserver { const root = state.snapshot?.rootTurn; if (root && root.turnId === turnId && isTerminalTurn(root)) { this.#finishWatchedTurn(state, turnId, "completed"); - void this.#closeIfIdle(state); } + void this.#closeIfIdle(state); } activeInteraction( @@ -308,6 +517,9 @@ export class RuntimeHostSessionObserver { async close(): Promise { if (this.#closed) return; this.#closed = true; + const pendingTranscripts = [...this.#pendingTranscriptConsumers.values()]; + this.#pendingTranscriptConsumers.clear(); + for (const pending of pendingTranscripts) pending.cancel(); const states = [...this.#states.values()]; this.#states.clear(); this.#observers.clear(); @@ -322,8 +534,14 @@ export class RuntimeHostSessionObserver { client: this.#client, sessionId, now: this.#now, - commit: (subscription, recovered) => - this.#commitSubscription(state, subscription, recovered), + transcriptReplicaOptions: { + accountPreparationBytes: (deltaBytes) => + this.#accountTranscriptPreparation(state, deltaBytes), + onChange: (replica, change) => + this.#broadcastTranscriptChange(state, replica, change), + }, + prepareActivation: (subscription, recovered) => + this.#prepareSubscriptionActivation(state, subscription, recovered), acceptFrame: (frame) => this.#acceptFrame(state, frame), recoveryStarted: (error) => { console.warn( @@ -336,6 +554,7 @@ export class RuntimeHostSessionObserver { "[runtime-host-session-observer] subscription recovered", subscriptionFailureIdentity(state, error), ); + void this.#closeIfIdle(state); }, recoveryFailed: (initialError, error) => { console.error( @@ -353,8 +572,10 @@ export class RuntimeHostSessionObserver { sessionId, targets: new Map(), watchedTurnIds: new Set(), + transcriptConsumers: new Map(), subscriptionOwner, - transcriptConsumed: false, + pendingTranscriptConsumers: 0, + transcriptAccess: 0, closing: false, }; this.#states.set(sessionId, state); @@ -370,7 +591,11 @@ export class RuntimeHostSessionObserver { } } - #acceptFrame(state: ObservedSessionState, frame: SubscriptionFrame): void { + async #acceptFrame(state: ObservedSessionState, frame: SubscriptionFrame): Promise { + if (frame.kind === 'subscription.transcript_advanced') { + await state.replica?.advance(frame.throughSequence); + return; + } if (frame.kind === "subscription.runtime_resource_pty_data") { this.#emitRuntimeResourcePtyData({ sessionId: frame.sessionId, @@ -514,82 +739,116 @@ export class RuntimeHostSessionObserver { this.#publishSubscriptionFailure(state, error); } - #commitSubscription( + async #prepareSubscriptionActivation( state: ObservedSessionState, subscription: PreparedSessionSubscription, recovered: boolean, - ): void { + ): Promise<() => void> { if (state.closing || this.#states.get(state.sessionId) !== state) { throw new Error("Runtime Host Session observer closed before commit"); } const previousSnapshot = state.snapshot; + const previousReplica = state.replica; const projector = new RuntimeHostSessionProjector( subscription.snapshot, - subscription.transcript, + subscription.replica.projectionSeed, this.#now, subscription.activeAssistantStreams, ); + const terminalTurnIds = new Set(); + for (const turnId of state.watchedTurnIds) { + if (subscription.snapshot.rootTurn?.turnId !== turnId) terminalTurnIds.add(turnId); + } + if (previousSnapshot?.rootTurn && !isTerminalTurn(previousSnapshot.rootTurn)) { + const nextRoot = subscription.snapshot.rootTurn; + if (!nextRoot || nextRoot.runId !== previousSnapshot.rootTurn.runId) { + terminalTurnIds.add(previousSnapshot.rootTurn.turnId); + } + } + const recordedTurns = await this.#readMissingRecordedTurns( + subscription.replica, + terminalTurnIds, + ); + if (state.closing || this.#states.get(state.sessionId) !== state) { + throw new Error('Runtime Host Session observer closed before commit'); + } const replacement = previousSnapshot ? replacementProjection( previousSnapshot, projector, - subscription.transcript, + previousSnapshot.rootTurn + ? subscription.replica.messagesForTurn(previousSnapshot.rootTurn.turnId) + : [], + recordedTurns, ) : undefined; const goalChanged = previousSnapshot ? !sameGoal(previousSnapshot.goal, subscription.snapshot.goal) : false; - state.snapshot = structuredClone(subscription.snapshot); - state.transcript = subscription.transcript; - state.transcriptConsumed = false; - state.projector = projector; - - if (replacement) { - for (const event of replacement.terminalEvents) { - this.#broadcast(state.sessionId, event); - } - for (const event of replacement.activeEvents) { - this.#broadcast(state.sessionId, event); - } - for (const group of state.targets.values()) group.seeded = true; - for (const turnId of replacement.terminalTurnIds) { - this.#finishWatchedTurn(state, turnId, "completed"); - this.#emitSessionsChanged("turn-status-change", state.sessionId, { - turnId, - }); - this.#emitSessionsChanged("message-appended", state.sessionId, { - turnId, - }); - } - this.#emitActiveInteractions(state); - if (goalChanged) { - this.#emitSessionsChanged("goal-change", state.sessionId); + return () => { + if ( + state.closing || + this.#states.get(state.sessionId) !== state || + state.snapshot !== previousSnapshot || + state.replica !== previousReplica + ) { + throw new Error('Runtime Host Session observer changed before activation'); } - const root = state.snapshot.rootTurn; - this.#emitSessionsChanged( - "status-change", - state.sessionId, - root ? { turnId: root.turnId } : undefined, - ); - if (root && !replacement.terminalTurnIds.has(root.turnId)) { - this.#emitSessionsChanged("message-appended", state.sessionId, { - turnId: root.turnId, - }); + state.snapshot = structuredClone(subscription.snapshot); + state.replica = subscription.replica; + subscription.replica.adoptResidentAccounting(); + state.projector = projector; + previousReplica?.close(); + this.#resetTranscriptConsumers(state); + this.#touchReplica(state); + + if (replacement) { + for (const event of replacement.terminalEvents) { + this.#broadcast(state.sessionId, event); + } + for (const event of replacement.activeEvents) { + this.#broadcast(state.sessionId, event); + } + for (const group of state.targets.values()) group.seeded = true; + for (const turnId of replacement.terminalTurnIds) { + this.#finishWatchedTurn(state, turnId, "completed"); + this.#emitSessionsChanged("turn-status-change", state.sessionId, { + turnId, + }); + this.#emitSessionsChanged("message-appended", state.sessionId, { + turnId, + }); + } + this.#emitActiveInteractions(state); + if (goalChanged) { + this.#emitSessionsChanged("goal-change", state.sessionId); + } + const root = state.snapshot.rootTurn; + this.#emitSessionsChanged( + "status-change", + state.sessionId, + root ? { turnId: root.turnId } : undefined, + ); + if (root && !replacement.terminalTurnIds.has(root.turnId)) { + this.#emitSessionsChanged("message-appended", state.sessionId, { + turnId: root.turnId, + }); + } + } else { + for (const group of state.targets.values()) this.#seedTarget(state, group); } - } else { - for (const group of state.targets.values()) this.#seedTarget(state, group); - } - this.#finishPersistedWatchedTurns( - state, - projector, - subscription.transcript, - ); + this.#finishPersistedWatchedTurns( + state, + projector, + subscription.replica, + recordedTurns, + ); - if (recovered) this.#emitSubscriptionRecovered(state.sessionId); - void this.#closeIfIdle(state); + if (recovered) this.#emitSubscriptionRecovered(state.sessionId); + }; } #emitActiveInteractions(state: ObservedSessionState): void { @@ -605,44 +864,56 @@ export class RuntimeHostSessionObserver { #finishPersistedWatchedTurns( state: ObservedSessionState, projector: RuntimeHostSessionProjector, - transcript: readonly StoredMessage[], + replica: DesktopTranscriptReplica, + recordedTurns: ReadonlyMap, ): void { + const root = projector.snapshot.rootTurn; for (const turnId of [...state.watchedTurnIds]) { - if (projector.seedStoredTerminal(turnId, transcript).length > 0) { + if (root?.turnId === turnId && isTerminalTurn(root)) { + this.#finishWatchedTurn(state, turnId, 'completed'); + continue; + } + const events = projector.seedStoredTerminal(turnId, replica.messagesForTurn(turnId)); + const recorded = recordedTurns.get(turnId); + if ( + events.some(isTerminalSessionEvent) || + (recorded && projector.seedRecordedTerminal(recorded).length > 0) + ) { this.#finishWatchedTurn(state, turnId, "completed"); } } } - async #loadCurrentTranscript(sessionId: string): Promise { - let refresh = this.#transcriptRefreshes.get(sessionId); - if (!refresh) { - refresh = this.#readCurrentTranscript(sessionId); - this.#transcriptRefreshes.set(sessionId, refresh); - const release = () => { - if (this.#transcriptRefreshes.get(sessionId) === refresh) { - this.#transcriptRefreshes.delete(sessionId); - } - }; - void refresh.then(release, release); - } - return refresh.then(cloneMessages); - } - - async #readCurrentTranscript(sessionId: string): Promise { - const handle = await this.#client.openSession(sessionId); - void drainFrames(handle.events).catch(() => undefined); - try { - return await handle.transcript; - } finally { - await handle.close(); - } + async #readMissingRecordedTurns( + replica: DesktopTranscriptReplica, + turnIds: ReadonlySet, + ): Promise> { + const missing = [...turnIds].filter( + (turnId) => !hasStoredTerminal(replica.messagesForTurn(turnId)), + ); + if (missing.length === 0 || !this.#client.listSessionTurns) return new Map(); + const wanted = new Set(missing); + return new Map( + (await this.#client.listSessionTurns(replica.sessionId)) + .filter((turn) => wanted.has(turn.turnId)) + .map((turn) => [turn.turnId, turn]), + ); } async #closeIfIdle(state: ObservedSessionState): Promise { - if (state.targets.size > 0 || state.watchedTurnIds.size > 0) return; + if ( + state.targets.size > 0 || + state.watchedTurnIds.size > 0 || + state.transcriptConsumers.size > 0 || + state.pendingTranscriptConsumers > 0 + ) return; await Promise.resolve(); - if (state.targets.size === 0 && state.watchedTurnIds.size === 0) { + if ( + state.targets.size === 0 && + state.watchedTurnIds.size === 0 && + state.transcriptConsumers.size === 0 && + state.pendingTranscriptConsumers === 0 + ) { await this.#closeState(state); } } @@ -687,6 +958,10 @@ export class RuntimeHostSessionObserver { this.#states.delete(state.sessionId); for (const group of state.targets.values()) this.#detachTarget(state, group); + for (const consumer of state.transcriptConsumers.values()) { + this.#detachTranscriptConsumer(state, consumer); + } + state.replica?.close(); } await state.subscriptionOwner.close(); } @@ -725,6 +1000,361 @@ export class RuntimeHostSessionObserver { if (this.#closed) throw new Error("Runtime Host Session observer is closed"); } + + #broadcastTranscriptChange( + state: ObservedSessionState, + replica: DesktopTranscriptReplica, + change: DesktopTranscriptReplicaChange, + ): void { + if (state.replica !== replica || state.closing) return; + state.projector?.noteTranscriptMessageIds( + change.durableUpserts.map((entry) => entry.message.id), + ); + this.#sendTranscriptChange(state, replica, change); + if (!change.hasNewer && change.durableUpserts.length > 0) { + this.#markTranscriptRead(state, replica); + } + this.#touchReplica(state); + void this.#closeIfIdle(state); + } + + #sendTranscriptChange( + state: ObservedSessionState, + replica: DesktopTranscriptReplica, + change: DesktopTranscriptReplicaChange, + ): void { + for (const consumer of [...state.transcriptConsumers.values()]) { + if (consumer.generation !== replica.generation) { + this.#requestTranscriptReset(state, consumer); + } else if (!this.#mergeTranscriptChange(consumer, change)) { + this.#detachTranscriptConsumer(state, consumer); + void this.#closeIfIdle(state); + } else { + void this.#scheduleTranscriptDelivery(state, consumer).catch(() => undefined); + } + } + } + + #resetTranscriptConsumers(state: ObservedSessionState): void { + for (const consumer of [...state.transcriptConsumers.values()]) { + this.#requestTranscriptReset(state, consumer); + } + } + + #scheduleTranscriptDelivery( + state: ObservedSessionState, + consumer: TranscriptConsumer, + ): Promise { + if (consumer.deliveryTask) return consumer.deliveryTask; + let task!: Promise; + task = (async () => { + try { + while (state.transcriptConsumers.get(consumer.consumerId) === consumer) { + if (consumer.resetRequested) { + consumer.resetRequested = false; + this.#clearPendingTranscriptChange(consumer); + const replica = state.replica; + if (!replica?.resident || state.closing) return; + const deliveryBytes = resetDeliveryWorkingSetBytes(replica.residentBytes); + if (!this.#adjustTranscriptDeliveryBytes(consumer, deliveryBytes)) { + throw new Error('Desktop transcript delivery capacity was reached'); + } + consumer.generation = replica.generation; + try { + await this.#sendTranscriptBatches( + consumer, + encodeDesktopTranscriptSnapshot(replica.snapshot()), + ); + } finally { + this.#adjustTranscriptDeliveryBytes(consumer, -deliveryBytes); + } + continue; + } + const pending = consumer.pendingChange; + if (!pending) return; + consumer.pendingChange = undefined; + try { + const replica = state.replica; + if (!replica?.resident || replica.generation !== consumer.generation) { + consumer.resetRequested = true; + continue; + } + await this.#sendTranscriptBatches( + consumer, + encodeDesktopTranscriptChange( + { + sessionId: replica.sessionId, + generation: replica.generation, + hostEpoch: replica.hostEpoch, + }, + { + durableThrough: pending.durableThrough, + durableUpserts: [...pending.durableUpserts.values()].map(({ entry }) => entry), + evictedDurableSequences: [...pending.evictedDurableSequences], + completedOverlayMessageIds: [...pending.completedOverlayMessageIds], + hasOlder: pending.hasOlder, + hasNewer: pending.hasNewer, + }, + ), + ); + } finally { + this.#adjustTranscriptDeliveryBytes(consumer, -pending.encodedBytes); + } + } + } catch (error) { + this.#detachTranscriptConsumer(state, consumer); + void this.#closeIfIdle(state); + throw error; + } + })().finally(() => { + if (consumer.deliveryTask === task) consumer.deliveryTask = undefined; + }); + consumer.deliveryTask = task; + void task.catch(() => undefined); + return task; + } + + #requestTranscriptReset(state: ObservedSessionState, consumer: TranscriptConsumer): void { + consumer.resetRequested = true; + this.#clearPendingTranscriptChange(consumer); + void this.#scheduleTranscriptDelivery(state, consumer).catch(() => undefined); + } + + #mergeTranscriptChange( + consumer: TranscriptConsumer, + change: DesktopTranscriptReplicaChange, + ): boolean { + if (consumer.resetRequested) return true; + const pending = consumer.pendingChange ?? { + durableThrough: change.durableThrough, + durableUpserts: new Map(), + evictedDurableSequences: new Set(), + completedOverlayMessageIds: new Set(), + hasOlder: change.hasOlder, + hasNewer: change.hasNewer, + encodedBytes: 0, + }; + let byteDelta = 0; + pending.durableThrough = change.durableThrough; + pending.hasOlder = change.hasOlder; + pending.hasNewer = change.hasNewer; + for (const entry of change.durableUpserts) { + const previous = pending.durableUpserts.get(entry.sequence); + if (previous) byteDelta -= previous.encodedBytes; + const encodedBytes = encodedTranscriptMessageBytes(entry.message); + pending.durableUpserts.set(entry.sequence, { entry, encodedBytes }); + byteDelta += encodedBytes; + if (pending.evictedDurableSequences.delete(entry.sequence)) { + byteDelta -= encodedTranscriptIdentityBytes(entry.sequence); + } + } + for (const sequence of change.evictedDurableSequences) { + const previous = pending.durableUpserts.get(sequence); + if (previous) { + pending.durableUpserts.delete(sequence); + byteDelta -= previous.encodedBytes; + } + if (!pending.evictedDurableSequences.has(sequence)) { + pending.evictedDurableSequences.add(sequence); + byteDelta += encodedTranscriptIdentityBytes(sequence); + } + } + for (const messageId of change.completedOverlayMessageIds) { + if (!pending.completedOverlayMessageIds.has(messageId)) { + pending.completedOverlayMessageIds.add(messageId); + byteDelta += encodedTranscriptIdentityBytes(messageId); + } + } + pending.encodedBytes += byteDelta; + consumer.pendingChange = pending; + if (this.#adjustTranscriptDeliveryBytes(consumer, byteDelta)) return true; + pending.encodedBytes -= byteDelta; + this.#clearPendingTranscriptChange(consumer); + return false; + } + + #clearPendingTranscriptChange(consumer: TranscriptConsumer): void { + const pending = consumer.pendingChange; + if (!pending) return; + consumer.pendingChange = undefined; + this.#adjustTranscriptDeliveryBytes(consumer, -pending.encodedBytes); + } + + #adjustTranscriptDeliveryBytes(consumer: TranscriptConsumer, delta: number): boolean { + consumer.deliveryBytes += delta; + if (delta <= 0 || this.#transcriptResidentBytes() <= DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES) { + return true; + } + consumer.deliveryBytes -= delta; + return false; + } + + #transcriptResidentBytes(): number { + let total = this.#transcriptPreparationBytes; + for (const state of this.#states.values()) { + total += state.replica?.residentBytes ?? 0; + for (const consumer of state.transcriptConsumers.values()) total += consumer.deliveryBytes; + } + return total; + } + + #accountTranscriptPreparation(state: ObservedSessionState, deltaBytes: number): void { + if (!Number.isSafeInteger(deltaBytes)) { + throw new RangeError('Invalid Desktop transcript preparation size'); + } + if (this.#transcriptPreparationBytes + deltaBytes < 0) { + throw new RangeError('Invalid Desktop transcript preparation release'); + } + this.#transcriptPreparationBytes += deltaBytes; + if (deltaBytes > 0 && !this.#touchReplica(state, state)) { + this.#transcriptPreparationBytes -= deltaBytes; + throw new RangeError('Desktop transcript preparation exceeds the global cache limit'); + } + } + + #deliverTranscriptBatch( + consumer: TranscriptConsumer, + batch: DesktopTranscriptBatchPayload, + ): Promise { + if (consumer.pendingDeliveries.size >= TRANSCRIPT_DELIVERY_WINDOW) { + throw new Error('Desktop transcript consumer delivery window is full'); + } + let resolve!: () => void; + let reject!: (error: Error) => void; + const acknowledged = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + const deliverySequence = ++consumer.deliverySequence; + const pending = { + generation: batch.generation, + deliverySequence, + resolve, + reject, + }; + consumer.pendingDeliveries.set(deliverySequence, pending); + const timeout = setTimeout( + () => reject(new Error('Desktop transcript delivery timed out')), + TRANSCRIPT_DELIVERY_TIMEOUT_MS, + ); + return (async () => { + try { + consumer.target.send(transcriptChannel(consumer.consumerId), { + ...batch, + deliverySequence, + }); + await acknowledged; + } finally { + clearTimeout(timeout); + if (consumer.pendingDeliveries.get(deliverySequence) === pending) { + consumer.pendingDeliveries.delete(deliverySequence); + } + } + })(); + } + + async #sendTranscriptBatches( + consumer: TranscriptConsumer, + batches: Iterable, + ): Promise { + const deliveries = new Set>(); + for (const batch of batches) { + let delivery!: Promise; + delivery = this.#deliverTranscriptBatch(consumer, batch).finally(() => { + deliveries.delete(delivery); + }); + deliveries.add(delivery); + void delivery.catch(() => undefined); + if (deliveries.size === TRANSCRIPT_DELIVERY_WINDOW) { + await Promise.race(deliveries); + } + } + await Promise.all(deliveries); + } + + #requireTranscriptConsumer( + request: DesktopTranscriptRangeRequest, + targetId?: number, + ): { + state: ObservedSessionState; + replica: DesktopTranscriptReplica; + consumer: TranscriptConsumer; + } { + const state = this.#transcriptConsumers.get(request.consumerId); + const consumer = state?.transcriptConsumers.get(request.consumerId); + const replica = state?.replica; + if (!state || !consumer || !replica) { + throw new Error('Desktop transcript consumer does not exist'); + } + if (targetId !== undefined && consumer.target.id !== targetId) { + throw new Error('Desktop transcript consumer belongs to another renderer'); + } + if (consumer.generation !== request.generation || replica.generation !== request.generation) { + throw new Error('Desktop transcript generation changed'); + } + return { state, replica, consumer }; + } + + #detachTranscriptConsumer( + state: ObservedSessionState, + consumer: TranscriptConsumer, + ): void { + if (state.transcriptConsumers.get(consumer.consumerId) !== consumer) return; + state.transcriptConsumers.delete(consumer.consumerId); + this.#transcriptConsumers.delete(consumer.consumerId); + consumer.resetRequested = false; + this.#clearPendingTranscriptChange(consumer); + for (const pending of consumer.pendingDeliveries.values()) { + pending.reject(new Error('Desktop transcript consumer was closed')); + } + consumer.pendingDeliveries.clear(); + consumer.target.off('destroyed', consumer.destroyedListener); + } + + #touchReplica(state: ObservedSessionState, protectedState?: ObservedSessionState): boolean { + state.transcriptAccess = ++this.#transcriptAccessClock; + let total = this.#transcriptResidentBytes(); + const replicas: Array<{ + state: ObservedSessionState; + replica: DesktopTranscriptReplica; + }> = []; + for (const candidate of this.#states.values()) { + if (!candidate.replica) continue; + replicas.push({ state: candidate, replica: candidate.replica }); + } + replicas.sort((left, right) => left.state.transcriptAccess - right.state.transcriptAccess); + for (const candidate of replicas) { + if (total <= DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES) break; + const before = candidate.replica.residentBytes; + const change = candidate.replica.trimDurable( + Math.max(0, before - (total - DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES)), + ); + if (change) this.#sendTranscriptChange(candidate.state, candidate.replica, change); + total -= before - candidate.replica.residentBytes; + } + for (const candidate of replicas) { + if (total <= DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES) break; + if ( + candidate.state === protectedState || + candidate.state.pendingTranscriptConsumers > 0 || + candidate.state.transcriptConsumers.size > 0 + ) { + continue; + } + const before = candidate.replica.residentBytes; + candidate.replica.discard(); + total -= before; + } + return this.#transcriptResidentBytes() <= DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES; + } + + #markTranscriptRead(state: ObservedSessionState, replica: DesktopTranscriptReplica): void { + if (state.transcriptConsumers.size === 0) return; + const messageId = replica.latestDurableVisibleMessageId(); + if (!messageId) return; + const update = this.#client.setSessionReadMarker?.(state.sessionId, messageId); + if (update) void update.catch(() => undefined); + } } function interactionToolUseId(interaction: InteractionPendingSnapshot): string { @@ -737,6 +1367,39 @@ function sessionEventChannel(sessionId: string): string { return `sessions:event:${sessionId}`; } +function transcriptChannel(consumerId: string): string { + return `sessions:transcript:${consumerId}`; +} + +function encodedTranscriptMessageBytes(message: StoredMessage): number { + return Buffer.byteLength(JSON.stringify(message), 'utf8'); +} + +function encodedTranscriptIdentityBytes(identity: number | string): number { + return Buffer.byteLength(JSON.stringify(identity), 'utf8'); +} + +function requireTranscriptRangeBytes(value: number): number { + if ( + !Number.isSafeInteger(value) || + value < 1 || + value > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES + ) { + throw new Error('Invalid Desktop transcript range byte limit'); + } + return value; +} + +function resetDeliveryWorkingSetBytes(residentBytes: number): number { + return ( + Math.min(residentBytes, DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES) + + Math.min( + residentBytes, + (TRANSCRIPT_DELIVERY_WINDOW * 2 + 1) * DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + ) + ); +} + function sameGoal( previous: SessionContinuitySnapshot["goal"] | undefined, next: SessionContinuitySnapshot["goal"], @@ -749,24 +1412,15 @@ function sameGoal( ); } -function cloneMessages(messages: readonly StoredMessage[]): StoredMessage[] { - return messages.map((message) => structuredClone(message)); -} - -async function drainFrames( - frames: AsyncIterable, -): Promise { - for await (const _frame of frames) { - // A one-shot transcript read still owns a live Host subscription until it - // closes. Drain bounded frames so transcript pagination cannot be evicted - // as a slow consumer. - } +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); } function replacementProjection( previous: SessionContinuitySnapshot, projector: RuntimeHostSessionProjector, transcript: readonly StoredMessage[], + recordedTurns: ReadonlyMap, ): { terminalEvents: SessionEvent[]; activeEvents: SessionEvent[]; @@ -782,6 +1436,10 @@ function replacementProjection( previousRoot.turnId, transcript, ); + if (!stored.some(isTerminalSessionEvent)) { + const recorded = recordedTurns.get(previousRoot.turnId); + if (recorded) stored.push(...projector.seedRecordedTerminal(recorded)); + } if (!stored.some(isTerminalSessionEvent)) { throw new RuntimeHostSubscriptionError( "projection_revision_invalid", @@ -810,6 +1468,14 @@ function replacementProjection( }; } +function hasStoredTerminal(messages: readonly StoredMessage[]): boolean { + return messages.some( + (message) => + message.type === 'turn_state' && + message.status !== 'running', + ); +} + function isTerminalSessionEvent( event: SessionEvent, ): event is Extract { diff --git a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts index 8df03f327e..e94900923f 100644 --- a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts +++ b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts @@ -1,5 +1,3 @@ -import type { StoredMessage } from '@maka/core/session'; -import { RuntimeHostSessionProjector } from "@maka/runtime-host/adapter"; import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; import type { SessionAssistantStreamIdentity, @@ -10,23 +8,32 @@ import type { DesktopRuntimeHostClient, DesktopRuntimeHostSession, } from "./runtime-host-client.js"; +import { + DesktopTranscriptReplica, + type DesktopTranscriptReplicaOptions, +} from './desktop-transcript-replica.js'; -const MAX_PENDING_FRAMES = 512; +const MAX_PENDING_FRAMES = 32; +const MAX_PENDING_FRAME_BYTES = 256 * 1024; type SessionSubscriptionClient = Pick; export interface PreparedSessionSubscription { readonly snapshot: SessionContinuitySnapshot; readonly activeAssistantStreams: readonly SessionAssistantStreamIdentity[]; - readonly transcript: StoredMessage[]; + readonly replica: DesktopTranscriptReplica; } export interface RuntimeHostSessionSubscriptionOwnerDeps { readonly client: SessionSubscriptionClient; readonly sessionId: string; readonly now: () => number; - commit(subscription: PreparedSessionSubscription, recovered: boolean): void; - acceptFrame(frame: SubscriptionFrame): void; + readonly transcriptReplicaOptions?: DesktopTranscriptReplicaOptions; + prepareActivation( + subscription: PreparedSessionSubscription, + recovered: boolean, + ): Promise<() => void>; + acceptFrame(frame: SubscriptionFrame): void | Promise; recoveryStarted(error: Error): void; recoveryCompleted(error: Error): void; recoveryFailed(initialError: Error, error: Error): void; @@ -37,7 +44,9 @@ interface SubscriptionAttempt { readonly handle: DesktopRuntimeHostSession; readonly pendingFrames: SubscriptionFrame[]; readonly failed: Promise; - phase: "preparing" | "active"; + pendingFrameBytes: number; + replica?: DesktopTranscriptReplica; + phase: 'preparing' | 'active' | 'retiring'; failure?: Error; fail(error: Error): void; } @@ -50,7 +59,9 @@ export class SessionRemovedSubscriptionError extends Error { export class RuntimeHostSessionSubscriptionOwner { readonly #deps: RuntimeHostSessionSubscriptionOwnerDeps; #attempt?: SubscriptionAttempt; + #candidate?: SubscriptionAttempt; #readyTask: Promise = Promise.resolve(); + #refreshTask?: Promise; #started = false; #closed = false; @@ -72,19 +83,104 @@ export class RuntimeHostSessionSubscriptionOwner { } } + refresh(): Promise { + if (this.#refreshTask) return this.#refreshTask; + const task = this.#refresh(); + const tracked = task.finally(() => { + if (this.#refreshTask === tracked) this.#refreshTask = undefined; + }); + this.#refreshTask = tracked; + return tracked; + } + async close(): Promise { if (this.#closed) return; this.#closed = true; const attempt = this.#attempt; + const candidate = this.#candidate; this.#attempt = undefined; + this.#candidate = undefined; attempt?.fail(ownerClosed()); - await attempt?.handle.close().catch(() => undefined); + candidate?.fail(ownerClosed()); + attempt?.replica?.close(); + candidate?.replica?.close(); + await Promise.all([ + attempt?.handle.close().catch(() => undefined), + candidate?.handle.close().catch(() => undefined), + ]); + } + + async #refresh(): Promise { + await this.waitUntilReady(); + this.#assertOpen(); + const readyTask = this.#readyTask; + const previous = this.#attempt; + if (!previous) throw ownerClosed(); + let attempt: SubscriptionAttempt | undefined; + try { + const prepared = await this.#prepare(); + attempt = prepared.attempt; + if (attempt.failure) throw attempt.failure; + previous.phase = 'retiring'; + const activate = await this.#prepareActivation(attempt, prepared.prepared, false); + if (attempt.failure) throw attempt.failure; + if (this.#closed || this.#candidate !== attempt || this.#attempt !== previous) { + throw ownerClosed(); + } + activate(); + this.#candidate = undefined; + this.#attempt = attempt; + previous.replica?.close(); + await previous.handle.close().catch(() => undefined); + await this.#drainPendingFrames(attempt); + attempt.phase = 'active'; + } catch (error) { + const failure = asError(error); + if (attempt && this.#attempt === attempt) { + this.#replaceReadyTask(this.#establish(attempt, failure)); + await this.waitUntilReady(); + return; + } + if (attempt && this.#candidate === attempt) this.#candidate = undefined; + attempt?.replica?.close(); + await attempt?.handle.close().catch(() => undefined); + if (this.#attempt === previous && previous.phase === 'retiring') { + if (previous.failure) { + this.#replaceReadyTask(this.#establish(previous, previous.failure)); + await this.waitUntilReady(); + return; + } + previous.phase = 'active'; + try { + await this.#drainPendingFrames(previous); + } catch (error) { + const recoveryError = asError(error); + this.#replaceReadyTask(this.#establish(previous, recoveryError)); + await this.waitUntilReady(); + return; + } + } + if (this.#readyTask !== readyTask) { + await this.waitUntilReady(); + if (this.#attempt?.replica?.resident) return; + } + throw failure; + } } async #establish(failed?: SubscriptionAttempt, initialError?: Error): Promise { let recoveryError = initialError; if (recoveryError) this.#deps.recoveryStarted(recoveryError); - if (failed) await failed.handle.close().catch(() => undefined); + if (failed) { + const candidate = this.#candidate; + this.#candidate = undefined; + candidate?.fail(ownerClosed()); + candidate?.replica?.close(); + await candidate?.handle.close().catch(() => undefined); + if (this.#attempt === failed) this.#attempt = undefined; + failed.replica?.close(); + await failed.handle.close().catch(() => undefined); + } while (true) { this.#assertOpen(); @@ -107,14 +203,22 @@ export class RuntimeHostSessionSubscriptionOwner { try { if (attempt.failure) throw attempt.failure; - this.#deps.commit(prepared, recoveryError !== undefined); + const activate = await this.#prepareActivation( + attempt, + prepared, + recoveryError !== undefined, + ); if (attempt.failure) throw attempt.failure; + if (this.#closed || this.#candidate !== attempt) throw ownerClosed(); + activate(); + this.#candidate = undefined; + this.#attempt = attempt; + await this.#drainPendingFrames(attempt); attempt.phase = "active"; - for (const frame of attempt.pendingFrames.splice(0)) { - this.#deps.acceptFrame(frame); - } } catch (error) { + if (this.#candidate === attempt) this.#candidate = undefined; if (this.#attempt === attempt) this.#attempt = undefined; + attempt.replica?.close(); await attempt.handle.close().catch(() => undefined); const failure = asError(error); if (isRecoverableSubscriptionFailure(failure)) { @@ -151,6 +255,7 @@ export class RuntimeHostSessionSubscriptionOwner { handle, pendingFrames: [], failed, + pendingFrameBytes: 0, phase: "preparing", fail(error) { if (attempt.failure) return; @@ -158,68 +263,93 @@ export class RuntimeHostSessionSubscriptionOwner { fail(error); }, }; - this.#attempt = attempt; + if (this.#candidate) { + await handle.close().catch(() => undefined); + throw new Error('Runtime Host Session replacement is already preparing'); + } + this.#candidate = attempt; void this.#pump(attempt); + const replicaPreparation = DesktopTranscriptReplica.prepare( + handle, + this.#deps.transcriptReplicaOptions, + ); try { const loaded = await Promise.race([ - handle.transcript.then( - (transcript) => ({ kind: "transcript" as const, transcript }), + replicaPreparation.then( + (replica) => ({ kind: "replica" as const, replica }), (error: unknown) => ({ kind: "failure" as const, error: asError(error) }), ), failed.then((error) => ({ kind: "failure" as const, error })), ]); if (loaded.kind === "failure") throw loaded.error; + attempt.replica = loaded.replica; if (attempt.failure) throw attempt.failure; - if (this.#closed || this.#attempt !== attempt) throw ownerClosed(); - validatePendingFrames( - handle.snapshot, - handle.activeAssistantStreams, - loaded.transcript, - attempt.pendingFrames, - this.#deps.now, - ); + if (this.#closed || this.#candidate !== attempt) throw ownerClosed(); return { attempt, prepared: { snapshot: structuredClone(handle.snapshot), activeAssistantStreams: structuredClone(handle.activeAssistantStreams), - transcript: loaded.transcript, + replica: loaded.replica, }, }; } catch (error) { - if (this.#attempt === attempt) this.#attempt = undefined; + if (this.#candidate === attempt) this.#candidate = undefined; + if (attempt.replica) attempt.replica.close(); + else void replicaPreparation.then((replica) => replica.close(), () => undefined); await handle.close().catch(() => undefined); throw error; } } + async #prepareActivation( + attempt: SubscriptionAttempt, + subscription: PreparedSessionSubscription, + recovered: boolean, + ): Promise<() => void> { + const result = await Promise.race([ + this.#deps.prepareActivation(subscription, recovered).then( + (activate) => ({ kind: 'ready' as const, activate }), + (error: unknown) => ({ kind: 'failure' as const, error: asError(error) }), + ), + attempt.failed.then((error) => ({ kind: 'failure' as const, error })), + ]); + if (result.kind === 'failure') throw result.error; + return result.activate; + } + async #pump(attempt: SubscriptionAttempt): Promise { try { for await (const frame of attempt.handle.events) { - if (this.#closed || this.#attempt !== attempt) return; + if (this.#closed || (this.#attempt !== attempt && this.#candidate !== attempt)) return; if (frame.kind === "subscription.closed") { throw subscriptionClosedError(frame.reason); } - if (attempt.phase === "preparing") { - if (attempt.pendingFrames.length >= MAX_PENDING_FRAMES) { + if (attempt.phase !== 'active') { + const frameBytes = Buffer.byteLength(JSON.stringify(frame), 'utf8'); + if ( + attempt.pendingFrames.length >= MAX_PENDING_FRAMES || + attempt.pendingFrameBytes + frameBytes > MAX_PENDING_FRAME_BYTES + ) { throw new RuntimeHostSubscriptionError( - "slow_consumer", - "Runtime Host Session transcript could not keep up with live events", + 'slow_consumer', + 'Runtime Host Session transcript could not keep up with live events', ); } attempt.pendingFrames.push(frame); + attempt.pendingFrameBytes += frameBytes; } else { - this.#deps.acceptFrame(frame); + await this.#deps.acceptFrame(frame); } } if (!this.#closed) { throw new Error("Runtime Host Session subscription ended unexpectedly"); } } catch (error) { - if (this.#closed || this.#attempt !== attempt) return; + if (this.#closed || (this.#attempt !== attempt && this.#candidate !== attempt)) return; const failure = asError(error); - if (attempt.phase === "preparing") { + if (attempt.phase !== 'active') { attempt.fail(failure); } else if (isRecoverableSubscriptionFailure(failure)) { this.#replaceReadyTask(this.#establish(attempt, failure)); @@ -237,27 +367,20 @@ export class RuntimeHostSessionSubscriptionOwner { }); } + async #drainPendingFrames(attempt: SubscriptionAttempt): Promise { + while (attempt.pendingFrames.length > 0) { + const frame = attempt.pendingFrames.shift()!; + attempt.pendingFrameBytes -= Buffer.byteLength(JSON.stringify(frame), 'utf8'); + await this.#deps.acceptFrame(frame); + if (attempt.failure) throw attempt.failure; + } + } + #assertOpen(): void { if (this.#closed) throw ownerClosed(); } } -function validatePendingFrames( - snapshot: SessionContinuitySnapshot, - activeAssistantStreams: readonly SessionAssistantStreamIdentity[], - transcript: readonly StoredMessage[], - frames: readonly SubscriptionFrame[], - now: () => number, -): void { - const projector = new RuntimeHostSessionProjector( - snapshot, - transcript, - now, - activeAssistantStreams, - ); - for (const frame of frames) projector.accept(frame); -} - function subscriptionClosedError( reason: "slow_consumer" | "session_removed", ): Error { diff --git a/apps/desktop/src/main/search/thread-search.ts b/apps/desktop/src/main/search/thread-search.ts index c0d7a97bd3..cc63018fe0 100644 --- a/apps/desktop/src/main/search/thread-search.ts +++ b/apps/desktop/src/main/search/thread-search.ts @@ -211,7 +211,7 @@ export async function runThreadSearch( const messages = await deps.readMessages(session.id); if (!messages) continue; - for (const message of messages) { + for (const [sequence, message] of messages.entries()) { if (results.length >= maxResults) { truncated = true; break; @@ -248,6 +248,7 @@ export async function runThreadSearch( kind: 'thread', sessionId: session.id, ...(turnId ? { turnId } : {}), + sequence, }, }); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 6440ac89c5..56aec7f324 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -40,7 +40,7 @@ import type { } from '@maka/core/runtime-inputs'; import type { PlanSessionState } from '@maka/core/plan'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; -import type { SessionChangedEvent, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; +import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { E2eFixtureState } from '@maka/core/e2e-fixture'; import type { ExternalSessionSummary } from '@maka/core/external-session'; @@ -77,6 +77,10 @@ import type { WebSearchProvider, WebSearchResponse } from '@maka/core/web-search import type { BrowserState, BrowserViewRect } from '@maka/core/browser'; import type { Task, TaskLedgerChangedEvent } from '@maka/core/task-ledger'; import type { DeepResearchChangedEvent, DeepResearchClientProgress } from '@maka/core/deep-research-run'; +import type { + DesktopTranscriptBatch, + DesktopTranscriptHandle, +} from './transcript-contract.js'; import type { PetPackManifestV1 } from '@maka/core/pet'; import type { OperationInput, @@ -429,7 +433,6 @@ export interface MakaBridge { >; stop(sessionId: string, input?: { source?: 'stop_button' }): Promise; steer(sessionId: string, text: string): Promise; - readMessages(sessionId: string): Promise; readExecutionBoundary(sessionId: string): Promise; listActiveInteractions(sessionId: string): Promise; subscribeActiveInteractions( @@ -439,6 +442,7 @@ export interface MakaBridge { }) => void, ): () => void; listTurns(sessionId: string): Promise; + listTurnLandmarks(sessionId: string): Promise>; compact(sessionId: string): Promise; resumeLatest(sessionId: string): Promise< | { disposition: 'started'; runId: string; turnId: string } @@ -492,13 +496,19 @@ export interface MakaBridge { cleanupSessionCopy(sessionId: string): Promise; abandonSessionCopy(sessionId: string): Promise; }; + transcripts: { + open( + sessionId: string, + handler: (batch: DesktopTranscriptBatch) => void, + registerCancellation?: (cancel: () => void) => void, + ): Promise; + }; externalSessions: { listSources(): Promise<{ adapterIds: string[] }>; - list(input: { - adapterId: string; - includeArchived?: boolean; - cursor?: string; - }): Promise<{ sessions: ExternalSessionSummary[]; nextCursor: string | null }>; + list(input: { adapterId: string; includeArchived?: boolean; cursor?: string }): Promise<{ + sessions: ExternalSessionSummary[]; + nextCursor: string | null; + }>; import(input: { adapterId: string; sourceSessionId: string; @@ -678,7 +688,15 @@ export interface MakaBridge { }; attachments: { pickFiles(): Promise< - | { ok: true; files: { approvalId: string; name: string; mimeType?: string; size: number }[] } + | { + ok: true; + files: { + approvalId: string; + name: string; + mimeType?: string; + size: number; + }[]; + } | { ok: false; reason: 'cancelled' } >; previewApproval(approvalId: string): Promise< @@ -861,7 +879,11 @@ export interface MakaBridge { ok: true; includedData: ConfigCategory[]; result: { - connections?: { created: number; overwritten: number; skipped: number }; + connections?: { + created: number; + overwritten: number; + skipped: number; + }; settings?: { applied: boolean }; credentials?: { applied: number; skipped: number }; memory?: { applied: boolean }; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 1979f253b2..7c789e09a8 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -23,6 +23,13 @@ import type { DesktopProjectSnapshot, } from './bridge-contract.js'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; +import { + DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + assertDesktopTranscriptBatch, + type DesktopTranscriptBatch, + type DesktopTranscriptHandle, + type DesktopTranscriptOpenResult, +} from './transcript-contract.js'; import type { DesktopDiagnosticCopyResult, DesktopErrorDiagnosticInput, @@ -63,7 +70,7 @@ import type { OrchestrationMode } from '@maka/core/orchestration'; import type { TurnOrchestration, SessionListFilter, RegenerateTurnInput } from '@maka/core/runtime-inputs'; import type { PlanSessionState } from '@maka/core/plan'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; -import type { SessionChangedEvent, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; +import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { E2eFixtureState } from '@maka/core/e2e-fixture'; import type { ExternalSessionSummary } from '@maka/core/external-session'; @@ -479,7 +486,10 @@ async function bridgeResult(operation: () => Promise, code: string): Promi } catch (error) { return { ok: false, - error: { code, message: error instanceof Error ? error.message : String(error) }, + error: { + code, + message: error instanceof Error ? error.message : String(error), + }, }; } } @@ -516,7 +526,10 @@ const makaBridge = { return ipcRenderer.invoke('runtime-host-ssh-terminal:getSnapshot'); }, write(sessionId: string, data: string) { - return ipcRenderer.invoke('runtime-host-ssh-terminal:write', { sessionId, data }); + return ipcRenderer.invoke('runtime-host-ssh-terminal:write', { + sessionId, + data, + }); }, resize(sessionId: string, cols: number, rows: number) { return ipcRenderer.invoke('runtime-host-ssh-terminal:resize', { @@ -677,9 +690,6 @@ const makaBridge = { steer(sessionId: string, text: string): Promise { return invokeActiveRuntimeHost('sessions:steer', sessionId, text); }, - readMessages(sessionId: string): Promise { - return invokeActiveRuntimeHost('sessions:readMessages', sessionId); - }, readExecutionBoundary(sessionId: string): Promise { return invokeActiveRuntimeHost('sessions:readExecutionBoundary', sessionId); }, @@ -697,6 +707,9 @@ const makaBridge = { listTurns(sessionId: string): Promise { return invokeActiveRuntimeHost('sessions:listTurns', sessionId); }, + listTurnLandmarks(sessionId) { + return invokeActiveRuntimeHost('sessions:listTurnLandmarks', sessionId); + }, regenerateTurn(sessionId: string, input: RegenerateTurnInput): Promise { return invokeActiveRuntimeHost('sessions:regenerateTurn', sessionId, input); }, @@ -824,6 +837,110 @@ const makaBridge = { return invokeActiveRuntimeHost('sessions:abandonSessionCopy', sessionId); }, }, + transcripts: { + async open( + sessionId: string, + handler: (batch: DesktopTranscriptBatch) => void, + registerCancellation?: (cancel: () => void) => void, + ): Promise { + const consumerId = crypto.randomUUID(); + const channel = `sessions:transcript:${consumerId}`; + let generation: string | undefined; + let closed = false; + let requestClose = () => {}; + let consumerScope: DesktopHostRef | undefined; + const listener = ( + _event: Electron.IpcRendererEvent, + scope: unknown, + value: unknown, + ) => { + if (closed) return; + let batch: DesktopTranscriptBatch; + try { + const host = requireDesktopHostRef(scope); + if ( + !activeRuntimeHost || + host.hostId !== activeRuntimeHost.hostId || + host.targetEpoch !== activeRuntimeHost.targetEpoch + ) return; + batch = assertDesktopTranscriptBatch(value); + if (batch.reset || generation === undefined) { + generation = batch.generation; + consumerScope = host; + } + if (batch.generation === generation) handler(batch); + } catch (error) { + requestClose(); + throw error; + } + if (consumerScope) { + void ipcRenderer.invoke( + 'sessions:transcript:ack', + consumerScope, + consumerId, + batch.generation, + batch.deliverySequence, + ).catch(requestClose); + } + }; + ipcRenderer.on(channel, listener); + const openDispatch = activeRuntimeHostRef().then((scope) => { + consumerScope = scope; + return { + completion: ipcRenderer.invoke( + 'sessions:transcript:open', + scope, + sessionId, + consumerId, + ) as Promise, + }; + }); + let closeTask: Promise | undefined; + requestClose = () => { + if (closed) return; + closed = true; + ipcRenderer.off(channel, listener); + closeTask = releaseSessionObservation(openDispatch, () => + ipcRenderer.invoke('sessions:transcript:close', consumerId), + ); + void closeTask.catch(() => undefined); + }; + registerCancellation?.(requestClose); + let opened: DesktopTranscriptOpenResult; + try { + opened = await openDispatch.then(({ completion }) => completion); + } catch (error) { + closed = true; + ipcRenderer.off(channel, listener); + throw error; + } + if (closed) throw new Error('Desktop transcript open was cancelled'); + generation ??= opened.generation; + const range = ( + operation: 'sessions:transcript:load-before' | 'sessions:transcript:load-around', + anchorSequence: number | null, + maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, + ): Promise => + ipcRenderer.invoke(operation, consumerScope, { + consumerId, + generation, + anchorSequence, + maxBytes, + }) as Promise; + return { + ...opened, + loadBefore: (anchorSequence, maxBytes) => + range('sessions:transcript:load-before', anchorSequence, maxBytes), + loadAround: (sequence, maxBytes) => + range('sessions:transcript:load-around', sequence, maxBytes), + async close() { + if (closed) return; + requestClose(); + await closeTask; + }, + }; + }, + }, externalSessions: { listSources(): Promise<{ adapterIds: string[] }> { return invokeActiveRuntimeHost('external-sessions:listSources'); @@ -1128,7 +1245,15 @@ const makaBridge = { }, attachments: { pickFiles(): Promise< - | { ok: true; files: { approvalId: string; name: string; mimeType?: string; size: number }[] } + | { + ok: true; + files: { + approvalId: string; + name: string; + mimeType?: string; + size: number; + }[]; + } | { ok: false; reason: 'cancelled' } > { return ipcRenderer.invoke('attachments:pickFiles'); @@ -1335,19 +1460,29 @@ const makaBridge = { return mutateScheduledTask({ kind: 'update', taskId: id, patch }); }, setEnabled(id: string, enabled: boolean): Promise { - return mutateScheduledTask({ kind: enabled ? 'resume' : 'pause', taskId: id }); + return mutateScheduledTask({ + kind: enabled ? 'resume' : 'pause', + taskId: id, + }); }, triggerNow(id: string): Promise { return mutateScheduledTask({ kind: 'trigger_now', taskId: id }); }, snooze(id: string): Promise { - return mutateScheduledTask({ kind: 'snooze', taskId: id, delayMs: 10 * 60 * 1000 }); + return mutateScheduledTask({ + kind: 'snooze', + taskId: id, + delayMs: 10 * 60 * 1000, + }); }, clearRunHistory(id: string): Promise { return mutateScheduledTask({ kind: 'clear_history', taskId: id }); }, async delete(id: string): Promise { - await runtimeHost.command('scheduled-task.mutate', { kind: 'delete', taskId: id }); + await runtimeHost.command('scheduled-task.mutate', { + kind: 'delete', + taskId: id, + }); }, subscribeChanges(handler: (event: { type: 'scheduled_tasks_changed'; reason: string; taskId?: string; ts: number }) => void): () => void { return subscribeActiveRuntimeHostEvent('scheduled-tasks:changed', handler); @@ -1451,7 +1586,9 @@ const makaBridge = { }, 'DAILY_REVIEW_DAY_FAILED'); }, async getConfig(): Promise { - const result = await runtimeHost.query('daily-review.query', { kind: 'config' }); + const result = await runtimeHost.query('daily-review.query', { + kind: 'config', + }); if (result.kind !== 'config') throw new Error('Invalid Daily Review config'); return result.config; }, @@ -1473,7 +1610,10 @@ const makaBridge = { return listDailyReviewArchives(); }, async getArchive(archiveId: string): Promise { - const result = await runtimeHost.query('daily-review.query', { kind: 'archive', archiveId }); + const result = await runtimeHost.query('daily-review.query', { + kind: 'archive', + archiveId, + }); if (result.kind !== 'archive') throw new Error('Invalid Daily Review archive'); return result.archive; }, @@ -1544,7 +1684,11 @@ const makaBridge = { ok: true; includedData: ConfigCategory[]; result: { - connections?: { created: number; overwritten: number; skipped: number }; + connections?: { + created: number; + overwritten: number; + skipped: number; + }; settings?: { applied: boolean }; credentials?: { applied: number; skipped: number }; memory?: { applied: boolean }; @@ -1741,7 +1885,10 @@ const makaBridge = { }, setPinned(skillRef: string, pinned: boolean): Promise< | { ok: true; skill: SkillEntry } - | { ok: false; reason: 'not_found' | 'blocked_path' | 'state_error' | 'write_failed' } + | { + ok: false; + reason: 'not_found' | 'blocked_path' | 'state_error' | 'write_failed'; + } > { return invokeActiveRuntimeHost('skills:setPinned', skillRef, pinned); }, diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts new file mode 100644 index 0000000000..99b0dd44db --- /dev/null +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -0,0 +1,121 @@ +export const DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES = 128 * 1024; +export const DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES = 512 * 1024; +export const DESKTOP_TRANSCRIPT_SESSION_CACHE_MAX_BYTES = 20 * 1024 * 1024; +export const DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES = 16 * 1024 * 1024; +export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; +export const DESKTOP_TRANSCRIPT_MESSAGE_MAX_BYTES = 16 * 1024 * 1024; + +export interface DesktopTranscriptFragment { + readonly source: 'durable' | 'overlay'; + readonly identity: number | string; + readonly order: number | null; + readonly byteOffset: number; + readonly totalBytes: number; + readonly data: Uint8Array; +} + +export interface DesktopTranscriptBatchPayload { + readonly sessionId: string; + readonly generation: string; + readonly hostEpoch: string; + readonly durableThrough: number | null; + readonly fragments: readonly DesktopTranscriptFragment[]; + readonly evictedDurableSequences: readonly number[]; + readonly completedOverlayMessageIds: readonly string[]; + readonly hasOlder: boolean; + readonly hasNewer: boolean; + readonly reset: boolean; + readonly ready: boolean; +} + +export interface DesktopTranscriptBatch extends DesktopTranscriptBatchPayload { + readonly deliverySequence: number; +} + +export interface DesktopTranscriptOpenResult { + readonly sessionId: string; + readonly generation: string; + readonly hostEpoch: string; + readonly readThroughMessageId: string | null; +} + +export interface DesktopTranscriptRangeRequest { + readonly consumerId: string; + readonly generation: string; + readonly anchorSequence: number | null; + readonly maxBytes: number; +} + +export interface DesktopTranscriptHandle extends DesktopTranscriptOpenResult { + loadBefore(anchorSequence: number | null, maxBytes?: number): Promise; + loadAround(sequence: number, maxBytes?: number): Promise; + close(): Promise; +} + +export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptBatch { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid Desktop transcript batch'); + } + const batch = value as Record; + if ( + typeof batch.sessionId !== 'string' || + !isSequence(batch.deliverySequence) || + typeof batch.generation !== 'string' || + typeof batch.hostEpoch !== 'string' || + (batch.durableThrough !== null && !isSequence(batch.durableThrough)) || + !Array.isArray(batch.fragments) || + !Array.isArray(batch.evictedDurableSequences) || + !batch.evictedDurableSequences.every(isSequence) || + batch.evictedDurableSequences.length > 256 || + !Array.isArray(batch.completedOverlayMessageIds) || + !batch.completedOverlayMessageIds.every( + (messageId) => typeof messageId === 'string' && messageId.length > 0 && messageId.length <= 256, + ) || + batch.completedOverlayMessageIds.length > 256 || + typeof batch.hasOlder !== 'boolean' || + typeof batch.hasNewer !== 'boolean' || + typeof batch.reset !== 'boolean' || + typeof batch.ready !== 'boolean' + ) { + throw new Error('Invalid Desktop transcript batch'); + } + let rawBytes = 0; + for (const value of batch.fragments) { + const fragment = value as Record; + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (fragment.source !== 'durable' && fragment.source !== 'overlay') || + (fragment.source === 'durable' + ? !isSequence(fragment.identity) + : typeof fragment.identity !== 'string' || fragment.identity.length === 0) || + (fragment.source === 'overlay' + ? !isSequence(fragment.order) + : fragment.order !== null) || + !isSequence(fragment.byteOffset) || + !isSequence(fragment.totalBytes) || + (fragment.totalBytes as number) < 1 || + !(fragment.data instanceof Uint8Array) || + fragment.data.byteLength > DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES + ) { + throw new Error('Invalid Desktop transcript fragment'); + } + const bytes = fragment.data.byteLength; + if ( + bytes < 1 || + (fragment.byteOffset as number) + bytes > (fragment.totalBytes as number) + ) { + throw new Error('Invalid Desktop transcript fragment bounds'); + } + rawBytes += bytes; + } + if (rawBytes > DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES) { + throw new Error('Desktop transcript batch exceeds its byte limit'); + } + return value as DesktopTranscriptBatch; +} + +function isSequence(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 688aafe755..06dc12950c 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -27,6 +27,7 @@ import { showSkillInvocationFeedback, skillInvocationDisplayText, } from './skill-invocation-feedback.js'; +import type { DesktopTranscriptRangeController } from './desktop-transcript-range-store.js'; export type PendingAttachment = { /** Unique per staged item; keys the preview cache and its cleanup, so a @@ -61,13 +62,10 @@ import { noRealConnectionReasonFromError, noRealConnectionSetupDescription, } from './model-connection-errors.js'; -import { readSettledMessages, type RefreshMessagesOptions } from './session-message-settlement.js'; +import type { RefreshMessagesOptions } from './session-message-settlement.js'; export type { RefreshMessagesOptions }; -const USER_MESSAGE_VISIBLE_TIMEOUT_MS = 1_200; -const USER_MESSAGE_VISIBLE_POLL_MS = 40; - type ComposerImportOwner = { sessionId: string | undefined; navSection: NavSelection['section']; @@ -153,6 +151,7 @@ export function createAppShellChatActions(deps: { setMessageLoadErrorBySession: MessageLoadErrorUpdater; setMessageRetryPendingBySession: BooleanRecordUpdater; setMessages: MessageListUpdater; + transcriptRangeRef: RefBox; setNavSelection: (selection: NavSelection) => void; /** #646: arm the "正在处理…" indicator locally at send() — the model-wait * window opens before any SessionEvent arrives (turn_started is not one). */ @@ -186,6 +185,7 @@ export function createAppShellChatActions(deps: { setMessageLoadErrorBySession, setMessageRetryPendingBySession, setMessages, + transcriptRangeRef, setNavSelection, setLiveTurnBySession, setInteractionBySession, @@ -401,13 +401,27 @@ export function createAppShellChatActions(deps: { }, ); } - if (activeIdRef.current === session.id) { - await refreshMessagesUntilTurn(session.id, turnId); - } await refreshSessions(); return true; } const sessionId = initialSessionId; + const transcript = transcriptRangeRef.current; + if (transcript) { + let hasNewer = false; + try { + const range = transcript.store.range(); + hasNewer = range.sessionId === sessionId && range.hasNewer; + } catch { + // An unopened transcript is not a sparse historical view. + } + if (hasNewer) { + await transcript.loadLatest(); + if (activeIdRef.current !== sessionId || transcriptRangeRef.current !== transcript) { + return false; + } + setMessages([...transcript.store.snapshot().messages]); + } + } optimisticSessionId = sessionId; optimisticTurnId = turnId; armTurnActive(sessionId, turnId); @@ -449,7 +463,6 @@ export function createAppShellChatActions(deps: { inlineReferences: sendResult.inlineReferences ?? [], }, ); - await refreshMessagesUntilTurn(sessionId, turnId); return true; } catch (error) { await discardUnsentSession(); @@ -476,7 +489,10 @@ export function createAppShellChatActions(deps: { const feedbackSessionId = optimisticSessionId ?? initialSessionId; const sendStillOwnsCurrentSurface = (feedbackSessionId !== undefined && - isShellSurfaceOwnerActive({ ...sendOwner, sessionId: feedbackSessionId })) || + isShellSurfaceOwnerActive({ + ...sendOwner, + sessionId: feedbackSessionId, + })) || (newChatOwner !== null && isNewChatSendSurfaceActive(newChatOwner)); if (!sendStillOwnsCurrentSurface) return false; if (isNoRealConnectionError(error)) { @@ -543,19 +559,35 @@ export function createAppShellChatActions(deps: { async function refreshMessages(sessionId: string, options: RefreshMessagesOptions = {}): Promise { try { - const result = await readSettledMessages(sessionId, options); - const next = result.messages; - if (activeIdRef.current === sessionId) { - markSessionReadLocally(sessionId, next); - setMessages(next); - setMessageLoadErrorBySession((current) => { - if (!current[sessionId]) return current; - const updated = { ...current }; - delete updated[sessionId]; - return updated; - }); + if (activeIdRef.current !== sessionId) return false; + const controller = transcriptRangeRef.current; + if (!controller) return false; + await controller.ready(); + if (activeIdRef.current !== sessionId || transcriptRangeRef.current !== controller) return false; + const requiredMessageId = options.requiredAssistantMessageId; + if ( + requiredMessageId !== undefined && + !controller.store.hasDurableMessage(requiredMessageId) && + !(await controller.waitForDurableMessage(requiredMessageId, 480)) + ) { + return false; } - return result.settled; + if (activeIdRef.current !== sessionId || transcriptRangeRef.current !== controller) { + return false; + } + const range = controller.store; + const snapshot = range.snapshot(); + if (snapshot.sessionId !== sessionId) return false; + const next = [...snapshot.messages]; + markSessionReadLocally(sessionId, next); + setMessages(next); + setMessageLoadErrorBySession((current) => { + if (!current[sessionId]) return current; + const updated = { ...current }; + delete updated[sessionId]; + return updated; + }); + return requiredMessageId === undefined || range.hasDurableMessage(requiredMessageId); } catch (error) { if (activeIdRef.current === sessionId) { const message = messageRefreshErrorMessage(error, uiLocale); @@ -571,42 +603,21 @@ export function createAppShellChatActions(deps: { async function retryMessages(sessionId: string) { if (!addPendingSessionAction(sessionId, messageRetryPendingRef, setMessageRetryPendingBySession)) return; try { - await refreshMessages(sessionId); + if (activeIdRef.current !== sessionId) return; + await transcriptRangeRef.current?.reload(); + } catch (error) { + if (activeIdRef.current !== sessionId) return; + const message = messageRefreshErrorMessage(error, uiLocale); + setMessageLoadErrorBySession((current) => ({ + ...current, + [sessionId]: message, + })); + toastApi.error(copy.refreshFailedTitle, message); } finally { clearPendingSessionAction(sessionId, messageRetryPendingRef, setMessageRetryPendingBySession); } } - async function refreshMessagesUntilTurn(sessionId: string, turnId: string): Promise { - const deadline = Date.now() + USER_MESSAGE_VISIBLE_TIMEOUT_MS; - while (Date.now() <= deadline) { - // PR-FE-BUG-HUNT-4 (kenji bug-hunt 2026-06-24 LOW): bail if the - // user navigated away from the session this poll was started for. - // Previously the loop kept burning IPC bandwidth for the full - // 1200ms after a session switch (the setState was gated, but the - // readMessages call still fired every 40ms). Now we stop the - // polling cycle itself. - if (activeIdRef.current !== sessionId) return; - try { - const next = await window.maka.sessions.readMessages(sessionId); - if (activeIdRef.current !== sessionId) return; - const hasSentUserTurn = next.some((message) => message.type === 'user' && message.turnId === turnId); - if (hasSentUserTurn) { - markSessionReadLocally(sessionId, next); - setMessages(next); - return; - } - } catch { - // Keep the current visible messages while the bounded retry loop - // waits for the async send path to persist the first user message. - } - await new Promise((resolve) => window.setTimeout(resolve, USER_MESSAGE_VISIBLE_POLL_MS)); - } - if (activeIdRef.current === sessionId) { - await refreshMessages(sessionId); - } - } - return { send, respondToSandboxBoundary, diff --git a/apps/desktop/src/renderer/app-shell-effects.ts b/apps/desktop/src/renderer/app-shell-effects.ts index 620463c524..1dc8075bbe 100644 --- a/apps/desktop/src/renderer/app-shell-effects.ts +++ b/apps/desktop/src/renderer/app-shell-effects.ts @@ -37,6 +37,11 @@ import { ShellRunHydration, type ShellRunUpdatesBySession, } from './shell-run-update-state.js'; +import { + createDesktopTranscriptRangeController, + DesktopTranscriptRangeStore, + type DesktopTranscriptRangeController, +} from './desktop-transcript-range-store.js'; type RefBox = { current: T }; const LAYOUT_PERSIST_DEBOUNCE_MS = 200; @@ -419,19 +424,22 @@ export function useActiveSessionEvents(options: { setMessageLoadErrorBySession: (updater: (current: Record) => Record) => void; setMessageLoadPending: (pending: boolean) => void; setMessages: (messages: StoredMessage[]) => void; + transcriptRangeRef: RefBox; setSessionEventHealthBySession: SessionEventHealthUpdater; toastApi: Pick; }) { const activeId = options.activeId; - const applyReadMessages = useEffectEvent((sessionId: string, next: StoredMessage[], isDisposed: () => boolean) => { + const applyTranscript = useEffectEvent(( + sessionId: string, + store: DesktopTranscriptRangeStore, + isDisposed: () => boolean, + ) => { if (!isDisposed() && options.activeIdRef.current === sessionId) { + const snapshot = store.snapshot(); + const next = [...snapshot.messages]; options.markSessionReadLocally(sessionId, next); - // Ignore an empty read: it can race a just-sent message's save and wipe - // the optimistic copy shown to the user. length is enough only because - // sends are serialized (one optimistic per session); parallel sends - // would need a merge instead. - if (next.length > 0) options.setMessages(next); - options.setMessageLoadPending(false); + options.setMessages(next); + if (snapshot.ready) options.setMessageLoadPending(false); } }); const applyReadError = useEffectEvent((sessionId: string, error: unknown, isDisposed: () => boolean) => { @@ -478,6 +486,7 @@ export function useActiveSessionEvents(options: { useLayoutEffect(() => { if (!activeId) return; let disposed = false; + const transcript = new DesktopTranscriptRangeStore(); const subscribedAt = Date.now(); options.setMessageLoadErrorBySession((current) => { if (!current[activeId]) return current; @@ -492,14 +501,29 @@ export function useActiveSessionEvents(options: { now: subscribedAt, }), })); - void window.maka.sessions - .readMessages(activeId) - .then((next) => { - applyReadMessages(activeId, next, () => disposed); - }) - .catch((error) => { - applyReadError(activeId, error, () => disposed); - }); + const openTranscript = (signal: AbortSignal) => + window.maka.transcripts.open( + activeId, + (batch) => { + if (disposed) return; + try { + if (transcript.accept(batch)) { + applyTranscript(activeId, transcript, () => disposed); + } + } catch (error) { + applyReadError(activeId, error, () => disposed); + } + }, + (cancel) => { + if (signal.aborted) cancel(); + else signal.addEventListener('abort', cancel, { once: true }); + }, + ); + const controller = createDesktopTranscriptRangeController(transcript, openTranscript); + void controller.ready().catch((error) => { + applyReadError(activeId, error, () => disposed); + }); + options.transcriptRangeRef.current = controller; const unsubscribe = window.maka.sessions.subscribeEvents( activeId, (event) => { @@ -509,6 +533,10 @@ export function useActiveSessionEvents(options: { ); return () => { disposed = true; + if (options.transcriptRangeRef.current?.store === transcript) { + options.transcriptRangeRef.current = undefined; + } + void controller.close(); unsubscribe(); markSessionEventStreamClosed(activeId); }; diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts index 668d5935b2..e29305568d 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts @@ -16,6 +16,7 @@ import { type SessionCopyAttemptPhase, type SessionCopyAttemptKey, } from './session-copy-attempt.js'; +import { readSettledMessages } from './session-message-settlement.js'; type RefBox = { current: T }; type MessageListUpdater = ( @@ -91,6 +92,7 @@ export function createAppShellRevisionActions(deps: { upsertSessionSummary, } = deps; const copy = getDesktopConversationCopy(uiLocale).actions; + let revisionPreparationAbort: AbortController | undefined; function revisionCopyKey(sourceSessionId: string, sourceTurnId: string): SessionCopyAttemptKey { return { @@ -290,6 +292,9 @@ export function createAppShellRevisionActions(deps: { } const sourceSessionId = startedDraft.sourceSessionId; let preparedSessionId: string | undefined; + const preparationAbort = new AbortController(); + revisionPreparationAbort?.abort(); + revisionPreparationAbort = preparationAbort; try { const newSession = await window.maka.sessions.reviseBeforeTurn(sourceSessionId, { sourceTurnId: startedDraft.sourceTurnId, @@ -307,20 +312,24 @@ export function createAppShellRevisionActions(deps: { upsertSessionSummary(newSession); openSessionInChat(newSession.id); setMessages([]); - const loaded = await refreshMessages(newSession.id); + const { messages: preparedMessages, settled } = await readSettledMessages(newSession.id, { + signal: preparationAbort.signal, + }); + if (!settled) throw new Error('Revised Session transcript did not become ready'); if ( - !loaded || activeIdRef.current !== newSession.id || revisionDraftRef.current !== prepared ) { await rollbackPreparedRevision(startedDraft, newSession.id, text); return false; } + setMessages(preparedMessages); composerRef.current?.focus(); toastApi.info(copy.revisionReadyTitle, copy.revisionReadyDescription); await refreshSessions(); return true; } catch (error) { + if (preparationAbort.signal.aborted) return false; if (preparedSessionId) { await rollbackPreparedRevision(startedDraft, preparedSessionId, text); } @@ -334,10 +343,13 @@ export function createAppShellRevisionActions(deps: { ); } return false; + } finally { + if (revisionPreparationAbort === preparationAbort) revisionPreparationAbort = undefined; } } async function cancelRevisionDraft(): Promise { + revisionPreparationAbort?.abort(); const draft = revisionDraftRef.current; if (!draft) return; const cleanupSessionId = draft.copyPhase !== 'reserved' diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 1f31e47947..30028fc7be 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -95,6 +95,7 @@ import { import { McpPage } from './mcp-page'; import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; import type { AppUpdateStatus, OnboardingSnapshot } from '../preload/bridge-contract.js'; +import { DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES } from '../preload/transcript-contract.js'; import { isAppUpdateInstallFailure, requestDownloadedAppUpdate, @@ -337,6 +338,7 @@ function AppShellContent({ clearRuntimeHostSessionState, messages, setMessages, + transcriptRangeRef, messageLoadPending, setMessageLoadPending, messageRetryPendingRef, @@ -370,7 +372,10 @@ function AppShellContent({ removeAttachment, clearSubmittedAttachments, clearAllAttachments, - } = useAppShellComposerAttachments({ draftKey: attachmentDraftKey, toastApi }); + } = useAppShellComposerAttachments({ + draftKey: attachmentDraftKey, + toastApi, + }); const { pendingQuotes, addQuote, @@ -384,6 +389,12 @@ function AppShellContent({ const [newChatSwarmModeActive, setNewChatSwarmModeActive] = useState(false); const [newChatGraphModeActive, setNewChatGraphModeActive] = useState(false); const [pendingOrchestrationModeBySession, setPendingOrchestrationModeBySession] = useState>({}); + const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); + const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ + sessionId: string; + throughSequence: number | null; + turns: readonly { turnId: string; sequence: number; label: string }[]; + }>(); const [petCompletionNonce, setPetCompletionNonce] = useState(0); // P3: session ids with a live embedded-browser view. The right-side // BrowserPanel mounts only for these, so ordinary chats reserve no space. @@ -1072,11 +1083,11 @@ function AppShellContent({ }); } - function openSessionInChat(sessionId: string, turnId?: string): void { + function openSessionInChat(sessionId: string, turnId?: string, sequence?: number): void { setNavSelection({ section: 'sessions', filter: 'chats' }); setActiveId(sessionId); if (turnId) { - setSearchScrollTarget({ sessionId, turnId, nonce: Date.now() }); + setSearchScrollTarget({ sessionId, turnId, sequence, nonce: Date.now() }); } else { setSearchScrollTarget(null); } @@ -1826,7 +1837,9 @@ function AppShellContent({ onArchive: archiveProject, onRestore: restoreProject, ...(projectCapabilities.chooseClientDirectory - ? { onRelink: (projectId: string) => relinkProject(projectId).then(() => undefined) } + ? { + onRelink: (projectId: string) => relinkProject(projectId).then(() => undefined), + } : {}), }; @@ -1879,6 +1892,7 @@ function AppShellContent({ setMessageLoadErrorBySession, setMessageRetryPendingBySession, setMessages, + transcriptRangeRef, setNavSelection, setLiveTurnBySession, setInteractionBySession, @@ -2315,9 +2329,79 @@ function AppShellContent({ setMessageLoadErrorBySession, setMessageLoadPending, setMessages, + transcriptRangeRef, setSessionEventHealthBySession, toastApi, }); + let newestDurablePromptSequence: number | null = null; + try { + const controller = transcriptRangeRef.current; + if (controller && controller.store.range().sessionId === activeId) { + newestDurablePromptSequence = controller.store.newestDurableUserSequence(); + } + } catch { + newestDurablePromptSequence = null; + } + useEffect(() => { + const sessionId = activeId; + if (!sessionId) { + setTranscriptTurnIndex(undefined); + return; + } + let disposed = false; + if ( + transcriptTurnIndex?.sessionId === sessionId && + (newestDurablePromptSequence === null || + (transcriptTurnIndex.throughSequence !== null && + newestDurablePromptSequence <= transcriptTurnIndex.throughSequence)) + ) return; + void window.maka.sessions.listTurnLandmarks(sessionId).then( + (snapshot) => { + if (disposed || activeIdRef.current !== sessionId) return; + setTranscriptTurnIndex({ + sessionId, + throughSequence: snapshot.throughSequence, + turns: snapshot.landmarks, + }); + }, + () => undefined, + ); + return () => { + disposed = true; + }; + }, [activeId, activeIdRef, newestDurablePromptSequence, transcriptTurnIndex]); + useEffect(() => { + const target = searchScrollTarget; + if (!target || target.sessionId !== activeId || target.sequence === undefined) return; + const sequence = target.sequence; + const controller = transcriptRangeRef.current; + if (!controller) return; + let disposed = false; + void controller.ready() + .then(() => controller.loadAround(sequence)) + .then(() => { + if ( + disposed || + transcriptRangeRef.current !== controller || + activeIdRef.current !== target.sessionId + ) return; + setMessages([...controller.store.snapshot().messages]); + }) + .catch((error) => { + if (disposed || activeIdRef.current !== target.sessionId) return; + setMessageLoadErrorBySession((current) => ({ + ...current, + [target.sessionId]: localizedShellErrorMessage( + error, + desktopConversationCopy.actions.operationFailedFallback, + uiLocale, + ), + })); + }); + return () => { + disposed = true; + }; + }, [activeId, searchScrollTarget?.nonce]); useShellRunUpdates({ activeId, setShellRunUpdatesBySession }); useSessionEventHealthPolling({ activeId, @@ -2471,6 +2555,38 @@ function AppShellContent({ } const activeMessageLoadError = activeId ? messageLoadErrorBySession[activeId] : undefined; + let activeTranscriptRange; + try { + const controller = transcriptRangeRef.current; + const range = controller?.store.range(); + if (range?.sessionId === activeId) activeTranscriptRange = range; + } catch { + activeTranscriptRange = undefined; + } + async function loadTranscriptHistory(target: 'earlier' | 'latest') { + const controller = transcriptRangeRef.current; + const sessionId = activeId; + if (!controller || !sessionId || historyLoadPendingSessionId) return; + setHistoryLoadPendingSessionId(sessionId); + try { + if (target === 'earlier') { + await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + } else { + await controller.loadLatest(); + } + } catch (error) { + toastApi.error( + desktopConversationCopy.actions.messageReadFailedTitle, + localizedShellErrorMessage( + error, + desktopConversationCopy.actions.operationFailedFallback, + uiLocale, + ), + ); + } finally { + setHistoryLoadPendingSessionId((current) => current === sessionId ? undefined : current); + } + } const homeSurfaceActive = navSelection.section === 'sessions' && messages.length === 0 && @@ -2546,7 +2662,9 @@ function AppShellContent({ style={ sessionListCollapsed ? undefined - : ({ '--maka-sidenav-width': `${sessionListWidth}px` } as CSSProperties) + : ({ + '--maka-sidenav-width': `${sessionListWidth}px`, + } as CSSProperties) } > loadTranscriptHistory('earlier')} + onReturnToLatestHistory={() => loadTranscriptHistory('latest')} liveContentSeedRevision={activeEventSeed.sessionId === activeId ? activeEventSeed.revision : 0} @@ -3001,6 +3124,14 @@ function AppShellContent({ } : undefined } + transcriptTurnIndex={ + transcriptTurnIndex && transcriptTurnIndex.sessionId === activeId + ? transcriptTurnIndex.turns + : undefined + } + onLoadTranscriptTurn={activeId + ? (target) => openSessionInChat(activeId, target.turnId, target.sequence) + : undefined} scrollBehavior={readScrollMotionBehavior()} branchBanner={branchBanner} onBranchBannerClick={handleBranchBannerClick} diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index eaeb6e7f4f..5719dd55f9 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -18,6 +18,7 @@ import type { SessionHealthNoticeView } from './use-shell-chat-model'; import type { WorkspaceReadinessRecovery } from './workspace-readiness-recovery'; import type { TaskReadinessNotice } from './task-readiness-notice'; import { getShellCopy } from './locales/shell-copy'; +import { getDesktopConversationCopy } from './locales/conversation-copy'; import { selectLiveTurn } from './use-app-shell-session-ui-reads'; import { useAppShellSessionUiSelector } from './use-app-shell-session-ui-selector'; import { useDeepResearchRun } from './use-deep-research-run'; @@ -68,6 +69,11 @@ interface ChatMessageSurfaceProps extends Omit< connections: LlmConnection[]; onRefreshConnections: () => Promise | void; onSkip: () => Promise | void; + hasOlderHistory: boolean; + hasNewerHistory: boolean; + historyLoadPending: boolean; + onLoadEarlierHistory: () => Promise | void; + onReturnToLatestHistory: () => Promise | void; } function captureLiveContent( @@ -101,9 +107,16 @@ export function ChatMessageSurface({ connections, onRefreshConnections, onSkip, + hasOlderHistory, + hasNewerHistory, + historyLoadPending, + onLoadEarlierHistory, + onReturnToLatestHistory, ...chatViewRest }: ChatMessageSurfaceProps) { - const copy = getShellCopy(useUiLocale()).app; + const locale = useUiLocale(); + const copy = getShellCopy(locale).app; + const transcriptCopy = getDesktopConversationCopy(locale).actions; // Every session-health-notice CTA routes to 设置 · 模型 (U1); this is the // action button's visible label. const goToModelsLabel = copy.goToModels; @@ -210,6 +223,14 @@ export function ChatMessageSurface({ shellRunUpdates={shellRunUpdates} deepResearchRun={deepResearchRun} emptyOverride={emptyOverride} + hasOlderHistory={hasOlderHistory} + historyLoadPending={historyLoadPending} + onLoadEarlierHistory={onLoadEarlierHistory} + returnToLatest={hasNewerHistory ? { + label: transcriptCopy.returnLatest, + isPending: historyLoadPending, + onClick: onReturnToLatestHistory, + } : undefined} /> {taskReadinessNotice && (
diff --git a/apps/desktop/src/renderer/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/desktop-transcript-range-store.ts new file mode 100644 index 0000000000..426dad7a7a --- /dev/null +++ b/apps/desktop/src/renderer/desktop-transcript-range-store.ts @@ -0,0 +1,321 @@ +import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; +import type { + DesktopTranscriptBatchPayload, + DesktopTranscriptFragment, + DesktopTranscriptHandle, +} from '../preload/transcript-contract.js'; + +export interface DesktopTranscriptRangeController { + readonly store: DesktopTranscriptRangeStore; + ready(): Promise; + waitForDurableMessage(messageId: string, timeoutMs: number): Promise; + loadBefore(maxBytes?: number): Promise; + loadAround(sequence: number): Promise; + loadLatest(): Promise; + reload(): Promise; + close(): Promise; +} + +export function createDesktopTranscriptRangeController( + store: DesktopTranscriptRangeStore, + open: (signal: AbortSignal) => Promise, +): DesktopTranscriptRangeController { + let closed = false; + let openController = new AbortController(); + let handle = open(openController.signal); + const current = async () => { + if (closed) throw new Error('Desktop transcript range is closed'); + return handle; + }; + return { + store, + async ready() { + await current(); + }, + async waitForDurableMessage(messageId, timeoutMs) { + await current(); + return store.waitForDurableMessage(messageId, timeoutMs); + }, + async loadBefore(maxBytes) { + const range = store.range(); + if (!range.hasOlder) return; + await (await current()).loadBefore(range.oldestSequence, maxBytes); + }, + async loadAround(sequence) { + await (await current()).loadAround(sequence); + }, + async loadLatest() { + const range = store.range(); + if (!range.hasNewer || range.durableThrough === null) return; + await (await current()).loadAround(range.durableThrough); + }, + async reload() { + const previous = handle; + openController.abort(); + handle = previous + .then((value) => value.close()) + .catch(() => undefined) + .then(() => { + if (closed) throw new Error('Desktop transcript range is closed'); + openController = new AbortController(); + return open(openController.signal); + }); + await handle; + }, + async close() { + if (closed) return; + closed = true; + openController.abort(); + await handle.then((value) => value.close()).catch(() => undefined); + }, + }; +} + +interface PendingRecord { + readonly source: 'durable' | 'overlay'; + readonly identity: number | string; + readonly order: number | null; + readonly totalBytes: number; + readonly bytes: Uint8Array; + receivedBytes: number; +} + +interface StoredRecord { + readonly message: StoredMessage; +} + +interface OverlayRecord extends StoredRecord { + readonly order: number; +} + +export interface DesktopTranscriptRangeState { + readonly sessionId: string; + readonly generation: string; + readonly hostEpoch: string; + readonly durableThrough: number | null; + readonly oldestSequence: number | null; + readonly newestSequence: number | null; + readonly hasOlder: boolean; + readonly hasNewer: boolean; + readonly ready: boolean; +} + +export interface DesktopTranscriptRangeSnapshot extends DesktopTranscriptRangeState { + readonly messages: readonly StoredMessage[]; +} + +export class DesktopTranscriptRangeStore { + readonly #durable = new Map(); + readonly #overlay = new Map(); + readonly #pending = new Map(); + #sessionId: string | undefined; + #generation: string | undefined; + #hostEpoch: string | undefined; + #durableThrough: number | null = null; + #oldestSequence: number | null = null; + #newestSequence: number | null = null; + #newestUserSequence: number | null = null; + #hasOlder = false; + #hasNewer = false; + #ready = false; + #batchChanged = false; + readonly #durableWaiters = new Set<() => void>(); + + accept(batch: DesktopTranscriptBatchPayload): boolean { + if (batch.reset) this.#reset(batch); + if ( + batch.sessionId !== this.#sessionId || + batch.generation !== this.#generation || + batch.hostEpoch !== this.#hostEpoch + ) { + return false; + } + let changed = + batch.reset || + batch.durableThrough !== this.#durableThrough || + batch.hasOlder !== this.#hasOlder || + batch.hasNewer !== this.#hasNewer; + this.#durableThrough = batch.durableThrough; + this.#hasOlder = batch.hasOlder; + this.#hasNewer = batch.hasNewer; + for (const sequence of batch.evictedDurableSequences) { + if (this.#durable.delete(sequence)) { + this.#refreshSequenceBounds(sequence); + changed = true; + } + } + for (const messageId of batch.completedOverlayMessageIds) { + changed = this.#overlay.delete(messageId) || changed; + } + for (const fragment of batch.fragments) { + changed = this.#acceptFragment(fragment) || changed; + } + if (batch.ready && !this.#ready) { + this.#ready = true; + changed = true; + } + this.#batchChanged = this.#batchChanged || changed; + if (!batch.ready) return false; + const committed = this.#batchChanged; + this.#batchChanged = false; + for (const notify of this.#durableWaiters) notify(); + return committed; + } + + snapshot(): DesktopTranscriptRangeSnapshot { + const range = this.range(); + const durable = [...this.#durable.entries()].sort(([left], [right]) => left - right); + const overlay = [...this.#overlay.values()].sort((left, right) => left.order - right.order); + return { + ...range, + messages: durable + .map(([, record]) => structuredClone(record.message)) + .concat(overlay.map((record) => structuredClone(record.message))), + }; + } + + range(): DesktopTranscriptRangeState { + if (!this.#sessionId || !this.#generation || !this.#hostEpoch) { + throw new Error('Desktop transcript range is not initialized'); + } + return { + sessionId: this.#sessionId, + generation: this.#generation, + hostEpoch: this.#hostEpoch, + durableThrough: this.#durableThrough, + oldestSequence: this.#oldestSequence, + newestSequence: this.#newestSequence, + hasOlder: this.#hasOlder, + hasNewer: this.#hasNewer, + ready: this.#ready, + }; + } + + hasDurableMessage(messageId: string): boolean { + for (const record of this.#durable.values()) { + if (record.message.id === messageId) return true; + } + return false; + } + + newestDurableUserSequence(): number | null { + return this.#newestUserSequence; + } + + waitForDurableMessage(messageId: string, timeoutMs: number): Promise { + if (this.hasDurableMessage(messageId)) return Promise.resolve(true); + return new Promise((resolve) => { + const finish = (found: boolean) => { + globalThis.clearTimeout(timeout); + this.#durableWaiters.delete(check); + resolve(found); + }; + const check = () => { + if (this.hasDurableMessage(messageId)) finish(true); + }; + const timeout = globalThis.setTimeout(() => finish(false), timeoutMs); + this.#durableWaiters.add(check); + check(); + }); + } + + #reset(batch: DesktopTranscriptBatchPayload): void { + this.#durable.clear(); + this.#overlay.clear(); + this.#pending.clear(); + this.#sessionId = batch.sessionId; + this.#generation = batch.generation; + this.#hostEpoch = batch.hostEpoch; + this.#durableThrough = batch.durableThrough; + this.#oldestSequence = null; + this.#newestSequence = null; + this.#newestUserSequence = null; + this.#hasOlder = batch.hasOlder; + this.#hasNewer = batch.hasNewer; + this.#ready = false; + this.#batchChanged = false; + } + + #acceptFragment(fragment: DesktopTranscriptFragment): boolean { + const key = `${fragment.source}:${typeof fragment.identity}:${fragment.identity}`; + let pending = this.#pending.get(key); + if (!pending) { + pending = { + source: fragment.source, + identity: fragment.identity, + order: fragment.order, + totalBytes: fragment.totalBytes, + bytes: new Uint8Array(fragment.totalBytes), + receivedBytes: 0, + }; + this.#pending.set(key, pending); + } + if ( + pending.source !== fragment.source || + pending.identity !== fragment.identity || + pending.order !== fragment.order || + pending.totalBytes !== fragment.totalBytes + ) { + throw new Error('Desktop transcript fragment identity changed'); + } + const bytes = fragment.data; + if ( + fragment.byteOffset < 0 || + fragment.byteOffset + bytes.byteLength > fragment.totalBytes + ) { + throw new Error('Desktop transcript fragment is outside its record'); + } + if (fragment.byteOffset !== pending.receivedBytes) { + throw new Error('Desktop transcript record has a fragment gap'); + } + pending.bytes.set(bytes, fragment.byteOffset); + pending.receivedBytes += bytes.byteLength; + if (pending.receivedBytes < pending.totalBytes) return false; + const encoded = new TextDecoder('utf-8', { fatal: true }).decode(pending.bytes); + const message = decodeStoredMessage(JSON.parse(encoded) as unknown); + this.#pending.delete(key); + if (pending.source === 'durable') { + if (!Number.isSafeInteger(pending.identity) || (pending.identity as number) < 0) { + throw new Error('Invalid Desktop transcript durable identity'); + } + const sequence = pending.identity as number; + const existing = this.#durable.get(sequence); + if (existing && JSON.stringify(existing.message) !== encoded) { + throw new Error('Desktop transcript durable record changed'); + } + this.#durable.set(sequence, { message }); + this.#oldestSequence = Math.min(this.#oldestSequence ?? sequence, sequence); + this.#newestSequence = Math.max(this.#newestSequence ?? sequence, sequence); + if (message.type === 'user') { + this.#newestUserSequence = Math.max(this.#newestUserSequence ?? sequence, sequence); + } + return !existing; + } + if (typeof pending.identity !== 'string' || message.id !== pending.identity) { + throw new Error('Desktop transcript overlay identity changed'); + } + if (pending.order === null || !Number.isSafeInteger(pending.order) || pending.order < 0) { + throw new Error('Invalid Desktop transcript overlay order'); + } + const existing = this.#overlay.get(pending.identity); + this.#overlay.set(pending.identity, { + message, + order: pending.order, + }); + return ( + !existing || + JSON.stringify(existing.message) !== encoded || + existing.order !== pending.order + ); + } + + #refreshSequenceBounds(deletedSequence: number): void { + if (deletedSequence !== this.#oldestSequence && deletedSequence !== this.#newestSequence) return; + this.#oldestSequence = null; + this.#newestSequence = null; + for (const sequence of this.#durable.keys()) { + this.#oldestSequence = Math.min(this.#oldestSequence ?? sequence, sequence); + this.#newestSequence = Math.max(this.#newestSequence ?? sequence, sequence); + } + } +} diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 5ce9f54eb9..cc50f57352 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -37,6 +37,7 @@ export interface DesktopConversationCopy { modelReboundTitle: string; modelReboundDescription: (modelId?: string) => string; messageReadFailedTitle: string; + returnLatest: string; }; attachments: { tooMany: string; tooLarge: string; duplicate: string }; model: { @@ -366,7 +367,7 @@ function enDetail(parts: readonly string[]): string { const COPY = { zh: { - actions: { stopFailedTitle: '停止失败', stopFailedFallback: '会话操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新会话列表失败', refreshSessionsFailedFallback: '刷新会话列表失败,请稍后重试。', conversationErrorTitle: '对话出错', conversationErrorFallback: '对话运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新会话 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原对话仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '对话操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原会话使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取对话失败' }, + actions: { stopFailedTitle: '停止失败', stopFailedFallback: '会话操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新会话列表失败', refreshSessionsFailedFallback: '刷新会话列表失败,请稍后重试。', conversationErrorTitle: '对话出错', conversationErrorFallback: '对话运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新会话 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原对话仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '对话操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原会话使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取对话失败', returnLatest: '返回最新消息' }, attachments: { tooMany: '附件数量超过 8 个', tooLarge: '附件大小超过 50MB', duplicate: '附件来源重复,请勿重复添加同一文件。' }, model: { fakeBackendLabel: '本地模拟连接', @@ -563,7 +564,7 @@ const COPY = { turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, }, en: { - actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The conversation action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh conversations', refreshSessionsFailedFallback: 'The conversation list could not be refreshed. Try again later.', conversationErrorTitle: 'Conversation error', conversationErrorFallback: 'The conversation run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New conversation: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original conversation is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The conversation action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load conversation' }, + actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The conversation action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh conversations', refreshSessionsFailedFallback: 'The conversation list could not be refreshed. Try again later.', conversationErrorTitle: 'Conversation error', conversationErrorFallback: 'The conversation run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New conversation: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original conversation is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The conversation action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load conversation', returnLatest: 'Return to latest' }, attachments: { tooMany: 'You can attach at most 8 files', tooLarge: 'Attachments must be 50 MB or smaller', duplicate: 'This attachment was already added.' }, model: { fakeBackendLabel: 'Local simulation', diff --git a/apps/desktop/src/renderer/session-message-settlement.ts b/apps/desktop/src/renderer/session-message-settlement.ts index 8e6456b5bc..e251ded53b 100644 --- a/apps/desktop/src/renderer/session-message-settlement.ts +++ b/apps/desktop/src/renderer/session-message-settlement.ts @@ -1,59 +1,92 @@ import type { StoredMessage } from '@maka/core/session'; +import { DesktopTranscriptRangeStore } from './desktop-transcript-range-store.js'; -/** - * Read-model settlement shared by the main chat and the quote companion. - * - * After a turn completes, the live projection must not be handed off to the - * persisted transcript until the matching assistant message is actually stored — - * otherwise a settlement lag makes the just-finished exchange flicker away. This - * reads the session's messages and, when a specific assistant message is - * required, retries with a short backoff until it lands (or the budget is spent). - */ - -/** Backoff between committed-assistant settlement reads. */ -const COMMITTED_ASSISTANT_SETTLE_DELAYS_MS = [120, 360] as const; +const COMMITTED_ASSISTANT_SETTLE_TIMEOUT_MS = 480; export interface RefreshMessagesOptions { requiredAssistantMessageId?: string; + signal?: AbortSignal; } -export function hasAssistantMessage( - messages: readonly StoredMessage[], - messageId: string, -): boolean { - return messages.some((message) => message.type === 'assistant' && message.id === messageId); +export function mergeSettledMessages( + current: readonly StoredMessage[], + incoming: readonly StoredMessage[], +): StoredMessage[] { + const incomingById = new Map(incoming.map((message) => [message.id, message])); + const knownIds = new Set(current.map((message) => message.id)); + return current + .map((message) => incomingById.get(message.id) ?? message) + .concat(incoming.filter((message) => !knownIds.has(message.id))); } -/** - * Read a session's messages, waiting (with backoff) for `requiredAssistantMessageId` - * to be persisted when one is given. `settled` reports whether that message was - * found; the caller only hands off from the live projection once it is. - */ export async function readSettledMessages( sessionId: string, options: RefreshMessagesOptions = {}, ): Promise<{ messages: StoredMessage[]; settled: boolean }> { - const requiredMessageId = options.requiredAssistantMessageId; - if (!requiredMessageId) { - return { messages: await window.maka.sessions.readMessages(sessionId), settled: true }; - } - - let lastError: unknown; - let lastMessages: StoredMessage[] | undefined; - for (let attempt = 0; attempt <= COMMITTED_ASSISTANT_SETTLE_DELAYS_MS.length; attempt += 1) { - try { - const messages = await window.maka.sessions.readMessages(sessionId); - if (hasAssistantMessage(messages, requiredMessageId)) { - return { messages, settled: true }; + const deadline = Date.now() + COMMITTED_ASSISTANT_SETTLE_TIMEOUT_MS; + const store = new DesktopTranscriptRangeStore(); + let notify: () => void = () => {}; + const changed = () => new Promise((resolve) => { + notify = resolve; + }); + let nextChange = changed(); + let cancelOpen = () => {}; + let rejectCancellation!: (error: Error) => void; + const cancellation = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + void cancellation.catch(() => undefined); + let cancelled = false; + const cancel = (error: Error) => { + if (cancelled) return; + cancelled = true; + cancelOpen(); + rejectCancellation(error); + }; + const abort = () => cancel(new Error('Desktop transcript settlement was cancelled')); + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) abort(); + const openTimeout = globalThis.setTimeout( + () => cancel(new Error('Desktop transcript settlement timed out while opening')), + Math.max(0, deadline - Date.now()), + ); + const opening = window.maka.transcripts.open( + sessionId, + (batch) => { + if (!store.accept(batch)) return; + notify(); + nextChange = changed(); + }, + (close) => { + cancelOpen = close; + if (cancelled) close(); + }, + ); + void opening.catch(() => undefined); + let handle: Awaited | undefined; + try { + handle = await Promise.race([opening, cancellation]); + globalThis.clearTimeout(openTimeout); + while (true) { + const snapshot = store.snapshot(); + const requiredMessageId = options.requiredAssistantMessageId; + const settled = + snapshot.ready && + (requiredMessageId === undefined || store.hasDurableMessage(requiredMessageId)); + if (settled || Date.now() >= deadline) { + return { messages: [...snapshot.messages], settled }; } - lastMessages = messages; - } catch (error) { - lastError = error; + await Promise.race([ + nextChange, + cancellation, + new Promise((resolve) => + globalThis.setTimeout(resolve, Math.max(0, deadline - Date.now())), + ), + ]); } - const delayMs = COMMITTED_ASSISTANT_SETTLE_DELAYS_MS[attempt]; - if (delayMs === undefined) break; - await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + } finally { + globalThis.clearTimeout(openTimeout); + options.signal?.removeEventListener('abort', abort); + await handle?.close().catch(() => undefined); } - if (lastMessages) return { messages: lastMessages, settled: false }; - throw lastError; } diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 08414bacb0..3880243750 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -304,3 +304,9 @@ color: var(--info-text); padding: var(--space-0-5) var(--space-1-5); } +.maka-transcript-history-controls { + display: flex; + justify-content: center; + gap: 8px; + padding: 8px 0; +} diff --git a/apps/desktop/src/renderer/styles/prompt-rail.css b/apps/desktop/src/renderer/styles/prompt-rail.css index e99ef39d9a..6c4e0f7696 100644 --- a/apps/desktop/src/renderer/styles/prompt-rail.css +++ b/apps/desktop/src/renderer/styles/prompt-rail.css @@ -1,4 +1,4 @@ -/* Codex-style prompt navigation rail: one tick per user prompt, pinned to the +/* Codex-style prompt navigation rail: bounded prompt landmarks pinned to the right edge of the chat scrollport. Low-key by default, brightens on hover; each tick jumps to that prompt and the active turn's tick stays highlighted. The tick bar draws in `currentColor` so active/hover just shift the neutral diff --git a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts index ce87996c28..c26fc83db3 100644 --- a/apps/desktop/src/renderer/use-app-shell-session-workspace.ts +++ b/apps/desktop/src/renderer/use-app-shell-session-workspace.ts @@ -8,6 +8,7 @@ import { hasNewTaskReloadIntent, markNewTaskReloadIntent, } from './new-task-reload-intent'; +import type { DesktopTranscriptRangeController } from './desktop-transcript-range-store.js'; type ToastApi = { error(title: string, description?: string): void; @@ -21,6 +22,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { const selectionRevisionRef = useRef(0); const bootstrapSelectionLeaseRef = useRef | null>(null); const [messages, setMessages] = useState([]); + const transcriptRangeRef = useRef(undefined); const [messageLoadPending, setMessageLoadPending] = useState(false); const messageRetryPendingRef = useRef>(new Set()); const stopPendingRef = useRef>(new Set()); @@ -81,6 +83,7 @@ export function useAppShellSessionWorkspace(toastApi: ToastApi) { clearRuntimeHostSessionState, messages, setMessages, + transcriptRangeRef, messageLoadPending, setMessageLoadPending, messageRetryPendingRef, diff --git a/apps/desktop/src/renderer/use-quote-companion.ts b/apps/desktop/src/renderer/use-quote-companion.ts index 40d7537386..9e54256831 100644 --- a/apps/desktop/src/renderer/use-quote-companion.ts +++ b/apps/desktop/src/renderer/use-quote-companion.ts @@ -32,7 +32,7 @@ import { type CompanionErrorCode, type EnsureCompanionForkResult, } from './quote-companion-core'; -import { readSettledMessages } from './session-message-settlement'; +import { mergeSettledMessages, readSettledMessages } from './session-message-settlement'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { snapshotCompanionQuotes, @@ -173,7 +173,9 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const subscribeToFork = useCallback((forkId: string) => { void readSettledMessages(forkId) .then(({ messages }) => { - if (mountedRef.current) setAllMessages(messages); + if (mountedRef.current) { + setAllMessages((current) => mergeSettledMessages(current, messages)); + } }) .catch(() => { if (mountedRef.current) setError(copyRef.current.errors.settlementFailed); @@ -199,12 +201,14 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // so the finished exchange never flickers away. void readSettledMessages(forkId, { ...(requiredAssistantMessageId(liveTurnRef.current) - ? { requiredAssistantMessageId: requiredAssistantMessageId(liveTurnRef.current) } + ? { + requiredAssistantMessageId: requiredAssistantMessageId(liveTurnRef.current), + } : {}), }) .then(({ messages: next }) => { if (!mountedRef.current || activeTurnIdRef.current !== settledTurnId) return; - setAllMessages(next); + setAllMessages((current) => mergeSettledMessages(current, next)); setLiveTurn((prev) => (prev ? reconcileTerminalLiveTurn(prev, next) : prev)); activeTurnIdRef.current = null; turnInFlightRef.current = false; @@ -306,25 +310,18 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const sourceSessionId = sourceSessionIdRef.current; const id = companionIdRef.current ?? pendingForkIdRef.current; if (id && sourceSessionId) { - void dismissCompanionCopy( - window.maka.sessions, - sourceSessionId, - panelId, - id, - ).then((cleaned) => { - if (cleaned) { - onForkVisibilityChangeRef.current?.({ - type: 'cleanup-succeeded', - sessionId: id, - }); - } - }); - } else if (sourceSessionId) { - void abandonPendingCompanionCopy( - window.maka.sessions, - sourceSessionId, - panelId, + void dismissCompanionCopy(window.maka.sessions, sourceSessionId, panelId, id).then( + (cleaned) => { + if (cleaned) { + onForkVisibilityChangeRef.current?.({ + type: 'cleanup-succeeded', + sessionId: id, + }); + } + }, ); + } else if (sourceSessionId) { + void abandonPendingCompanionCopy(window.maka.sessions, sourceSessionId, panelId); } }); }; @@ -385,7 +382,9 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // automatic connection/model rebound in the read-only model label. void readSettledMessages(result.forkId) .then(({ messages: next }) => { - if (mountedRef.current) setAllMessages(next); + if (mountedRef.current) { + setAllMessages((current) => mergeSettledMessages(current, next)); + } }) .catch(() => {}); void window.maka.sessions diff --git a/apps/desktop/src/renderer/use-shell-search.ts b/apps/desktop/src/renderer/use-shell-search.ts index e3fe85271a..0ead83876e 100644 --- a/apps/desktop/src/renderer/use-shell-search.ts +++ b/apps/desktop/src/renderer/use-shell-search.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; -type OpenSessionInChat = (sessionId: string, turnId?: string) => void; +type OpenSessionInChat = (sessionId: string, turnId?: string, sequence?: number) => void; /** * Owns the search-modal slice (issue #1043): the open flag, the scroll-target @@ -15,6 +15,7 @@ export function useShellSearch({ openSessionInChatRef }: { openSessionInChatRef: const [searchScrollTarget, setSearchScrollTarget] = useState<{ sessionId: string; turnId: string; + sequence?: number; nonce: number; } | null>(null); @@ -27,8 +28,8 @@ export function useShellSearch({ openSessionInChatRef }: { openSessionInChatRef: [], ); - const searchModalOnNavigate = useCallback((sessionId: string, turnId?: string) => { - openSessionInChatRef.current(sessionId, turnId); + const searchModalOnNavigate = useCallback((sessionId: string, turnId?: string, sequence?: number) => { + openSessionInChatRef.current(sessionId, turnId, sequence); }, [openSessionInChatRef]); return { diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 1eae2e1348..8d2a1209ac 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1300,6 +1300,14 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< return (await this.transcript).map(decodeMessage); } + async loadTranscriptOverlay(_decodeMessage: (value: unknown) => T): Promise { + return []; + } + + async decodeTranscriptPage(): Promise { + throw new Error('Fake subscription does not expose transcript pages'); + } + async loadTranscriptPage(): Promise { throw new Error('Fake subscription does not expose transcript pages'); } diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index e8be672d51..6ce3fe35b5 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -1,6 +1,7 @@ import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; import { type SessionEvent } from '@maka/core/events'; import { + createRuntimeHostSessionProjectionSeed, RuntimeHostSessionProjector, isRuntimeHostTerminalTurn as isTerminalTurn, type RuntimeHostTerminalTurn as TerminalTurnSnapshot, @@ -99,7 +100,10 @@ export class RuntimeHostSessionChannel { ): Promise { const subscription = await options.connection.openSessionSubscription({ sessionId: options.sessionId, - transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, + transcript: { + kind: 'tail', + maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, + }, }); const initialRoot = structuredClone(subscription.snapshot.rootTurn); const channel = new RuntimeHostSessionChannel(subscription, [], options, options.connection); @@ -137,7 +141,7 @@ export class RuntimeHostSessionChannel { this.messages.push(...(messages ?? []).map((message) => structuredClone(message))); this.#projector = new RuntimeHostSessionProjector( this.snapshot, - this.messages, + createRuntimeHostSessionProjectionSeed(this.messages, this.snapshot), this.#now, subscription.activeAssistantStreams, ); @@ -303,7 +307,10 @@ export class RuntimeHostSessionChannel { try { replacement = await this.#connection.openSessionSubscription({ sessionId: this.sessionId, - transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, + transcript: { + kind: 'tail', + maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, + }, }); } catch (error) { if (this.#canRecover(error)) continue; @@ -350,7 +357,7 @@ export class RuntimeHostSessionChannel { this.snapshot = nextSnapshot; this.#projector = new RuntimeHostSessionProjector( nextSnapshot, - this.messages, + createRuntimeHostSessionProjectionSeed(this.messages, nextSnapshot), this.#now, this.#subscription.activeAssistantStreams, ); @@ -516,7 +523,10 @@ export class RuntimeHostSessionChannel { class SessionEventQueue implements AsyncIterable, AsyncIterator { readonly #items: SessionEvent[] = []; #waiting: - | { resolve(value: IteratorResult): void; reject(error: unknown): void } + | { + resolve(value: IteratorResult): void; + reject(error: unknown): void; + } | undefined; #done = false; #finishAfterItems = false; diff --git a/packages/core/src/search.ts b/packages/core/src/search.ts index 29575e1f31..65a12e2555 100644 --- a/packages/core/src/search.ts +++ b/packages/core/src/search.ts @@ -60,7 +60,12 @@ export interface WebFetchRequest { } /** Non-URL navigation target; web results continue to use `url`. */ -export type SearchResultTarget = { kind: 'thread'; sessionId: string; turnId?: string }; +export type SearchResultTarget = { + kind: 'thread'; + sessionId: string; + turnId?: string; + sequence?: number; +}; export interface SearchResult { source: SearchSourceKind; diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 0d6217c144..874b4d3528 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -813,6 +813,8 @@ export interface TurnStateMessage { export interface TurnRecord { turnId: string; + firstSequence?: number; + userPromptPreview?: string; status: TurnStatus; /** * Whether `status` came from a `turn_state` message or was reconstructed by diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts index cba49697cc..89a7778007 100644 --- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts +++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts @@ -519,6 +519,42 @@ test('migrates the released transcript query grant when opening an existing acce } }); +test('adds bounded turn landmarks to an existing turn-query grant', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-turn-landmarks-')); + const credential = 'maka_rh_existing_turn_client'; + try { + await writeFile( + join(directory, 'runtime-host-access.json'), + `${JSON.stringify({ + schemaVersion: 1, + credentials: [ + { + credentialId: 'existing-turn-client', + credentialHash: createHash('sha256').update(credential).digest('hex'), + principalId: 'existing-turn-client', + principalKind: 'remote_owner', + status: 'active', + operationGrants: ['host.status', 'session.turns.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + })}\n`, + { mode: 0o600 }, + ); + + const authority = await openRuntimeHostAccessAuthority(directory); + assert.deepEqual(authority.authenticate(credential)?.operationGrants, [ + 'host.status', + 'session.turns.query', + 'session.turn_landmarks.query', + ]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + test('a rejected required WebSocket listener releases Local IPC and root ownership', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-websocket-startup-rollback-')); const root = join(base, 'root'); diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 266e088a23..fa3ff2c24d 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -19,7 +19,9 @@ import { } from '@maka/storage/execution-stores'; import { SESSION_CATALOG_RESULT_MAX_BYTES, + SESSION_TURN_QUERY_RESULT_MAX_BYTES, type SessionConfigurationUpdateInput, + type SessionTurnContribution, } from '../protocol/index.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; import { HostProjectMembershipGate } from '../server/project-membership-gate.js'; @@ -61,6 +63,64 @@ test('projects only bounded execution boundary presentation facts', async () => }); }); +test('reduces turn pages to their encoded wire budget without skipping contributions', async () => { + const contributions: SessionTurnContribution[] = Array.from({ length: 128 }, (_, index) => ({ + turnId: `turn-${index}`, + firstSequence: index, + latestState: null, + userPromptPreview: '\0'.repeat(256), + hasAssistantMessage: false, + hasAssistantOutput: false, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + })); + const requestedLimits: number[] = []; + const fixture = createFixture({ + stores: { + readTurnContributionsSnapshot: async (_sessionId, _watermark, position, limit) => { + requestedLimits.push(limit); + const end = Math.min(position + limit, contributions.length); + return { + throughSequence: contributions.length - 1, + contributions: contributions.slice(position, end), + nextPosition: end < contributions.length ? end : null, + }; + }, + }, + }); + + const seen: string[] = []; + let position = 0; + do { + const outcome = await fixture.coordinator.handlers['session.turns.query']( + { + sessionId: fixture.sessionId, + throughSequence: null, + position, + maxContributions: 128, + }, + context, + ); + assert.equal(outcome.ok, true); + if (!outcome.ok) assert.fail('Turn query failed'); + assert.ok( + Buffer.byteLength(JSON.stringify(outcome.result), 'utf8') <= + SESSION_TURN_QUERY_RESULT_MAX_BYTES, + ); + seen.push(...outcome.result.contributions.map((contribution) => contribution.turnId)); + if (outcome.result.nextPosition === null) break; + assert.ok(outcome.result.nextPosition > position); + position = outcome.result.nextPosition; + } while (true); + + assert.deepEqual( + seen, + contributions.map((contribution) => contribution.turnId), + ); + assert.ok(requestedLimits.some((limit) => limit < 128)); +}); + test('metadata replacement preserves execution-semantic labels and ignores injected ones', async () => { const fixture = createFixture({ labels: ['old-user-label', DEEP_RESEARCH_SESSION_LABEL], @@ -836,6 +896,12 @@ function createFixture( readCatalogRecord: async () => catalogRecord(header, revision), readExecutionBoundary: async () => createGenesisExecutionBoundary('ask'), readHeaderRecordSnapshot: async () => headerSnapshot(header, revision), + readTurnContributionsSnapshot: async () => ({ + throughSequence: null, + contributions: [], + nextPosition: null, + }), + readTurnLandmarksSnapshot: async () => ({ throughSequence: null, landmarks: [] }), updateHeaderVersioned: async (_sessionId, patch, expectedRevision) => { if (expectedRevision !== revision) { throw new SessionMetadataVersionConflictError(sessionId, expectedRevision, revision); diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index e1cb01a342..006df52eed 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -1,13 +1,16 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; -import { RuntimeHostSessionProjector } from '../adapter/session-projector.js'; +import { + createRuntimeHostSessionProjectionSeed, + RuntimeHostSessionProjector, +} from '../adapter/session-projector.js'; import type { SessionContinuitySnapshot, SubscriptionFrame } from '../protocol/index.js'; test('applies authoritative replacement once and does not complete it again at Turn terminal', () => { const projector = new RuntimeHostSessionProjector( snapshot(), - [assistant('message-1', 'draft')], + createRuntimeHostSessionProjectionSeed([assistant('message-1', 'draft')], snapshot()), () => 10, [{ kind: 'text', turnId: 'turn-1', messageId: 'message-1' }], ); @@ -54,9 +57,12 @@ test('seeds only streams identified as active by the Host catch-up state', () => thinking: { text: 'still working' }, }, ]; - const projector = new RuntimeHostSessionProjector(snapshot(), transcript, () => 10, [ - { kind: 'thinking', turnId: 'turn-1', messageId: 'active-step' }, - ]); + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed(transcript, snapshot()), + () => 10, + [{ kind: 'thinking', turnId: 'turn-1', messageId: 'active-step' }], + ); assert.deepEqual( projector @@ -81,10 +87,15 @@ test('does not replay settled transcript steps when the active step reaches term thinking: { text: 'active thought' }, }, ]; - const projector = new RuntimeHostSessionProjector(snapshot(), transcript, () => 10, [ - { kind: 'text', turnId: 'turn-1', messageId: 'active-step' }, - { kind: 'thinking', turnId: 'turn-1', messageId: 'active-step' }, - ]); + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed(transcript, snapshot()), + () => 10, + [ + { kind: 'text', turnId: 'turn-1', messageId: 'active-step' }, + { kind: 'thinking', turnId: 'turn-1', messageId: 'active-step' }, + ], + ); assert.deepEqual( projector @@ -174,7 +185,12 @@ function snapshot(overrides: Partial = {}): SessionCo status: 'running', }, goal: null, - queue: { hostEpoch: 'host-1', queueRevision: 0, steering: [], followup: [] }, + queue: { + hostEpoch: 'host-1', + queueRevision: 0, + steering: [], + followup: [], + }, interactions: { pending: [] }, ...overrides, }; diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts index c8d6c63ddf..5d9ed1820a 100644 --- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts +++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts @@ -402,9 +402,157 @@ test('reassembles a large message from bounded backward pages', async () => { ); }); +test('decodes one bounded page without walking the remaining transcript', async () => { + const message = { + type: 'user' as const, + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'hello', + }; + const encoded = Buffer.from(JSON.stringify(message), 'utf8'); + const splitAt = Math.floor(encoded.byteLength / 2); + const requests: Array<{ cursor: string | null; maxBytes: number }> = []; + const subscription = new ClientSessionSubscription( + openResult('host-1', 'subscription-bounded-page', { + throughSequence: 4, + overlayMessageCount: 0, + durable: { + ...transcriptPage({ + rawBytes: encoded.byteLength - splitAt, + fragments: [ + { + kind: 'durable', + sequence: 4, + byteOffset: splitAt, + totalBytes: encoded.byteLength, + payloadDigest: null, + data: encoded.subarray(splitAt).toString('base64'), + }, + ], + nextCursor: 'complete-message', + }), + throughSequence: 4, + }, + overlay: { ...transcriptPage({ source: 'overlay' }), throughSequence: 4 }, + }), + async () => undefined, + async (input) => { + requests.push({ cursor: input.cursor, maxBytes: input.maxBytes }); + return { + ...transcriptPage({ + rawBytes: splitAt, + fragments: [ + { + kind: 'durable', + sequence: 4, + byteOffset: 0, + totalBytes: encoded.byteLength, + payloadDigest: null, + data: encoded.subarray(0, splitAt).toString('base64'), + }, + ], + nextCursor: 'older-records', + }), + throughSequence: 4, + }; + }, + ); + + const assemblyDeltas: number[] = []; + const decoded = await subscription.decodeTranscriptPage( + subscription.transcriptBootstrap!.durable, + decodeStoredMessage, + undefined, + (deltaBytes) => assemblyDeltas.push(deltaBytes), + ); + + assert.deepEqual(decoded, { + messages: [{ identity: 4, message }], + nextCursor: 'older-records', + }); + assert.deepEqual(requests, [{ cursor: 'complete-message', maxBytes: splitAt }]); + assert.deepEqual(assemblyDeltas, [encoded.byteLength, -encoded.byteLength]); + + requests.length = 0; + await assert.rejects( + subscription.decodeTranscriptPage( + subscription.transcriptBootstrap!.durable, + decodeStoredMessage, + encoded.byteLength - 1, + ), + RangeError, + ); + assert.deepEqual(requests, []); +}); + +test('loads and releases only the active overlay', async () => { + const overlay = { + type: 'user' as const, + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'active', + }; + const bytes = Buffer.from(JSON.stringify(overlay), 'utf8'); + let releases = 0; + const subscription = new ClientSessionSubscription( + openResult('host-1', 'subscription-overlay-only', overlayBootstrap(bytes)), + async () => undefined, + async () => { + throw new Error('durable transcript must not be read'); + }, + async () => { + releases += 1; + }, + ); + + assert.deepEqual(await subscription.loadTranscriptOverlay(decodeStoredMessage), [overlay]); + assert.equal(releases, 1); + await assert.rejects( + subscription.loadTranscriptOverlay(decodeStoredMessage), + hasSubscriptionReason('correlation_changed'), + ); + assert.equal(releases, 1); +}); + +test('rejects an oversized active overlay before releasing it', async () => { + const overlay = { + type: 'user' as const, + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'active', + }; + const bytes = Buffer.from(JSON.stringify(overlay), 'utf8'); + let releases = 0; + const subscription = new ClientSessionSubscription( + openResult('host-1', 'subscription-overlay-limit', overlayBootstrap(bytes)), + async () => undefined, + async () => { + throw new Error('durable transcript must not be read'); + }, + async () => { + releases += 1; + }, + ); + + await assert.rejects( + subscription.loadTranscriptOverlay(decodeStoredMessage, bytes.byteLength - 1), + RangeError, + ); + assert.equal(releases, 0); +}); + test('releases a materialized overlay through the connection-bound control operation', async () => { const message = Buffer.from( - JSON.stringify({ type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'overlay' }), + JSON.stringify({ + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'overlay', + }), 'utf8', ); await withProtocolPeer( @@ -424,7 +572,9 @@ test('releases a materialized overlay through the connection-bound control opera const release = decodeClientFrame(await transport.read(1_000)); assert.ok(!('kind' in release)); assert.equal(release.operation, 'session.transcript.overlay.release'); - assert.deepEqual(release.input, { subscriptionId: opened.subscriptionId }); + assert.deepEqual(release.input, { + subscriptionId: opened.subscriptionId, + }); await writeProtocolFrame(transport, { requestId: release.requestId, operation: 'session.transcript.overlay.release', @@ -439,7 +589,13 @@ test('releases a materialized overlay through the connection-bound control opera transcript: { kind: 'tail', maxBytes: 16 * 1024 }, }); assert.deepEqual(await subscription.loadTranscript(decodeStoredMessage), [ - { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'overlay' }, + { + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'overlay', + }, ]); await subscription.close(); }, @@ -448,7 +604,13 @@ test('releases a materialized overlay through the connection-bound control opera test('fails the connection when overlay release is not confirmed', async () => { const message = Buffer.from( - JSON.stringify({ type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'overlay' }), + JSON.stringify({ + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'overlay', + }), 'utf8', ); await withProtocolPeer( @@ -491,7 +653,13 @@ test('fails the connection when overlay release is not confirmed', async () => { test('keeps the connection usable when close wins the overlay release race', async () => { const message = Buffer.from( - JSON.stringify({ type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'overlay' }), + JSON.stringify({ + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'overlay', + }), 'utf8', ); await withProtocolPeer( @@ -552,7 +720,13 @@ test('keeps the connection usable when close wins the overlay release race', asy const loading = subscription.loadTranscript(decodeStoredMessage); await subscription.close(); assert.deepEqual(await loading, [ - { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'overlay' }, + { + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'overlay', + }, ]); assert.equal((await connection.request('host.status', {})).hostEpoch, connection.hostEpoch); }, @@ -561,7 +735,13 @@ test('keeps the connection usable when close wins the overlay release race', asy test('rejects a durable sequence gap', async () => { const message = Buffer.from( - JSON.stringify({ type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'hello' }), + JSON.stringify({ + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'hello', + }), 'utf8', ); const fragment = { @@ -598,7 +778,13 @@ test('rejects a durable sequence gap', async () => { test('rejects a durable message that does not match its payload digest', async () => { const message = Buffer.from( - JSON.stringify({ type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'hello' }), + JSON.stringify({ + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'hello', + }), 'utf8', ); const subscription = new ClientSessionSubscription( @@ -626,15 +812,29 @@ test('rejects a durable message that does not match its payload digest', async ( }, ); + const assemblyDeltas: number[] = []; await assert.rejects( - () => subscription.loadTranscript(decodeStoredMessage), + () => + subscription.decodeTranscriptPage( + subscription.transcriptBootstrap!.durable, + decodeStoredMessage, + undefined, + (deltaBytes) => assemblyDeltas.push(deltaBytes), + ), hasSubscriptionReason('correlation_changed'), ); + assert.deepEqual(assemblyDeltas, [message.byteLength, -message.byteLength]); }); test('rejects a transcript cursor that does not advance', async () => { const message = Buffer.from( - JSON.stringify({ type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'hello' }), + JSON.stringify({ + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'hello', + }), 'utf8', ); const repeated = transcriptPage({ @@ -671,7 +871,13 @@ test('rejects a transcript cursor that does not advance', async () => { test('rejects an overlay that terminates before its declared high-water', async () => { const message = Buffer.from( - JSON.stringify({ type: 'assistant', id: 'assistant-1', turnId: 'turn-1', ts: 1, text: '' }), + JSON.stringify({ + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 1, + text: '', + }), 'utf8', ); const subscription = new ClientSessionSubscription( @@ -710,7 +916,13 @@ test('rejects an overlay that terminates before its declared high-water', async test('acknowledges the overlay only after complete materialization', async () => { const message = Buffer.from( - JSON.stringify({ type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'ok' }), + JSON.stringify({ + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'ok', + }), 'utf8', ); let releases = 0; @@ -735,7 +947,13 @@ test('acknowledges the overlay only after complete materialization', async () => test('acknowledges a complete overlay before waiting for durable continuation pages', async () => { const durableMessage = Buffer.from( - JSON.stringify({ type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'history' }), + JSON.stringify({ + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'history', + }), 'utf8', ); const overlayMessage = Buffer.from( @@ -815,7 +1033,13 @@ test('acknowledges a complete overlay before waiting for durable continuation pa test('close stops transcript pagination after the in-flight page', async () => { const message = Buffer.from( - JSON.stringify({ type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'hello' }), + JSON.stringify({ + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'hello', + }), 'utf8', ); const page = deferred>(); diff --git a/packages/runtime-host/src/__tests__/session-turns.test.ts b/packages/runtime-host/src/__tests__/session-turns.test.ts new file mode 100644 index 0000000000..2e9d842de6 --- /dev/null +++ b/packages/runtime-host/src/__tests__/session-turns.test.ts @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + decodeSessionTurnsQueryResult, + decodeSessionTurnLandmarksQueryResult, + projectSessionTurnLandmarkForWire, + projectSessionTurnContribution, + projectSessionTurnContributionForWire, + SESSION_TURN_DIAGNOSTIC_MAX_BYTES, + SESSION_TURN_LANDMARK_RESULT_MAX_BYTES, +} from '../protocol/session-turns.js'; + +test('keeps a full sampled landmark index inside its encoded result budget', () => { + const result = { + sessionId: 'session-1', + throughSequence: 1_000, + landmarks: Array.from({ length: 64 }, (_, index) => + projectSessionTurnLandmarkForWire({ + turnId: `${index}`.padEnd(128, 't'), + sequence: Number.MAX_SAFE_INTEGER - index, + label: '\0'.repeat(256), + }), + ), + }; + + assert.ok( + Buffer.byteLength(JSON.stringify(result), 'utf8') <= SESSION_TURN_LANDMARK_RESULT_MAX_BYTES, + ); + assert.doesNotThrow(() => decodeSessionTurnLandmarksQueryResult(result)); +}); + +test('keeps legacy assistant presence distinct from retained output', () => { + assert.deepEqual( + projectSessionTurnContribution({ + turnId: 'turn-1', + firstSequence: 0, + latestState: null, + userPromptPreview: 'hello', + hasAssistantMessage: true, + hasAssistantOutput: false, + hasToolResult: false, + hasFailedToolResult: true, + hasAbortNote: false, + }), + { + turnId: 'turn-1', + firstSequence: 0, + userPromptPreview: 'hello', + status: 'completed', + statusSource: 'inferred', + partialOutputRetained: false, + }, + ); +}); + +test('bounds turn diagnostics before publishing a contribution', () => { + const contribution = projectSessionTurnContributionForWire({ + turnId: 'turn-1', + firstSequence: 0, + latestState: { + sequence: 0, + message: { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 1, + status: 'failed', + partialOutputRetained: false, + errorClass: '失败'.repeat(100_000), + }, + }, + userPromptPreview: 'hello', + hasAssistantMessage: false, + hasAssistantOutput: false, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }); + + assert.ok( + Buffer.byteLength(contribution.latestState!.message.errorClass!, 'utf8') <= + SESSION_TURN_DIAGNOSTIC_MAX_BYTES, + ); + assert.doesNotThrow(() => + decodeSessionTurnsQueryResult({ + sessionId: 'session-1', + throughSequence: 0, + contributions: [contribution], + nextPosition: null, + }), + ); +}); + +test('rejects invalid turn-state references before publishing a contribution', () => { + assert.throws(() => + projectSessionTurnContributionForWire({ + turnId: 'turn-1', + firstSequence: 0, + latestState: { + sequence: 0, + message: { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 1, + status: 'completed', + parentTurnId: 'x'.repeat(129), + partialOutputRetained: false, + }, + }, + userPromptPreview: null, + hasAssistantMessage: false, + hasAssistantOutput: false, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }), + ); +}); diff --git a/packages/runtime-host/src/adapter/index.ts b/packages/runtime-host/src/adapter/index.ts index 6e38697d86..ef9895c47d 100644 --- a/packages/runtime-host/src/adapter/index.ts +++ b/packages/runtime-host/src/adapter/index.ts @@ -1,8 +1,10 @@ export { + createRuntimeHostSessionProjectionSeed, RuntimeHostSessionProjector, isRuntimeHostTerminalTurn, foldRuntimeHostAssistantDelta, projectRuntimeHostInteractionRequest, type RuntimeHostProjectionUpdate, + type RuntimeHostSessionProjectionSeed, type RuntimeHostTerminalTurn, } from './session-projector.js'; diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 6fdc758036..b306c88e25 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -1,5 +1,5 @@ import type { ActiveInteractionRequestEvent, SessionEvent } from '@maka/core/events'; -import type { StoredMessage } from '@maka/core/session'; +import type { StoredMessage, TurnRecord } from '@maka/core/session'; import type { InteractionPendingSnapshot, SessionContinuitySnapshot, @@ -20,6 +20,32 @@ interface AssistantAccumulator { replacing: boolean; } +export interface RuntimeHostSessionProjectionSeed { + readonly durableInFlightMessageIds: readonly string[]; + readonly activeAssistantMessages: readonly Extract[]; +} + +export function createRuntimeHostSessionProjectionSeed( + transcript: readonly StoredMessage[], + snapshot: SessionContinuitySnapshot, +): RuntimeHostSessionProjectionSeed { + const inFlightMessageIds = new Set( + rootQueueInFlight(snapshot.queue).map((entry) => entry.messageId), + ); + return { + durableInFlightMessageIds: transcript + .filter((message) => inFlightMessageIds.has(message.id)) + .map((message) => message.id), + activeAssistantMessages: + snapshot.rootTurn === null + ? [] + : transcript.filter( + (message): message is Extract => + message.type === 'assistant' && message.turnId === snapshot.rootTurn?.turnId, + ), + }; +} + export type RuntimeHostTerminalTurn = Extract< TurnSnapshot, { status: 'completed' } | { status: 'failed' } | { status: 'cancelled' } @@ -41,17 +67,17 @@ export class RuntimeHostSessionProjector { constructor( snapshot: SessionContinuitySnapshot, - transcript: readonly StoredMessage[], + seed: RuntimeHostSessionProjectionSeed, now: () => number = Date.now, activeAssistantStreams: readonly SessionAssistantStreamIdentity[] = [], ) { this.#snapshot = structuredClone(snapshot); this.#now = now; - this.#transcriptIds = new Set(transcript.map((message) => message.id)); + this.#transcriptIds = new Set(seed.durableInFlightMessageIds); const root = snapshot.rootTurn; if (!root) return; - for (const message of transcript) { - if (message.type !== 'assistant' || message.turnId !== root.turnId) continue; + for (const message of seed.activeAssistantMessages) { + if (message.turnId !== root.turnId) continue; if (message.thinking?.text) { this.#accumulators.set(accumulatorKey('thinking', message.id), { kind: 'thinking', @@ -130,6 +156,15 @@ export class RuntimeHostSessionProjector { return events; } + noteTranscriptMessageIds(messageIds: readonly string[]): void { + const inFlight = new Set( + rootQueueInFlight(this.#snapshot.queue).map((entry) => entry.messageId), + ); + for (const messageId of messageIds) { + if (inFlight.has(messageId)) this.#transcriptIds.add(messageId); + } + } + seedTerminal(turn: RuntimeHostTerminalTurn): SessionEvent[] { return this.#terminalEvents(turn, true); } @@ -199,6 +234,46 @@ export class RuntimeHostSessionProjector { return events; } + seedRecordedTerminal(turn: TurnRecord): SessionEvent[] { + if (turn.statusSource !== 'recorded' || turn.status === 'running') return []; + const ts = this.#now(); + const id = `host-recorded-terminal:${turn.turnId}:${turn.status}`; + if (turn.status === 'completed') { + return [ + { + type: 'complete', + id, + turnId: turn.turnId, + ts, + stopReason: 'end_turn', + }, + ]; + } + if (turn.status === 'failed') { + const reason = turn.errorClass ?? 'runtime_error'; + return [ + { + type: 'error', + id, + turnId: turn.turnId, + ts, + recoverable: false, + reason, + message: `Turn failed: ${reason}`, + }, + ]; + } + return [ + { + type: 'abort', + id, + turnId: turn.turnId, + ts, + reason: abortReason(turn.abortSource ?? ''), + }, + ]; + } + accept(frame: SubscriptionFrame): RuntimeHostProjectionUpdate { const events: SessionEvent[] = []; if (frame.kind === 'subscription.session_delta') { @@ -247,6 +322,10 @@ export class RuntimeHostSessionProjector { const previousSnapshot = this.#snapshot; const next = frame.snapshot; this.#snapshot = structuredClone(next); + const nextInFlight = new Set(rootQueueInFlight(next.queue).map((entry) => entry.messageId)); + for (const messageId of this.#transcriptIds) { + if (!nextInFlight.has(messageId)) this.#transcriptIds.delete(messageId); + } const resolvedInteractions = removedPendingInteractions(previousSnapshot, next); for (const interaction of newlyPendingInteractions(previousSnapshot, next)) { events.push(...projectRuntimeHostInteractionRequest(interaction, this.#now())); @@ -274,7 +353,13 @@ export class RuntimeHostSessionProjector { ? root : undefined; if (terminalTurn) events.push(...this.#terminalEvents(terminalTurn)); - return { events, previousSnapshot, startedTurn, terminalTurn, resolvedInteractions }; + return { + events, + previousSnapshot, + startedTurn, + terminalTurn, + resolvedInteractions, + }; } #terminalEvents(root: RuntimeHostTerminalTurn, includeSettled = false): SessionEvent[] { diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 9bbc5fee80..c941a15988 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -55,6 +55,7 @@ export { } from './reconnect-lifecycle.js'; export { RuntimeHostSubscriptionError, + type DecodedSessionTranscriptPage, type RuntimeHostSessionSubscription, type RuntimeHostSubscriptionFailureReason, } from './session-subscription.js'; diff --git a/packages/runtime-host/src/client/session-subscription.ts b/packages/runtime-host/src/client/session-subscription.ts index d4ea130596..8afaac31eb 100644 --- a/packages/runtime-host/src/client/session-subscription.ts +++ b/packages/runtime-host/src/client/session-subscription.ts @@ -46,12 +46,31 @@ export interface RuntimeHostSessionSubscription extends AsyncIterable(decodeMessage: (value: unknown) => T): Promise; + loadTranscriptOverlay( + decodeMessage: (value: unknown) => T, + maxMessageBytes?: number, + accountAssemblyBytes?: (deltaBytes: number) => void, + ): Promise; + decodeTranscriptPage( + page: SessionTranscriptPage, + decodeMessage: (value: unknown) => T, + maxMessageBytes?: number, + accountAssemblyBytes?: (deltaBytes: number) => void, + ): Promise>; loadTranscriptPage( input: Omit, ): Promise; close(): Promise; } +export interface DecodedSessionTranscriptPage { + readonly messages: readonly { + readonly identity: number; + readonly message: T; + }[]; + readonly nextCursor: string | null; +} + interface QueuedFrame { frame: SubscriptionFrame; encodedBytes: number; @@ -88,6 +107,7 @@ export class ClientSessionSubscription #closeTask: Promise | undefined; #transcriptTask: Promise | undefined; #overlayTask: Promise> | undefined; + #overlayConsumed = false; #latestTranscriptThroughSequence: number | null; constructor( @@ -154,6 +174,85 @@ export class ClientSessionSubscription return this.#transcriptTask.then((messages) => messages.map(decodeMessage)); } + loadTranscriptOverlay( + decodeMessage: (value: unknown) => T, + maxMessageBytes = Number.MAX_SAFE_INTEGER, + accountAssemblyBytes: (deltaBytes: number) => void = () => undefined, + ): Promise { + this.#assertTranscriptReadable(); + const bootstrap = this.transcriptBootstrap; + if (!bootstrap) { + return Promise.reject( + new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session subscription was opened without transcript access', + ), + ); + } + return this.#consumeTranscriptOverlay(bootstrap, maxMessageBytes, accountAssemblyBytes).then( + (messages) => messages.map((entry) => decodeMessage(entry.value)), + ); + } + + async decodeTranscriptPage( + page: SessionTranscriptPage, + decodeMessage: (value: unknown) => T, + maxMessageBytes = Number.MAX_SAFE_INTEGER, + accountAssemblyBytes: (deltaBytes: number) => void = () => undefined, + ): Promise> { + this.#assertTranscriptReadable(); + this.#assertTranscriptPage(page, { + source: page.source, + direction: page.direction, + throughSequence: page.throughSequence, + maxBytes: Math.max(1, page.rawBytes), + }); + const assembler = new TranscriptFragmentAssembler( + page.source, + page.direction, + maxMessageBytes, + accountAssemblyBytes, + ); + try { + assembler.accept(page.fragments); + let cursor = page.nextCursor; + while (assembler.continuationBytes !== null) { + if (cursor === null) { + throw new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session transcript message ended before every fragment arrived', + ); + } + const requestedCursor = cursor; + const continuation = await this.loadTranscriptPage({ + source: page.source, + direction: page.direction, + throughSequence: page.throughSequence, + cursor, + anchorSequence: null, + maxBytes: Math.min(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, assembler.continuationBytes), + }); + if (continuation.nextCursor === requestedCursor) { + throw new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session transcript cursor did not advance', + ); + } + assembler.accept(continuation.fragments); + cursor = continuation.nextCursor; + } + return { + messages: assembler.finish().map((entry) => ({ + identity: entry.identity, + message: decodeMessage(entry.value), + })), + nextCursor: cursor, + }; + } finally { + assembler.release(); + } + } + loadTranscriptPage( input: Omit, ): Promise { @@ -178,13 +277,14 @@ export class ClientSessionSubscription ), ); } - return this.#readTranscriptPage({ subscriptionId: this.subscriptionId, ...input }).then( - (page) => { - this.#assertTranscriptReadable(); - this.#assertTranscriptPage(page, input); - return page; - }, - ); + return this.#readTranscriptPage({ + subscriptionId: this.subscriptionId, + ...input, + }).then((page) => { + this.#assertTranscriptReadable(); + this.#assertTranscriptPage(page, input); + return page; + }); } async #loadTranscript(): Promise { @@ -196,7 +296,7 @@ export class ClientSessionSubscription 'Session subscription was opened without transcript access', ); } - const overlay = await this.#loadAndReleaseTranscriptOverlay(bootstrap); + const overlay = await this.#consumeTranscriptOverlay(bootstrap); const durable = await this.#loadTranscriptSource(bootstrap.durable); assertCompleteIdentities(durable, bootstrap.throughSequence); const messages = durable.map((entry) => entry.value); @@ -218,16 +318,33 @@ export class ClientSessionSubscription return messages; } - #loadAndReleaseTranscriptOverlay( + #consumeTranscriptOverlay( bootstrap: SessionTranscriptBootstrap, + maxMessageBytes = Number.MAX_SAFE_INTEGER, + accountAssemblyBytes: (deltaBytes: number) => void = () => undefined, ): Promise> { + if (this.#overlayConsumed && !this.#overlayTask) { + return Promise.reject( + new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session transcript overlay was already consumed', + ), + ); + } this.#overlayTask ??= (async () => { - const overlay = await this.#loadTranscriptSource(bootstrap.overlay); + const overlay = await this.#loadTranscriptSource( + bootstrap.overlay, + maxMessageBytes, + accountAssemblyBytes, + ); assertCompleteIdentities( overlay, bootstrap.overlayMessageCount === 0 ? null : bootstrap.overlayMessageCount - 1, ); - if (bootstrap.overlayMessageCount === 0) return overlay; + if (bootstrap.overlayMessageCount === 0) { + this.#overlayConsumed = true; + return overlay; + } try { await this.#releaseTranscriptOverlay(); } catch (cause) { @@ -238,16 +355,19 @@ export class ClientSessionSubscription { cause }, ); } + this.#overlayConsumed = true; return overlay; - })().catch((error: unknown) => { - this.#overlayTask = undefined; - throw error; + })(); + const task = this.#overlayTask; + return task.finally(() => { + if (this.#overlayTask === task) this.#overlayTask = undefined; }); - return this.#overlayTask; } async #loadTranscriptSource( initial: SessionTranscriptPage, + maxMessageBytes = Number.MAX_SAFE_INTEGER, + accountAssemblyBytes: (deltaBytes: number) => void = () => undefined, ): Promise> { this.#assertTranscriptPage(initial, { source: initial.source, @@ -255,28 +375,37 @@ export class ClientSessionSubscription throughSequence: initial.throughSequence, maxBytes: Math.max(1, initial.rawBytes), }); - const assembler = new TranscriptFragmentAssembler(initial.source, initial.direction); - assembler.accept(initial.fragments); - let cursor = initial.nextCursor; - while (cursor !== null) { - const page = await this.loadTranscriptPage({ - source: initial.source, - direction: initial.direction, - throughSequence: initial.throughSequence, - cursor, - anchorSequence: null, - maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, - }); - if (page.nextCursor === cursor) { - throw new RuntimeHostSubscriptionError( - 'correlation_changed', - 'Session transcript cursor did not advance', - ); + const assembler = new TranscriptFragmentAssembler( + initial.source, + initial.direction, + maxMessageBytes, + accountAssemblyBytes, + ); + try { + assembler.accept(initial.fragments); + let cursor = initial.nextCursor; + while (cursor !== null) { + const page = await this.loadTranscriptPage({ + source: initial.source, + direction: initial.direction, + throughSequence: initial.throughSequence, + cursor, + anchorSequence: null, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, + }); + if (page.nextCursor === cursor) { + throw new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session transcript cursor did not advance', + ); + } + assembler.accept(page.fragments); + cursor = page.nextCursor; } - assembler.accept(page.fragments); - cursor = page.nextCursor; + return assembler.finish(); + } finally { + assembler.release(); } - return assembler.finish(); } #assertTranscriptReadable(): void { @@ -432,12 +561,13 @@ export class ClientSessionSubscription class TranscriptFragmentAssembler { readonly #messages: Array<{ identity: number; value: unknown }> = []; + #assemblyBytes = 0; #current: | { identity: number; totalBytes: number; payloadDigest: `sha256:${string}` | null; - chunks: Buffer[]; + data: Buffer; edge: number; } | undefined; @@ -446,12 +576,20 @@ class TranscriptFragmentAssembler { constructor( private readonly source: 'durable' | 'overlay', private readonly direction: 'older' | 'newer', + private readonly maxMessageBytes = Number.MAX_SAFE_INTEGER, + private readonly accountAssemblyBytes: (deltaBytes: number) => void = () => undefined, ) {} accept(fragments: readonly SessionTranscriptFragment[]): void { for (const fragment of fragments) this.#accept(fragment); } + get continuationBytes(): number | null { + const current = this.#current; + if (!current) return null; + return this.direction === 'older' ? current.edge : current.totalBytes - current.edge; + } + finish(): Array<{ identity: number; value: unknown }> { if (this.#current) { throw new RuntimeHostSubscriptionError( @@ -463,6 +601,12 @@ class TranscriptFragmentAssembler { return this.#messages; } + release(): void { + if (this.#assemblyBytes === 0) return; + this.accountAssemblyBytes(-this.#assemblyBytes); + this.#assemblyBytes = 0; + } + #accept(fragment: SessionTranscriptFragment): void { if (fragment.kind !== this.source) { throw new RuntimeHostSubscriptionError( @@ -492,7 +636,13 @@ class TranscriptFragmentAssembler { 'Session transcript message has a fragment gap', ); } - this.#current.chunks.push(bytes); + if (fragment.byteOffset + bytes.byteLength > this.#current.totalBytes) { + throw new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session transcript fragment exceeds its declared message size', + ); + } + bytes.copy(this.#current.data, fragment.byteOffset); this.#current.edge = this.direction === 'older' ? fragment.byteOffset : fragment.byteOffset + bytes.byteLength; if ( @@ -504,6 +654,9 @@ class TranscriptFragmentAssembler { } #start(identity: number, totalBytes: number, payloadDigest: `sha256:${string}` | null): void { + if (totalBytes > this.maxMessageBytes) { + throw new RangeError('Session transcript message exceeds the local byte limit'); + } if ( this.#lastStartedIdentity !== undefined && (this.direction === 'older' @@ -516,29 +669,35 @@ class TranscriptFragmentAssembler { ); } this.#lastStartedIdentity = identity; - this.#current = { - identity, - totalBytes, - payloadDigest, - chunks: [], - edge: this.direction === 'older' ? totalBytes : 0, - }; + this.accountAssemblyBytes(totalBytes); + try { + this.#current = { + identity, + totalBytes, + payloadDigest, + data: Buffer.allocUnsafe(totalBytes), + edge: this.direction === 'older' ? totalBytes : 0, + }; + this.#assemblyBytes += totalBytes; + } catch (error) { + this.accountAssemblyBytes(-totalBytes); + throw error; + } } #completeCurrent(): void { const current = this.#current!; - const chunks = this.direction === 'older' ? current.chunks.reverse() : current.chunks; - const data = Buffer.concat(chunks, current.totalBytes); try { if ( current.payloadDigest !== null && - `sha256:${createHash('sha256').update(data).digest('hex')}` !== current.payloadDigest + `sha256:${createHash('sha256').update(current.data).digest('hex')}` !== + current.payloadDigest ) { throw new Error('payload digest mismatch'); } this.#messages.push({ identity: current.identity, - value: JSON.parse(data.toString('utf8')) as unknown, + value: JSON.parse(current.data.toString('utf8')) as unknown, }); } catch (cause) { throw new RuntimeHostSubscriptionError( diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 69ad900863..9c5e607986 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -64,6 +64,7 @@ export * from './session-catalog-change.js'; export * from './scheduled-task-change.js'; export * from './session-retirement.js'; export * from './session-transcript.js'; +export * from './session-turns.js'; export * from './task-ledger.js'; export * from './workspace.js'; @@ -71,7 +72,7 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 19 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 20 as const; // Transcript pages amortize storage and network round trips with a 512 KiB raw // payload. Base64 expansion plus the bounded fragment envelope must still fit in // one transport message; narrower domains retain their own encoded limits. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 905bdda1ef..7cb9923142 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -33,6 +33,7 @@ import { SCHEDULED_TASK_OPERATION_SPECS } from './scheduled-task.js'; import { SESSION_CATALOG_OPERATION_SPECS } from './session-catalog.js'; import { SESSION_CONTINUITY_OPERATION_SPECS } from './session-continuity.js'; import { SESSION_TRANSCRIPT_OPERATION_SPECS } from './session-transcript.js'; +import { SESSION_TURNS_OPERATION_SPECS } from './session-turns.js'; import { SESSION_REVISION_OPERATION_SPECS } from './session-revision.js'; import { SESSION_RETIREMENT_OPERATION_SPECS } from './session-retirement.js'; import { SESSION_EFFECT_OPERATION_SPECS } from './session-effects.js'; @@ -145,6 +146,7 @@ export * from './session-catalog.js'; export * from './session-revision.js'; export * from './session-retirement.js'; export * from './session-transcript.js'; +export * from './session-turns.js'; export * from './session-effects.js'; export * from './skill-catalog.js'; export * from './usage-pricing.js'; @@ -174,6 +176,7 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps( INTERACTION_OPERATION_SPECS, SESSION_CONTINUITY_OPERATION_SPECS, SESSION_TRANSCRIPT_OPERATION_SPECS, + SESSION_TURNS_OPERATION_SPECS, SESSION_CATALOG_OPERATION_SPECS, SESSION_EFFECT_OPERATION_SPECS, SESSION_REVISION_OPERATION_SPECS, @@ -273,6 +276,8 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'session.revision.create', 'session.transcript.page', 'session.transcript.overlay.release', + 'session.turn_landmarks.query', + 'session.turns.query', 'session.workspace.relocate', 'skill.catalog.invocable.query', 'skill.catalog.mutate', diff --git a/packages/runtime-host/src/protocol/session-turns.ts b/packages/runtime-host/src/protocol/session-turns.ts new file mode 100644 index 0000000000..52295d068b --- /dev/null +++ b/packages/runtime-host/src/protocol/session-turns.ts @@ -0,0 +1,436 @@ +import { decodeStoredMessage, type TurnRecord, type TurnStateMessage } from '@maka/core/session'; +import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { + requireCount, + requireEncodedByteLimit, + requireEntityId, + requireExactRecord, + requireUtf8String, +} from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; +import { defineOperation } from './operation-spec.js'; + +export const SESSION_TURN_QUERY_MAX_CONTRIBUTIONS = 128; +export const SESSION_TURN_QUERY_RESULT_MAX_BYTES = 192 * 1024; +export const SESSION_TURN_DIAGNOSTIC_MAX_BYTES = 128; +export const SESSION_TURN_PROMPT_PREVIEW_MAX_BYTES = 256; +export const SESSION_TURN_LANDMARK_MAX_ITEMS = 64; +export const SESSION_TURN_LANDMARK_LABEL_MAX_BYTES = 96; +export const SESSION_TURN_LANDMARK_RESULT_MAX_BYTES = 64 * 1024; + +export interface SessionTurnLandmark { + readonly turnId: string; + readonly sequence: number; + readonly label: string; +} + +export interface SessionTurnLandmarksQueryInput { + readonly sessionId: string; + readonly maxLandmarks: number; +} + +export interface SessionTurnLandmarksQueryResult { + readonly sessionId: string; + readonly throughSequence: number | null; + readonly landmarks: readonly SessionTurnLandmark[]; +} + +export function projectSessionTurnLandmarkForWire( + landmark: SessionTurnLandmark, +): SessionTurnLandmark { + return { + turnId: requireEntityId(landmark.turnId, 'turnId'), + sequence: requireCount(landmark.sequence, 'Session turn landmark sequence'), + label: truncateUtf8(landmark.label, SESSION_TURN_LANDMARK_LABEL_MAX_BYTES), + }; +} + +export interface SessionTurnContribution { + readonly turnId: string; + readonly firstSequence: number; + readonly latestState: { + readonly sequence: number; + readonly message: TurnStateMessage; + } | null; + readonly userPromptPreview: string | null; + readonly hasAssistantMessage: boolean; + readonly hasAssistantOutput: boolean; + readonly hasToolResult: boolean; + readonly hasFailedToolResult: boolean; + readonly hasAbortNote: boolean; +} + +export interface SessionTurnsQueryInput { + readonly sessionId: string; + readonly throughSequence: number | null; + readonly position: number; + readonly maxContributions: number; +} + +export interface SessionTurnsQueryResult { + readonly sessionId: string; + readonly throughSequence: number | null; + readonly contributions: readonly SessionTurnContribution[]; + readonly nextPosition: number | null; +} + +export function mergeSessionTurnContributions( + current: SessionTurnContribution, + next: SessionTurnContribution, +): SessionTurnContribution { + if (current.turnId !== next.turnId) { + throw new Error('Cannot merge contributions from different Turns'); + } + return { + turnId: current.turnId, + firstSequence: Math.min(current.firstSequence, next.firstSequence), + latestState: + current.latestState === null || + (next.latestState !== null && next.latestState.sequence > current.latestState.sequence) + ? next.latestState + : current.latestState, + userPromptPreview: current.userPromptPreview ?? next.userPromptPreview, + hasAssistantMessage: current.hasAssistantMessage || next.hasAssistantMessage, + hasAssistantOutput: current.hasAssistantOutput || next.hasAssistantOutput, + hasToolResult: current.hasToolResult || next.hasToolResult, + hasFailedToolResult: current.hasFailedToolResult || next.hasFailedToolResult, + hasAbortNote: current.hasAbortNote || next.hasAbortNote, + }; +} + +export function projectSessionTurnContributionForWire( + contribution: SessionTurnContribution, +): SessionTurnContribution { + const latestState = contribution.latestState; + const projectedLatestState = latestState + ? { + sequence: latestState.sequence, + message: projectTurnStateMessageForWire(latestState.message), + } + : null; + return { + turnId: requireEntityId(contribution.turnId, 'turnId'), + firstSequence: contribution.firstSequence, + latestState: projectedLatestState, + userPromptPreview: + contribution.userPromptPreview === null + ? null + : truncateUtf8(contribution.userPromptPreview, SESSION_TURN_PROMPT_PREVIEW_MAX_BYTES), + hasAssistantMessage: contribution.hasAssistantMessage, + hasAssistantOutput: contribution.hasAssistantOutput, + hasToolResult: contribution.hasToolResult, + hasFailedToolResult: contribution.hasFailedToolResult, + hasAbortNote: contribution.hasAbortNote, + }; +} + +function projectTurnStateMessageForWire(message: TurnStateMessage): TurnStateMessage { + return { + type: 'turn_state', + id: requireEntityId(message.id, 'messageId'), + turnId: requireEntityId(message.turnId, 'turnId'), + ts: message.ts, + status: message.status, + ...(message.parentTurnId === undefined + ? {} + : { parentTurnId: requireEntityId(message.parentTurnId, 'parentTurnId') }), + ...(message.retriedFromTurnId === undefined + ? {} + : { retriedFromTurnId: requireEntityId(message.retriedFromTurnId, 'retriedFromTurnId') }), + ...(message.regeneratedFromTurnId === undefined + ? {} + : { + regeneratedFromTurnId: requireEntityId( + message.regeneratedFromTurnId, + 'regeneratedFromTurnId', + ), + }), + ...(message.branchOfTurnId === undefined + ? {} + : { branchOfTurnId: requireEntityId(message.branchOfTurnId, 'branchOfTurnId') }), + ...(message.parentSessionId === undefined + ? {} + : { parentSessionId: requireEntityId(message.parentSessionId, 'parentSessionId') }), + ...(message.abortedAt === undefined ? {} : { abortedAt: message.abortedAt }), + ...(message.abortSource + ? { + abortSource: truncateUtf8(message.abortSource, SESSION_TURN_DIAGNOSTIC_MAX_BYTES), + } + : {}), + ...(message.errorClass + ? { errorClass: truncateUtf8(message.errorClass, SESSION_TURN_DIAGNOSTIC_MAX_BYTES) } + : {}), + partialOutputRetained: message.partialOutputRetained, + }; +} + +export function projectSessionTurnContribution(contribution: SessionTurnContribution): TurnRecord { + const state = contribution.latestState?.message; + const partialOutputRetained = contribution.hasAssistantOutput || contribution.hasToolResult; + if (state) { + return { + turnId: contribution.turnId, + firstSequence: contribution.firstSequence, + ...(contribution.userPromptPreview + ? { userPromptPreview: contribution.userPromptPreview } + : {}), + status: state.status, + statusSource: 'recorded', + ...(state.parentTurnId ? { parentTurnId: state.parentTurnId } : {}), + ...(state.retriedFromTurnId ? { retriedFromTurnId: state.retriedFromTurnId } : {}), + ...(state.regeneratedFromTurnId + ? { regeneratedFromTurnId: state.regeneratedFromTurnId } + : {}), + ...(state.branchOfTurnId ? { branchOfTurnId: state.branchOfTurnId } : {}), + ...(state.parentSessionId ? { parentSessionId: state.parentSessionId } : {}), + ...(state.abortedAt !== undefined ? { abortedAt: state.abortedAt } : {}), + ...(state.abortSource ? { abortSource: state.abortSource } : {}), + ...(state.errorClass ? { errorClass: state.errorClass } : {}), + partialOutputRetained: state.partialOutputRetained || partialOutputRetained, + }; + } + return { + turnId: contribution.turnId, + firstSequence: contribution.firstSequence, + ...(contribution.userPromptPreview + ? { userPromptPreview: contribution.userPromptPreview } + : {}), + status: contribution.hasAbortNote + ? 'aborted' + : contribution.hasAssistantMessage + ? 'completed' + : contribution.hasFailedToolResult + ? 'failed' + : 'completed', + statusSource: 'inferred', + partialOutputRetained, + }; +} + +const QUERY_ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'invalid_request', + 'not_found', + 'persistence_failed', + 'internal_failure', +] as const; + +export const SESSION_TURNS_OPERATION_SPECS = { + 'session.turn_landmarks.query': defineOperation< + SessionTurnLandmarksQueryInput, + SessionTurnLandmarksQueryResult, + (typeof QUERY_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: QUERY_ERRORS, + decodeInput: decodeSessionTurnLandmarksQueryInput, + decodeOutput: decodeSessionTurnLandmarksQueryResult, + assertOutputForInput: (input, output) => { + if (input.sessionId !== output.sessionId) { + throw invalidProtocolFrame('Session turn landmark query identity changed'); + } + }, + }), + 'session.turns.query': defineOperation< + SessionTurnsQueryInput, + SessionTurnsQueryResult, + (typeof QUERY_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: QUERY_ERRORS, + decodeInput: decodeSessionTurnsQueryInput, + decodeOutput: decodeSessionTurnsQueryResult, + assertOutputForInput: (input, output) => { + if ( + input.sessionId !== output.sessionId || + (input.throughSequence !== null && input.throughSequence !== output.throughSequence) + ) { + throw invalidProtocolFrame('Session turn query identity changed'); + } + }, + }), +} as const; + +export function decodeSessionTurnLandmarksQueryInput( + value: unknown, +): SessionTurnLandmarksQueryInput { + const input = requireExactRecord(value, 'Session turn landmark query input', [ + 'sessionId', + 'maxLandmarks', + ]); + const maxLandmarks = requireCount(input.maxLandmarks, 'Session turn landmark limit'); + if (maxLandmarks < 1 || maxLandmarks > SESSION_TURN_LANDMARK_MAX_ITEMS) { + throw invalidProtocolFrame('Invalid Session turn landmark limit'); + } + return { + sessionId: requireEntityId(input.sessionId, 'sessionId'), + maxLandmarks, + }; +} + +export function decodeSessionTurnLandmarksQueryResult( + value: unknown, +): SessionTurnLandmarksQueryResult { + requireEncodedByteLimit( + value, + 'Session turn landmark query result', + SESSION_TURN_LANDMARK_RESULT_MAX_BYTES, + ); + const result = requireExactRecord(value, 'Session turn landmark query result', [ + 'sessionId', + 'throughSequence', + 'landmarks', + ]); + if ( + !Array.isArray(result.landmarks) || + result.landmarks.length > SESSION_TURN_LANDMARK_MAX_ITEMS + ) { + throw invalidProtocolFrame('Invalid Session turn landmarks'); + } + return { + sessionId: requireEntityId(result.sessionId, 'sessionId'), + throughSequence: + result.throughSequence === null + ? null + : requireCount(result.throughSequence, 'Session turn landmark watermark'), + landmarks: result.landmarks.map((value) => { + const landmark = requireExactRecord(value, 'Session turn landmark', [ + 'turnId', + 'sequence', + 'label', + ]); + return { + turnId: requireEntityId(landmark.turnId, 'turnId'), + sequence: requireCount(landmark.sequence, 'Session turn landmark sequence'), + label: requireUtf8String( + landmark.label, + 'Session turn landmark label', + SESSION_TURN_LANDMARK_LABEL_MAX_BYTES, + ), + }; + }), + }; +} + +export function decodeSessionTurnsQueryInput(value: unknown): SessionTurnsQueryInput { + const input = requireExactRecord(value, 'Session turn query input', [ + 'sessionId', + 'throughSequence', + 'position', + 'maxContributions', + ]); + const maxContributions = requireCount( + input.maxContributions, + 'Session turn query contribution limit', + ); + if (maxContributions < 1 || maxContributions > SESSION_TURN_QUERY_MAX_CONTRIBUTIONS) { + throw invalidProtocolFrame('Invalid Session turn query contribution limit'); + } + return { + sessionId: requireEntityId(input.sessionId, 'sessionId'), + throughSequence: + input.throughSequence === null + ? null + : requireCount(input.throughSequence, 'Session turn query watermark'), + position: requireCount(input.position, 'Session turn query position'), + maxContributions, + }; +} + +export function decodeSessionTurnsQueryResult(value: unknown): SessionTurnsQueryResult { + requireEncodedByteLimit(value, 'Session turn query result', SESSION_TURN_QUERY_RESULT_MAX_BYTES); + const result = requireExactRecord(value, 'Session turn query result', [ + 'sessionId', + 'throughSequence', + 'contributions', + 'nextPosition', + ]); + if ( + !Array.isArray(result.contributions) || + result.contributions.length > SESSION_TURN_QUERY_MAX_CONTRIBUTIONS + ) { + throw invalidProtocolFrame('Invalid Session turn query contributions'); + } + return { + sessionId: requireEntityId(result.sessionId, 'sessionId'), + throughSequence: + result.throughSequence === null + ? null + : requireCount(result.throughSequence, 'Session turn query watermark'), + contributions: result.contributions.map(decodeSessionTurnContribution), + nextPosition: + result.nextPosition === null + ? null + : requireCount(result.nextPosition, 'Session turn query next position'), + }; +} + +function decodeSessionTurnContribution(value: unknown): SessionTurnContribution { + const contribution = requireExactRecord(value, 'Session turn contribution', [ + 'turnId', + 'firstSequence', + 'latestState', + 'userPromptPreview', + 'hasAssistantMessage', + 'hasAssistantOutput', + 'hasToolResult', + 'hasFailedToolResult', + 'hasAbortNote', + ]); + let latestState: SessionTurnContribution['latestState'] = null; + if (contribution.latestState !== null) { + const state = requireExactRecord(contribution.latestState, 'Session turn state contribution', [ + 'sequence', + 'message', + ]); + const message = decodeStoredMessage(state.message); + if (message.type !== 'turn_state') { + throw invalidProtocolFrame('Invalid Session turn state contribution'); + } + if (message.abortSource !== undefined) { + requireUtf8String( + message.abortSource, + 'Session turn abort source', + SESSION_TURN_DIAGNOSTIC_MAX_BYTES, + ); + } + if (message.errorClass !== undefined) { + requireUtf8String( + message.errorClass, + 'Session turn error class', + SESSION_TURN_DIAGNOSTIC_MAX_BYTES, + ); + } + latestState = { + sequence: requireCount(state.sequence, 'Session turn state sequence'), + message, + }; + } + return { + turnId: requireEntityId(contribution.turnId, 'turnId'), + firstSequence: requireCount(contribution.firstSequence, 'Session turn first sequence'), + latestState, + userPromptPreview: + contribution.userPromptPreview === null + ? null + : requireUtf8String( + contribution.userPromptPreview, + 'Session turn prompt preview', + SESSION_TURN_PROMPT_PREVIEW_MAX_BYTES, + ), + hasAssistantMessage: requireBoolean(contribution.hasAssistantMessage), + hasAssistantOutput: requireBoolean(contribution.hasAssistantOutput), + hasToolResult: requireBoolean(contribution.hasToolResult), + hasFailedToolResult: requireBoolean(contribution.hasFailedToolResult), + hasAbortNote: requireBoolean(contribution.hasAbortNote), + }; +} + +function requireBoolean(value: unknown): boolean { + if (typeof value !== 'boolean') throw invalidProtocolFrame('Invalid Session turn contribution'); + return value; +} diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts index e7c63b6d74..167df39fee 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -15,6 +15,11 @@ const TRANSCRIPT_QUERY_REPLACEMENT_GRANTS = [ 'session.transcript.page', 'session.transcript.overlay.release', ] as const satisfies readonly OperationKey[]; +const TURN_QUERY_GRANT = 'session.turns.query'; +const TURN_QUERY_REPLACEMENT_GRANTS = [ + TURN_QUERY_GRANT, + 'session.turn_landmarks.query', +] as const satisfies readonly OperationKey[]; export const ACCESS_FILE_NAME = 'runtime-host-access.json'; @@ -199,7 +204,11 @@ function migrateStoredOperationGrants(grants: readonly string[]): readonly strin const seen = new Set(); for (const stored of grants) { const replacements = - stored === LEGACY_TRANSCRIPT_QUERY_GRANT ? TRANSCRIPT_QUERY_REPLACEMENT_GRANTS : [stored]; + stored === LEGACY_TRANSCRIPT_QUERY_GRANT + ? TRANSCRIPT_QUERY_REPLACEMENT_GRANTS + : stored === TURN_QUERY_GRANT + ? TURN_QUERY_REPLACEMENT_GRANTS + : [stored]; for (const replacement of replacements) { if (seen.has(replacement)) continue; seen.add(replacement); diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 60634e5778..ae953c7da0 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -52,6 +52,11 @@ import { type SessionModelTarget, type SessionReadMarkerSetInput, type SessionUpdateResult, + type SessionTurnsQueryInput, + type SessionTurnLandmarksQueryInput, + projectSessionTurnLandmarkForWire, + SESSION_TURN_QUERY_RESULT_MAX_BYTES, + projectSessionTurnContributionForWire, } from '../protocol/index.js'; import type { SessionCatalogOperationHandlerMap } from './operation-dispatcher.js'; import { type SessionAdmissionLease, SessionAdmissionGate } from './session-admission-gate.js'; @@ -68,6 +73,8 @@ type SessionCatalogStores = Pick< | 'readCatalogRecord' | 'readExecutionBoundary' | 'readHeaderRecordSnapshot' + | 'readTurnContributionsSnapshot' + | 'readTurnLandmarksSnapshot' | 'updateHeaderVersioned' >; @@ -124,6 +131,8 @@ export class HostSessionCatalogCoordinator { 'session.workspace.relocate': (input) => this.#relocateWorkspace(input), 'session.read_marker.set': (input) => this.#setReadMarker(input), 'session.execution_boundary.query': (input) => this.#queryExecutionBoundary(input), + 'session.turn_landmarks.query': (input) => this.#queryTurnLandmarks(input), + 'session.turns.query': (input) => this.#queryTurns(input), }; readonly #stores: SessionCatalogStores; @@ -227,6 +236,73 @@ export class HostSessionCatalogCoordinator { } } + async #queryTurns( + input: SessionTurnsQueryInput, + ): Promise> { + try { + let maxContributions = input.maxContributions; + let throughSequence = input.throughSequence; + while (true) { + const page = await this.#stores.readTurnContributionsSnapshot( + input.sessionId, + throughSequence, + input.position, + maxContributions, + ); + throughSequence = page.throughSequence; + const result = { + sessionId: input.sessionId, + throughSequence: page.throughSequence, + contributions: page.contributions.map(projectSessionTurnContributionForWire), + nextPosition: page.nextPosition, + }; + const resultBytes = Buffer.byteLength(JSON.stringify(result), 'utf8'); + if (resultBytes <= SESSION_TURN_QUERY_RESULT_MAX_BYTES) { + return { ok: true, result }; + } + if (maxContributions === 1) { + throw new Error('Session turn contribution exceeds the wire limit'); + } + maxContributions = Math.max( + 1, + Math.min( + maxContributions - 1, + Math.floor( + (page.contributions.length * SESSION_TURN_QUERY_RESULT_MAX_BYTES) / resultBytes, + ), + ), + ); + } + } catch (error) { + if (isNotFound(error)) return turnsFailure('not_found', 'Session does not exist'); + return turnsFailure('persistence_failed', 'Session turns are unavailable'); + } + } + + async #queryTurnLandmarks( + input: SessionTurnLandmarksQueryInput, + ): Promise> { + try { + const snapshot = await this.#stores.readTurnLandmarksSnapshot( + input.sessionId, + input.maxLandmarks, + ); + return { + ok: true, + result: { + sessionId: input.sessionId, + throughSequence: snapshot.throughSequence, + landmarks: snapshot.landmarks.map(projectSessionTurnLandmarkForWire), + }, + }; + } catch (error) { + if (isNotFound(error)) { + return turnLandmarksFailure('not_found', 'Session does not exist'); + } + return turnLandmarksFailure('persistence_failed', 'Session turn landmarks are unavailable'); + } + } + async #create(input: SessionCreateInput): Promise> { let prepared: PreparedSessionCreate; try { @@ -1085,6 +1161,20 @@ function executionBoundaryFailure( return { ok: false, error: { code, message } }; } +function turnsFailure( + code: OperationError<'session.turns.query'>['code'], + message: string, +): Extract, { readonly ok: false }> { + return { ok: false, error: { code, message } }; +} + +function turnLandmarksFailure( + code: OperationError<'session.turn_landmarks.query'>['code'], + message: string, +): Extract, { readonly ok: false }> { + return { ok: false, error: { code, message } }; +} + function projectExecutionBoundary(boundary: ExecutionBoundary): ExecutionBoundarySummary { if (boundary.kind !== 'managed') return { kind: boundary.kind, revision: boundary.revision }; return { diff --git a/packages/runtime/src/fake-backend.ts b/packages/runtime/src/fake-backend.ts index c42687d874..0bf71fd5d3 100644 --- a/packages/runtime/src/fake-backend.ts +++ b/packages/runtime/src/fake-backend.ts @@ -194,7 +194,7 @@ export class FakeBackend implements AgentBackend { } if (pending.length > 0) { const nextText = rewriteTarget - ? `${waitingPrefix}6 NEW` + ? `${waitingPrefix}6 NEW streamed after the remount` : `${waitingPrefix}\n\nAcknowledged steering: ${steered.join(' | ')}`; const delta = nextText.slice(waitingText.length); waitingText = nextText; diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index 23ca49733b..f77ba0eff8 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -244,8 +244,10 @@ describe('SQLite SessionStore', () => { byteOffset: oversized.fragments[0]!.byteOffset, }); const fragments = [...oversized.fragments]; - let continuation: { readonly position: number; readonly byteOffset: number | null } | null = - oversized.next; + let continuation: { + readonly position: number; + readonly byteOffset: number | null; + } | null = oversized.next; while (continuation?.position === 3 && continuation.byteOffset !== null) { const page = await store.readTranscriptPageSnapshot(session.id, { direction: 'older', @@ -323,10 +325,12 @@ describe('SQLite SessionStore', () => { const legacy = new DatabaseSync(path); const legacyRecord = JSON.stringify(message); legacy - .prepare(` + .prepare( + ` UPDATE session_messages SET record_json = ? WHERE session_id = ? AND sequence = 0 - `) + `, + ) .run(legacyRecord, sessionId); legacy.exec(` DROP TABLE session_message_chunks; @@ -378,11 +382,13 @@ describe('SQLite SessionStore', () => { const inspect = new DatabaseSync(path); try { inspect - .prepare(` + .prepare( + ` UPDATE session_message_chunks SET data = zeroblob(length(data)) WHERE session_id = ? AND sequence = 0 AND chunk_index = 1 - `) + `, + ) .run(sessionId); } finally { inspect.close(); @@ -404,16 +410,20 @@ describe('SQLite SessionStore', () => { const rewritten = new DatabaseSync(path); try { const chunk = rewritten - .prepare(` + .prepare( + ` SELECT data FROM session_message_chunks WHERE session_id = ? AND sequence = 0 AND chunk_index = 1 - `) + `, + ) .get(sessionId) as { data: Uint8Array }; rewritten - .prepare(` + .prepare( + ` UPDATE session_message_chunks SET sha256 = ? WHERE session_id = ? AND sequence = 0 AND chunk_index = 1 - `) + `, + ) .run(createHash('sha256').update(chunk.data).digest('hex'), sessionId); } finally { rewritten.close(); @@ -518,6 +528,291 @@ describe('SQLite SessionStore', () => { } }); + test('pages turn contributions at a fixed transcript watermark', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-turn-contributions-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + await store.appendMessages(session.id, [ + { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'one' }, + { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 2, + text: 'answer', + modelId: 'model-1', + }, + { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 3, + status: 'completed', + partialOutputRetained: true, + }, + { type: 'user', id: 'user-2', turnId: 'turn-2', ts: 4, text: 'two' }, + ]); + + const first = await store.readTurnContributionsSnapshot(session.id, null, 0, 1); + assert.equal(first.throughSequence, 3); + assert.equal(first.nextPosition, 3); + assert.deepEqual(first.contributions, [ + { + turnId: 'turn-1', + firstSequence: 0, + latestState: { + sequence: 2, + message: { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 3, + status: 'completed', + partialOutputRetained: true, + }, + }, + userPromptPreview: 'one', + hasAssistantMessage: true, + hasAssistantOutput: true, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }, + ]); + + await store.appendMessage(session.id, { + type: 'assistant', + id: 'assistant-2', + turnId: 'turn-2', + ts: 5, + text: 'later', + modelId: 'model-1', + }); + const second = await store.readTurnContributionsSnapshot( + session.id, + first.throughSequence, + first.nextPosition!, + 1, + ); + assert.equal(second.throughSequence, 3); + assert.deepEqual( + second.contributions.map((entry) => entry.turnId), + ['turn-2'], + ); + assert.equal(second.nextPosition, null); + + await store.appendMessage(session.id, { + type: 'assistant', + id: 'assistant-large', + turnId: 'turn-large', + ts: 6, + text: 'x'.repeat(70 * 1024), + modelId: 'model-1', + }); + const chunked = await store.readTurnContributionsSnapshot(session.id, null, 0, 128); + assert.deepEqual( + chunked.contributions.find((entry) => entry.turnId === 'turn-large'), + { + turnId: 'turn-large', + firstSequence: 5, + latestState: null, + userPromptPreview: null, + hasAssistantMessage: true, + hasAssistantOutput: true, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('bounds turn contribution source scanning independently of turn count', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-turn-source-bound-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + await store.appendMessages( + session.id, + Array.from({ length: 1_025 }, (_, index) => ({ + type: 'assistant' as const, + id: `assistant-${index}`, + turnId: 'turn-1', + ts: index, + text: 'x', + modelId: 'model-1', + })), + ); + + const first = await store.readTurnContributionsSnapshot(session.id, null, 0, 128); + assert.equal(first.nextPosition, 1_024); + assert.equal(first.contributions.length, 1); + const second = await store.readTurnContributionsSnapshot( + session.id, + first.throughSequence, + first.nextPosition!, + 128, + ); + assert.equal(second.nextPosition, null); + assert.equal(second.contributions.length, 1); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('samples a bounded prompt landmark index across the durable transcript', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-turn-landmarks-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + await store.appendMessages( + session.id, + Array.from({ length: 40 }, (_, index) => [ + { + type: 'user' as const, + id: `user-${index}`, + turnId: `turn-${index}`, + ts: index * 2, + text: index === 20 ? 'x'.repeat(70 * 1024) : `prompt ${index}`, + }, + { + type: 'assistant' as const, + id: `assistant-${index}`, + turnId: `turn-${index}`, + ts: index * 2 + 1, + text: 'answer', + modelId: 'model-1', + }, + ]).flat(), + ); + + const snapshot = await store.readTurnLandmarksSnapshot(session.id, 8); + + assert.equal(snapshot.throughSequence, 79); + assert.ok(snapshot.landmarks.length <= 8); + assert.ok(snapshot.landmarks.length > 1); + assert.equal( + snapshot.landmarks.some((landmark) => landmark.turnId === 'turn-20'), + false, + ); + assert.deepEqual( + [...snapshot.landmarks].sort((left, right) => left.sequence - right.sequence), + snapshot.landmarks, + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('keeps every prompt landmark when long turns fit within the landmark limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-turn-landmarks-long-turns-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + await store.appendMessages( + session.id, + Array.from({ length: 3 }, (_, turnIndex) => [ + { + type: 'user' as const, + id: `user-${turnIndex}`, + turnId: `turn-${turnIndex}`, + ts: turnIndex * 10_000, + text: `prompt ${turnIndex}`, + }, + ...Array.from({ length: turnIndex === 0 ? 1_000 : 4_000 }, (_, messageIndex) => ({ + type: 'assistant' as const, + id: `assistant-${turnIndex}-${messageIndex}`, + turnId: `turn-${turnIndex}`, + ts: turnIndex * 10_000 + messageIndex + 1, + text: 'x', + modelId: 'model-1', + })), + ]).flat(), + ); + const database = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); + try { + const insert = database.prepare(` + INSERT INTO core_root_turn_admissions(session_id, turn_id, admitted_at, record_json) + VALUES (?, ?, ?, ?) + `); + for (let turnIndex = 0; turnIndex < 3; turnIndex += 1) { + insert.run( + session.id, + `turn-${turnIndex}`, + turnIndex, + JSON.stringify({ userMessageId: `user-${turnIndex}` }), + ); + } + } finally { + database.close(); + } + + const snapshot = await store.readTurnLandmarksSnapshot(session.id, 64); + + assert.deepEqual( + snapshot.landmarks.map((landmark) => landmark.turnId), + ['turn-0', 'turn-1', 'turn-2'], + ); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('keeps legacy prompts when newer turns have indexed admissions', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-turn-landmarks-mixed-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + await store.appendMessages( + session.id, + Array.from({ length: 10 }, (_, index) => [ + { + type: 'user' as const, + id: `user-${index}`, + turnId: `turn-${index}`, + ts: index * 2, + text: `prompt ${index}`, + }, + { + type: 'assistant' as const, + id: `assistant-${index}`, + turnId: `turn-${index}`, + ts: index * 2 + 1, + text: 'answer', + modelId: 'model-1', + }, + ]).flat(), + ); + const database = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); + try { + database + .prepare(` + INSERT INTO core_root_turn_admissions(session_id, turn_id, admitted_at, record_json) + VALUES (?, ?, ?, ?) + `) + .run(session.id, 'turn-9', 9, JSON.stringify({ userMessageId: 'user-9' })); + } finally { + database.close(); + } + + const snapshot = await store.readTurnLandmarksSnapshot(session.id, 8); + + assert.equal(snapshot.landmarks.length, 8); + assert.equal(snapshot.landmarks[0]?.turnId, 'turn-0'); + assert.equal(snapshot.landmarks.at(-1)?.turnId, 'turn-9'); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('clears unread when the current read marker is already the latest visible message', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-session-read-marker-')); const store = createSessionStore(root); diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index dbdda2c171..32d8fb0fd9 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -234,7 +234,9 @@ export async function openInteractiveExecutionStoresForWrite( lease: StorageRootLease<'interactive', 'write'>, ): Promise> { const interactionStore = await openSqliteInteractiveInteractionStoreForWrite(lease); - return openExecutionStoresForWrite(lease, 'interactive', { interactionStore }); + return openExecutionStoresForWrite(lease, 'interactive', { + interactionStore, + }); } async function openExecutionStoresForWrite( @@ -364,6 +366,17 @@ async function createExecutionStoresForWrite sessionStore.readTranscriptMessagesSnapshot(sessionId, request)), readTranscriptHighWaterSnapshot: (sessionId) => run(() => sessionStore.readTranscriptHighWaterSnapshot(sessionId)), + readTurnContributionsSnapshot: (sessionId, throughSequence, position, maxContributions) => + run(() => + sessionStore.readTurnContributionsSnapshot( + sessionId, + throughSequence, + position, + maxContributions, + ), + ), + readTurnLandmarksSnapshot: (sessionId, maxLandmarks) => + run(() => sessionStore.readTurnLandmarksSnapshot(sessionId, maxLandmarks)), readMessagesForRecovery: (sessionId) => run(() => sessionStore.readMessagesForRecovery(sessionId)), listTurnsSnapshot: (sessionId) => run(() => sessionStore.listTurnsSnapshot(sessionId)), diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index fbd4e00a7b..147ba58117 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -55,6 +55,7 @@ import { type SessionSummary, type StoredMessage, type TurnRecord, + type TurnStateMessage, type UserMessage, } from '@maka/core/session'; @@ -185,7 +186,42 @@ export interface SessionTranscriptStoragePage { /** Returned in traversal order for the requested direction. */ readonly fragments: readonly SessionTranscriptStorageFragment[]; readonly rawBytes: number; - readonly next: { readonly position: number; readonly byteOffset: number | null } | null; + readonly next: { + readonly position: number; + readonly byteOffset: number | null; + } | null; +} + +export interface SessionTurnContribution { + readonly turnId: string; + readonly firstSequence: number; + readonly latestState: { + readonly sequence: number; + readonly message: TurnStateMessage; + } | null; + readonly userPromptPreview: string | null; + readonly hasAssistantMessage: boolean; + readonly hasAssistantOutput: boolean; + readonly hasToolResult: boolean; + readonly hasFailedToolResult: boolean; + readonly hasAbortNote: boolean; +} + +export interface SessionTurnContributionPage { + readonly throughSequence: number | null; + readonly contributions: readonly SessionTurnContribution[]; + readonly nextPosition: number | null; +} + +export interface SessionTurnLandmark { + readonly turnId: string; + readonly sequence: number; + readonly label: string; +} + +export interface SessionTurnLandmarkSnapshot { + readonly throughSequence: number | null; + readonly landmarks: readonly SessionTurnLandmark[]; } export interface SessionStore { @@ -204,6 +240,16 @@ export interface SessionStore { request: SessionTranscriptPageRequest, ): Promise; readTranscriptHighWaterSnapshot(sessionId: string): Promise; + readTurnContributionsSnapshot( + sessionId: string, + throughSequence: number | null, + position: number, + maxContributions: number, + ): Promise; + readTurnLandmarksSnapshot( + sessionId: string, + maxLandmarks: number, + ): Promise; /** Read durable messages for startup recovery. */ readMessagesForRecovery(sessionId: string): Promise; /** Derive durable turns without triggering connection-lock self-healing. */ @@ -664,6 +710,29 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.readTranscriptHighWater(sessionId); } + async readTurnContributionsSnapshot( + sessionId: string, + throughSequence: number | null, + position: number, + maxContributions: number, + ): Promise { + await this.ensureReady(); + return this.metadata.readTurnContributions( + sessionId, + throughSequence, + position, + maxContributions, + ); + } + + async readTurnLandmarksSnapshot( + sessionId: string, + maxLandmarks: number, + ): Promise { + await this.ensureReady(); + return this.metadata.readTurnLandmarks(sessionId, maxLandmarks); + } + async readMessagesForRecovery(sessionId: string): Promise { await this.ensureReady(); return this.metadata.readMessagesForRecovery(sessionId); @@ -1169,7 +1238,9 @@ function toSummary(header: SessionHeader, messages: StoredMessage[] = []): Sessi ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), ...(header.subagentParent ? { subagentParent: header.subagentParent } : {}), ...(header.subagentRuntime - ? { subagentRuntime: subagentSessionRuntimeSummary(header.subagentRuntime) } + ? { + subagentRuntime: subagentSessionRuntimeSummary(header.subagentRuntime), + } : {}), ...(header.subagentWorkspace ? { subagentWorkspace: header.subagentWorkspace } : {}), ...(header.revisionRootSessionId diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 39aead3a05..3c46213542 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -85,6 +85,9 @@ import { type SessionTranscriptMessageLookupRequest, type SessionTranscriptPageRequest, type SessionTranscriptStoragePage, + type SessionTurnContribution, + type SessionTurnContributionPage, + type SessionTurnLandmarkSnapshot, } from './session-store.js'; import { isDiscardableConversationCopy, @@ -107,6 +110,9 @@ import { export { SQLITE_SESSION_METADATA_SCHEMA_VERSION } from './sqlite-session-metadata-schema.js'; const SQLITE_TRANSCRIPT_MESSAGE_LOOKUP_BATCH_SIZE = 256; +const SQLITE_TURN_CONTRIBUTION_MAX_SOURCE_MESSAGES = 1_024; +const SQLITE_TURN_CONTRIBUTION_MAX_SOURCE_BYTES = 4 * 1024 * 1024; +const SQLITE_TURN_LANDMARK_LEGACY_NEIGHBOR_MESSAGES = 32; const require = createRequire(import.meta.url); const AGENT_GRAPH_CONTROL_DELETE_TABLES = [...SQLITE_AGENT_GRAPH_CONTROL_TABLES].reverse(); @@ -1388,7 +1394,12 @@ export class SqliteSessionMetadataStore { const throughSequence = request.throughSequence === undefined ? actualHighWater : request.throughSequence; if (throughSequence === null) { - return { throughSequence: null, fragments: [], rawBytes: 0, next: null }; + return { + throughSequence: null, + fragments: [], + rawBytes: 0, + next: null, + }; } if (actualHighWater === null || throughSequence > actualHighWater) { throw new Error(`Session transcript watermark is ahead of durable storage: ${sessionId}`); @@ -1608,6 +1619,316 @@ export class SqliteSessionMetadataStore { return nullableStoredMessageSequence(row.high_water, sessionId); } + async readTurnContributions( + sessionId: string, + throughSequence: number | null, + position: number, + maxContributions: number, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + if ( + (throughSequence !== null && + (!Number.isSafeInteger(throughSequence) || throughSequence < 0)) || + !Number.isSafeInteger(position) || + position < 0 || + !Number.isSafeInteger(maxContributions) || + maxContributions < 1 || + maxContributions > 128 + ) { + throw new Error('Invalid Session turn contribution request'); + } + return this.readTransaction(() => { + if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); + const highWaterRow = this.db + .prepare('SELECT MAX(sequence) AS high_water FROM session_messages WHERE session_id = ?') + .get(sessionId) as { high_water?: unknown }; + const actualHighWater = nullableStoredMessageSequence(highWaterRow.high_water, sessionId); + const fixedThrough = throughSequence ?? actualHighWater; + if (fixedThrough === null) { + return { throughSequence: null, contributions: [], nextPosition: null }; + } + if (actualHighWater === null || fixedThrough > actualHighWater) { + throw new Error(`Session turn watermark is ahead of durable storage: ${sessionId}`); + } + const contributions = new Map(); + let nextPosition: number | null = position; + let sourceMessages = 0; + let sourceBytes = 0; + while (nextPosition <= fixedThrough) { + const rows = this.db + .prepare( + ` + SELECT message.sequence, message.record_json, payload.record_bytes, payload.sha256 + FROM session_messages AS message + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE message.session_id = ? + AND message.sequence >= ? + AND message.sequence <= ? + ORDER BY message.sequence ASC + LIMIT 128 + `, + ) + .all(sessionId, nextPosition, fixedThrough) as StoredSessionMessagePayloadRow[]; + if (rows.length === 0) { + throw new StoredSessionMessageIncompatibleError(sessionId, nextPosition); + } + for (const row of rows) { + const sequence = requireStoredMessageSequence(row.sequence, sessionId); + const recordBytes = storedMessageRecordBytes(row, sessionId, sequence); + if ( + sourceMessages > 0 && + (sourceMessages >= SQLITE_TURN_CONTRIBUTION_MAX_SOURCE_MESSAGES || + sourceBytes + recordBytes > SQLITE_TURN_CONTRIBUTION_MAX_SOURCE_BYTES) + ) { + return { + throughSequence: fixedThrough, + contributions: [...contributions.values()], + nextPosition: sequence, + }; + } + const message = decodeStoredMessageRecordRow(this.db, sessionId, row); + sourceMessages += 1; + sourceBytes += recordBytes; + if (!('turnId' in message) || typeof message.turnId !== 'string') { + nextPosition = sequence + 1; + continue; + } + const turnId = message.turnId; + if (turnId && !contributions.has(turnId) && contributions.size >= maxContributions) { + nextPosition = sequence; + return { + throughSequence: fixedThrough, + contributions: [...contributions.values()], + nextPosition, + }; + } + contributions.set( + turnId, + foldTurnContribution(contributions.get(turnId), turnId, sequence, message), + ); + nextPosition = sequence + 1; + } + } + return { + throughSequence: fixedThrough, + contributions: [...contributions.values()], + nextPosition: null, + }; + }); + } + + async readTurnLandmarks( + sessionId: string, + maxLandmarks: number, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + if (!Number.isSafeInteger(maxLandmarks) || maxLandmarks < 1 || maxLandmarks > 64) { + throw new Error('Invalid Session turn landmark limit'); + } + return this.readTransaction(() => { + if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); + const throughRow = this.db + .prepare( + ` + SELECT sequence AS through_sequence + FROM session_messages + WHERE session_id = ? + ORDER BY sequence DESC + LIMIT 1 + `, + ) + .get(sessionId) as { through_sequence?: unknown } | undefined; + const throughSequence = nullableStoredMessageSequence( + throughRow?.through_sequence, + sessionId, + ); + if (throughSequence === null) { + return { throughSequence: null, landmarks: [] }; + } + + const promptRows = this.db + .prepare( + ` + SELECT admission.admitted_at, message.sequence + FROM core_root_turn_admissions AS admission + JOIN session_messages AS message + ON message.session_id = admission.session_id + AND message.message_id = json_extract(admission.record_json, '$.userMessageId') + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE admission.session_id = ? AND payload.sequence IS NULL + ORDER BY admission.admitted_at ASC, admission.turn_id ASC + LIMIT ? + `, + ) + .all(sessionId, maxLandmarks + 1) as TurnLandmarkCandidateRow[]; + const selected = new Set(); + if (promptRows.length <= maxLandmarks) { + for (const row of promptRows) { + selected.add(requireStoredMessageSequence(row.sequence, sessionId)); + } + } + + const forward = this.db.prepare(` + SELECT admission.admitted_at, message.sequence + FROM core_root_turn_admissions AS admission + JOIN session_messages AS message + ON message.session_id = admission.session_id + AND message.message_id = json_extract(admission.record_json, '$.userMessageId') + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE admission.session_id = ? AND admission.admitted_at >= ? AND payload.sequence IS NULL + ORDER BY admission.admitted_at ASC, admission.turn_id ASC + LIMIT 1 + `); + const backward = this.db.prepare(` + SELECT admission.admitted_at, message.sequence + FROM core_root_turn_admissions AS admission + JOIN session_messages AS message + ON message.session_id = admission.session_id + AND message.message_id = json_extract(admission.record_json, '$.userMessageId') + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE admission.session_id = ? AND admission.admitted_at < ? AND payload.sequence IS NULL + ORDER BY admission.admitted_at DESC, admission.turn_id DESC + LIMIT 1 + `); + if (promptRows.length > maxLandmarks) { + const firstAdmittedAt = requireTurnLandmarkAdmittedAt(promptRows[0]?.admitted_at); + const lastRow = this.db + .prepare( + ` + SELECT admitted_at + FROM core_root_turn_admissions + WHERE session_id = ? + ORDER BY admitted_at DESC, turn_id DESC + LIMIT 1 + `, + ) + .get(sessionId) as TurnLandmarkCandidateRow | undefined; + const lastAdmittedAt = requireTurnLandmarkAdmittedAt(lastRow?.admitted_at); + for (let index = 0; index < maxLandmarks; index += 1) { + const target = + maxLandmarks === 1 + ? lastAdmittedAt + : firstAdmittedAt + + Math.floor(((lastAdmittedAt - firstAdmittedAt) * index) / (maxLandmarks - 1)); + const candidates = [ + ...(forward.all(sessionId, target) as TurnLandmarkCandidateRow[]), + ...(backward.all(sessionId, target) as TurnLandmarkCandidateRow[]), + ]; + let nearest: TurnLandmarkCandidateRow | undefined; + for (const candidate of candidates) { + const admittedAt = requireTurnLandmarkAdmittedAt(candidate.admitted_at); + if ( + nearest === undefined || + Math.abs(admittedAt - target) < + Math.abs(requireTurnLandmarkAdmittedAt(nearest.admitted_at) - target) + ) { + nearest = candidate; + } + } + if (nearest) selected.add(requireStoredMessageSequence(nearest.sequence, sessionId)); + } + } + const firstIndexedSequence = + promptRows.length > 0 + ? requireStoredMessageSequence(promptRows[0]?.sequence, sessionId) + : null; + const legacyThrough = + firstIndexedSequence === null ? throughSequence : firstIndexedSequence - 1; + if (legacyThrough >= 0) { + const firstRow = this.db + .prepare( + ` + SELECT sequence AS first_sequence + FROM session_messages + WHERE session_id = ? + ORDER BY sequence ASC + LIMIT 1 + `, + ) + .get(sessionId) as { first_sequence?: unknown } | undefined; + const firstSequence = nullableStoredMessageSequence(firstRow?.first_sequence, sessionId); + if (firstSequence === null || firstSequence > legacyThrough) { + throw new StoredSessionMessageIncompatibleError(sessionId, legacyThrough); + } + const forwardLegacy = this.db.prepare(` + SELECT message.sequence, message.message_type, payload.sequence AS payload_sequence + FROM session_messages AS message + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE message.session_id = ? AND message.sequence >= ? AND message.sequence <= ? + ORDER BY message.sequence ASC + LIMIT ${SQLITE_TURN_LANDMARK_LEGACY_NEIGHBOR_MESSAGES} + `); + const backwardLegacy = this.db.prepare(` + SELECT message.sequence, message.message_type, payload.sequence AS payload_sequence + FROM session_messages AS message + LEFT JOIN session_message_payloads AS payload + ON payload.session_id = message.session_id AND payload.sequence = message.sequence + WHERE message.session_id = ? AND message.sequence < ? AND message.sequence >= ? + ORDER BY message.sequence DESC + LIMIT ${SQLITE_TURN_LANDMARK_LEGACY_NEIGHBOR_MESSAGES} + `); + const targetCount = Math.min(maxLandmarks, legacyThrough - firstSequence + 1); + for (let index = 0; index < targetCount; index += 1) { + const target = + targetCount === 1 + ? legacyThrough + : firstSequence + + Math.floor(((legacyThrough - firstSequence) * index) / (targetCount - 1)); + const candidates = [ + ...(forwardLegacy.all(sessionId, target, legacyThrough) as LegacyTurnLandmarkRow[]), + ...(backwardLegacy.all(sessionId, target, firstSequence) as LegacyTurnLandmarkRow[]), + ]; + let nearest: number | undefined; + for (const candidate of candidates) { + const sequence = requireStoredMessageSequence(candidate.sequence, sessionId); + if (candidate.message_type !== 'user' || candidate.payload_sequence !== null) continue; + if (nearest === undefined || Math.abs(sequence - target) < Math.abs(nearest - target)) { + nearest = sequence; + } + } + if (nearest !== undefined) selected.add(nearest); + } + } + + const selectedSequences = [...selected].sort((left, right) => left - right); + const sampledSequences = + selectedSequences.length <= maxLandmarks + ? selectedSequences + : Array.from( + { length: maxLandmarks }, + (_, index) => + selectedSequences[ + maxLandmarks === 1 + ? selectedSequences.length - 1 + : Math.floor(((selectedSequences.length - 1) * index) / (maxLandmarks - 1)) + ]!, + ); + const landmarks = readStoredMessageRows(this.db, sessionId, sampledSequences).flatMap( + ({ sequence, recordJson }) => { + let message: StoredMessage; + try { + message = decodeStoredMessage(JSON.parse(recordJson) as unknown); + } catch (error) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence, { cause: error }); + } + if (message.type !== 'user') { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + const label = (message.displayText ?? message.text).trim(); + return label ? [{ turnId: message.turnId, sequence, label }] : []; + }, + ); + return { throughSequence, landmarks }; + }); + } + async readMessagesForRecovery(sessionId: string): Promise { return this.readMessagesWith(sessionId, decodeStoredMessage); } @@ -1621,10 +1942,12 @@ export class SqliteSessionMetadataStore { if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); const sequences = ( this.db - .prepare(` + .prepare( + ` SELECT sequence FROM session_messages WHERE session_id = ? ORDER BY sequence DESC LIMIT ? - `) + `, + ) .all(sessionId, limit) as Array<{ sequence?: unknown }> ) .map((row) => requireStoredMessageSequence(row.sequence, sessionId)) @@ -3477,7 +3800,10 @@ export class SqliteSessionMetadataStore { private insertSessionMessagesSync( sessionId: string, firstSequence: number, - entries: readonly { readonly message: StoredMessage; readonly json: string }[], + entries: readonly { + readonly message: StoredMessage; + readonly json: string; + }[], ): void { const insertMessage = this.db.prepare(` INSERT INTO session_messages( @@ -4879,6 +5205,130 @@ function decodeStoredMessageRow( } } +interface StoredSessionMessagePayloadRow { + readonly sequence?: unknown; + readonly record_json?: unknown; + readonly record_bytes?: unknown; + readonly sha256?: unknown; +} + +interface TurnLandmarkCandidateRow { + readonly sequence?: unknown; + readonly admitted_at?: unknown; +} + +interface LegacyTurnLandmarkRow { + readonly sequence?: unknown; + readonly message_type?: unknown; + readonly payload_sequence?: unknown; +} + +function requireTurnLandmarkAdmittedAt(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error('Invalid root Turn admission timestamp'); + } + return value; +} + +function storedMessageRecordBytes( + row: StoredSessionMessagePayloadRow, + sessionId: string, + sequence: number, +): number { + if (row.record_bytes !== null) { + return requireTranscriptRecordByteLength(row.record_bytes, sessionId, sequence); + } + if ( + typeof row.record_json !== 'string' || + row.record_json === SQLITE_SESSION_MESSAGE_CHUNK_MARKER + ) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + return Buffer.byteLength(row.record_json, 'utf8'); +} + +function decodeStoredMessageRecordRow( + db: DatabaseSync, + sessionId: string, + row: StoredSessionMessagePayloadRow, +): StoredMessage { + const sequence = requireStoredMessageSequence(row.sequence, sessionId); + return decodeStoredMessageRow( + { + sequence, + record_json: readStoredMessageRecordJson(db, sessionId, sequence, row), + }, + sessionId, + ); +} + +function readStoredMessageRecordJson( + db: DatabaseSync, + sessionId: string, + sequence: number, + row: StoredSessionMessagePayloadRow, +): string { + let recordJson: string; + if (row.record_bytes === null) { + if ( + typeof row.record_json !== 'string' || + row.record_json === SQLITE_SESSION_MESSAGE_CHUNK_MARKER + ) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + recordJson = row.record_json; + } else { + const recordBytes = requireTranscriptRecordByteLength(row.record_bytes, sessionId, sequence); + if ( + row.record_json !== SQLITE_SESSION_MESSAGE_CHUNK_MARKER || + recordBytes <= SQLITE_SESSION_MESSAGE_CHUNK_BYTES || + typeof row.sha256 !== 'string' + ) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + const data = readChunkedTranscriptRecord(db, sessionId, sequence, recordBytes); + if (createHash('sha256').update(data).digest('hex') !== row.sha256) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence); + } + recordJson = data.toString('utf8'); + } + return recordJson; +} + +function foldTurnContribution( + current: SessionTurnContribution | undefined, + turnId: string, + sequence: number, + message: StoredMessage, +): SessionTurnContribution { + const contribution = current ?? { + turnId, + firstSequence: sequence, + latestState: null, + userPromptPreview: null, + hasAssistantMessage: false, + hasAssistantOutput: false, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }; + const userPrompt = message.type === 'user' ? (message.displayText ?? message.text).trim() : ''; + return { + ...contribution, + latestState: message.type === 'turn_state' ? { sequence, message } : contribution.latestState, + userPromptPreview: contribution.userPromptPreview ?? (userPrompt || null), + hasAssistantMessage: contribution.hasAssistantMessage || message.type === 'assistant', + hasAssistantOutput: + contribution.hasAssistantOutput || + (message.type === 'assistant' && message.text.trim().length > 0), + hasToolResult: contribution.hasToolResult || message.type === 'tool_result', + hasFailedToolResult: + contribution.hasFailedToolResult || (message.type === 'tool_result' && message.isError), + hasAbortNote: + contribution.hasAbortNote || (message.type === 'system_note' && message.kind === 'abort'), + }; +} + function readStoredMessageRows( db: DatabaseSync, sessionId: string, @@ -4897,39 +5347,16 @@ function readStoredMessageRows( ORDER BY message.sequence `, ) - .all(sessionId, ...sequences) as Array<{ - sequence?: unknown; - record_json?: unknown; - record_bytes?: unknown; - sha256?: unknown; - }>; + .all(sessionId, ...sequences) as StoredSessionMessagePayloadRow[]; if (rows.length !== sequences.length) { throw new StoredSessionMessageIncompatibleError(sessionId, -1); } return rows.map((row) => { const sequence = requireStoredMessageSequence(row.sequence, sessionId); - if (row.record_bytes === null) { - if ( - typeof row.record_json !== 'string' || - row.record_json === SQLITE_SESSION_MESSAGE_CHUNK_MARKER - ) { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence); - } - return { sequence, recordJson: row.record_json }; - } - const recordBytes = requireTranscriptRecordByteLength(row.record_bytes, sessionId, sequence); - if ( - row.record_json !== SQLITE_SESSION_MESSAGE_CHUNK_MARKER || - recordBytes <= SQLITE_SESSION_MESSAGE_CHUNK_BYTES || - typeof row.sha256 !== 'string' - ) { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence); - } - const data = readChunkedTranscriptRecord(db, sessionId, sequence, recordBytes); - if (createHash('sha256').update(data).digest('hex') !== row.sha256) { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence); - } - return { sequence, recordJson: data.toString('utf8') }; + return { + sequence, + recordJson: readStoredMessageRecordJson(db, sessionId, sequence, row), + }; }); } @@ -4940,12 +5367,14 @@ function readChunkedTranscriptRecord( recordBytes: number, ): Buffer { const rows = db - .prepare(` + .prepare( + ` SELECT chunk_index, data, sha256 FROM session_message_chunks WHERE session_id = ? AND sequence = ? ORDER BY chunk_index - `) + `, + ) .all(sessionId, sequence) as Array<{ chunk_index?: unknown; data?: unknown; @@ -5116,7 +5545,10 @@ function readTranscriptSlices( ON message.session_id = ? AND message.sequence = requested.sequence `, ) - .all(...inlineParameters, sessionId) as Array<{ sequence?: unknown; data?: unknown }>; + .all(...inlineParameters, sessionId) as Array<{ + sequence?: unknown; + data?: unknown; + }>; for (const row of inlineRows) { const sequence = requireStoredMessageSequence(row.sequence, sessionId); if (!(row.data instanceof Uint8Array)) { @@ -5138,7 +5570,9 @@ function validateTranscriptRecord( JSON.parse(typeof data === 'string' ? data : data.toString('utf8')) as unknown, ); } catch (error) { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence, { cause: error }); + throw new StoredSessionMessageIncompatibleError(sessionId, sequence, { + cause: error, + }); } } diff --git a/packages/ui/src/__tests__/chat-scroll-anchor.test.ts b/packages/ui/src/__tests__/chat-scroll-anchor.test.ts new file mode 100644 index 0000000000..4cb0daf53e --- /dev/null +++ b/packages/ui/src/__tests__/chat-scroll-anchor.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseHTML } from 'linkedom'; +import { captureChatScrollAnchor, restoreChatScrollAnchor } from '../chat-scroll-anchor.js'; + +test('reuses the visible article while progressive history grows above it', () => { + const { document } = parseHTML('
'); + const root = document.querySelector('#root')!; + Object.defineProperty(root, 'scrollTop', { value: 0, writable: true }); + root.getBoundingClientRect = () => ({ top: 100 }) as DOMRect; + let rectReads = 0; + for (let index = 0; index < 200; index += 1) { + const turn = document.createElement('section'); + turn.dataset.turnId = `turn-${index}`; + const article = document.createElement('article'); + article.dataset.sender = 'assistant'; + article.getBoundingClientRect = () => { + rectReads += 1; + return { top: index < 180 ? 0 : 120, bottom: index < 180 ? 80 : 160 } as DOMRect; + }; + turn.append(article); + root.append(turn); + } + + const first = captureChatScrollAnchor(root); + assert.equal(first?.turnId, 'turn-180'); + rectReads = 0; + const second = captureChatScrollAnchor(root); + assert.equal(second?.turnId, 'turn-180'); + assert.ok(rectReads <= 4); + assert.equal(restoreChatScrollAnchor(root, second), true); +}); + +test('skips message descendants while advancing the cached anchor', () => { + const { document } = parseHTML('
'); + const root = document.querySelector('#root')!; + Object.defineProperty(root, 'scrollTop', { value: 0, writable: true }); + root.getBoundingClientRect = () => ({ top: 100 }) as DOMRect; + + const first = document.createElement('article'); + first.dataset.sender = 'assistant'; + first.getBoundingClientRect = () => ({ top: 120, bottom: 160 }) as DOMRect; + let parent = first; + let descendantReads = 0; + for (let index = 0; index < 1_000; index += 1) { + const child = document.createElement('div'); + parent.append(child); + const current = parent; + const nested = child; + Object.defineProperty(current, 'firstElementChild', { + configurable: true, + get() { + descendantReads += 1; + return nested; + }, + }); + parent = child; + } + const second = document.createElement('article'); + second.dataset.sender = 'assistant'; + second.getBoundingClientRect = () => ({ top: 120, bottom: 160 }) as DOMRect; + const firstTurn = document.createElement('section'); + firstTurn.dataset.turnId = 'turn-1'; + firstTurn.append(first); + const secondTurn = document.createElement('section'); + secondTurn.dataset.turnId = 'turn-2'; + secondTurn.append(second); + root.append(firstTurn, secondTurn); + + assert.equal(captureChatScrollAnchor(root)?.turnId, 'turn-1'); + first.getBoundingClientRect = () => ({ top: 0, bottom: 80 }) as DOMRect; + assert.equal(captureChatScrollAnchor(root)?.turnId, 'turn-2'); + assert.equal(descendantReads, 0); +}); diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 64a8756fcd..07536a5da9 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -96,6 +96,32 @@ describe("steering timeline", () => { assert.deepEqual(timelineText(deduplicated), ["text:before", "user:inserted instruction"]); }); + test("keeps the current answer ahead of a durable steering event that arrives first", () => { + const persisted = materializeTurns([ + originalUser, + { + type: "user", + id: "steer-1", + turnId: "t1", + ts: 2, + text: "inserted instruction", + steeringEventId: "event-steer", + }, + ]); + const live = applyLiveTurnEvent(armLiveTurn("t1"), { + type: "text_delta", + id: "event-before", + messageId: "before-steer", + turnId: "t1", + ts: 1, + text: "before", + }); + + const [overlaid] = overlayLiveTurn(persisted, live); + + assert.deepEqual(timelineText(overlaid), ["text:before", "user:inserted instruction"]); + }); + test("keeps a persisted tool before live steering during handoff", () => { const persisted = materializeTurns([ originalUser, diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts index e026f42a69..a90d189c40 100644 --- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts +++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts @@ -35,6 +35,7 @@ function railHoldHarness() { scrollTop: 900, /** The target's top edge, relative to the scrollport's. 0 = landed. */ targetTop: 600, + targetPresent: true, filled: false, queried: '', settled: 0, @@ -58,7 +59,7 @@ function railHoldHarness() { getBoundingClientRect: () => ({ top: 0 }) as DOMRect, querySelector: (selector: string) => { state.queried = selector; - return target; + return state.targetPresent ? target : null; }, addEventListener: (type: string, listener: EventListener) => listeners.set(type, listener), removeEventListener: (type: string) => listeners.delete(type), @@ -151,6 +152,36 @@ test('a jump holds until the transcript reports itself filled and still', () => harness.restore(); }); +test('a jump waits for an unloaded destination before settling', () => { + const harness = railHoldHarness(); + const { root, scheduler, state } = harness; + state.filled = true; + state.targetPresent = false; + + holdJumpDestination({ + root, + readTargetId: () => 'turn-7', + isTranscriptFilled: () => state.filled, + onSettled: () => { + state.settled += 1; + }, + scheduler, + }); + + for (let index = 0; index < 10; index += 1) harness.runFrame(); + assert.equal(state.settled, 0, 'a sparse transcript cannot settle before the target arrives'); + + state.targetPresent = true; + harness.runFrame(); + assert.equal(harness.scrolled.length, 1, 'the arriving target is placed at the top'); + harness.runFrame(); + harness.runFrame(); + harness.runFrame(); + assert.equal(state.settled, 1, 'the landed target settles normally'); + + harness.restore(); +}); + test('a jump gives the transcript back the moment the reader touches it', () => { const harness = railHoldHarness(); const { root, scheduler, state, listeners } = harness; diff --git a/packages/ui/src/chat-scroll-anchor.ts b/packages/ui/src/chat-scroll-anchor.ts new file mode 100644 index 0000000000..119605a22d --- /dev/null +++ b/packages/ui/src/chat-scroll-anchor.ts @@ -0,0 +1,110 @@ +export interface ChatScrollAnchor { + readonly turnId: string; + readonly sender: string | undefined; + readonly reverseIndex: number; + readonly top: number; + readonly element: HTMLElement; +} + +const lastAnchorByRoot = new WeakMap(); + +export function captureChatScrollAnchor(root: HTMLElement): ChatScrollAnchor | undefined { + const rootTop = root.getBoundingClientRect().top; + const article = firstVisibleArticle(root, rootTop); + const turn = article?.closest('[data-turn-id]'); + const sender = article?.dataset.sender; + const matches = turn + ? Array.from(turn.querySelectorAll('article')) + .filter((candidate) => candidate.dataset.sender === sender) + : []; + const index = article ? matches.indexOf(article) : -1; + if (!article || !turn?.dataset.turnId || index < 0) return undefined; + lastAnchorByRoot.set(root, article); + return { + turnId: turn.dataset.turnId, + sender, + reverseIndex: matches.length - index - 1, + top: article.getBoundingClientRect().top, + element: article, + }; +} + +export function restoreChatScrollAnchor( + root: HTMLElement, + anchor: ChatScrollAnchor | undefined, +): boolean { + if (!anchor) return false; + const retainedTurn = anchor.element.closest('[data-turn-id]'); + let article = + root.contains(anchor.element) && + retainedTurn?.dataset.turnId === anchor.turnId && + anchor.element.dataset.sender === anchor.sender + ? anchor.element + : undefined; + if (!article) { + const turn = root.querySelector( + `[data-turn-id="${CSS.escape(anchor.turnId)}"]`, + ); + const matches = turn + ? Array.from(turn.querySelectorAll('article')) + .filter((candidate) => candidate.dataset.sender === anchor.sender) + : []; + article = matches.at(-anchor.reverseIndex - 1); + } + if (!article) return false; + lastAnchorByRoot.set(root, article); + root.scrollTop += article.getBoundingClientRect().top - anchor.top; + return true; +} + +function firstVisibleArticle(root: HTMLElement, rootTop: number): HTMLElement | undefined { + const cached = lastAnchorByRoot.get(root); + let article = cached && root.contains(cached) ? cached : nextArticle(root, root); + if (!article) return undefined; + if (article.getBoundingClientRect().bottom > rootTop) { + while (true) { + const previous = previousArticle(root, article); + if (!previous) break; + if (previous.getBoundingClientRect().bottom <= rootTop) break; + article = previous; + } + return article; + } + while ((article = nextArticle(root, article))) { + if (article.getBoundingClientRect().bottom > rootTop) return article; + } + return undefined; +} + +function nextArticle(root: HTMLElement, from: HTMLElement): HTMLElement | undefined { + let node: HTMLElement | null = from; + let descend = node.tagName !== 'ARTICLE'; + while (node) { + if (descend && node.firstElementChild) { + node = node.firstElementChild as HTMLElement; + } else { + while (node && node !== root && !node.nextElementSibling) node = node.parentElement; + if (!node || node === root) return undefined; + node = node.nextElementSibling as HTMLElement; + } + if (node.tagName === 'ARTICLE') return node; + descend = true; + } + return undefined; +} + +function previousArticle(root: HTMLElement, from: HTMLElement): HTMLElement | undefined { + let node: HTMLElement | null = from; + while (node && node !== root) { + if (node.previousElementSibling) { + node = node.previousElementSibling as HTMLElement; + while (node.tagName !== 'ARTICLE' && node.lastElementChild) { + node = node.lastElementChild as HTMLElement; + } + } else { + node = node.parentElement; + } + if (node?.tagName === 'ARTICLE') return node; + } + return undefined; +} diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 6d879d4212..8f88dd535d 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -6,7 +6,7 @@ import { } from './icons.js'; import { DeepResearchEmptyHero, EmptyChatHero } from './chat-empty-hero.js'; import type { ChatModelChoice } from './chat-model-helpers.js'; -import { PromptAnchorRail } from './prompt-anchor-rail.js'; +import { PromptAnchorRail, type PromptAnchorRailTurn } from './prompt-anchor-rail.js'; import { useMessageSelectionQuote } from './use-message-selection-quote.js'; import type { DeepResearchClientProgress } from '@maka/core/deep-research-run'; import type { ProviderType } from '@maka/core/llm-connections'; @@ -167,6 +167,16 @@ export function ChatView(props: { */ scrollTargetTurn?: { turnId: string; nonce: number }; scrollBehavior: ScrollBehavior; + hasOlderHistory?: boolean; + historyLoadPending?: boolean; + onLoadEarlierHistory?(): Promise | void; + returnToLatest?: { + label: string; + isPending: boolean; + onClick(): Promise | void; + }; + transcriptTurnIndex?: ReadonlyArray<{ turnId: string; sequence: number; label: string }>; + onLoadTranscriptTurn?(target: { turnId: string; sequence: number }): void; /** * PR109f: when the active session is a branched session * (`parentSessionId` set on its summary), show a banner above the @@ -319,7 +329,7 @@ export function ChatView(props: { // per-entry comparison is O(1) per turn because an unaffected turn keeps its // object identity, so its text is the same string reference. const promptRailTurnsRef = useRef>([]); - const promptRailTurns = useMemo(() => { + const loadedPromptRailTurns = useMemo(() => { const next = turns .filter((turn) => (turn.user?.text ?? '').trim().length > 0) .map((turn) => ({ @@ -340,6 +350,19 @@ export function ChatView(props: { promptRailTurnsRef.current = next; return next; }, [turns]); + const promptRailTurns = useMemo(() => { + const index = props.transcriptTurnIndex; + if (!index || index.length === 0) return loadedPromptRailTurns; + const loadedByTurnId = new Map(loadedPromptRailTurns.map((turn) => [turn.turnId, turn])); + return index.map((turn) => ({ + ...(loadedByTurnId.get(turn.turnId) ?? { + turnId: turn.turnId, + label: turn.label, + reply: '', + }), + sequence: turn.sequence, + })); + }, [loadedPromptRailTurns, props.transcriptTurnIndex]); // Stable event wrappers (advanced-use-latest): parent handlers are // recreated per render upstream; routing through refs keeps the // memoized TurnView's function props identity-stable without @@ -378,11 +401,16 @@ export function ChatView(props: { return items; }, [props.conversationItems]); const turnIds = useMemo(() => new Set(turns.map((turn) => turn.turnId)), [turns]); + const turnIdsRef = useRef(turnIds); + turnIdsRef.current = turnIds; + const loadTranscriptTurnRef = useRef(props.onLoadTranscriptTurn); + loadTranscriptTurnRef.current = props.onLoadTranscriptTurn; const chatLayout = useChatLayoutContext(); if (!chatLayout) { throw new Error('ChatView must be rendered inside ChatSurfaceLayout'); } const scrollRef = chatLayout.scrollContainerRef; + const [latestNavigationNonce, setLatestNavigationNonce] = useState(0); // #2052: the first commit after a session switch mounts only a tail window // of turns; the rest arrive in idle chunks with scroll compensation. The // full `turns` array above still feeds deriveTurnPresentation and the @@ -420,6 +448,12 @@ export function ChatView(props: { targetTurnId: props.scrollTargetTurn?.turnId, seededGeometry, }); + const navigatePromptRailFallback = useCallback((turn: PromptAnchorRailTurn) => { + if (turnIdsRef.current.has(turn.turnId)) revealTurn(turn.turnId); + else if (turn.sequence !== undefined) { + loadTranscriptTurnRef.current?.({ turnId: turn.turnId, sequence: turn.sequence }); + } + }, [revealTurn]); useEffect(() => { if (!turnsFilled && !seededGeometry && scrollRef.current) { setLookupPass((count) => count + 1); @@ -478,6 +512,10 @@ export function ChatView(props: { target: props.scrollTargetTurn, behavior: props.scrollBehavior, warmupReady: turnsFilled, + hasOlderHistory: props.hasOlderHistory, + historyLoadPending: props.historyLoadPending, + onLoadEarlierHistory: props.onLoadEarlierHistory, + latestNavigationNonce, }); const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( scrollRef, @@ -575,6 +613,21 @@ export function ChatView(props: { return (
+ {props.returnToLatest ? ( +
+
+ ) : null} diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index f9aa70a87c..dbf1026538 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -312,6 +312,7 @@ export type TurnTimelineItem = kind: "user"; message: ChatItem; messageId: string; + steeringEventId?: string; } | { kind: "thinking"; @@ -448,9 +449,19 @@ export function overlayLiveTurn( } for (const message of liveTurn.pendingSteering ?? []) liveSteeringIds.add(message.id); const timeline: TurnTimelineItem[] = []; - for (const item of current.timeline) { + const lastSettledContentIndex = current.timeline.findLastIndex((item) => item.kind !== "user"); + const deferredSteering: Extract[] = []; + for (const [index, item] of current.timeline.entries()) { if (item.kind !== "tools") { if (item.kind === "user" && liveSteeringIds.has(item.messageId)) continue; + if ( + item.kind === "user" && + item.steeringEventId !== undefined && + index > lastSettledContentIndex + ) { + deferredSteering.push(item); + continue; + } if (liveContentKeys.has(`${item.kind}\0${item.messageId}`)) continue; timeline.push(item); continue; @@ -511,6 +522,7 @@ export function overlayLiveTurn( } } appendLiveSteering(liveTurn.pendingSteering ?? []); + timeline.push(...deferredSteering); const mergedTimeline = mergeAdjacentTimeline(timeline); const next = { ...current, @@ -927,6 +939,7 @@ function buildTurnTimeline( kind: "user", message: chatItemFromUserMessage(message), messageId: message.id, + ...(message.steeringEventId ? { steeringEventId: message.steeringEventId } : {}), }); } else if (message.type === "tool_call") { const item = toolItemByUseId.get(message.id); diff --git a/packages/ui/src/progressive-turn-mount.ts b/packages/ui/src/progressive-turn-mount.ts index 52284fb42f..e1baaa2ba1 100644 --- a/packages/ui/src/progressive-turn-mount.ts +++ b/packages/ui/src/progressive-turn-mount.ts @@ -35,10 +35,9 @@ export const DEFAULT_MOUNT_WINDOW: MountWindowConfig = { // a tail of this size stays well under one 120Hz frame budget while still // filling the viewport on ordinary window heights. initialWindow: 10, - // Small enough that a fill step cannot become its own long frame, large - // enough that a 30-turn session finishes filling within a handful of idle - // callbacks. - fillChunk: 4, + // A historical turn can contain a large rendered answer, so idle filling + // adds one turn per commit instead of coupling several expensive renders. + fillChunk: 1, }; function tailStart(length: number, config: MountWindowConfig): number { @@ -75,9 +74,12 @@ export function reconcileMountWindow( next: { key: string | undefined; length: number }, config: MountWindowConfig, ensureIndex?: number, + prependedCount = 0, ): MountWindowState { let start = state.start; - if (state.key !== next.key || next.length - state.length > config.initialWindow) { + if (state.key === next.key && next.length > state.length && prependedCount > 0) { + start = Math.min(next.length, start + prependedCount); + } else if (state.key !== next.key || next.length - state.length > config.initialWindow) { start = tailStart(next.length, config); } else if (start >= next.length && next.length > 0) { start = tailStart(next.length, config); diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx index c8d66041da..50ecc90cdf 100644 --- a/packages/ui/src/prompt-anchor-rail.tsx +++ b/packages/ui/src/prompt-anchor-rail.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef, useState, type CSSProperties, type RefObject } from 'react'; +import { memo, useEffect, useMemo, useRef, useState, type CSSProperties, type RefObject } from 'react'; import { Button } from '@astryxdesign/core/Button'; import { HoverCard } from '@astryxdesign/core/HoverCard'; import { useUiLocale } from './locale-context.js'; @@ -16,6 +16,7 @@ const HOVER_FALLOFF_TICKS = 3; * as restraint. */ const PREVIEW_DELAY_MS = 120; +const MAX_PROMPT_RAIL_TICKS = 64; /** Quiet frames after the transcript is filled that end a jump's hold. */ const JUMP_SETTLE_QUIET_FRAMES = 3; @@ -104,13 +105,15 @@ export function holdJumpDestination(input: { onSettled(); }; - const reaim = (): boolean => { + const reaim = (): { found: boolean; corrected: boolean } => { const turnId = readTargetId(); - if (turnId === null) return false; + if (turnId === null) return { found: false, corrected: false }; const target = root.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); - if (!target) return false; + if (!target) return { found: false, corrected: false }; const offset = target.getBoundingClientRect().top - root.getBoundingClientRect().top; - if (Math.abs(offset) <= JUMP_LANDED_TOLERANCE_PX) return false; + if (Math.abs(offset) <= JUMP_LANDED_TOLERANCE_PX) { + return { found: true, corrected: false }; + } const before = root.scrollTop; // `auto`: this is a correction, not a second journey. (target as HTMLElement).scrollIntoView({ behavior: 'auto', block: 'start' }); @@ -119,7 +122,7 @@ export function holdJumpDestination(input: { // as this scroller can put it — the last turn of a transcript cannot reach // it at all. Report it as landed, or the hold would keep trying until its // frame budget ran out. - return root.scrollTop !== before; + return { found: true, corrected: root.scrollTop !== before }; }; const hold = (): void => { @@ -135,13 +138,21 @@ export function holdJumpDestination(input: { // A still frame that is nonetheless off-target is the other failure: a // scroll that was cancelled part-way and will never resume on its own, // which is what happens when the mount's compensation lands on top of one. - const corrected = grew || (!moved && isTranscriptFilled()) ? reaim() : false; + const target = grew || (!moved && isTranscriptFilled()) + ? reaim() + : { found: false, corrected: false }; // Quiet means nothing moved at all — not the content, not the position. // Height alone was not enough: with the transcript already mounted there // is nothing to re-aim through, and the hold released three frames in, // handing the highlight and the auto-follow release back while the jump's // own scroll was still in flight. - if (!grew && !moved && !corrected && isTranscriptFilled()) quietFrames += 1; + if ( + !grew && + !moved && + !target.corrected && + target.found && + isTranscriptFilled() + ) quietFrames += 1; else quietFrames = 0; if (quietFrames >= JUMP_SETTLE_QUIET_FRAMES || framesRun >= JUMP_HOLD_FRAME_BUDGET) stop(); }; @@ -185,15 +196,14 @@ export interface PromptAnchorRailTurn { turnId: string; label: string; reply?: string; + sequence?: number; } export interface PromptAnchorRailProps { turns: readonly PromptAnchorRailTurn[]; scrollRef: RefObject; /** When progressive mount has not yet placed the turn in the DOM. */ - onNavigateFallback?: (turnId: string) => void; - /** Bumped when turn DOM membership changes without `turns` changing. */ - mountedTurnsRevision?: number; + onNavigateFallback?: (turn: PromptAnchorRailTurn) => void; /** * Release Astryx's auto-follow before a jump scrolls. * @@ -215,8 +225,8 @@ export interface PromptAnchorRailProps { transcriptFilled?: boolean; } -/** Right-edge rail: one tick per user prompt, scrolls to `[data-turn-id]`. */ -export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, mountedTurnsRevision, onNavigateStart, transcriptFilled }: PromptAnchorRailProps): React.ReactElement | null { +/** Right-edge rail: bounded prompt landmarks that scroll to `[data-turn-id]`. */ +export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, onNavigateStart, transcriptFilled }: PromptAnchorRailProps): React.ReactElement | null { const copy = getConversationCopy(useUiLocale()).sessions; const [activeTurnId, setActiveTurnId] = useState(null); const [safeArea, setSafeArea] = useState<{ scrollport: number; dock: number } | null>(null); @@ -237,19 +247,54 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe transcriptFilledRef.current = transcriptFilled ?? true; const onNavigateStartRef = useRef(onNavigateStart); onNavigateStartRef.current = onNavigateStart; + const turnIndexById = useMemo( + () => new Map(turns.map((turn, index) => [turn.turnId, index])), + [turns], + ); + const railTurns = useMemo(() => { + if (turns.length <= MAX_PROMPT_RAIL_TICKS) return turns; + return Array.from({ length: MAX_PROMPT_RAIL_TICKS }, (_, index) => + turns[Math.round(index * (turns.length - 1) / (MAX_PROMPT_RAIL_TICKS - 1))]!, + ); + }, [turns]); + + const railTurnIdFor = (turnId: string): string | null => { + const turnIndex = turnIndexById.get(turnId); + if (turnIndex === undefined) return null; + if (turns.length === railTurns.length) return turnId; + const railIndex = Math.round(turnIndex * (railTurns.length - 1) / (turns.length - 1)); + return railTurns[railIndex]?.turnId ?? null; + }; useEffect(() => { const root = scrollRef.current; if (!root || turns.length === 0) return; const idByElement = new Map(); - for (const turn of turns) { - const el = root.querySelector(`[data-turn-id="${CSS.escape(turn.turnId)}"]`); - if (el) idByElement.set(el, turn.turnId); - } - if (idByElement.size === 0) return; - const visible = new Set(); + const observeElement = (element: Element): void => { + const turnId = element.getAttribute('data-turn-id'); + if (!turnId || !turnIndexById.has(turnId) || idByElement.has(element)) return; + idByElement.set(element, turnId); + observer.observe(element); + }; + const unobserveElement = (element: Element): void => { + const turnId = idByElement.get(element); + if (!turnId) return; + idByElement.delete(element); + visible.delete(turnId); + observer.unobserve(element); + }; + const visitTurnElements = (node: Node, visit: (element: Element) => void): void => { + if (!(node instanceof Element)) return; + if (node.hasAttribute('data-turn-id')) visit(node); + for (const element of node.querySelectorAll('[data-turn-id]')) visit(element); + }; + const activeFor = (turnId: string | null): void => { + if (turnId === null) return; + const railTurnId = railTurnIdFor(turnId); + if (railTurnId !== null) setActiveTurnId(railTurnId); + }; const resolveActive = (): void => { // A jump owns the highlight until its scroll settles. Without this the // observer walks the highlight through every prompt the scroll passes, @@ -257,11 +302,28 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe // glide alone would only turn one long slide into a burst of hops. if (jumpTargetRef.current !== null) return; if (root.scrollHeight - root.scrollTop - root.clientHeight <= SCROLL_END_EPSILON_PX) { - setActiveTurnId(turns[turns.length - 1]!.turnId); + let latest: string | null = null; + let latestIndex = -1; + for (const turnId of idByElement.values()) { + const index = turnIndexById.get(turnId) ?? -1; + if (index > latestIndex) { + latest = turnId; + latestIndex = index; + } + } + activeFor(latest); return; } - const firstVisible = turns.find((turn) => visible.has(turn.turnId)); - if (firstVisible) setActiveTurnId(firstVisible.turnId); + let firstVisible: string | null = null; + let firstIndex = Number.POSITIVE_INFINITY; + for (const turnId of visible) { + const index = turnIndexById.get(turnId) ?? Number.POSITIVE_INFINITY; + if (index < firstIndex) { + firstVisible = turnId; + firstIndex = index; + } + } + activeFor(firstVisible); }; const observer = new IntersectionObserver( @@ -276,7 +338,16 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe }, { root, rootMargin: '0px 0px -66% 0px', threshold: 0 }, ); - for (const el of idByElement.keys()) observer.observe(el); + for (const element of root.querySelectorAll('[data-turn-id]')) observeElement(element); + + const mutationObserver = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.removedNodes) visitTurnElements(node, unobserveElement); + for (const node of record.addedNodes) visitTurnElements(node, observeElement); + } + resolveActive(); + }); + mutationObserver.observe(root, { childList: true, subtree: true }); let frame = 0; const onScroll = (): void => { @@ -290,10 +361,11 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe return () => { observer.disconnect(); + mutationObserver.disconnect(); root.removeEventListener('scroll', onScroll); if (frame !== 0) cancelAnimationFrame(frame); }; - }, [scrollRef, turns, mountedTurnsRevision]); + }, [scrollRef, turnIndexById, railTurns]); useEffect(() => { const root = scrollRef.current; @@ -369,7 +441,8 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe }); }, [jump, scrollRef]); - function jumpTo(turnId: string): void { + function jumpTo(turn: PromptAnchorRailTurn): void { + const turnId = turn.turnId; const root = scrollRef.current; const el = root?.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); // Before the scroll, not after: auto-follow has to be released while the @@ -388,7 +461,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe // Landing reliably beats animating unreliably. (el as HTMLElement).scrollIntoView({ behavior: 'auto', block: 'start' }); } else if (!el) { - onNavigateFallback?.(turnId); + onNavigateFallback?.(turn); } jumpSequenceRef.current += 1; setJump({ sequence: jumpSequenceRef.current, turnId }); @@ -396,7 +469,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe } // A rail is only useful once there are a few prompts to jump between. - if (turns.length < 3) return null; + if (railTurns.length < 3) return null; return (
setHoveredIndex(null)} > - {turns.map((turn, index) => { + {railTurns.map((turn, index) => { const isActive = turn.turnId === activeTurnId; const preview = turn.label.trim() || copy.emptyPrompt; const replyPreview = (turn.reply ?? '').replace(/\s+/g, ' ').trim().slice(0, 140); @@ -447,7 +520,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe className="maka-prompt-rail-tick" data-active={isActive ? 'true' : undefined} aria-current={isActive ? 'true' : undefined} - onClick={() => jumpTo(turn.turnId)} + onClick={() => jumpTo(turn)} onPointerEnter={() => setHoveredIndex(index)} style={ { diff --git a/packages/ui/src/search-modal.tsx b/packages/ui/src/search-modal.tsx index 049089d897..ba9bdbd528 100644 --- a/packages/ui/src/search-modal.tsx +++ b/packages/ui/src/search-modal.tsx @@ -123,7 +123,7 @@ function searchModalThrownErrorMessage( export function SearchModal(props: { isOpen: boolean; onOpenChange(isOpen: boolean): void; - onNavigateToSession?(sessionId: string, turnId?: string): void; + onNavigateToSession?(sessionId: string, turnId?: string, sequence?: number): void; deps?: SearchModalDeps; }) { const locale = useUiLocale(); @@ -143,6 +143,7 @@ export function SearchModal(props: { const pendingNavigationRef = useRef<{ sessionId: string; turnId?: string; + sequence?: number; } | null>(null); useEffect(() => { @@ -151,7 +152,11 @@ export function SearchModal(props: { pendingNavigationRef.current = null; if (!navigation || !props.onNavigateToSession) return; const frame = window.requestAnimationFrame(() => { - props.onNavigateToSession?.(navigation.sessionId, navigation.turnId); + props.onNavigateToSession?.( + navigation.sessionId, + navigation.turnId, + navigation.sequence, + ); }); return () => window.cancelAnimationFrame(frame); }, [props.isOpen, props.onNavigateToSession]); @@ -223,6 +228,7 @@ export function SearchModal(props: { pendingNavigationRef.current = { sessionId: result.target.sessionId, turnId: result.target.turnId, + sequence: result.target.sequence, }; }} renderItem={(item) => { diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 6d5d3f36f3..88e05dbe38 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -1,6 +1,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'; import type { StoredMessage } from '@maka/core/session'; import { createArrivalBottomPin, type ArrivalBottomPin } from './arrival-bottom-pin.js'; +import { captureChatScrollAnchor, restoreChatScrollAnchor } from './chat-scroll-anchor.js'; import { createTurnSizeWarmup } from './turn-size-warmup.js'; export function useChatScroll(input: { @@ -17,9 +18,76 @@ export function useChatScroll(input: { * 250px placeholder size for the life of the session. */ warmupReady?: boolean; + hasOlderHistory?: boolean; + historyLoadPending?: boolean; + onLoadEarlierHistory?(): Promise | void; + latestNavigationNonce?: number; }) { const [highlightedTurnId, setHighlightedTurnId] = useState(null); const arrivalPin = useRef(null); + const loadEarlierRef = useRef(input.onLoadEarlierHistory); + loadEarlierRef.current = input.onLoadEarlierHistory; + const sessionIdRef = useRef(input.sessionId); + sessionIdRef.current = input.sessionId; + const historyLoadPendingRef = useRef(input.historyLoadPending); + historyLoadPendingRef.current = input.historyLoadPending; + const canLoadEarlier = input.onLoadEarlierHistory !== undefined; + const earlierLoadRequest = useRef(null); + + useEffect(() => { + earlierLoadRequest.current = null; + }, [input.sessionId]); + + useEffect(() => { + const root = input.scrollRef.current; + if (!root || !input.hasOlderHistory || !canLoadEarlier) return; + let previousScrollTop = root.scrollTop; + const requestEarlier = (): void => { + if (historyLoadPendingRef.current || earlierLoadRequest.current) return; + const scrollHeight = root.scrollHeight; + const anchor = captureChatScrollAnchor(root); + const sessionId = sessionIdRef.current; + const request = {}; + earlierLoadRequest.current = request; + arrivalPin.current?.release(); + void Promise.resolve(loadEarlierRef.current?.()).catch(() => undefined).finally(() => { + window.requestAnimationFrame(() => { + if ( + earlierLoadRequest.current === request && + sessionIdRef.current === sessionId && + input.scrollRef.current === root && + root.isConnected && + !restoreChatScrollAnchor(root, anchor) + ) { + root.scrollTop += root.scrollHeight - scrollHeight; + } + if (earlierLoadRequest.current === request) earlierLoadRequest.current = null; + }); + }); + }; + const nearStart = (): boolean => + root.scrollTop <= Math.max(640, root.clientHeight * 2); + const onScroll = (): void => { + const nextScrollTop = root.scrollTop; + if (nextScrollTop < previousScrollTop && nearStart()) requestEarlier(); + previousScrollTop = nextScrollTop; + }; + const onWheel = (event: WheelEvent): void => { + if (event.deltaY < 0 && nearStart()) requestEarlier(); + }; + root.addEventListener('scroll', onScroll, { passive: true }); + root.addEventListener('wheel', onWheel, { passive: true }); + return () => { + root.removeEventListener('scroll', onScroll); + root.removeEventListener('wheel', onWheel); + }; + }, [ + input.hasOlderHistory, + input.historyLoadPending, + canLoadEarlier, + input.scrollRef, + input.sessionId, + ]); // ChatLayout owns steady-state following. A session change is product // navigation rather than content growth, so the new transcript must be at its @@ -59,7 +127,7 @@ export function useChatScroll(input: { arrivalPin.current = null; delete viewport.dataset.arrivalPin; }; - }, [input.sessionId, input.hasTurns, input.scrollRef]); + }, [input.sessionId, input.hasTurns, input.scrollRef, input.latestNavigationNonce]); // Withdraw a previous transcript's terminal marker in the same commit that // changes the session. ChatLayout owns the DOM ref, so on the first mount its @@ -129,7 +197,13 @@ export function useChatScroll(input: { window.clearTimeout(settleTimer); cancelWarmup?.(); }; - }, [input.sessionId, input.hasTurns, input.warmupReady, input.scrollRef]); + }, [ + input.sessionId, + input.hasTurns, + input.warmupReady, + input.scrollRef, + input.latestNavigationNonce, + ]); useEffect(() => { const target = input.target; diff --git a/packages/ui/src/use-progressive-turn-mount.ts b/packages/ui/src/use-progressive-turn-mount.ts index e5f611af73..ed66b4388e 100644 --- a/packages/ui/src/use-progressive-turn-mount.ts +++ b/packages/ui/src/use-progressive-turn-mount.ts @@ -10,6 +10,11 @@ import { } from './progressive-turn-mount.js'; import { prefixHeightFor, type TurnGeometryRecord } from './turn-size-index.js'; import { createBrowserWarmupScheduler, type WarmupScheduler } from './turn-size-warmup.js'; +import { + captureChatScrollAnchor, + restoreChatScrollAnchor, + type ChatScrollAnchor, +} from './chat-scroll-anchor.js'; /** * React adapter for the #2052 progressive transcript mount. @@ -64,15 +69,28 @@ export function useProgressiveTurnMount(input: { const [mountWindow, setMountWindow] = useState(() => initialMountWindow(input.sessionId, input.turnIds.length, config), ); + const previousFirstTurnIdRef = useRef(input.turnIds[0]); + const previousFirstTurnId = previousFirstTurnIdRef.current; + const prependedCount = + mountWindow.key === input.sessionId && previousFirstTurnId !== undefined + ? Math.max(0, input.turnIds.indexOf(previousFirstTurnId)) + : 0; const reconciled = reconcileMountWindow( mountWindow, { key: input.sessionId, length: input.turnIds.length }, config, ensureIndex, + prependedCount, ); if (reconciled !== mountWindow) setMountWindow(reconciled); - const beforeFillRef = useRef(undefined); + useLayoutEffect(() => { + previousFirstTurnIdRef.current = input.turnIds[0]; + }, [input.sessionId, input.turnIds]); + + const beforeFillRef = useRef<( + ViewportMetrics & { anchor?: ChatScrollAnchor } + ) | undefined>(undefined); const schedulerRef = useRef(undefined); if (schedulerRef.current === undefined) { schedulerRef.current = input.scheduler ?? createBrowserWarmupScheduler(); @@ -83,9 +101,16 @@ export function useProgressiveTurnMount(input: { if (!scheduler || reconciled.start === 0) return; return scheduler.requestIdle(() => { const root = input.scrollRef.current; - beforeFillRef.current = root - ? { scrollTop: root.scrollTop, scrollHeight: root.scrollHeight, clientHeight: root.clientHeight } - : undefined; + if (root) { + beforeFillRef.current = { + scrollTop: root.scrollTop, + scrollHeight: root.scrollHeight, + clientHeight: root.clientHeight, + anchor: captureChatScrollAnchor(root), + }; + } else { + beforeFillRef.current = undefined; + } setMountWindow((current) => fillMountWindow(current, config)); }); }, [reconciled.start, reconciled.key, config, input.scrollRef]); @@ -96,7 +121,9 @@ export function useProgressiveTurnMount(input: { if (!before) return; const root = input.scrollRef.current; if (!root) return; - root.scrollTop = compensateFillScroll(before, root.scrollHeight).scrollTop; + if (!restoreChatScrollAnchor(root, before.anchor)) { + root.scrollTop = compensateFillScroll(before, root.scrollHeight).scrollTop; + } }, [reconciled.start, input.scrollRef]); // Fill progress published the way the warm-up publishes its own terminal