diff --git a/apps/desktop/e2e/native-transcript-perf.spec.ts b/apps/desktop/e2e/native-transcript-perf.spec.ts index 8631bd63fb..d990c258c4 100644 --- a/apps/desktop/e2e/native-transcript-perf.spec.ts +++ b/apps/desktop/e2e/native-transcript-perf.spec.ts @@ -47,6 +47,8 @@ interface StressSample extends BrowserCounters { firstTurnId: string | null; lastTurnId: string | null; mountedTurns: number; + positionWindow: number; + gapRows: number; } interface StressSweep { @@ -203,26 +205,6 @@ async function returnToLatest(page: Page): Promise { else await page.locator('.maka-prompt-rail-tick').last().click({ force: true }); } -async function traverseFullHistoryAndReturnToTail(page: Page): Promise { - for (let iteration = 0; iteration < PROMPT_RAIL_PROMPT_COUNT; iteration += 1) { - const firstBefore = await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'); - if (firstBefore?.endsWith('-1')) break; - await page.evaluate((selector) => { - const root = document.querySelector(selector); - if (!root) throw new Error('the chat scroll container is missing'); - root.scrollTop = 0; - root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); - }, SCROLLER); - await expect.poll(async () => - page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), - ).not.toBe(firstBefore); - } - await expect(page.locator('[data-turn-id="turn-prompt-rail-1"]')).toHaveCount(1); - await returnToLatest(page); - await expect(page.locator(`[data-turn-id="turn-prompt-rail-${PROMPT_RAIL_PROMPT_COUNT}"]`)) - .toHaveCount(1); -} - async function measureSessionSwitch(page: Page): Promise { await ensureSidebarExpanded(page); const rows = page.locator('.maka-session-row'); @@ -273,7 +255,6 @@ performanceTest('warm native transcript scroll metrics', async ({ promptRailWind const cdp = await page.context().newCDPSession(page); await cdp.send('Performance.enable'); await prepareFrameRecorder(page); - await traverseFullHistoryAndReturnToTail(page); await moveToTail(page); // Warm Chromium, React and the transcript path in both directions before sampling. @@ -281,6 +262,7 @@ performanceTest('warm native transcript scroll metrics', async ({ promptRailWind await scrollGesture(page, 600, 120); await moveToTail(page); await collectGarbage(cdp); + await page.waitForTimeout(100); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())), )); @@ -341,6 +323,9 @@ stressTest('600+ Turn repeated paging keeps the active range on a memory plateau firstTurnId: await page.locator('[data-turn-id]').first().getAttribute('data-turn-id'), lastTurnId: await page.locator('[data-turn-id]').last().getAttribute('data-turn-id'), mountedTurns: await page.locator('[data-turn-id]').count(), + positionWindow: Number(await page.locator('[data-position-source-count]').first() + .getAttribute('data-position-source-count')), + gapRows: await page.locator('.maka-transcript-gap-row').count(), ...counters, }; return sample; @@ -422,10 +407,14 @@ stressTest('600+ Turn repeated paging keeps the active range on a memory plateau const allSamples = sweeps.flatMap((sweep) => sweep.samples); const mountedMax = Math.max(...allSamples.map((sample) => sample.mountedTurns)); + const positionWindowMax = Math.max(...allSamples.map((sample) => sample.positionWindow)); + const gapRowsMax = Math.max(...allSamples.map((sample) => sample.gapRows)); console.log(`TRANSCRIPT_STRESS ${JSON.stringify({ fixtureTurns: PROMPT_RAIL_PROMPT_COUNT, sweeps, mountedMax, + positionWindowMax, + gapRowsMax, nodeMin: Math.min(...allSamples.map((sample) => sample.nodes)), nodeMax: Math.max(...allSamples.map((sample) => sample.nodes)), nodeMaxSecondToFirstRatio, @@ -434,6 +423,8 @@ stressTest('600+ Turn repeated paging keeps the active range on a memory plateau expect(mountedMax).toBeLessThanOrEqual( transcriptContract.DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, ); + expect(positionWindowMax).toBeLessThanOrEqual(128); + expect(gapRowsMax).toBeLessThanOrEqual(2); expect(nodeMaxSecondToFirstRatio).toBeLessThanOrEqual( 1 + SECONDARY_RESOURCE_GROWTH_RATIO, ); diff --git a/apps/desktop/e2e/partial-history-notice.spec.ts b/apps/desktop/e2e/partial-history-notice.spec.ts index 9b70c12b6a..9221986d2b 100644 --- a/apps/desktop/e2e/partial-history-notice.spec.ts +++ b/apps/desktop/e2e/partial-history-notice.spec.ts @@ -18,7 +18,8 @@ */ import type { Page } from '@playwright/test'; -import { expect, test } from './fixtures'; +import { FAKE_STREAM_UNTIL_STEERING_PROMPT } from '@maka/runtime/test-only/fake-backend'; +import { COMPOSER_INPUT, expect, test } from './fixtures'; const NOTICE = '.maka-transcript-history-controls'; @@ -62,18 +63,90 @@ test('partial history is a quiet reading-column control with neutral rail ticks' await page.setViewportSize({ width: 1_400, height: 800 }); await expect(page.locator(NOTICE)).toHaveCount(0); + const composer = page.locator(COMPOSER_INPUT); + const settlingPrompt = FAKE_STREAM_UNTIL_STEERING_PROMPT; + const settlingSteering = 'finish the active overlay'; + await composer.fill(settlingPrompt); + await composer.press('Enter'); + const liveBubble = page.locator('.maka-bubble-streaming'); + await expect(liveBubble).toBeVisible(); + const activeTurn = liveBubble.locator('xpath=ancestor::*[@data-transcript-turn-id][1]'); + const activeTurnId = await activeTurn.getAttribute('data-transcript-turn-id'); + if (!activeTurnId) throw new Error('the live Turn is missing its transcript identity'); + await expect(page.locator('.maka-chat-message-list')).toHaveAttribute( + 'data-position-source-count', + '9', + ); + await expect(activeTurn.locator('[data-turn-status="running"]')).toHaveCount(1); + const firstPrompt = page.locator( '.maka-prompt-rail-tick[data-prompt-turn-id="turn-partial-history-1"]', ); await expect(firstPrompt).toBeVisible(); await firstPrompt.click(); + const logicalRows = () => page.locator( + '.maka-chat-message-list .maka-transcript-turn, .maka-chat-message-list .maka-transcript-gap-row', + ).evaluateAll((rows) => rows.map((row) => + row.getAttribute('data-transcript-turn-id') ?? `gap:${row.getAttribute('data-transcript-gap')}`)); + await expect.poll(async () => { + const rows = await logicalRows(); + const activeIndex = rows.indexOf(activeTurnId); + return { + activeAtEnd: activeIndex === rows.length - 1, + activeStreaming: await liveBubble.count() === 1, + hasFirst: rows.includes('turn-partial-history-1'), + }; + }).toEqual({ + activeAtEnd: true, + activeStreaming: true, + hasFirst: true, + }); + + const activeHistoryState = await page.locator('.maka-chat-message-list').evaluate((list, id) => { + const rows = [...list.querySelectorAll( + '.maka-transcript-turn, .maka-transcript-gap-row', + )]; + const rowIds = rows.map((row) => + row.dataset.transcriptTurnId ?? `gap:${row.dataset.transcriptGap}`); + const firstIndex = rowIds.indexOf('turn-partial-history-1'); + const activeIndex = rowIds.indexOf(id); + return { + rowIds, + activeIndex, + firstIndex, + gapBetween: rowIds.slice(firstIndex + 1, activeIndex).some((row) => row.startsWith('gap:')), + turnSourceCount: list.dataset.turnSourceCount, + positionSourceCount: list.dataset.positionSourceCount, + gaps: rows.flatMap((row) => row.dataset.transcriptGap ? [{ + direction: row.dataset.transcriptGap, + text: row.textContent?.replace(/\s+/g, ' ').trim(), + }] : []), + }; + }, activeTurnId); + expect(activeHistoryState.gapBetween).toBe(true); + const notice = page.locator(NOTICE); await expect(notice).toBeVisible(); await expect(notice).toContainText('正在查看较早的消息'); await expect(notice.getByRole('button', { name: '返回最新消息' })).toBeVisible(); await expect(notice).not.toContainText(/保存|加载/); + const oldRows = await logicalRows(); + expect(oldRows[0]).toBe('turn-partial-history-1'); + expect(oldRows.at(-1)).toBe(activeTurnId); + const activeIndex = oldRows.indexOf(activeTurnId); + expect(oldRows.slice(1, activeIndex).some((row) => row.startsWith('gap:'))).toBe(true); + const loadedHistoricalTurns = new Set(oldRows.filter((row) => row.startsWith('turn-'))); + const missingHistoricalTurns = Array.from({ length: 8 }, (_, index) => + `turn-partial-history-${index + 1}`).filter((turnId) => !loadedHistoricalTurns.has(turnId)); + expect(missingHistoricalTurns.length).toBeGreaterThan(0); + await expect( + page.locator(`[data-transcript-turn-id=${JSON.stringify(activeTurnId)}]`), + ).toHaveCount(1); + const loadGap = page.getByRole('button', { name: '载入这段内容' }).first(); + await expect(loadGap).toBeVisible(); + const regular = await noticePresentation(page); expect(regular).toEqual({ backgroundColor: 'rgba(0, 0, 0, 0)', @@ -129,9 +202,49 @@ test('partial history is a quiet reading-column control with neutral rail ticks' expect(narrow.fitsViewport).toBe(true); expect(narrow.hasHorizontalOverflow).toBe(false); + await loadGap.click(); + await expect.poll(async () => { + const loaded = await page.locator('[data-transcript-turn-id]').evaluateAll((turns) => + turns.map((turn) => turn.getAttribute('data-transcript-turn-id'))); + return missingHistoricalTurns.filter((turnId) => loaded.includes(turnId)).length; + }).toBeGreaterThan(0); + await expect(liveBubble).toBeVisible(); await notice.getByRole('button', { name: '返回最新消息' }).click(); + const historicalTail = page.locator('[data-turn-id="turn-partial-history-8"]'); + await expect(historicalTail).toBeVisible({ timeout: 20_000 }); + await expect(historicalTail.locator('[data-turn-status="failed"]')).toHaveCount(0); await expect(notice).toHaveCount(0); await expect( - page.locator('[data-turn-id="turn-partial-history-8"]'), - ).toBeVisible(); + page.locator(`[data-transcript-turn-id=${JSON.stringify(activeTurnId)}]`), + ).toHaveCount(1); + + const scroller = page.locator('[data-chat-scroll-container="true"]'); + const distanceFromBottom = () => scroller.evaluate((element) => + Math.abs(element.scrollHeight - element.scrollTop - element.clientHeight)); + expect(await distanceFromBottom()).toBeLessThanOrEqual(2); + + await composer.fill(settlingSteering); + await composer.press('Shift+Enter'); + await expect(liveBubble).toHaveCount(0, { timeout: 30_000 }); + const settledTurn = page.locator( + `[data-transcript-turn-id=${JSON.stringify(activeTurnId)}]`, + ); + await expect(settledTurn).toHaveCount(1); + const userBubbleTexts = await settledTurn.locator( + '.maka-chat-message-bubble-user', + ).allTextContents(); + expect(userBubbleTexts.filter((text) => text.includes(settlingPrompt))).toHaveLength(1); + expect(userBubbleTexts.filter((text) => text.includes(settlingSteering))).toHaveLength(1); + const assistantBubble = settledTurn.locator('.maka-chat-message-bubble-assistant'); + await expect(assistantBubble).toHaveCount(1); + await expect(assistantBubble).toContainText( + `Acknowledged steering: ${settlingSteering}`, + ); + const tailTurnIds = await page.locator('[data-transcript-turn-id]').evaluateAll((turns) => + turns.map((turn) => turn.getAttribute('data-transcript-turn-id'))); + expect(tailTurnIds.filter((turnId) => turnId === activeTurnId)).toHaveLength(1); + expect(tailTurnIds.indexOf('turn-partial-history-8')).toBeLessThan( + tailTurnIds.indexOf(activeTurnId), + ); + expect(await distanceFromBottom()).toBeLessThanOrEqual(2); }); diff --git a/apps/desktop/e2e/transcript-bidirectional-scroll.spec.ts b/apps/desktop/e2e/transcript-bidirectional-scroll.spec.ts new file mode 100644 index 0000000000..b53a4b29f3 --- /dev/null +++ b/apps/desktop/e2e/transcript-bidirectional-scroll.spec.ts @@ -0,0 +1,152 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Page } from '@playwright/test'; +import { expect, test } from './fixtures'; + +const SCROLLER = '[data-chat-scroll-container="true"]'; +const TURN_PREFIX = 'turn-partial-history-'; +const TURN_COUNT = 8; +const WHEEL_DELTA_PX = 40; +const MAX_TURN_DISPLACEMENT_PX = 160; + +interface TranscriptSnapshot { + readonly visible: readonly number[]; + readonly turnOffsets: Readonly>; + readonly gaps: readonly string[]; + readonly scrollTop: number; + readonly distanceToTail: number; +} + +async function transcriptSnapshot(page: Page): Promise { + return page.evaluate(([scrollerSelector, turnPrefix]) => { + const root = document.querySelector(scrollerSelector); + if (!root) throw new Error('the chat scroll container is missing'); + const rootRect = root.getBoundingClientRect(); + const visibleTurns = [...root.querySelectorAll('[data-turn-id]')] + .flatMap((turn) => { + const turnId = turn.dataset.turnId; + if (!turnId?.startsWith(turnPrefix)) return []; + const rect = turn.getBoundingClientRect(); + if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return []; + return [{ + index: Number(turnId.slice(turnPrefix.length)), + top: Math.round(rect.top - rootRect.top), + }]; + }) + .filter(({ index }) => Number.isSafeInteger(index)); + return { + visible: visibleTurns.map(({ index }) => index), + turnOffsets: Object.fromEntries(visibleTurns.map(({ index, top }) => [index, top])), + gaps: [...root.querySelectorAll('[data-transcript-gap]')] + .map((gap) => gap.dataset.transcriptGap) + .filter((direction): direction is string => direction !== undefined), + scrollTop: Math.round(root.scrollTop), + distanceToTail: Math.round(root.scrollHeight - root.scrollTop - root.clientHeight), + }; + }, [SCROLLER, TURN_PREFIX] as const); +} + +async function wheelToTranscriptEdge( + page: Page, + direction: 'older' | 'newer', +): Promise { + const step = direction === 'older' ? -1 : 1; + const target = direction === 'older' ? 1 : TURN_COUNT; + let frontier = direction === 'older' ? TURN_COUNT + 1 : 0; + const observed: number[] = []; + let previousSnapshot: TranscriptSnapshot | undefined; + + const recordVisibleTurns = (snapshot: TranscriptSnapshot): void => { + if (previousSnapshot) { + const retainedTurn = snapshot.visible.find((index) => + previousSnapshot?.visible.includes(index)); + expect( + retainedTurn, + `${direction} wheel replaced the viewport instead of preserving its reading anchor`, + ).toBeDefined(); + const displacement = Math.abs( + snapshot.turnOffsets[retainedTurn!] + - previousSnapshot.turnOffsets[retainedTurn!], + ); + expect( + displacement, + `${direction} wheel moved the retained Turn ${retainedTurn} by ${displacement}px: ${JSON.stringify({ previousSnapshot, snapshot })}`, + ).toBeLessThanOrEqual(MAX_TURN_DISPLACEMENT_PX); + } + const candidates = [...new Set(snapshot.visible)] + .filter((index) => step < 0 ? index < frontier : index > frontier) + .sort((left, right) => step < 0 ? right - left : left - right); + for (const index of candidates) { + expect( + index, + `${direction} wheel traversal skipped from Turn ${frontier} to Turn ${index}`, + ).toBe(frontier + step); + frontier = index; + observed.push(index); + } + previousSnapshot = snapshot; + }; + + const root = page.locator(SCROLLER); + await root.hover(); + for (let attempt = 0; attempt < 300; attempt += 1) { + const snapshot = await transcriptSnapshot(page); + recordVisibleTurns(snapshot); + const reachedDataEdge = !snapshot.gaps.includes(direction); + const reachedScrollEdge = direction === 'older' + ? snapshot.scrollTop <= 1 + : snapshot.distanceToTail <= 1; + if (frontier === target && reachedDataEdge && reachedScrollEdge) return observed; + + await page.mouse.wheel(0, step * WHEEL_DELTA_PX); + await page.waitForTimeout(24); + } + + throw new Error( + `${direction} wheel traversal did not reach Turn ${target}: ${JSON.stringify(await transcriptSnapshot(page))}`, + ); +} + +test('real wheel input traverses every bounded Turn to the oldest edge and back to the latest edge', async ({ + partialHistoryWindow: page, +}) => { + test.slow(); + await page.setViewportSize({ width: 900, height: 520 }); + + await expect.poll(async () => { + const snapshot = await transcriptSnapshot(page); + return { + latestVisible: snapshot.visible.includes(TURN_COUNT), + atTail: snapshot.distanceToTail <= 1, + hasOlder: snapshot.gaps.includes('older'), + }; + }, { message: 'the isolated transcript opens at its bounded latest range' }).toEqual({ + latestVisible: true, + atTail: true, + hasOlder: true, + }); + + expect(await wheelToTranscriptEdge(page, 'older')).toEqual( + Array.from({ length: TURN_COUNT }, (_, index) => TURN_COUNT - index), + ); + expect(await wheelToTranscriptEdge(page, 'newer')).toEqual( + Array.from({ length: TURN_COUNT }, (_, index) => index + 1), + ); +}); diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index 9b8a18f029..4ca6f8997f 100644 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -204,7 +204,10 @@ async function sendPrompt(page: Page, text: string): Promise { await composer.fill(text); // Switching Session or model restarts asynchronous send admission. await awaitSendReady(page); - await composer.press('Enter'); + // This suite measures transcript scrolling, not keyboard submission. A + // transient editor popup deliberately consumes Enter while it closes, so + // use the form's real submit control and let Playwright await actionability. + await page.locator('.maka-composer button[type="submit"]').click(); } /** Answered turns, so a second send can be waited for without a stale match. */ @@ -309,6 +312,10 @@ test('switching Sessions restores a Turn anchor while a tail Session follows bac await modelSwitcher.click(); await page.getByRole('menuitemradio', { name: 'glm-4.5', exact: true }).click(); await expect(modelSwitcher).toContainText('glm-4.5'); + await expect.poll(() => page.evaluate(async (sessionId) => + (await window.maka.sessions.list()).find((session) => session.id === sessionId)?.model, + tailSessionId), { message: 'the model change reaches the authoritative Session catalog' }) + .toBe('glm-4.5'); await page.evaluate((sessionId) => { const state = { complete: false, unsubscribe: () => undefined }; diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index e54ddacfd2..46ae958838 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -980,7 +980,7 @@ "react": 1 }, "importSpecifiers": 184, - "nonTriviaTokens": 15692 + "nonTriviaTokens": 15728 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts index 4e2dde6c2e..debf5504bd 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-ui-state.test.ts @@ -414,6 +414,43 @@ describe('app shell session UI state controller', () => { assert.deepEqual(unavailable, { sessionId: 'session', turnId: 'removed' }); }); + it('consumes a search target once so later range changes do not restore it', async () => { + let residentSequence: number | null = 5; + const loadedSequences: number[] = []; + const controller = { + store: { + range: () => ({ sessionId: 'session' }), + sequenceForTurn: () => residentSequence, + newestDurableUserSequence: () => 5, + snapshot: () => ({ messages: [] }), + }, + ready: async () => undefined, + loadAround: async (sequence: number) => { + loadedSequences.push(sequence); + }, + }; + const restore = (nonce: number) => transcriptReadingPosition.restoreRange({ + sessionId: 'session', + searchTarget: { sessionId: 'session', turnId: 'turn', sequence: 5, nonce }, + controller, + isCurrent: () => true, + setMessages: () => undefined, + setReadingAnchor: () => undefined, + onError: (error: unknown) => assert.fail(String(error)), + }); + + restore(1); + await new Promise((resolve) => setImmediate(resolve)); + residentSequence = null; + restore(1); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(loadedSequences, []); + + restore(2); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(loadedSequences, [5]); + }); + it('keeps the synchronous live-turn ref aligned with reducer updates', () => { const controller = createAppShellSessionUiStateController(); const projection = armLiveTurn('turn-1'); diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index ba076c2fc0..0bd34b9e5d 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -101,6 +101,7 @@ test('moves a fragmented overlay record to durable storage without duplicating i durableThrough: null, durable: [], overlay: [message], + positionRange: unavailablePositionRange(null, false, false), hasOlder: false, hasNewer: false, })]; @@ -123,6 +124,7 @@ test('moves a fragmented overlay record to durable storage without duplicating i durableUpserts: [{ sequence: 4, message }], evictedDurableSequences: [], completedOverlayMessageIds: [message.id], + positionRange: null, hasOlder: true, hasNewer: false, })]; @@ -150,6 +152,7 @@ test('retains the newest observed durable prompt across eviction', () => { { sequence: 3, message: userMessage('newer', 'user-3') }, ], overlay: [], + positionRange: unavailablePositionRange(3, false, false), hasOlder: false, hasNewer: false, })) store.accept(batch); @@ -160,12 +163,40 @@ test('retains the newest observed durable prompt across eviction', () => { durableUpserts: [{ sequence: 4, message: assistantMessage('latest') }], evictedDurableSequences: [3], completedOverlayMessageIds: [], + positionRange: null, hasOlder: false, hasNewer: false, })) store.accept(batch); assert.equal(store.newestDurableUserSequence(), 3); }); +test('does not treat a state-only Turn as resident transcript content', () => { + const store = transcriptStore(); + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 1, + durable: [{ + sequence: 1, + message: { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 2, + status: 'completed', + partialOutputRetained: true, + }, + }], + overlay: [], + positionRange: unavailablePositionRange(1, true, false), + hasOlder: true, + hasNewer: false, + })) store.accept(batch); + + assert.equal(store.sequenceForTurn('turn-1'), null); +}); + test('drops stale transcript batches after a generation reset', () => { const store = transcriptStore(); const oldBatches = [...encodeDesktopTranscriptSnapshot({ @@ -175,6 +206,7 @@ test('drops stale transcript batches after a generation reset', () => { durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('old') }], overlay: [], + positionRange: unavailablePositionRange(1, false, false), hasOlder: false, hasNewer: false, })]; @@ -186,6 +218,7 @@ test('drops stale transcript batches after a generation reset', () => { durableThrough: 2, durable: [{ sequence: 2, message: nextMessage }], overlay: [], + positionRange: unavailablePositionRange(2, true, false), hasOlder: true, hasNewer: false, })]; @@ -199,6 +232,7 @@ test('drops stale transcript batches after a generation reset', () => { durableUpserts: [{ sequence: 3, message: assistantMessage('stale') }], evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: null, hasOlder: false, hasNewer: false, }, @@ -221,6 +255,7 @@ test('keeps unchanged message references stable across immutable range snapshots durableThrough: 1, durable: [{ sequence: 1, message: firstMessage }], overlay: [], + positionRange: unavailablePositionRange(1, false, false), hasOlder: false, hasNewer: false, })) store.accept(batch); @@ -236,6 +271,7 @@ test('keeps unchanged message references stable across immutable range snapshots durableUpserts: [{ sequence: 2, message: secondMessage }], evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: null, hasOlder: false, hasNewer: false, })) store.accept(batch); @@ -246,6 +282,46 @@ test('keeps unchanged message references stable across immutable range snapshots assert.deepEqual(second.messages, [firstMessage, secondMessage]); }); +test('keeps a null position sidecar as an explicit no-update batch', () => { + const identity = { + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + }; + const store = transcriptStore(); + for (const batch of encodeDesktopTranscriptSnapshot({ + ...identity, + durableThrough: 1, + durable: [{ sequence: 1, message: userMessage('first', 'user-1') }], + overlay: [], + positionRange: { + state: 'ready', + throughSequence: 1, + revision: 2, + positions: [{ turnId: 'turn-user-1', firstSequence: 1 }], + hasOlder: false, + hasNewer: false, + }, + hasOlder: false, + hasNewer: false, + })) store.accept(batch); + const positions = store.snapshot().positionRange; + + const batches = [...encodeDesktopTranscriptChange(identity, { + durableThrough: 1, + durableUpserts: [], + evictedDurableSequences: [], + completedOverlayMessageIds: [], + positionRange: null, + hasOlder: false, + hasNewer: false, + })]; + assert.equal(batches.length, 1); + assert.equal(batches[0]!.positionRange, null); + assert.equal(store.accept(batches[0]!), false); + assert.strictEqual(store.snapshot().positionRange, positions); +}); + test('bounds the default active transcript range by Turn identities', async () => { const messages = Array.from({ length: 200 }, (_, sequence) => ({ identity: sequence, @@ -287,6 +363,140 @@ test('bounds the default active transcript range by Turn identities', async () = assert.equal(snapshot.hasNewer, false); }); +test('keeps a bounded Turn position sidecar on the existing transcript replica', async () => { + const messages = [ + { identity: 0, message: userMessage('first', 'user-1') }, + { identity: 1, message: assistantMessage('answer', 'assistant-1') }, + { identity: 2, message: userMessage('second', 'user-2') }, + ]; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 2, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: transcriptPage('older', null, 2), + overlay: { ...transcriptPage('older', null, 2), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async () => ({ messages, nextCursor: null }), + loadTranscriptPositionsPage: async (input) => ({ + kind: 'page', + sessionId: 'session-1', + direction: input.direction, + throughSequence: input.throughSequence, + revision: 0, + positions: [ + { turnId: 'turn-1', firstSequence: 0 }, + { turnId: 'turn-2', firstSequence: 2 }, + ], + hasOlder: false, + hasNewer: false, + nextCursor: null, + }), + async close() {}, + }); + + const replica = await DesktopTranscriptReplica.prepare(handle); + + assert.deepEqual(replica.snapshot().positionRange, { + state: 'ready', + throughSequence: 2, + revision: 0, + positions: [ + { turnId: 'turn-1', firstSequence: 0 }, + { turnId: 'turn-2', firstSequence: 2 }, + ], + hasOlder: false, + hasNewer: false, + }); +}); + +test('retries a building position sidecar without withholding durable bodies', async () => { + const messages = [{ identity: 0, message: userMessage('first', 'user-1') }]; + let positionReads = 0; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 0, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: transcriptPage('older', null, 0), + overlay: { ...transcriptPage('older', null, 0), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async () => ({ messages, nextCursor: null }), + loadTranscriptPositionsPage: async (input) => { + positionReads += 1; + if (positionReads === 1) { + return { + kind: 'building' as const, + sessionId: 'session-1', + throughSequence: input.throughSequence, + indexedThroughSequence: null, + retryAfterMs: 25 as const, + }; + } + return { + kind: 'page' as const, + sessionId: 'session-1', + direction: input.direction, + throughSequence: input.throughSequence, + revision: 0, + positions: [{ turnId: 'turn-1', firstSequence: 0 }], + hasOlder: false, + hasNewer: false, + nextCursor: null, + }; + }, + async close() {}, + }); + + const replica = await DesktopTranscriptReplica.prepare(handle); + assert.deepEqual(replica.snapshot().durable, messages.map((entry) => ({ + sequence: entry.identity, + message: entry.message, + }))); + assert.equal(replica.snapshot().positionRange?.state, 'building'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(replica.snapshot().positionRange?.state, 'ready'); + assert.equal(positionReads, 2); + await replica.close(); +}); + +test('degrades only the position sidecar when its pager fails', async () => { + const messages = [{ identity: 0, message: userMessage('first', 'user-1') }]; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 0, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: transcriptPage('older', null, 0), + overlay: { ...transcriptPage('older', null, 0), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async () => ({ messages, nextCursor: null }), + loadTranscriptPositionsPage: async () => { + throw new Error('position pager unavailable'); + }, + async close() {}, + }); + + const replica = await DesktopTranscriptReplica.prepare(handle); + + assert.equal(replica.snapshot().durable.length, 1); + assert.equal(replica.snapshot().positionRange?.state, 'unavailable'); + await replica.close(); +}); + test('bounds the default active transcript range by presentation bytes', async () => { const messages = syntheticLargeTranscript(); const bootstrapPage = transcriptPage('older', null, messages.length - 1); @@ -462,6 +672,7 @@ test('keeps an oversized latest Turn when returning from history to a trailing s const replica = await DesktopTranscriptReplica.prepare(handle); await replica.loadBefore(latest.identity, 128 * 1024); + assert.ok(replica.snapshot().durable.some(({ sequence }) => sequence === older.identity)); assert.equal(replica.snapshot().hasNewer, true); await replica.loadAround(trailingNote.identity, 128 * 1024); @@ -469,7 +680,276 @@ test('keeps an oversized latest Turn when returning from history to a trailing s assert.ok(replica.snapshot().durable.some(({ sequence }) => sequence === latest.identity)); }); -test('keeps a bounded contiguous window while moving between history and the tail', async () => { +test('retains the reader anchor when it fits beside the Host-protected older Turn', async () => { + const messages = [0, 1, 2].map((sequence) => ({ + identity: sequence, + message: { + ...assistantMessage('x'.repeat(200 * 1024), `assistant-${sequence}`), + turnId: `turn-${sequence}`, + }, + })); + const bootstrapPage = { + ...transcriptPage('older', 'older', 2), + rangeBoundarySequence: 2, + protectedTurnSequence: 2, + }; + const olderPage = { + ...transcriptPage('older', null, 2), + rangeBoundarySequence: 0, + protectedTurnSequence: 1, + }; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 2, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, 2), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (page) => page === bootstrapPage + ? { messages: messages.slice(2), nextCursor: 'older' } + : { messages: messages.slice(0, 2), nextCursor: null }, + loadTranscriptPage: async () => olderPage, + async close() {}, + }); + const replica = await DesktopTranscriptReplica.prepare(handle, { + maxResidentBytes: 450 * 1024, + }); + + await replica.loadBefore(2, 450 * 1024); + + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [1, 2]); +}); + +test('retains an oversized reader anchor as bounded overlap with the next older Turn', async () => { + const messages = [ + { + identity: 0, + message: { + ...assistantMessage('x'.repeat(600 * 1024), 'assistant-0'), + turnId: 'turn-0', + }, + }, + { + identity: 1, + message: { + ...assistantMessage('x'.repeat(180 * 1024), 'assistant-1'), + turnId: 'turn-1', + }, + }, + ]; + const bootstrapPage = { + ...transcriptPage('older', 'older', 1), + rangeBoundarySequence: 1, + protectedTurnSequence: 1, + }; + const olderPage = { + ...transcriptPage('older', null, 1), + rangeBoundarySequence: 0, + protectedTurnSequence: 0, + }; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 1, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, 1), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (page) => page === bootstrapPage + ? { messages: messages.slice(1), nextCursor: 'older' } + : { messages: messages.slice(0, 1), nextCursor: null }, + loadTranscriptPage: async () => olderPage, + async close() {}, + }); + const replica = await DesktopTranscriptReplica.prepare(handle, { + maxResidentBytes: DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + }); + + await replica.loadBefore(1, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [0, 1]); + assert.ok(replica.residentBytes > DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); +}); + +test('ignores Host protection that is absent from the decoded older page', async () => { + const messages = [0, 1, 2].map((sequence) => ({ + identity: sequence, + message: { + ...assistantMessage('x'.repeat(200 * 1024), `assistant-${sequence}`), + turnId: `turn-${sequence}`, + }, + })); + const bootstrapPage = { + ...transcriptPage('older', 'older', 2), + rangeBoundarySequence: 2, + protectedTurnSequence: 2, + }; + const olderPage = { + ...transcriptPage('older', null, 2), + rangeBoundarySequence: 0, + protectedTurnSequence: 99, + }; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: 2, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { ...transcriptPage('older', null, 2), source: 'overlay' }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (page) => page === bootstrapPage + ? { messages: messages.slice(2), nextCursor: 'older' } + : { messages: messages.slice(0, 2), nextCursor: null }, + loadTranscriptPage: async () => olderPage, + async close() {}, + }); + const replica = await DesktopTranscriptReplica.prepare(handle, { + maxResidentBytes: 450 * 1024, + }); + + await replica.loadBefore(2, 450 * 1024); + + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [1, 2]); +}); + +test('keeps the reader anchor beside the Host-protected older Turn across loose records', async () => { + const older = { + identity: 0, + message: { ...assistantMessage('older', 'assistant-0'), turnId: 'turn-0' }, + }; + const notes = Array.from({ length: 10 }, (_, index) => ({ + identity: index + 1, + message: { + type: 'system_note' as const, + id: `note-${index + 1}`, + ts: index + 1, + kind: 'context_compacted' as const, + }, + })); + const anchor = { + identity: 11, + message: { ...assistantMessage('anchor', 'assistant-11'), turnId: 'turn-11' }, + }; + const bootstrapPage = { + ...transcriptPage('older', 'older', anchor.identity), + rangeBoundarySequence: anchor.identity, + protectedTurnSequence: anchor.identity, + }; + const olderPage = { + ...transcriptPage('older', null, anchor.identity), + rangeBoundarySequence: older.identity, + protectedTurnSequence: older.identity, + }; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: anchor.identity, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { + ...transcriptPage('older', null, anchor.identity), + source: 'overlay', + }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (page) => page === bootstrapPage + ? { messages: [anchor], nextCursor: 'older' } + : { messages: [older, ...notes], nextCursor: null }, + loadTranscriptPage: async () => olderPage, + async close() {}, + }); + const replica = await DesktopTranscriptReplica.prepare(handle, { + maxResidentTurns: 10, + }); + + await replica.loadBefore(anchor.identity, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + + assert.ok(replica.snapshot().durable.some(({ sequence }) => sequence === older.identity)); + assert.ok(replica.snapshot().durable.some(({ sequence }) => sequence === anchor.identity)); + assert.equal(replica.snapshot().hasNewer, false); +}); + +test('keeps the reader anchor beside the Host-protected older Turn across a large loose record', async () => { + const older = { + identity: 0, + message: { ...assistantMessage('older', 'assistant-0'), turnId: 'turn-0' }, + }; + const note = { + identity: 1, + message: { + type: 'system_note' as const, + id: 'large-note-1', + ts: 1, + kind: 'context_compacted' as const, + data: 'x'.repeat(300 * 1024), + }, + }; + const anchor = { + identity: 2, + message: { + ...assistantMessage('x'.repeat(200 * 1024), 'assistant-2'), + turnId: 'turn-2', + }, + }; + const bootstrapPage = { + ...transcriptPage('older', 'older', anchor.identity), + rangeBoundarySequence: anchor.identity, + protectedTurnSequence: anchor.identity, + }; + const olderPage = { + ...transcriptPage('older', null, anchor.identity), + rangeBoundarySequence: older.identity, + protectedTurnSequence: older.identity, + }; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events: { async *[Symbol.asyncIterator]() {} }, + transcriptBootstrap: { + throughSequence: anchor.identity, + durableCoverage: 'complete', + overlayMessageCount: 0, + durable: bootstrapPage, + overlay: { + ...transcriptPage('older', null, anchor.identity), + source: 'overlay', + }, + }, + loadTranscriptOverlay: async () => [], + decodeTranscriptPage: async (page) => page === bootstrapPage + ? { messages: [anchor], nextCursor: 'older' } + : { messages: [older, note], nextCursor: null }, + loadTranscriptPage: async () => olderPage, + async close() {}, + }); + const replica = await DesktopTranscriptReplica.prepare(handle, { + maxResidentBytes: 450 * 1024, + }); + + await replica.loadBefore(anchor.identity, 450 * 1024); + + assert.ok(replica.snapshot().durable.some(({ sequence }) => sequence === older.identity)); + assert.ok(replica.snapshot().durable.some(({ sequence }) => sequence === anchor.identity)); + assert.equal(replica.snapshot().hasNewer, false); +}); + +test('keeps a contiguous overlap while paging from history toward the tail', async () => { const messages = [0, 1, 2, 3, 4].map((sequence) => ({ identity: sequence, message: { @@ -491,7 +971,7 @@ test('keeps a bounded contiguous window while moving between history and the tai }); const bootstrapPage = page('older'); const olderPage = page('older'); - const latestPage = page(null); + const latestPage = { ...page(null), direction: 'newer' as const }; const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), @@ -509,7 +989,7 @@ test('keeps a bounded contiguous window while moving between history and the tai : candidate === olderPage ? { messages: messages.slice(1, 3), nextCursor: 'older' } : { messages: messages.slice(4), nextCursor: null }, - loadTranscriptPage: async (input) => input.anchorSequence === 3 ? olderPage : latestPage, + loadTranscriptPage: async (input) => input.direction === 'older' ? olderPage : latestPage, async close() {}, }); const maxResidentBytes = ( @@ -526,8 +1006,8 @@ test('keeps a bounded contiguous window while moving between history and the tai assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2, 3]); assert.equal(replica.snapshot().hasNewer, true); - await replica.loadAround(4, 128 * 1024); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [4]); + await replica.loadAfter(3, 128 * 1024); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [3, 4]); assert.equal(replica.snapshot().hasNewer, false); assert.ok(replica.residentBytes <= maxResidentBytes); }); @@ -1303,6 +1783,7 @@ test('reopens a failed transcript range with a fresh generation', async () => { durableThrough: null, durable: [], overlay: [], + positionRange: unavailablePositionRange(null, false, false), hasOlder: false, hasNewer: false, })) @@ -1313,6 +1794,7 @@ test('reopens a failed transcript range with a fresh generation', async () => { hostEpoch: 'host-2', readThroughMessageId: null, async loadBefore() {}, + async loadAfter() {}, async loadAround() {}, async close() {}, }; @@ -1324,7 +1806,7 @@ test('reopens a failed transcript range with a fresh generation', async () => { await controller.close(); }); -test('forwards a larger logical history range without changing batch size', async () => { +test('forwards one larger logical history range per resident Turn anchor', async () => { const store = transcriptStore(); for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'session-1', @@ -1339,25 +1821,138 @@ test('forwards a larger logical history range without changing batch size', asyn }, ], overlay: [], + positionRange: unavailablePositionRange(2, true, false), hasOlder: true, hasNewer: false, })) store.accept(batch); - let request: { anchorSequence: number | null; maxBytes?: number } | undefined; + const requests: Array<{ anchorSequence: number | null; maxBytes?: number }> = []; const controller = createDesktopTranscriptRangeController(store, async () => ({ sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: 'assistant-1', async loadBefore(anchorSequence, maxBytes) { - request = { anchorSequence, maxBytes }; + requests.push({ anchorSequence, maxBytes }); }, + async loadAfter() {}, async loadAround() {}, async close() {}, })); await controller.loadBefore(512 * 1024, 'turn-2'); + await controller.loadBefore(512 * 1024, 'turn-2'); + + assert.deepEqual(requests, [{ anchorSequence: 2, maxBytes: 512 * 1024 }]); + await controller.close(); +}); + +test('forwards the final record of a multi-message Turn once when loading later history', async () => { + const store = transcriptStore(); + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 4, + durable: [ + { + sequence: 1, + message: { ...assistantMessage('prompt', 'assistant-1'), turnId: 'turn-2' }, + }, + { + sequence: 2, + message: { ...assistantMessage('anchor', 'assistant-2'), turnId: 'turn-2' }, + }, + ], + overlay: [], + positionRange: unavailablePositionRange(4, false, true), + hasOlder: false, + hasNewer: true, + })) store.accept(batch); + const requests: Array<{ anchorSequence: number | null; maxBytes?: number }> = []; + const controller = createDesktopTranscriptRangeController(store, async () => ({ + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + readThroughMessageId: 'assistant-2', + async loadBefore() {}, + async loadAfter(anchorSequence, maxBytes) { + requests.push({ anchorSequence, maxBytes }); + }, + async loadAround() {}, + async close() {}, + })); + + await controller.loadAfter(512 * 1024, 'turn-2'); + await controller.loadAfter(512 * 1024, 'turn-2'); + + assert.deepEqual(requests, [{ anchorSequence: 2, maxBytes: 512 * 1024 }]); + await controller.close(); +}); + +test('uses resident sequence edges to progress across loose-only history pages', async () => { + const store = transcriptStore(); + const installRange = ( + noteSequence: number, + turnSequence: number | undefined, + turnId: string | undefined, + hasOlder: boolean, + ) => { + for (const batch of encodeDesktopTranscriptSnapshot({ + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + durableThrough: 32, + durable: [ + { + sequence: noteSequence, + message: { + type: 'system_note' as const, + id: `note-${noteSequence}`, + ts: noteSequence, + kind: 'context_compacted' as const, + }, + }, + ...(turnSequence === undefined || turnId === undefined ? [] : [{ + sequence: turnSequence, + message: { + ...assistantMessage(`turn at ${turnSequence}`, `assistant-${turnSequence}`), + turnId, + }, + }]), + ], + overlay: [], + positionRange: unavailablePositionRange( + 32, + hasOlder, + turnSequence !== undefined && turnSequence < 32, + ), + hasOlder, + hasNewer: (turnSequence ?? noteSequence) < 32, + })) store.accept(batch); + }; + installRange(31, 32, 'turn-3', true); - assert.deepEqual(request, { anchorSequence: 2, maxBytes: 512 * 1024 }); + const anchors: Array = []; + const controller = createDesktopTranscriptRangeController(store, async () => ({ + sessionId: 'session-1', + generation: 'generation-1', + hostEpoch: 'host-1', + readThroughMessageId: 'assistant-32', + async loadBefore(anchorSequence) { + anchors.push(anchorSequence); + if (anchors.length === 1) installRange(21, undefined, undefined, true); + else installRange(11, 12, 'turn-1', false); + }, + async loadAfter() {}, + async loadAround() {}, + async close() {}, + })); + + await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES); + + assert.deepEqual(anchors, [31, 21]); + assert.equal(store.range().hasOlder, false); await controller.close(); }); @@ -1373,6 +1968,7 @@ test('waits for the required durable message on the current transcript generatio durableThrough: null, durable: [], overlay: [], + positionRange: unavailablePositionRange(null, false, false), hasOlder: false, hasNewer: false, })) store.accept(batch); @@ -1382,6 +1978,7 @@ test('waits for the required durable message on the current transcript generatio durableUpserts: [{ sequence: 0, message: assistantMessage('complete') }], evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: null, hasOlder: false, hasNewer: false, })) store.accept(batch); @@ -1423,6 +2020,21 @@ function transcriptStore(): DesktopTranscriptRangeStore { return new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); } +function unavailablePositionRange( + throughSequence: number | null, + hasOlder: boolean, + hasNewer: boolean, +) { + return { + state: 'unavailable' as const, + throughSequence, + revision: null, + positions: [], + hasOlder, + hasNewer, + }; +} + function userMessage( text: string, id: string, 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 0b82aef122..14e0770fe5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts @@ -194,6 +194,9 @@ function subscription( loadTranscriptPage: async () => { throw new Error('Fake subscription does not expose transcript pages'); }, + loadTranscriptPositionsPage: async () => { + throw new Error('Fake subscription does not expose transcript positions'); + }, close: async () => { lifecycle.push(`${sessionId}:close`); }, 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 1b713542e9..e872405736 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 @@ -39,6 +39,15 @@ import { type DesktopTranscriptBatch, type DesktopTranscriptOpenResult, } from '../../preload/transcript-contract.js'; + +const UNAVAILABLE_POSITION_RANGE = { + state: 'unavailable', + throughSequence: null, + revision: null, + positions: [], + hasOlder: false, + hasNewer: false, +} as const; import { RuntimeHostSessionObservationRegistry } from "../runtime-host-session-observation-registry.js"; import { RuntimeHostSessionObserver, @@ -438,6 +447,7 @@ test('restores transcript consumers across Host replacement', async () => { fragments: [], evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: UNAVAILABLE_POSITION_RANGE, hasOlder: false, hasNewer: false, reset: true, @@ -451,6 +461,7 @@ test('restores transcript consumers across Host replacement', async () => { }; }, async loadTranscriptBefore() {}, + async loadTranscriptAfter() {}, async loadTranscriptAround() {}, async closeTranscript() {}, }); @@ -508,6 +519,7 @@ test('does not hold Host observation recovery on transcript replay', async () => return transcriptResult(generation); }, async loadTranscriptBefore() {}, + async loadTranscriptAfter() {}, async loadTranscriptAround() {}, async closeTranscript() {}, }); @@ -537,6 +549,7 @@ test('does not hold Host observation recovery on transcript replay', async () => async loadTranscriptBefore() { transcriptRangeStarted = true; }, + async loadTranscriptAfter() {}, async loadTranscriptAround() {}, acknowledgeTranscript() { transcriptAcknowledged = true; @@ -598,6 +611,7 @@ test('releases one renderer target before reload without restoring its observati }; }, async loadTranscriptBefore() {}, + async loadTranscriptAfter() {}, async loadTranscriptAround() {}, async closeTranscript(consumerId: string) { closedTranscripts.push(consumerId); @@ -651,6 +665,7 @@ test('releases one renderer target before reload without restoring its observati throw new Error('released transcript was restored'); }, async loadTranscriptBefore() {}, + async loadTranscriptAfter() {}, async loadTranscriptAround() {}, async closeTranscript() {}, }; @@ -683,6 +698,7 @@ test('fences transcript range failures to the current registration and Host sour }; }, loadTranscriptBefore, + async loadTranscriptAfter() {}, async loadTranscriptAround() {}, async closeTranscript() {}, }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts index 8a22cd60a3..94edd32b80 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts @@ -36,6 +36,7 @@ export function runtimeHostSessionFixture(input: { loadTranscriptOverlay?: DesktopRuntimeHostSession['loadTranscriptOverlay']; decodeTranscriptPage?: DesktopRuntimeHostSession['decodeTranscriptPage']; loadTranscriptPage?: DesktopRuntimeHostSession['loadTranscriptPage']; + loadTranscriptPositionsPage?: DesktopRuntimeHostSession['loadTranscriptPositionsPage']; close(): Promise; }): DesktopRuntimeHostSession { const sessionId = input.snapshot.session.sessionId; @@ -61,6 +62,18 @@ export function runtimeHostSessionFixture(input: { })), loadTranscriptPage: input.loadTranscriptPage ?? (async () => emptyPage(sessionId, 'durable')), + loadTranscriptPositionsPage: input.loadTranscriptPositionsPage ?? + (async (request) => ({ + kind: 'page', + sessionId, + direction: request.direction, + throughSequence: request.throughSequence, + revision: request.revision ?? 0, + positions: [], + hasOlder: false, + hasNewer: false, + nextCursor: null, + })), close: input.close, }; } diff --git a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts index 77ae054556..9b0e125801 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-controller.test.ts @@ -266,4 +266,19 @@ describe('createSessionOpenCommand', () => { ); assert.equal(targets[1], null); }); + + it('gives consecutive same-clock Turn jumps distinct identities', () => { + const targets: Array<{ nonce: number } | null> = []; + const openSession = createSessionOpenCommand({ + activateSession: () => undefined, + exitWorkHub: () => undefined, + selectSessionSurface: () => undefined, + setSearchTarget: (target) => targets.push(target), + }); + + openSession('a', 'turn-1', 0); + openSession('a', 'turn-2', 2); + + assert.notEqual(targets[0]?.nonce, targets[1]?.nonce); + }); }); diff --git a/apps/desktop/src/main/__tests__/transcript-identity.test.ts b/apps/desktop/src/main/__tests__/transcript-identity.test.ts index bd853f7083..46ee94d292 100644 --- a/apps/desktop/src/main/__tests__/transcript-identity.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-identity.test.ts @@ -34,6 +34,7 @@ function batch(overrides: Partial = {}): DesktopTranscri fragments: [], evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: null, hasOlder: false, hasNewer: false, reset: false, diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts index b982a265d2..78fe6e9444 100644 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts @@ -33,6 +33,15 @@ import { projectWorkHubCoordinationTurns, } from '../../renderer/workhub-coordination-port.js'; +const UNAVAILABLE_POSITION_RANGE = { + state: 'unavailable', + throughSequence: null, + revision: null, + positions: [], + hasOlder: false, + hasNewer: false, +} as const; + function desktopSession( id: string, overrides: Partial = {}, @@ -88,6 +97,7 @@ function transcriptsWith(messages: readonly StoredMessage[]) { fragments, evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: UNAVAILABLE_POSITION_RANGE, hasOlder: false, hasNewer: false, reset: true, @@ -99,6 +109,7 @@ function transcriptsWith(messages: readonly StoredMessage[]) { hostEpoch: 'epoch-reconcile', readThroughMessageId: null, loadBefore: async () => {}, + loadAfter: async () => {}, loadAround: async () => {}, close: async () => {}, }; @@ -375,6 +386,7 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and fragments: [], evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: UNAVAILABLE_POSITION_RANGE, hasOlder: false, hasNewer: false, reset: true, @@ -386,6 +398,7 @@ test('Coordination transcript adapter emits an initial empty ready snapshot and hostEpoch: 'epoch-1', readThroughMessageId: null, loadBefore: async () => {}, + loadAfter: async () => {}, loadAround: async () => {}, close: async () => { closes += 1; }, }; @@ -471,6 +484,7 @@ test('Coordination transcript reset rebuilds active linkage outside the resident fragments: [fragment(recent, 1)], evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: UNAVAILABLE_POSITION_RANGE, hasOlder: true, hasNewer: false, reset: true, @@ -492,12 +506,14 @@ test('Coordination transcript reset rebuilds active linkage outside the resident fragments: [fragment(assignment, 0)], evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: UNAVAILABLE_POSITION_RANGE, hasOlder: false, hasNewer: false, reset: false, ready: historyBatchReady, }); }, + loadAfter: async () => {}, loadAround: async () => {}, close: async () => {}, }; @@ -536,6 +552,7 @@ test('Coordination transcript reset rebuilds active linkage outside the resident fragments: [fragment(recent, 1)], evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: UNAVAILABLE_POSITION_RANGE, hasOlder: true, hasNewer: false, reset: true, @@ -555,6 +572,7 @@ test('Coordination transcript reset rebuilds active linkage outside the resident fragments: [fragment(recent, 1)], evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: UNAVAILABLE_POSITION_RANGE, hasOlder: false, hasNewer: false, reset: false, @@ -682,6 +700,7 @@ test('desktop adapter rebuilds recent turns from the Session transcript and clos fragments, evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: UNAVAILABLE_POSITION_RANGE, hasOlder: false, hasNewer: false, reset: true, @@ -693,6 +712,7 @@ test('desktop adapter rebuilds recent turns from the Session transcript and clos hostEpoch: 'epoch-1', readThroughMessageId: null, loadBefore: async () => {}, + loadAfter: async () => {}, loadAround: async () => {}, close: async () => { closes += 1; @@ -751,12 +771,13 @@ test('desktop adapter cancels an unavailable transcript without hiding ready Ses source: 'durable', identity: 0, order: null, byteOffset: 0, totalBytes: data.byteLength, data, }], - evictedDurableSequences: [], completedOverlayMessageIds: [], + evictedDurableSequences: [], completedOverlayMessageIds: [], positionRange: UNAVAILABLE_POSITION_RANGE, hasOlder: false, hasNewer: false, reset: true, ready: true, }); return { sessionId: readyId, generation: 'generation-ready', hostEpoch: 'epoch-ready', - readThroughMessageId: null, loadBefore: async () => {}, loadAround: async () => {}, + readThroughMessageId: null, loadBefore: async () => {}, loadAfter: async () => {}, + loadAround: async () => {}, close: async () => {}, }; }, diff --git a/apps/desktop/src/main/desktop-transcript-ipc.ts b/apps/desktop/src/main/desktop-transcript-ipc.ts index 86bf93495b..450ea45586 100644 --- a/apps/desktop/src/main/desktop-transcript-ipc.ts +++ b/apps/desktop/src/main/desktop-transcript-ipc.ts @@ -22,6 +22,7 @@ import { DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, type DesktopTranscriptBatchPayload, type DesktopTranscriptFragment, + type DesktopTranscriptPositionRange, } from '../preload/transcript-contract.js'; import type { DesktopSequencedTranscriptMessage, @@ -41,6 +42,7 @@ interface TranscriptBatchContent { readonly overlay: readonly StoredMessage[]; readonly evictedDurableSequences: readonly number[]; readonly completedOverlayMessageIds: readonly string[]; + readonly positionRange: DesktopTranscriptPositionRange | null; readonly hasOlder: boolean; readonly hasNewer: boolean; readonly reset: boolean; @@ -55,6 +57,7 @@ export function encodeDesktopTranscriptSnapshot( overlay: snapshot.overlay, evictedDurableSequences: [], completedOverlayMessageIds: [], + positionRange: snapshot.positionRange, hasOlder: snapshot.hasOlder, hasNewer: snapshot.hasNewer, reset: true, @@ -71,6 +74,7 @@ export function encodeDesktopTranscriptChange( overlay: [], evictedDurableSequences: change.evictedDurableSequences, completedOverlayMessageIds: change.completedOverlayMessageIds, + positionRange: change.positionRange, hasOlder: change.hasOlder, hasNewer: change.hasNewer, reset: false, @@ -134,6 +138,7 @@ function* encodeDesktopTranscriptBatches( fragments: batchFragments, evictedDurableSequences, completedOverlayMessageIds, + positionRange: first || ready ? content.positionRange : null, hasOlder: content.hasOlder, hasNewer: content.hasNewer, reset: content.reset && first, diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index 195ccc7630..649ca97a60 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -32,6 +32,7 @@ import { DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS, DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, + type DesktopTranscriptPositionRange, } from '../preload/transcript-contract.js'; import type { DesktopRuntimeHostSession } from './runtime-host-client.js'; @@ -60,6 +61,7 @@ export interface DesktopTranscriptReplicaSnapshot { readonly durableThrough: number | null; readonly durable: readonly DesktopSequencedTranscriptMessage[]; readonly overlay: readonly StoredMessage[]; + readonly positionRange: DesktopTranscriptPositionRange; readonly hasOlder: boolean; readonly hasNewer: boolean; } @@ -69,6 +71,7 @@ export interface DesktopTranscriptReplicaChange { readonly durableUpserts: readonly DesktopSequencedTranscriptMessage[]; readonly evictedDurableSequences: readonly number[]; readonly completedOverlayMessageIds: readonly string[]; + readonly positionRange: DesktopTranscriptPositionRange | null; readonly hasOlder: boolean; readonly hasNewer: boolean; } @@ -100,6 +103,7 @@ export class DesktopTranscriptReplica { #targetThrough: number | null; #hasOlder: boolean; #hasNewer = false; + #positionRange: DesktopTranscriptPositionRange; #resident = true; #residentExternallyAccounted = true; #closed = false; @@ -127,6 +131,14 @@ export class DesktopTranscriptReplica { this.#durableThrough = handle.transcriptBootstrap.throughSequence; this.#targetThrough = this.#durableThrough; this.#hasOlder = handle.transcriptBootstrap.durable.nextCursor !== null; + this.#positionRange = { + state: 'unavailable', + throughSequence: this.#durableThrough, + revision: null, + positions: [], + hasOlder: this.#hasOlder, + hasNewer: this.#hasNewer, + }; } static async prepare( @@ -151,6 +163,7 @@ export class DesktopTranscriptReplica { replica.#durableThrough ?? undefined, ); + await replica.#refreshPositionRange('older', null); if (replica.#overlayBytes > replica.#maxOverlayBytes) { throw new RangeError('Desktop transcript overlay exceeds the session cache limit'); } @@ -194,6 +207,7 @@ export class DesktopTranscriptReplica { durableThrough: this.#durableThrough, durable: this.#orderedDurable(false), overlay: [...this.#overlay.values()], + positionRange: this.#positionRange, hasOlder: this.#hasOlder, hasNewer: this.#hasNewer, }; @@ -246,7 +260,7 @@ export class DesktopTranscriptReplica { anchorSequence: anchor, maxBytes, }); - await this.#withDecodedPage(page, (decoded) => { + await this.#withDecodedPage(page, async (decoded) => { this.#assertOpen(); // Same post-await `#resident` invariant as `#replaceWithRange` and the // paged catch-up: a concurrent `discard()` may have reclaimed this @@ -263,11 +277,67 @@ export class DesktopTranscriptReplica { } const completedOverlayMessageIds = this.#installDurable(decoded.messages); this.#hasOlder = decoded.nextCursor !== null; + const pageProtectedSequence = + page.protectedTurnSequence !== null + && decoded.messages.some(({ identity }) => identity === page.protectedTurnSequence) + ? page.protectedTurnSequence + : undefined; const evictedDurableSequences = this.#evictToBudget( undefined, 'newest', + pageProtectedSequence ?? anchor ?? undefined, anchor ?? undefined, ); + await this.#refreshPositionRange('older', anchor); + this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); + }); + } + + async loadAfter( + anchorSequence: number | null, + maxBytes: number, + ): Promise { + return this.#enqueue(() => this.#loadAfter(anchorSequence, maxBytes)); + } + + async #loadAfter(anchorSequence: number | null, maxBytes: number): Promise { + this.#assertOpen(); + const throughSequence = this.#durableThrough; + if (throughSequence === null) return; + const anchor = anchorSequence ?? this.#newestSequence(); + if (anchor === null || anchor >= throughSequence) return; + const page = await this.#handle.loadTranscriptPage({ + source: 'durable', + direction: 'newer', + throughSequence, + cursor: null, + anchorSequence: anchor, + maxBytes, + }); + await this.#withDecodedPage(page, async (decoded) => { + this.#assertOpen(); + if (!this.#resident) return; + this.#acceptRange(decoded.messages); + if ( + decoded.messages.length > 0 + && !this.#matchesCoverageStep(decoded.messages[0]!.identity, anchor + 1) + ) { + throw correlationError('Desktop transcript newer page did not meet its anchor'); + } + const completedOverlayMessageIds = this.#installDurable(decoded.messages); + this.#hasNewer = decoded.nextCursor !== null; + const pageProtectedSequence = + page.protectedTurnSequence !== null + && decoded.messages.some(({ identity }) => identity === page.protectedTurnSequence) + ? page.protectedTurnSequence + : undefined; + const evictedDurableSequences = this.#evictToBudget( + undefined, + 'oldest', + pageProtectedSequence ?? anchor, + anchor, + ); + await this.#refreshPositionRange('newer', anchor); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); }); } @@ -297,7 +367,7 @@ export class DesktopTranscriptReplica { anchorSequence: loadTail ? sequence + 1 : sequence === 0 ? null : sequence - 1, maxBytes, }); - await this.#withDecodedPage(page, (decoded) => { + await this.#withDecodedPage(page, async (decoded) => { this.#assertOpen(); // `#resident` can flip to false across the `await` above (a concurrent // `discard()` reclaims memory for a non-visible session while the page is @@ -328,6 +398,10 @@ export class DesktopTranscriptReplica { loadTail ? (page.protectedTurnSequence ?? sequence) : sequence, ), ); + await this.#refreshPositionRange( + loadTail ? 'older' : 'newer', + loadTail ? null : sequence === 0 ? null : sequence - 1, + ); this.#publish(decoded.messages, completedOverlayMessageIds, evictedDurableSequences); }); } @@ -457,6 +531,7 @@ export class DesktopTranscriptReplica { throw correlationError('Desktop transcript catch-up ended before its watermark'); } this.#durableThrough = target; + await this.#refreshPositionRange('older', null); this.#publish([], [], []); } } @@ -550,15 +625,101 @@ export class DesktopTranscriptReplica { (sequence) => !this.#durable.has(sequence), ), completedOverlayMessageIds, + positionRange: this.#positionRange, hasOlder: this.#hasOlder, hasNewer: this.#hasNewer, }; } + async #refreshPositionRange( + direction: 'older' | 'newer', + anchorSequence: number | null, + ): Promise { + if (this.#closed || !this.#resident) return; + const throughSequence = this.#durableThrough; + try { + let result = await this.#handle.loadTranscriptPositionsPage({ + direction, + throughSequence, + revision: this.#positionRange.revision, + cursor: null, + anchorSequence, + maxPositions: 128, + }); + if (this.#closed || !this.#resident) return; + if (result.kind === 'stale') { + result = await this.#handle.loadTranscriptPositionsPage({ + direction, + throughSequence, + revision: null, + cursor: null, + anchorSequence, + maxPositions: 128, + }); + if (this.#closed || !this.#resident) return; + } + if (result.kind === 'page') { + this.#positionRange = { + state: 'ready', + throughSequence: result.throughSequence, + revision: result.revision, + positions: result.positions, + hasOlder: result.hasOlder, + hasNewer: result.hasNewer, + }; + return; + } + if (result.kind === 'building') { + this.#positionRange = { + state: 'building', + throughSequence: result.throughSequence, + revision: null, + positions: [], + hasOlder: this.#hasOlder, + hasNewer: this.#hasNewer, + }; + this.#schedulePositionRetry(direction, anchorSequence, result.retryAfterMs); + return; + } + this.#positionRange = { + state: 'unavailable', + throughSequence, + revision: result.currentRevision, + positions: [], + hasOlder: this.#hasOlder, + hasNewer: this.#hasNewer, + }; + } catch { + this.#positionRange = { + state: 'unavailable', + throughSequence, + revision: null, + positions: [], + hasOlder: this.#hasOlder, + hasNewer: this.#hasNewer, + }; + } + } + + #schedulePositionRetry( + direction: 'older' | 'newer', + anchorSequence: number | null, + retryAfterMs: number, + ): void { + globalThis.setTimeout(() => { + if (this.#closed || !this.#resident || this.#positionRange.state !== 'building') return; + void this.#enqueue(async () => { + await this.#refreshPositionRange(direction, anchorSequence); + if (!this.#closed && this.#resident) this.#publish([], [], []); + }).catch(() => undefined); + }, retryAfterMs); + } + #evictToBudget( budget: number | undefined = undefined, edge: 'oldest' | 'newest' = 'oldest', protectedSequence?: number, + secondaryProtectedSequence?: number, ): number[] { const residentBudget = budget ?? this.#maxResidentBytes + this.#overlayBytes; const evicted: number[] = []; @@ -576,22 +737,27 @@ export class DesktopTranscriptReplica { let oldestIndex = 0; let newestIndex = orderedTurns.length - 1; let residentTurns = orderedTurns.length; - const protectedEntry = protectedSequence === undefined - ? undefined - : this.#durable.get(protectedSequence); - const protectedTurnKey = protectedEntry === undefined - ? undefined - : residentTurnKey(protectedEntry); - const protectedIndex = protectedTurnKey === undefined - ? -1 - : orderedTurns.findIndex(([turnKey]) => turnKey === protectedTurnKey); + const protectedTurnKeys = new Set(); + for (const sequence of [protectedSequence, secondaryProtectedSequence]) { + if (sequence === undefined) continue; + const entry = this.#durable.get(sequence); + if (entry) protectedTurnKeys.add(residentTurnKey(entry)); + } + const protectedIndexes = orderedTurns.flatMap(([turnKey], index) => + protectedTurnKeys.has(turnKey) ? [index] : []); + const protectedOldestIndex = protectedIndexes.length > 0 + ? Math.min(...protectedIndexes) + : -1; + const protectedNewestIndex = protectedIndexes.length > 0 + ? Math.max(...protectedIndexes) + : -1; const take = ( candidateEdge: 'oldest' | 'newest', ): readonly [string, number[]] | undefined => { const index = candidateEdge === 'oldest' ? oldestIndex : newestIndex; if (oldestIndex > newestIndex) return undefined; const turn = orderedTurns[index]; - if (!turn || turn[0] === protectedTurnKey) return undefined; + if (!turn || protectedTurnKeys.has(turn[0])) return undefined; if (candidateEdge === 'oldest') oldestIndex += 1; else newestIndex -= 1; return turn; @@ -600,11 +766,11 @@ export class DesktopTranscriptReplica { this.#residentBytes > residentBudget || residentTurns > this.#maxResidentTurns ) { - let evictionEdge = protectedIndex < 0 + let evictionEdge = protectedOldestIndex < 0 ? edge - : protectedIndex - oldestIndex > newestIndex - protectedIndex + : protectedOldestIndex - oldestIndex > newestIndex - protectedNewestIndex ? 'oldest' - : protectedIndex - oldestIndex < newestIndex - protectedIndex + : protectedOldestIndex - oldestIndex < newestIndex - protectedNewestIndex ? 'newest' : edge; let turn = take(evictionEdge); @@ -644,6 +810,14 @@ export class DesktopTranscriptReplica { return oldest; } + #newestSequence(): number | null { + let newest: number | null = null; + for (const sequence of this.#durable.keys()) { + if (newest === null || sequence > newest) newest = sequence; + } + return newest; + } + #clearDurable(): void { for (const entry of this.#durable.values()) this.#adjustResidentBytes(-entry.encodedBytes); this.#durable.clear(); diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 9e971d2033..4d142d6162 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -235,7 +235,7 @@ export async function seedE2eFixture(input: { await mkdir(input.workspaceRoot, { recursive: true }); const storageRoot = await resolveStorageRoot({ path: input.workspaceRoot, kind: 'interactive' }); await writeSettings(input.workspaceRoot, scenario); - await writeConnections(input.workspaceRoot, now, scenario); + const defaultConnectionId = await writeConnections(input.workspaceRoot, now, scenario); await writeSession( input.workspaceRoot, scenario === 'agent-graph-layout' ? agentGraphSession(now) : turnSession(now), @@ -269,7 +269,7 @@ export async function seedE2eFixture(input: { if (scenario === 'chat-partial-history') { await writeSession( input.workspaceRoot, - partialHistorySession(now), + partialHistorySession(now, defaultConnectionId), partialHistoryMessages(now), ); } diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts index 1ff6f54af6..3f7a5ff4d8 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-chat.ts @@ -160,12 +160,13 @@ export function promptRailMessages(now: number): StoredMessage[] { return messages; } -export function partialHistorySession(now: number): SessionHeader { +export function partialHistorySession(now: number, connectionId?: string): SessionHeader { return header({ id: PARTIAL_HISTORY_SESSION_ID, name: '超长对话历史范围示例', connection: 'zai-live', model: 'glm-5.1', + connectionId, now, lastMessageAt: now - 60_000, }); @@ -175,12 +176,13 @@ export function partialHistorySession(now: number): SessionHeader { * Eight turns whose durable transcript is well over the Desktop range budget. * The whitespace is stored but collapses when rendered, keeping this a useful * visual fixture while forcing the initial open to contain only the latest - * contiguous range. + * contiguous range. Turn 1 alone exceeds 512 KiB so the oversized-record path + * participates in the same tail → history → tail scenario. */ export function partialHistoryMessages(now: number): StoredMessage[] { const messages: StoredMessage[] = []; - const rangePadding = ' '.repeat(180 * 1024); for (let index = 1; index <= 8; index += 1) { + const rangePadding = ' '.repeat((index === 1 ? 600 : 180) * 1024); const turnId = `turn-partial-history-${index}`; const ts = now - (9 - index) * 60_000; messages.push({ @@ -199,5 +201,13 @@ export function partialHistoryMessages(now: number): StoredMessage[] { modelId: 'glm-5.1', }); } + messages.push({ + type: 'turn_state', + id: 'state-partial-history-8', + turnId: 'turn-partial-history-8', + ts: now - 58_000, + status: 'completed', + partialOutputRetained: true, + }); return messages; } diff --git a/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts b/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts index 0236a0de9f..9081f4f1a0 100644 --- a/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts +++ b/apps/desktop/src/main/e2e-fixture/scenarios-settings.ts @@ -80,7 +80,7 @@ export async function writeConnections( workspaceRoot: string, now: number, scenario: E2eFixtureScenario, -): Promise { +): Promise { const zaiLive: ConnectionCatalogEntryDraft = { slug: 'zai-live', name: 'Z.ai Live Fixture', @@ -101,10 +101,10 @@ export async function writeConnections( const capability = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' }); const owner = await tryAcquireInteractiveRootOwner(capability); if (!owner) throw new Error('Unable to acquire the connection catalog fixture root'); + let defaultConnectionId: string | undefined; try { const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); let revision = 0; - let defaultConnectionId: string | undefined; for (const draft of drafts) { const created = await stores.connectionCatalog.create({ expectedCatalogRevision: revision, @@ -175,6 +175,7 @@ export async function writeConnections( } finally { await owner.close(); } + return defaultConnectionId!; } function findFixtureConnection(snapshot: ConnectionCatalogSnapshot, slug: string) { diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index 906182d998..756fdbf581 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -54,6 +54,7 @@ export function header(input: { now: number; lastMessageAt: number; projectId?: string; + connectionId?: string; }): SessionHeader { return { id: input.id, @@ -71,6 +72,7 @@ export function header(input: { hasUnread: false, ...(input.projectId ? { projectId: input.projectId } : {}), backend: 'ai-sdk', + ...(input.connectionId ? { llmConnectionId: input.connectionId } : {}), llmConnectionSlug: input.connection, connectionLocked: true, model: input.model, diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 5a39dba44b..0691ec331d 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -127,6 +127,8 @@ import { type SessionTranscriptBootstrap, type SessionTranscriptPage, type SessionTranscriptPageInput, + type SessionTranscriptPositionsPageInput, + type SessionTranscriptPositionsPageResult, mergeSessionTurnContributions, projectSessionTurnContribution, type SessionConversationCopyInput, @@ -220,6 +222,9 @@ export interface DesktopRuntimeHostSession { loadTranscriptPage( input: Omit, ): Promise; + loadTranscriptPositionsPage( + input: Omit, + ): Promise; close(): Promise; } @@ -1832,6 +1837,12 @@ class DesktopSessionHandle implements DesktopRuntimeHostSession { return this.subscription.loadTranscriptPage(input); } + loadTranscriptPositionsPage( + input: Omit, + ): Promise { + return this.subscription.loadTranscriptPositionsPage(input); + } + close(): Promise { this.#closeTask ??= this.subscription.close().finally(this.onClose); return this.#closeTask; 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 acd6dab4aa..c9b7f21f5b 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 @@ -173,6 +173,7 @@ export interface RuntimeHostSessionExecutionIpcDeps { export interface RuntimeHostSessionObservationIpcDeps { observations: Pick< RuntimeHostSessionObservationRegistry, + | 'loadTranscriptAfter' | 'loadTranscriptAround' | 'loadTranscriptBefore' | 'observe' @@ -216,6 +217,12 @@ export function registerRuntimeHostSessionObservationIpc( event.sender.id, ); }); + ipcMain.handle('sessions:transcript:load-after', async (event, input: unknown) => { + await deps.observations.loadTranscriptAfter( + normalizeTranscriptRangeRequest(input), + event.sender.id, + ); + }); ipcMain.handle('sessions:transcript:load-around', async (event, input: unknown) => { await deps.observations.loadTranscriptAround( normalizeTranscriptRangeRequest(input), 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 81e650240b..39447365ea 100644 --- a/apps/desktop/src/main/runtime-host-session-observation-registry.ts +++ b/apps/desktop/src/main/runtime-host-session-observation-registry.ts @@ -34,6 +34,7 @@ type SessionObservationSource = Pick { + await this.#runTranscriptOperation(request.consumerId, (source) => + source.loadTranscriptAfter(request, targetId), + ); + } + async loadTranscriptAround( request: DesktopTranscriptRangeRequest, targetId?: number, diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index acf5e12377..f69aa796e4 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -43,6 +43,7 @@ import { type DesktopTranscriptBatch, type DesktopTranscriptBatchPayload, type DesktopTranscriptOpenResult, + type DesktopTranscriptPositionRange, type DesktopTranscriptRangeRequest, } from '../preload/transcript-contract.js'; import { @@ -144,6 +145,7 @@ interface PendingTranscriptChange { readonly durableUpserts: Map; readonly evictedDurableSequences: Set; readonly completedOverlayMessageIds: Set; + positionRange: DesktopTranscriptPositionRange | null; hasOlder: boolean; hasNewer: boolean; encodedBytes: number; @@ -332,6 +334,18 @@ export class RuntimeHostSessionObserver { ); } + async loadTranscriptAfter( + request: DesktopTranscriptRangeRequest, + targetId?: number, + ): Promise { + await this.#runTranscriptRangeOperation(request, targetId, (replica) => + replica.loadAfter( + request.anchorSequence, + requireTranscriptRangeBytes(request.maxBytes), + ), + ); + } + async loadTranscriptAround( request: DesktopTranscriptRangeRequest, targetId?: number, @@ -1155,6 +1169,7 @@ export class RuntimeHostSessionObserver { durableUpserts: [...pending.durableUpserts.values()].map(({ entry }) => entry), evictedDurableSequences: [...pending.evictedDurableSequences], completedOverlayMessageIds: [...pending.completedOverlayMessageIds], + positionRange: pending.positionRange, hasOlder: pending.hasOlder, hasNewer: pending.hasNewer, }, @@ -1194,6 +1209,7 @@ export class RuntimeHostSessionObserver { durableUpserts: new Map(), evictedDurableSequences: new Set(), completedOverlayMessageIds: new Set(), + positionRange: null, hasOlder: change.hasOlder, hasNewer: change.hasNewer, encodedBytes: 0, @@ -1202,6 +1218,13 @@ export class RuntimeHostSessionObserver { pending.durableThrough = change.durableThrough; pending.hasOlder = change.hasOlder; pending.hasNewer = change.hasNewer; + if (change.positionRange !== null) { + const previousBytes = pending.positionRange + ? Buffer.byteLength(JSON.stringify(pending.positionRange), 'utf8') + : 0; + pending.positionRange = change.positionRange; + byteDelta += Buffer.byteLength(JSON.stringify(change.positionRange), 'utf8') - previousBytes; + } for (const entry of change.durableUpserts) { const previous = pending.durableUpserts.get(entry.sequence); if (previous) byteDelta -= previous.encodedBytes; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b4013452f4..3915828eba 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2407,7 +2407,10 @@ const makaBridge = { if (closed) throw new Error('Desktop transcript open was cancelled'); identity ??= { generation: opened.generation, hostEpoch: opened.hostEpoch }; const range = ( - operation: 'sessions:transcript:load-before' | 'sessions:transcript:load-around', + operation: + | 'sessions:transcript:load-before' + | 'sessions:transcript:load-after' + | 'sessions:transcript:load-around', anchorSequence: number | null, maxBytes = DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, ): Promise => { @@ -2428,6 +2431,8 @@ const makaBridge = { sessionId, loadBefore: (anchorSequence, maxBytes) => range('sessions:transcript:load-before', anchorSequence, maxBytes), + loadAfter: (anchorSequence, maxBytes) => + range('sessions:transcript:load-after', anchorSequence, maxBytes), loadAround: (sequence, maxBytes) => range('sessions:transcript:load-around', sequence, maxBytes), async close() { diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts index 41f00924c7..7e5979d5cb 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -23,6 +23,20 @@ export const DESKTOP_TRANSCRIPT_ACTIVE_RANGE_MAX_TURNS = 10; export const DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES = 16 * 1024 * 1024; export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; +export interface DesktopTranscriptTurnPosition { + readonly turnId: string; + readonly firstSequence: number; +} + +export interface DesktopTranscriptPositionRange { + readonly state: 'ready' | 'building' | 'unavailable'; + readonly throughSequence: number | null; + readonly revision: number | null; + readonly positions: readonly DesktopTranscriptTurnPosition[]; + readonly hasOlder: boolean; + readonly hasNewer: boolean; +} + export interface DesktopTranscriptFragment { readonly source: 'durable' | 'overlay'; readonly identity: number | string; @@ -40,6 +54,8 @@ export interface DesktopTranscriptBatchPayload { readonly fragments: readonly DesktopTranscriptFragment[]; readonly evictedDurableSequences: readonly number[]; readonly completedOverlayMessageIds: readonly string[]; + /** Null means this batch does not change the bounded position sidecar. */ + readonly positionRange: DesktopTranscriptPositionRange | null; readonly hasOlder: boolean; readonly hasNewer: boolean; readonly reset: boolean; @@ -67,6 +83,7 @@ export interface DesktopTranscriptRangeRequest { export interface DesktopTranscriptHandle extends DesktopTranscriptOpenResult { loadBefore(anchorSequence: number | null, maxBytes?: number): Promise; + loadAfter(anchorSequence: number | null, maxBytes?: number): Promise; loadAround(sequence: number, maxBytes?: number): Promise; close(): Promise; } @@ -91,6 +108,10 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB (messageId) => typeof messageId === 'string' && messageId.length > 0 && messageId.length <= 256, ) || batch.completedOverlayMessageIds.length > 256 || + !isPositionRange(batch.positionRange) || + (batch.positionRange !== null && + batch.positionRange.throughSequence !== batch.durableThrough) || + (batch.reset && batch.positionRange === null) || typeof batch.hasOlder !== 'boolean' || typeof batch.hasNewer !== 'boolean' || typeof batch.reset !== 'boolean' || @@ -135,6 +156,45 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB return value as DesktopTranscriptBatch; } +function isPositionRange(value: unknown): value is DesktopTranscriptPositionRange | null { + if (value === null) return true; + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const range = value as Record; + if ( + (range.state !== 'ready' && range.state !== 'building' && range.state !== 'unavailable') || + (range.throughSequence !== null && !isSequence(range.throughSequence)) || + (range.revision !== null && !isSequence(range.revision)) || + !Array.isArray(range.positions) || + range.positions.length > 128 || + (range.state === 'ready' && range.revision === null) || + (range.state !== 'ready' && range.positions.length > 0) || + typeof range.hasOlder !== 'boolean' || + typeof range.hasNewer !== 'boolean' + ) { + return false; + } + let previous = -1; + const turnIds = new Set(); + for (const value of range.positions) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const position = value as Record; + if ( + typeof position.turnId !== 'string' || + position.turnId.length === 0 || + position.turnId.length > 128 || + !isSequence(position.firstSequence) || + (range.throughSequence !== null && position.firstSequence > range.throughSequence) || + position.firstSequence <= previous || + turnIds.has(position.turnId) + ) { + return false; + } + previous = position.firstSequence; + turnIds.add(position.turnId); + } + return true; +} + function isSequence(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) >= 0; } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index a847a21794..20306eec86 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2584,7 +2584,10 @@ function AppShellContent({ setAnchor: sessionUiController.setTranscriptReadingAnchor, }); } - async function loadTranscriptHistory(target: 'earlier' | 'latest', anchorTurnId?: string) { + async function loadTranscriptHistory( + target: 'earlier' | 'later' | 'latest', + anchorTurnId?: string, + ) { const controller = transcriptRangeRef.current; const sessionId = activeId; if (!controller || !sessionId || historyLoadPendingRef.current) return; @@ -2593,6 +2596,8 @@ function AppShellContent({ try { if (target === 'earlier') { await controller.loadBefore(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchorTurnId); + } else if (target === 'later') { + await controller.loadAfter(DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, anchorTurnId); } else { await controller.loadLatest(); } @@ -3088,11 +3093,13 @@ function AppShellContent({ loadTranscriptHistory('earlier', anchorTurnId)} + onLoadLaterHistory={(anchorTurnId) => + loadTranscriptHistory('later', anchorTurnId)} onReturnToLatestHistory={() => loadTranscriptHistory('latest')} liveContentSeedRevision={liveContentSeedRevision(activeEventSeed, activeId)} messages={messages} diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index d9684f3263..2b7ba2c50a 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -41,6 +41,12 @@ import { useExternalStoreSelector } from './use-external-store-selector'; import { useDeepResearchRun } from './use-deep-research-run'; import { ChatRecoveryNotice, SessionHealthRecoveryNotice } from './chat-recovery-notice'; +type TranscriptRangeProjection = Readonly<{ + hasOlder: boolean; + hasNewer: boolean; + positionRange: ComponentProps['transcriptPositionRange']; +}>; + const selectShellRunRecord = (state: AppShellSessionUiState, sessionId: string | undefined) => sessionId ? state.shellRunUpdatesBySession[sessionId] : undefined; @@ -89,10 +95,11 @@ interface ChatMessageSurfaceProps extends Omit< connections: LlmConnection[]; onRefreshConnections: () => Promise | void; onSkip: () => Promise | void; - hasOlderHistory: boolean; - hasNewerHistory: boolean; + olderHistoryRange: TranscriptRangeProjection | false | undefined; + newerHistoryRange: TranscriptRangeProjection | false | undefined; historyLoadPending: boolean; onLoadEarlierHistory: (anchorTurnId?: string) => Promise | void; + onLoadLaterHistory: (anchorTurnId?: string) => Promise | void; onReturnToLatestHistory: () => Promise | void; } @@ -126,14 +133,16 @@ export function ChatMessageSurface({ connections, onRefreshConnections, onSkip, - hasOlderHistory, - hasNewerHistory, + olderHistoryRange, + newerHistoryRange, historyLoadPending, onLoadEarlierHistory, + onLoadLaterHistory, onReturnToLatestHistory, ...chatViewRest }: ChatMessageSurfaceProps) { const locale = useUiLocale(); + const transcriptRange = olderHistoryRange || newerHistoryRange || undefined; const copy = getShellCopy(locale).app; const transcriptCopy = getDesktopConversationCopy(locale).actions; // Configuration notices share the Settings label; identity recovery supplies @@ -247,9 +256,12 @@ export function ChatMessageSurface({ deepResearchRun={deepResearchRun} emptyOverride={emptyOverride} goalIndicator={goalProjection.goalIndicator} - hasOlderHistory={hasOlderHistory} + hasOlderHistory={transcriptRange?.hasOlder === true} + hasNewerHistory={transcriptRange?.hasNewer === true} onLoadEarlierHistory={onLoadEarlierHistory} - returnToLatest={hasNewerHistory ? { + onLoadLaterHistory={onLoadLaterHistory} + transcriptPositionRange={transcriptRange?.positionRange} + returnToLatest={transcriptRange?.hasNewer ? { title: transcriptCopy.partialHistoryTitle, label: transcriptCopy.returnLatest, isPending: historyLoadPending, diff --git a/apps/desktop/src/renderer/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/desktop-transcript-range-store.ts index 6cee40e944..d0385bf9f9 100644 --- a/apps/desktop/src/renderer/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/desktop-transcript-range-store.ts @@ -23,6 +23,7 @@ import type { DesktopTranscriptBatchPayload, DesktopTranscriptFragment, DesktopTranscriptHandle, + DesktopTranscriptPositionRange, } from '../preload/transcript-contract.js'; import { projectDesktopStoredMessage } from '../shared/desktop-session-projection.js'; import { parseDesktopSessionKey } from '../shared/runtime-host-identity.js'; @@ -32,6 +33,7 @@ export interface DesktopTranscriptRangeController { ready(): Promise; waitForDurableMessage(messageId: string, timeoutMs: number): Promise; loadBefore(maxBytes?: number, anchorTurnId?: string): Promise; + loadAfter(maxBytes?: number, anchorTurnId?: string): Promise; loadAround(sequence: number): Promise; loadLatest(): Promise; reload(): Promise; @@ -43,6 +45,8 @@ export function createDesktopTranscriptRangeController( open: (signal: AbortSignal) => Promise, ): DesktopTranscriptRangeController { let closed = false; + let lastBeforeAnchor: number | null | undefined; + let lastAfterAnchor: number | null | undefined; let openController = new AbortController(); let handle = open(openController.signal); const current = async () => { @@ -61,22 +65,50 @@ export function createDesktopTranscriptRangeController( async loadBefore(maxBytes, anchorTurnId) { const range = store.range(); if (!range.hasOlder) return; - await (await current()).loadBefore( - anchorTurnId === undefined - ? range.oldestSequence - : store.sequenceForTurn(anchorTurnId) ?? range.oldestSequence, - maxBytes, - ); + lastAfterAnchor = undefined; + const anchorSequence = anchorTurnId === undefined + ? range.oldestSequence + : store.sequenceForTurn(anchorTurnId) ?? range.oldestSequence; + if (lastBeforeAnchor === anchorSequence) return; + lastBeforeAnchor = anchorSequence; + try { + await (await current()).loadBefore(anchorSequence, maxBytes); + } catch (error) { + if (lastBeforeAnchor === anchorSequence) lastBeforeAnchor = undefined; + throw error; + } + }, + async loadAfter(maxBytes, anchorTurnId) { + const range = store.range(); + if (!range.hasNewer) return; + lastBeforeAnchor = undefined; + const anchorSequence = anchorTurnId === undefined + ? range.newestSequence + : store.lastSequenceForTurn(anchorTurnId) ?? range.newestSequence; + if (lastAfterAnchor === anchorSequence) return; + lastAfterAnchor = anchorSequence; + try { + await (await current()).loadAfter(anchorSequence, maxBytes); + } catch (error) { + if (lastAfterAnchor === anchorSequence) lastAfterAnchor = undefined; + throw error; + } }, async loadAround(sequence) { + lastBeforeAnchor = undefined; + lastAfterAnchor = undefined; await (await current()).loadAround(sequence); }, async loadLatest() { const range = store.range(); if (!range.hasNewer || range.durableThrough === null) return; + lastBeforeAnchor = undefined; + lastAfterAnchor = undefined; await (await current()).loadAround(range.durableThrough); }, async reload() { + lastBeforeAnchor = undefined; + lastAfterAnchor = undefined; const previous = handle; openController.abort(); handle = previous @@ -125,6 +157,7 @@ export interface DesktopTranscriptRangeState { readonly newestSequence: number | null; readonly hasOlder: boolean; readonly hasNewer: boolean; + readonly positionRange: DesktopTranscriptPositionRange; readonly ready: boolean; } @@ -150,6 +183,14 @@ export class DesktopTranscriptRangeStore { #newestUserSequence: number | null = null; #hasOlder = false; #hasNewer = false; + #positionRange: DesktopTranscriptPositionRange = { + state: 'unavailable', + throughSequence: null, + revision: null, + positions: [], + hasOlder: false, + hasNewer: false, + }; #ready = false; #batchChanged = false; #snapshot: DesktopTranscriptRangeSnapshot | undefined; @@ -179,6 +220,10 @@ export class DesktopTranscriptRangeStore { this.#durableThrough = batch.durableThrough; this.#hasOlder = batch.hasOlder; this.#hasNewer = batch.hasNewer; + if (batch.positionRange !== null && !samePositionRange(this.#positionRange, batch.positionRange)) { + this.#positionRange = freezeTranscriptValue(structuredClone(batch.positionRange)); + changed = true; + } for (const sequence of batch.evictedDurableSequences) { if (this.#durable.delete(sequence)) { removeOrdered(this.#durableOrder, sequence); @@ -235,6 +280,7 @@ export class DesktopTranscriptRangeStore { newestSequence: this.#newestSequence, hasOlder: this.#hasOlder, hasNewer: this.#hasNewer, + positionRange: this.#positionRange, ready: this.#ready, }; } @@ -252,7 +298,21 @@ export class DesktopTranscriptRangeStore { sequenceForTurn(turnId: string): number | null { for (const sequence of this.#durableOrder) { - if (this.#durable.get(sequence)?.message.turnId === turnId) return sequence; + const message = this.#durable.get(sequence)?.message; + // A bounded tail can contain only a lightweight state backfill for an + // evicted Turn. That record enriches resident content, but is not proof + // that the Turn body is available for navigation. + if (message?.type !== 'turn_state' && message?.turnId === turnId) return sequence; + } + return null; + } + + lastSequenceForTurn(turnId: string): number | null { + for (let index = this.#durableOrder.length - 1; index >= 0; index -= 1) { + const sequence = this.#durableOrder[index]; + if (sequence === undefined) continue; + const message = this.#durable.get(sequence)?.message; + if (message?.type !== 'turn_state' && message?.turnId === turnId) return sequence; } return null; } @@ -292,6 +352,10 @@ export class DesktopTranscriptRangeStore { this.#newestUserSequence = null; this.#hasOlder = batch.hasOlder; this.#hasNewer = batch.hasNewer; + if (batch.positionRange === null) { + throw new Error('Desktop transcript reset omitted its position range'); + } + this.#positionRange = freezeTranscriptValue(structuredClone(batch.positionRange)); this.#ready = false; this.#batchChanged = false; this.#snapshot = undefined; @@ -407,6 +471,13 @@ export class DesktopTranscriptRangeStore { } } +function samePositionRange( + left: DesktopTranscriptPositionRange, + right: DesktopTranscriptPositionRange, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + function freezeTranscriptValue(value: T): T { if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value; for (const child of Object.values(value)) freezeTranscriptValue(child); diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts index afbb9f2873..999af29536 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts @@ -36,6 +36,13 @@ interface SearchTarget { readonly sessionId: string; readonly turnId: string; readonly sequence?: number; + readonly nonce?: number; +} + +const handledSearchTargetByController = new WeakMap(); + +function searchTargetKey(target: SearchTarget): string { + return `${target.sessionId}:${target.turnId}:${target.sequence ?? ''}:${target.nonce ?? ''}`; } export function currentTranscriptRange( @@ -133,6 +140,11 @@ export function restoreSessionTranscriptRange(options: { const searchTarget = options.searchTarget?.sessionId === sessionId ? options.searchTarget : undefined; + const searchKey = searchTarget ? searchTargetKey(searchTarget) : undefined; + if ( + searchKey !== undefined + && handledSearchTargetByController.get(controller) === searchKey + ) return; const target = searchTarget ?? readingAnchor; if (!target || (searchTarget && target.sequence === undefined)) return; const restoringReadingAnchor = searchTarget === undefined && readingAnchor !== undefined; @@ -145,6 +157,7 @@ export function restoreSessionTranscriptRange(options: { } const residentSequence = controller.store.sequenceForTurn(target.turnId); if (residentSequence !== null) { + if (searchKey !== undefined) handledSearchTargetByController.set(controller, searchKey); if (restoringReadingAnchor && readingAnchor?.sequence === undefined) { options.setReadingAnchor(sessionId, { turnId: target.turnId, sequence: residentSequence }); } @@ -157,6 +170,7 @@ export function restoreSessionTranscriptRange(options: { if (!current() || controller.store.range().sessionId !== sessionId) { return { loaded: false, unavailable: false }; } + if (searchKey !== undefined) handledSearchTargetByController.set(controller, searchKey); return { loaded: true, unavailable: restoringReadingAnchor diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/session-open-command.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-open-command.ts index 9980caa686..a6d2ab5feb 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/session-open-command.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-open-command.ts @@ -41,12 +41,14 @@ export interface SessionOpenCommandDeps { * moved below the shell (#4109). */ export function createSessionOpenCommand(deps: SessionOpenCommandDeps) { + let nonce = Date.now(); return (sessionId: string, turnId?: string, sequence?: number): void => { deps.exitWorkHub(); deps.selectSessionSurface(); deps.activateSession(sessionId); + if (turnId) nonce += 1; deps.setSearchTarget( - turnId ? { sessionId, turnId, sequence, nonce: Date.now() } : null, + turnId ? { sessionId, turnId, sequence, nonce } : null, ); }; } diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index ebe039fd44..ef388199b2 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -45,9 +45,9 @@ /* Inserting earlier turns above the reader must not move what they are reading. The browser's scroll anchoring does exactly that, so state the dependency on the scroller that runs it rather than inheriting the `auto` - default: Maka reads no geometry and restores no position of its own. The - one case anchoring declines is a scroller sitting at zero, compensated in - useChatScroll after the turns land. */ + default. Bounded range replacement is the one case where Chromium cannot + retain its chosen node; useChatScroll holds the resident Turn across that + commit. A scroller sitting at zero also needs its 1px paging foothold there. */ [data-chat-scroll-container='true'] { overflow-anchor: auto; } @@ -332,3 +332,10 @@ width: min(var(--maka-reading-measure), calc(100% - (2 * var(--space-6)))); margin: var(--space-2) auto 0; } + +.maka-transcript-gap-row { + width: min(var(--maka-reading-measure), calc(100% - (2 * var(--space-6)))); + margin: var(--space-2) auto; + padding-block: var(--space-1); + border-block: 1px solid var(--border-subtle); +} 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 1acaa8841b..6e9b9ab23a 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -2909,6 +2909,10 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< throw new Error('Fake subscription does not expose transcript pages'); } + async loadTranscriptPositionsPage(): Promise { + throw new Error('Fake subscription does not expose transcript positions'); + } + async close(): Promise { this.#closed = true; for (const waiter of this.#waiters.splice(0)) { diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts index ff6b96769b..68a60e55cb 100644 --- a/packages/runtime-host/src/__tests__/connection-session.test.ts +++ b/packages/runtime-host/src/__tests__/connection-session.test.ts @@ -461,6 +461,20 @@ test('flushes concurrent subscription opens before activating their live frame s ok: false, error: { code: 'operation_unavailable', message: 'not used' }, }), + 'session.transcript.positions.page': async (input) => ({ + ok: true, + result: { + kind: 'page', + sessionId: 'session-1', + direction: input.direction, + throughSequence: input.throughSequence, + revision: input.revision ?? 0, + positions: [], + hasOlder: false, + hasNewer: false, + nextCursor: null, + }, + }), }, attachConnection: (_connectionId, attachedSink) => { sink = attachedSink; diff --git a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts index 8d2dbecb96..e9cc3f1cc5 100644 --- a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts +++ b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts @@ -142,6 +142,39 @@ export function transcriptReader( (message, sequence) => sequence <= request.throughSequence! && request.messageIds.includes(message.id), ), + readDurableTurnPositions: async (_sessionId, request) => { + const positions = durable.flatMap((message, firstSequence) => + message.type === 'user' && + (request.throughSequence === null || firstSequence <= request.throughSequence) && + !durable + .slice(0, firstSequence) + .some((candidate) => candidate.type === 'user' && candidate.turnId === message.turnId) + ? [{ turnId: message.turnId, firstSequence }] + : [], + ); + const boundary = + request.anchorSequence ?? + (request.direction === 'older' ? (request.throughSequence ?? -1) + 1 : -1); + const candidates = positions.filter(({ firstSequence }) => + request.direction === 'older' ? firstSequence < boundary : firstSequence > boundary, + ); + const selected = + request.direction === 'older' + ? candidates.slice(-request.maxPositions) + : candidates.slice(0, request.maxPositions); + return { + kind: 'page' as const, + throughSequence: request.throughSequence, + revision: 0, + positions: selected, + hasOlder: + selected.length > 0 && + positions.some(({ firstSequence }) => firstSequence < selected[0]!.firstSequence), + hasNewer: + selected.length > 0 && + positions.some(({ firstSequence }) => firstSequence > selected.at(-1)!.firstSequence), + }; + }, readActiveOverlay: async () => overlay, }; } diff --git a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts index 564dbfd27e..6a4ddc89ad 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts @@ -33,10 +33,212 @@ import { TranscriptPageRequestError, updateSubscriberTranscriptHighWater, } from '../server/session-transcript-pager.js'; +import { readSessionTranscriptPositionsPage } from '../server/session-transcript-position-pager.js'; import type { SessionTranscriptReader } from '../server/session-transcript-reader.js'; import { projectSharedSessionTranscriptMessage } from '../server/shared-session-transcript.js'; import { transcriptReader } from './fixtures/session-transcript-reader.js'; +test('pages owner Turn positions with a subscription-bound stateless cursor', async () => { + const reader = transcriptReader(Array.from({ length: 4 }, (_, index) => userMessage(index))); + const { state } = await createSessionTranscriptBootstrap({ + reader, + sessionId: 'session-1', + subscriptionId: 'subscription-1', + throughSequence: 3, + rootTurn: null, + activeAssistantStreams: [], + maxBytes: 16 * 1024, + projection: 'owner', + }); + const first = await readSessionTranscriptPositionsPage({ + reader, + state, + request: { + subscriptionId: 'subscription-1', + direction: 'newer', + throughSequence: 3, + revision: null, + cursor: null, + anchorSequence: null, + maxPositions: 2, + }, + }); + assert.equal(first.kind, 'page'); + if (first.kind !== 'page') return; + assert.deepEqual(first.positions, [ + { turnId: 'turn-0', firstSequence: 0 }, + { turnId: 'turn-1', firstSequence: 1 }, + ]); + assert.ok(first.nextCursor); + + // A later append may advance the live subscription while this cursor keeps + // paging the fixed, signed watermark it started from. + assert.equal(updateSubscriberTranscriptHighWater(state, 4), true); + + const second = await readSessionTranscriptPositionsPage({ + reader, + state, + request: { + subscriptionId: 'subscription-1', + direction: 'newer', + throughSequence: 3, + revision: null, + cursor: first.nextCursor, + anchorSequence: null, + maxPositions: 2, + }, + }); + assert.equal(second.kind, 'page'); + if (second.kind !== 'page') return; + assert.deepEqual(second.positions, [ + { turnId: 'turn-2', firstSequence: 2 }, + { turnId: 'turn-3', firstSequence: 3 }, + ]); + assert.equal(second.nextCursor, null); + const tampered = `${first.nextCursor!.slice(0, -1)}${first.nextCursor!.endsWith('A') ? 'B' : 'A'}`; + await assert.rejects( + readSessionTranscriptPositionsPage({ + reader, + state, + request: { + subscriptionId: 'subscription-1', + direction: 'newer', + throughSequence: 3, + revision: null, + cursor: tampered, + anchorSequence: null, + maxPositions: 2, + }, + }), + TranscriptPageRequestError, + ); + await assert.rejects( + readSessionTranscriptPositionsPage({ + reader, + state, + request: { + subscriptionId: 'subscription-1', + direction: 'older', + throughSequence: 3, + revision: null, + cursor: first.nextCursor, + anchorSequence: null, + maxPositions: 2, + }, + }), + TranscriptPageRequestError, + ); + await assert.rejects( + readSessionTranscriptPositionsPage({ + reader, + state, + request: { + subscriptionId: 'subscription-1', + direction: 'newer', + throughSequence: 2, + revision: null, + cursor: first.nextCursor, + anchorSequence: null, + maxPositions: 2, + }, + }), + TranscriptPageRequestError, + ); + + const { state: otherState } = await createSessionTranscriptBootstrap({ + reader, + sessionId: 'session-1', + subscriptionId: 'subscription-2', + throughSequence: 3, + rootTurn: null, + activeAssistantStreams: [], + maxBytes: 16 * 1024, + projection: 'owner', + }); + await assert.rejects( + readSessionTranscriptPositionsPage({ + reader, + state: otherState, + request: { + subscriptionId: 'subscription-2', + direction: 'newer', + throughSequence: 3, + revision: null, + cursor: first.nextCursor, + anchorSequence: null, + maxPositions: 2, + }, + }), + TranscriptPageRequestError, + ); +}); + +test('reports position build progress and structural revision changes', async () => { + const base = transcriptReader([userMessage(0)]); + const { state } = await createSessionTranscriptBootstrap({ + reader: base, + sessionId: 'session-1', + subscriptionId: 'subscription-1', + throughSequence: 0, + rootTurn: null, + activeAssistantStreams: [], + maxBytes: 16 * 1024, + projection: 'owner', + }); + const building = await readSessionTranscriptPositionsPage({ + reader: { + ...base, + readDurableTurnPositions: async () => ({ + kind: 'building', + throughSequence: 0, + indexedThroughSequence: null, + }), + }, + state, + request: { + subscriptionId: 'subscription-1', + direction: 'older', + throughSequence: 0, + revision: null, + cursor: null, + anchorSequence: null, + maxPositions: 128, + }, + }); + assert.deepEqual(building, { + kind: 'building', + sessionId: 'session-1', + throughSequence: 0, + indexedThroughSequence: null, + retryAfterMs: 25, + }); + + const stale = await readSessionTranscriptPositionsPage({ + reader: { + ...base, + readDurableTurnPositions: async () => ({ + kind: 'page', + throughSequence: 0, + revision: 2, + positions: [{ turnId: 'turn-0', firstSequence: 0 }], + hasOlder: false, + hasNewer: false, + }), + }, + state, + request: { + subscriptionId: 'subscription-1', + direction: 'older', + throughSequence: 0, + revision: 1, + cursor: null, + anchorSequence: null, + maxPositions: 128, + }, + }); + assert.deepEqual(stale, { kind: 'stale', sessionId: 'session-1', currentRevision: 2 }); +}); + test('reads newly durable messages forward from an announced watermark', async () => { const durable = [userMessage(0), userMessage(1)]; const reader = transcriptReader(durable); diff --git a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts index da33e1ce1f..02b978959b 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts @@ -24,6 +24,8 @@ import { decodeSessionTranscriptBootstrap, decodeSessionTranscriptPage, decodeSessionTranscriptPageInput, + decodeSessionTranscriptPositionsPageInput, + decodeSessionTranscriptPositionsPageResult, encodeProtocolMessage, HOST_OPERATION_SPECS, RUNTIME_HOST_MAX_MESSAGE_BYTES, @@ -104,6 +106,71 @@ test('Session transcript protocol accepts bounded correlated pages and bootstrap ); }); +test('Session transcript position protocol accepts bounded pages and build progress', () => { + const positionsInput = { + subscriptionId: 'subscription-1', + direction: 'newer' as const, + throughSequence: 20, + revision: null, + cursor: null, + anchorSequence: 4, + maxPositions: 128, + }; + const positionsPage = { + kind: 'page' as const, + sessionId: 'session-1', + direction: 'newer' as const, + throughSequence: 20, + revision: 3, + positions: [ + { turnId: 'turn-2', firstSequence: 8 }, + { turnId: 'turn-3', firstSequence: 15 }, + ], + hasOlder: true, + hasNewer: false, + nextCursor: null, + }; + assert.deepEqual(decodeSessionTranscriptPositionsPageInput(positionsInput), positionsInput); + assert.deepEqual(decodeSessionTranscriptPositionsPageResult(positionsPage), positionsPage); + assert.deepEqual( + decodeSessionTranscriptPositionsPageResult({ + kind: 'building', + sessionId: 'session-1', + throughSequence: 20, + indexedThroughSequence: 7, + retryAfterMs: 25, + }), + { + kind: 'building', + sessionId: 'session-1', + throughSequence: 20, + indexedThroughSequence: 7, + retryAfterMs: 25, + }, + ); + assert.throws( + () => + decodeSessionTranscriptPositionsPageInput({ + ...positionsInput, + cursor: 'continuation', + anchorSequence: null, + revision: 3, + }), + isProtocolError, + ); + assert.throws( + () => + decodeSessionTranscriptPositionsPageResult({ + ...positionsPage, + positions: Array.from({ length: 129 }, (_, index) => ({ + turnId: `turn-${index}`, + firstSequence: index, + })), + }), + isProtocolError, + ); +}); + test('a maximum single-fragment continuation remains transport safe', () => { const data = Buffer.alloc(SESSION_TRANSCRIPT_PAGE_MAX_BYTES, 0x61); const result = { diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index abbd00d4f5..4acfbf3cca 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -614,6 +614,7 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { throw error; } }, + (query) => this.request('session.transcript.positions.page', query, timeoutMs), ); this.#subscriptions.set(result.subscriptionId, subscription); return subscription; diff --git a/packages/runtime-host/src/client/session-subscription.ts b/packages/runtime-host/src/client/session-subscription.ts index a458dc9743..fbd054975c 100644 --- a/packages/runtime-host/src/client/session-subscription.ts +++ b/packages/runtime-host/src/client/session-subscription.ts @@ -31,6 +31,8 @@ import { type SessionTranscriptFragment, type SessionTranscriptPage, type SessionTranscriptPageInput, + type SessionTranscriptPositionsPageInput, + type SessionTranscriptPositionsPageResult, } from '../protocol/index.js'; const MAX_CLIENT_QUEUED_FRAMES = 32; @@ -81,6 +83,9 @@ export interface RuntimeHostSessionSubscription extends AsyncIterable, ): Promise; + loadTranscriptPositionsPage( + input: Omit, + ): Promise; close(): Promise; } @@ -109,6 +114,9 @@ export class ClientSessionSubscription readonly #readTranscriptPage: ( input: SessionTranscriptPageInput, ) => Promise; + readonly #readTranscriptPositionsPage: ( + input: SessionTranscriptPositionsPageInput, + ) => Promise; readonly #releaseTranscriptOverlay: () => Promise; readonly #expectedSessionId: string; readonly #queue: QueuedFrame[] = []; @@ -136,6 +144,14 @@ export class ClientSessionSubscription requestClose: () => Promise, readTranscriptPage: (input: SessionTranscriptPageInput) => Promise, releaseTranscriptOverlay: () => Promise = async () => undefined, + readTranscriptPositionsPage: ( + input: SessionTranscriptPositionsPageInput, + ) => Promise = async () => { + throw new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session transcript positions are unavailable', + ); + }, ) { this.hostEpoch = result.hostEpoch; this.subscriptionId = result.subscriptionId; @@ -148,6 +164,7 @@ export class ClientSessionSubscription this.#latestTranscriptThroughSequence = result.transcript?.throughSequence ?? null; this.#requestClose = requestClose; this.#readTranscriptPage = readTranscriptPage; + this.#readTranscriptPositionsPage = readTranscriptPositionsPage; this.#releaseTranscriptOverlay = releaseTranscriptOverlay; } @@ -334,6 +351,41 @@ export class ClientSessionSubscription }); } + loadTranscriptPositionsPage( + input: Omit, + ): Promise { + this.#assertTranscriptReadable(); + if ( + input.throughSequence !== null && + (this.#latestTranscriptThroughSequence === null || + input.throughSequence > this.#latestTranscriptThroughSequence) + ) { + return Promise.reject( + new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session transcript watermark has not been announced', + ), + ); + } + return this.#readTranscriptPositionsPage({ + subscriptionId: this.subscriptionId, + ...input, + }).then((result) => { + this.#assertTranscriptReadable(); + if ( + result.sessionId !== this.#expectedSessionId || + (result.kind !== 'stale' && result.throughSequence !== input.throughSequence) || + (result.kind === 'page' && result.direction !== input.direction) + ) { + throw new RuntimeHostSubscriptionError( + 'correlation_changed', + 'Session transcript position page changed identity', + ); + } + return result; + }); + } + async #loadTranscript(): Promise { this.#assertTranscriptReadable(); const bootstrap = this.transcriptBootstrap; diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 32d21d3f7b..21a23b50ef 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 103 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 104 as const; +// 104: Owners can page bounded durable Turn positions through an existing +// transcript subscription without adding a second transcript body protocol. // 103: `github-copilot` joins `OAUTH_LOGIN_PROVIDERS`, the Host answers the // closed `oauth.enrollment.query`, and `connection.onboarding.save` admits // canonical OAuth material with an empty enable-all-discovered selection. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 0d52b1445e..59bfcdd28a 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -323,6 +323,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'session.remove.preview', 'session.revision.abandon', 'session.revision.create', + 'session.transcript.positions.page', 'session.transcript.page', 'session.transcript.overlay.release', 'session.turn_landmarks.query', diff --git a/packages/runtime-host/src/protocol/session-transcript.ts b/packages/runtime-host/src/protocol/session-transcript.ts index e50b0d03e9..412f291843 100644 --- a/packages/runtime-host/src/protocol/session-transcript.ts +++ b/packages/runtime-host/src/protocol/session-transcript.ts @@ -37,6 +37,9 @@ export const SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES = SESSION_TRANSCRIPT_PAGE_MAX export const SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES = 4_096; export const SESSION_TRANSCRIPT_PAGE_RESULT_MAX_BYTES = 744 * 1024; export const SESSION_TRANSCRIPT_CURSOR_MAX_BYTES = 1024; +export const SESSION_TRANSCRIPT_POSITION_MAX_ITEMS = 128; +export const SESSION_TRANSCRIPT_POSITION_RESULT_MAX_BYTES = 64 * 1024; +export const SESSION_TRANSCRIPT_POSITION_RETRY_AFTER_MS = 25 as const; export type SessionTranscriptPageSource = 'durable' | 'overlay'; export type SessionTranscriptPageDirection = 'older' | 'newer'; @@ -100,6 +103,46 @@ export interface SessionTranscriptOverlayReleaseResult { readonly subscriptionId: string; } +export interface SessionTranscriptPositionsPageInput { + readonly subscriptionId: string; + readonly direction: SessionTranscriptPageDirection; + readonly throughSequence: number | null; + readonly revision: number | null; + readonly cursor: string | null; + readonly anchorSequence: number | null; + readonly maxPositions: number; +} + +export interface SessionTranscriptTurnPosition { + readonly turnId: string; + readonly firstSequence: number; +} + +export type SessionTranscriptPositionsPageResult = + | { + readonly kind: 'page'; + readonly sessionId: string; + readonly direction: SessionTranscriptPageDirection; + readonly throughSequence: number | null; + readonly revision: number; + readonly positions: readonly SessionTranscriptTurnPosition[]; + readonly hasOlder: boolean; + readonly hasNewer: boolean; + readonly nextCursor: string | null; + } + | { + readonly kind: 'building'; + readonly sessionId: string; + readonly throughSequence: number | null; + readonly indexedThroughSequence: number | null; + readonly retryAfterMs: typeof SESSION_TRANSCRIPT_POSITION_RETRY_AFTER_MS; + } + | { + readonly kind: 'stale'; + readonly sessionId: string; + readonly currentRevision: number; + }; + const QUERY_ERRORS = [ 'host_not_ready', 'host_draining', @@ -112,6 +155,14 @@ const QUERY_ERRORS = [ ] as const; export const SESSION_TRANSCRIPT_OPERATION_SPECS = { + 'session.transcript.positions.page': defineOperation({ + mode: 'query', + availability: 'ready', + errors: QUERY_ERRORS, + decodeInput: decodeSessionTranscriptPositionsPageInput, + decodeOutput: decodeSessionTranscriptPositionsPageResult, + assertOutputForInput: assertSessionTranscriptPositionsPageOutput, + }), 'session.transcript.page': defineOperation({ mode: 'query', availability: 'ready', @@ -134,6 +185,186 @@ export const SESSION_TRANSCRIPT_OPERATION_SPECS = { }), } as const; +export function decodeSessionTranscriptPositionsPageInput( + value: unknown, +): SessionTranscriptPositionsPageInput { + const input = requireExactRecord(value, 'Session transcript positions page input', [ + 'subscriptionId', + 'direction', + 'throughSequence', + 'revision', + 'cursor', + 'anchorSequence', + 'maxPositions', + ]); + const cursor = + input.cursor === null + ? null + : requireUtf8String( + input.cursor, + 'Session transcript position cursor', + SESSION_TRANSCRIPT_CURSOR_MAX_BYTES, + ); + const revision = + input.revision === null + ? null + : requireCount(input.revision, 'Session transcript position revision'); + const anchorSequence = + input.anchorSequence === null + ? null + : requireCount(input.anchorSequence, 'Session transcript position anchor sequence'); + if (cursor !== null && (anchorSequence !== null || revision !== null)) { + throw invalidProtocolFrame( + 'Session transcript position cursor, anchor, and revision are mutually exclusive', + ); + } + const maxPositions = requireCount(input.maxPositions, 'Session transcript position page limit'); + if (maxPositions < 1 || maxPositions > SESSION_TRANSCRIPT_POSITION_MAX_ITEMS) { + throw invalidProtocolFrame('Invalid Session transcript position page limit'); + } + return { + subscriptionId: requireId(input.subscriptionId, 'subscriptionId'), + direction: decodeDirection(input.direction), + throughSequence: + input.throughSequence === null + ? null + : requireCount(input.throughSequence, 'Session transcript position watermark'), + revision, + cursor, + anchorSequence, + maxPositions, + }; +} + +export function decodeSessionTranscriptPositionsPageResult( + value: unknown, +): SessionTranscriptPositionsPageResult { + requireEncodedByteLimit( + value, + 'Session transcript positions page result', + SESSION_TRANSCRIPT_POSITION_RESULT_MAX_BYTES, + ); + const result = requireRecord(value, 'Session transcript positions page result'); + if (result.kind === 'building') { + const exact = requireExactRecord(result, 'Session transcript positions build result', [ + 'kind', + 'sessionId', + 'throughSequence', + 'indexedThroughSequence', + 'retryAfterMs', + ]); + if (exact.retryAfterMs !== SESSION_TRANSCRIPT_POSITION_RETRY_AFTER_MS) { + throw invalidProtocolFrame('Invalid Session transcript position retry delay'); + } + return { + kind: 'building', + sessionId: requireEntityId(exact.sessionId, 'sessionId'), + throughSequence: + exact.throughSequence === null + ? null + : requireCount(exact.throughSequence, 'Session transcript position watermark'), + indexedThroughSequence: + exact.indexedThroughSequence === null + ? null + : requireCount( + exact.indexedThroughSequence, + 'Session transcript indexed position watermark', + ), + retryAfterMs: SESSION_TRANSCRIPT_POSITION_RETRY_AFTER_MS, + }; + } + if (result.kind === 'stale') { + const exact = requireExactRecord(result, 'Session transcript positions stale result', [ + 'kind', + 'sessionId', + 'currentRevision', + ]); + return { + kind: 'stale', + sessionId: requireEntityId(exact.sessionId, 'sessionId'), + currentRevision: requireCount( + exact.currentRevision, + 'Session transcript current position revision', + ), + }; + } + const exact = requireExactRecord(result, 'Session transcript positions page result', [ + 'kind', + 'sessionId', + 'direction', + 'throughSequence', + 'revision', + 'positions', + 'hasOlder', + 'hasNewer', + 'nextCursor', + ]); + if (exact.kind !== 'page' || !Array.isArray(exact.positions)) { + throw invalidProtocolFrame('Invalid Session transcript position page kind'); + } + if (exact.positions.length > SESSION_TRANSCRIPT_POSITION_MAX_ITEMS) { + throw invalidProtocolFrame('Session transcript position page exceeds its item limit'); + } + const throughSequence = + exact.throughSequence === null + ? null + : requireCount(exact.throughSequence, 'Session transcript position watermark'); + const positions = exact.positions.map((value) => { + const position = requireExactRecord(value, 'Session transcript Turn position', [ + 'turnId', + 'firstSequence', + ]); + return { + turnId: requireEntityId(position.turnId, 'turnId'), + firstSequence: requireCount(position.firstSequence, 'Session transcript Turn first sequence'), + }; + }); + for (let index = 0; index < positions.length; index += 1) { + const position = positions[index]!; + if ( + (throughSequence !== null && position.firstSequence > throughSequence) || + (index > 0 && positions[index - 1]!.firstSequence >= position.firstSequence) + ) { + throw invalidProtocolFrame('Invalid Session transcript Turn position order'); + } + } + return { + kind: 'page', + sessionId: requireEntityId(exact.sessionId, 'sessionId'), + direction: decodeDirection(exact.direction), + throughSequence, + revision: requireCount(exact.revision, 'Session transcript position revision'), + positions, + hasOlder: requireBoolean(exact.hasOlder, 'Session transcript older position coverage'), + hasNewer: requireBoolean(exact.hasNewer, 'Session transcript newer position coverage'), + nextCursor: + exact.nextCursor === null + ? null + : requireUtf8String( + exact.nextCursor, + 'Session transcript position cursor', + SESSION_TRANSCRIPT_CURSOR_MAX_BYTES, + ), + }; +} + +function assertSessionTranscriptPositionsPageOutput( + input: SessionTranscriptPositionsPageInput, + output: SessionTranscriptPositionsPageResult, +): void { + if (output.kind === 'stale') return; + if (output.throughSequence !== input.throughSequence) { + throw invalidProtocolFrame('Session transcript position watermark changed'); + } + if ( + output.kind === 'page' && + (output.direction !== input.direction || + (input.revision !== null && output.revision !== input.revision)) + ) { + throw invalidProtocolFrame('Session transcript position page does not match request'); + } +} + function decodeSessionTranscriptOverlayReleaseInput( value: unknown, ): SessionTranscriptOverlayReleaseInput { @@ -446,6 +677,11 @@ function decodeDirection(value: unknown): SessionTranscriptPageDirection { return value; } +function requireBoolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') throw invalidProtocolFrame(`Invalid ${label}`); + return value; +} + function requirePageByteLimit(value: unknown): number { const limit = requireCount(value, 'Session transcript page byte limit'); if (limit < 1 || limit > SESSION_TRANSCRIPT_PAGE_MAX_BYTES) { diff --git a/packages/runtime-host/src/server/connection-session.ts b/packages/runtime-host/src/server/connection-session.ts index 9d5141a7ed..ff67e34d21 100644 --- a/packages/runtime-host/src/server/connection-session.ts +++ b/packages/runtime-host/src/server/connection-session.ts @@ -176,7 +176,8 @@ export class RuntimeHostConnectionSession { #dispatch(frame: RequestFrame): void { if (frame.operation === 'host.status') this.#inFlightStatusRequests += 1; const handling = - frame.operation === 'session.transcript.page' + frame.operation === 'session.transcript.page' || + frame.operation === 'session.transcript.positions.page' ? this.#transcriptPageTail.then(() => this.#handleRequest(frame)) : this.#handleRequest(frame); const task = handling @@ -188,7 +189,10 @@ export class RuntimeHostConnectionSession { } }); this.#requests.set(frame.requestId, task); - if (frame.operation === 'session.transcript.page') { + if ( + frame.operation === 'session.transcript.page' || + frame.operation === 'session.transcript.positions.page' + ) { this.#transcriptPageTail = task.catch(() => undefined); } } @@ -221,7 +225,8 @@ export class RuntimeHostConnectionSession { const continuity = frame.operation === 'subscription.open' || frame.operation === 'subscription.close' || - frame.operation === 'session.transcript.page' + frame.operation === 'session.transcript.page' || + frame.operation === 'session.transcript.positions.page' ? this.#ensureContinuity() : undefined; const response = await dispatchOperation(frame, this.#options.resolveHandlers(), { diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index 2596e1a8af..171f69ab34 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -46,6 +46,7 @@ import { type SessionToolEvent, type SessionTranscriptAdvancedFrame, type SessionTranscriptPageInput, + type SessionTranscriptPositionsPageInput, type OperationOutcome, type SubscriptionFrame, type SubscriptionOpenInput, @@ -77,6 +78,7 @@ import { TranscriptPageRequestError, updateSubscriberTranscriptHighWater, } from './session-transcript-pager.js'; +import { readSessionTranscriptPositionsPage } from './session-transcript-position-pager.js'; import { ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, type SessionTranscriptReader, @@ -253,6 +255,8 @@ export class SessionContinuityCoordinator implements SessionContinuityService { }, 'session.transcript.page': (input, context) => this.#readTranscriptPage(context.connectionId, input), + 'session.transcript.positions.page': (input, context) => + this.#readTranscriptPositionsPage(context.connectionId, input), 'session.transcript.overlay.release': async (input, context) => { const existing = this.#subscriptions.get(input.subscriptionId); if (!existing) { @@ -1150,6 +1154,64 @@ export class SessionContinuityCoordinator implements SessionContinuityService { }); } + async #readTranscriptPositionsPage( + connectionId: string, + input: SessionTranscriptPositionsPageInput, + ): Promise> { + const subscriber = this.#ownedSubscriber(connectionId, input.subscriptionId); + if (!subscriber) return transcriptPositionsSubscriptionNotFound(); + if (!this.#transcriptReader || !subscriber.transcript) { + return { + ok: false, + error: { code: 'operation_unavailable', message: 'Session transcript is unavailable' }, + }; + } + if (subscriber.transcript.projection !== 'owner') { + return { + ok: false, + error: { code: 'operation_unavailable', message: 'Transcript positions require an owner' }, + }; + } + const connection = this.#connections.get(connectionId); + if (!connection || !this.#canObserve(subscriber, subscriber.sessionId)) { + this.#closeSubscriber(subscriber, 'access_revoked'); + return transcriptPositionsSubscriptionNotFound(); + } + const transcript = subscriber.transcript; + return this.sessionAdmission.run(subscriber.sessionId, async () => { + if ( + this.#ownedSubscriber(connectionId, input.subscriptionId) !== subscriber || + this.#connections.get(connectionId) !== connection || + !this.#canObserve(subscriber, subscriber.sessionId) + ) { + return transcriptPositionsSubscriptionNotFound(); + } + try { + const page = await readSessionTranscriptPositionsPage({ + reader: this.#transcriptReader!, + state: transcript, + request: input, + }); + if ( + this.#ownedSubscriber(connectionId, input.subscriptionId) !== subscriber || + this.#connections.get(connectionId) !== connection || + !this.#canObserve(subscriber, subscriber.sessionId) + ) { + return transcriptPositionsSubscriptionNotFound(); + } + return { ok: true, result: page }; + } catch (error) { + if (error instanceof TranscriptPageRequestError) { + return { ok: false, error: { code: 'invalid_request', message: error.message } }; + } + return { + ok: false, + error: { code: 'persistence_failed', message: 'Session transcript is unavailable' }, + }; + } + }); + } + #prepareTranscriptOverlay( state: SessionProjectionState, sessionId: string, @@ -1875,6 +1937,13 @@ function transcriptSubscriptionNotFound(): OperationOutcome<'session.transcript. }; } +function transcriptPositionsSubscriptionNotFound(): OperationOutcome<'session.transcript.positions.page'> { + return { + ok: false, + error: { code: 'not_found', message: 'Session subscription was not found' }, + }; +} + function terminalFrameByteBudget(subscriber: Subscriber, hostEpoch: string): number { return Math.max( slowConsumerFrameBytes(subscriber, hostEpoch), diff --git a/packages/runtime-host/src/server/session-transcript-pager.ts b/packages/runtime-host/src/server/session-transcript-pager.ts index b6cd126825..5799082dc7 100644 --- a/packages/runtime-host/src/server/session-transcript-pager.ts +++ b/packages/runtime-host/src/server/session-transcript-pager.ts @@ -17,7 +17,7 @@ * under the License. */ -import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { randomBytes } from 'node:crypto'; import type { StoredMessage } from '@maka/core/session'; import { SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, @@ -37,6 +37,10 @@ import { type SessionTranscriptReader, } from './session-transcript-reader.js'; import { projectSharedSessionTranscriptMessage } from './shared-session-transcript.js'; +import { + decodeTranscriptSignedCursor, + encodeTranscriptSignedCursor, +} from './transcript-signed-cursor.js'; type SessionTranscriptProjection = 'owner' | 'shared'; @@ -791,28 +795,13 @@ function emptyPage( } function encodeCursor(cursor: TranscriptCursorState, secret: Buffer): string { - const payload = Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); - return `${payload}.${signCursor(payload, secret).toString('base64url')}`; + return encodeTranscriptSignedCursor(cursor, secret); } function decodeCursor(value: string, secret: Buffer): TranscriptCursorState { let decoded: unknown; try { - const parts = value.split('.'); - if (parts.length !== 2) throw new Error('invalid cursor envelope'); - const [payload, signatureValue] = parts as [string, string]; - const bytes = Buffer.from(payload, 'base64url'); - const signature = Buffer.from(signatureValue, 'base64url'); - const expected = signCursor(payload, secret); - if ( - bytes.toString('base64url') !== payload || - signature.toString('base64url') !== signatureValue || - signature.byteLength !== expected.byteLength || - !timingSafeEqual(signature, expected) - ) { - throw new Error('invalid cursor signature'); - } - decoded = JSON.parse(bytes.toString('utf8')) as unknown; + decoded = decodeTranscriptSignedCursor(value, secret); } catch (cause) { throw new TranscriptPageRequestError('Invalid transcript cursor', { cause }); } @@ -853,10 +842,6 @@ function decodeCursor(value: string, secret: Buffer): TranscriptCursorState { return cursor as unknown as TranscriptCursorState; } -function signCursor(payload: string, secret: Buffer): Buffer { - return createHmac('sha256', secret).update(payload, 'utf8').digest(); -} - function mergeActiveAssistantStreams( overlay: readonly StoredMessage[], prefixes: Iterable, diff --git a/packages/runtime-host/src/server/session-transcript-position-pager.ts b/packages/runtime-host/src/server/session-transcript-position-pager.ts new file mode 100644 index 0000000000..2990b6846d --- /dev/null +++ b/packages/runtime-host/src/server/session-transcript-position-pager.ts @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + SESSION_TRANSCRIPT_POSITION_RETRY_AFTER_MS, + type SessionTranscriptPositionsPageInput, + type SessionTranscriptPositionsPageResult, +} from '../protocol/index.js'; +import type { SessionTranscriptReader } from './session-transcript-reader.js'; +import { + type SubscriberTranscriptState, + TranscriptPageRequestError, +} from './session-transcript-pager.js'; +import { + decodeTranscriptSignedCursor, + encodeTranscriptSignedCursor, +} from './transcript-signed-cursor.js'; + +const POSITION_CURSOR_DOMAIN = 'session-transcript-turn-positions-v1'; + +interface PositionCursorState { + readonly version: 1; + readonly subscriptionId: string; + readonly sessionId: string; + readonly direction: 'older' | 'newer'; + readonly throughSequence: number | null; + readonly revision: number; + readonly boundarySequence: number; +} + +export async function readSessionTranscriptPositionsPage(input: { + reader: SessionTranscriptReader; + state: SubscriberTranscriptState; + request: SessionTranscriptPositionsPageInput; +}): Promise { + const { reader, state, request } = input; + if (state.projection !== 'owner') { + throw new TranscriptPageRequestError('Transcript positions require an owner subscription'); + } + if ( + request.subscriptionId !== state.subscriptionId || + (request.throughSequence !== null && + (state.durableThroughSequence === null || + request.throughSequence > state.durableThroughSequence)) + ) { + throw new TranscriptPageRequestError('Transcript position request does not match subscription'); + } + + let revision = request.revision; + let anchorSequence = request.anchorSequence; + if (request.cursor !== null) { + const cursor = decodePositionCursor(request.cursor, state.cursorSecret); + if ( + cursor.subscriptionId !== state.subscriptionId || + cursor.sessionId !== state.sessionId || + cursor.direction !== request.direction || + cursor.throughSequence !== request.throughSequence + ) { + throw new TranscriptPageRequestError('Transcript position cursor does not match request'); + } + revision = cursor.revision; + anchorSequence = cursor.boundarySequence; + } + + const page = await reader.readDurableTurnPositions(state.sessionId, { + direction: request.direction, + throughSequence: request.throughSequence, + anchorSequence, + maxPositions: request.maxPositions, + }); + if (page.kind === 'building') { + return { + kind: 'building', + sessionId: state.sessionId, + throughSequence: page.throughSequence, + indexedThroughSequence: page.indexedThroughSequence, + retryAfterMs: SESSION_TRANSCRIPT_POSITION_RETRY_AFTER_MS, + }; + } + if (revision !== null && page.revision !== revision) { + return { kind: 'stale', sessionId: state.sessionId, currentRevision: page.revision }; + } + const hasContinuation = request.direction === 'older' ? page.hasOlder : page.hasNewer; + const edge = + request.direction === 'older' + ? page.positions[0]?.firstSequence + : page.positions.at(-1)?.firstSequence; + if (hasContinuation && edge === undefined) { + throw new Error('Session transcript position page has an empty continuation'); + } + return { + kind: 'page', + sessionId: state.sessionId, + direction: request.direction, + throughSequence: page.throughSequence, + revision: page.revision, + positions: page.positions, + hasOlder: page.hasOlder, + hasNewer: page.hasNewer, + nextCursor: + hasContinuation && edge !== undefined + ? encodeTranscriptSignedCursor( + { + version: 1, + subscriptionId: state.subscriptionId, + sessionId: state.sessionId, + direction: request.direction, + throughSequence: page.throughSequence, + revision: page.revision, + boundarySequence: edge, + } satisfies PositionCursorState, + state.cursorSecret, + POSITION_CURSOR_DOMAIN, + ) + : null, + }; +} + +function decodePositionCursor(value: string, secret: Buffer): PositionCursorState { + let decoded: unknown; + try { + decoded = decodeTranscriptSignedCursor(value, secret, POSITION_CURSOR_DOMAIN); + } catch (cause) { + throw new TranscriptPageRequestError('Invalid transcript position cursor', { cause }); + } + if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) { + throw new TranscriptPageRequestError('Invalid transcript position cursor'); + } + const cursor = decoded as Record; + const keys = [ + 'version', + 'subscriptionId', + 'sessionId', + 'direction', + 'throughSequence', + 'revision', + 'boundarySequence', + ]; + if ( + Object.keys(cursor).length !== keys.length || + keys.some((key) => !Object.hasOwn(cursor, key)) || + cursor.version !== 1 || + typeof cursor.subscriptionId !== 'string' || + typeof cursor.sessionId !== 'string' || + (cursor.direction !== 'older' && cursor.direction !== 'newer') || + (cursor.throughSequence !== null && !isCount(cursor.throughSequence)) || + !isCount(cursor.revision) || + !isCount(cursor.boundarySequence) + ) { + throw new TranscriptPageRequestError('Invalid transcript position cursor fields'); + } + return cursor as unknown as PositionCursorState; +} + +function isCount(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts index 442d3b8851..cb9242ce61 100644 --- a/packages/runtime-host/src/server/session-transcript-reader.ts +++ b/packages/runtime-host/src/server/session-transcript-reader.ts @@ -35,6 +35,8 @@ import type { SessionTranscriptRecordScanPage, SessionTranscriptRecordScanRequest, SessionTranscriptStoragePage, + SessionTurnPositionPageRequest, + SessionTurnPositionPageResult, } from '@maka/storage/execution-stores'; import { SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES, type TurnSnapshot } from '../protocol/index.js'; @@ -57,6 +59,8 @@ export function createSessionTranscriptReader(input: { input.stores.sessionStore.readTranscriptRecordsSnapshot(sessionId, request), readDurableMessagesById: (sessionId, request) => input.stores.sessionStore.readTranscriptMessagesSnapshot(sessionId, request), + readDurableTurnPositions: (sessionId, request) => + input.stores.sessionStore.readTurnPositionsSnapshot(sessionId, request), readActiveOverlay: async (sessionId, rootTurn) => { if (!rootTurn || isTerminalTurn(rootTurn)) return []; @@ -93,6 +97,10 @@ export interface SessionTranscriptReader { sessionId: string, request: SessionTranscriptMessageLookupRequest, ): Promise; + readDurableTurnPositions( + sessionId: string, + request: SessionTurnPositionPageRequest, + ): Promise; readActiveOverlay( sessionId: string, rootTurn: TurnSnapshot | null, diff --git a/packages/runtime-host/src/server/transcript-signed-cursor.ts b/packages/runtime-host/src/server/transcript-signed-cursor.ts new file mode 100644 index 0000000000..df05538d9a --- /dev/null +++ b/packages/runtime-host/src/server/transcript-signed-cursor.ts @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export function encodeTranscriptSignedCursor( + value: unknown, + secret: Buffer, + domain?: string, +): string { + const payload = Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); + return `${payload}.${sign(payload, secret, domain).toString('base64url')}`; +} + +export function decodeTranscriptSignedCursor( + value: string, + secret: Buffer, + domain?: string, +): unknown { + const parts = value.split('.'); + if (parts.length !== 2) throw new Error('invalid cursor envelope'); + const [payload, signatureValue] = parts as [string, string]; + const bytes = Buffer.from(payload, 'base64url'); + const signature = Buffer.from(signatureValue, 'base64url'); + const expected = sign(payload, secret, domain); + if ( + bytes.toString('base64url') !== payload || + signature.toString('base64url') !== signatureValue || + signature.byteLength !== expected.byteLength || + !timingSafeEqual(signature, expected) + ) { + throw new Error('invalid cursor signature'); + } + return JSON.parse(bytes.toString('utf8')) as unknown; +} + +function sign(payload: string, secret: Buffer, domain?: string): Buffer { + const hmac = createHmac('sha256', secret); + if (domain !== undefined) hmac.update(domain, 'utf8').update('\0', 'utf8'); + return hmac.update(payload, 'utf8').digest(); +} diff --git a/packages/runtime/src/test-only/fake-backend.ts b/packages/runtime/src/test-only/fake-backend.ts index 213089f261..823ca03e20 100644 --- a/packages/runtime/src/test-only/fake-backend.ts +++ b/packages/runtime/src/test-only/fake-backend.ts @@ -45,6 +45,7 @@ export const FAKE_ASK_SANDBOX_BOUNDARY_PROMPT = '__e2e_ask_sandbox_boundary__'; export const FAKE_WAIT_FOR_STEERING_PROMPT = '__e2e_wait_for_steering__'; export const FAKE_WAIT_FOR_STEERING_LARGE_RESPONSE_PROMPT = '__e2e_wait_for_steering_large_response__'; +export const FAKE_STREAM_UNTIL_STEERING_PROMPT = '__e2e_stream_until_steering__'; export const FAKE_HOLD_OPEN_PROMPT = '__e2e_hold_open__'; export const FAKE_HOLD_OPEN_REWRITE_PROMPT = '__e2e_hold_open_rewrite__'; export const FAKE_MERMAID_PROMPT = '__e2e_mermaid__'; @@ -205,8 +206,71 @@ export class FakeBackend implements AgentBackend { }; }); }; + const appendMessage = + this.ctx.appendMessage ?? + ((message: StoredMessage) => this.ctx.store.appendMessage(this.sessionId, message)); try { + if (input.text === FAKE_STREAM_UNTIL_STEERING_PROMPT) { + let waitingText = 'Fake backend is streaming until the test sends steering.'; + yield { + type: 'text_delta', + id: randomUUID(), + turnId, + ts: Date.now(), + messageId, + text: waitingText, + }; + let pending = drainSteering(); + while (pending.length === 0 && !this.stopped) { + await sleep(5); + pending = drainSteering(); + } + if (this.stopped) { + yield { type: 'abort', id: randomUUID(), turnId, ts: Date.now(), reason: 'user_stop' }; + yield { + type: 'complete', + id: randomUUID(), + turnId, + ts: Date.now(), + stopReason: 'user_stop', + }; + return; + } + for (const { leaseId, event } of pending) { + yield event; + settleOutstanding(leaseId); + } + const ack = `\n\nAcknowledged steering: ${steered.join(' | ')}`; + waitingText += ack; + yield { + type: 'text_delta', + id: randomUUID(), + turnId, + ts: Date.now(), + messageId, + text: ack, + }; + const ts = Date.now(); + await appendMessage({ + type: 'assistant', + id: messageId, + turnId, + ts, + text: waitingText, + modelId: this.ctx.header.model, + }); + yield { type: 'text_complete', id: randomUUID(), turnId, ts, messageId, text: waitingText }; + yield { + type: 'complete', + id: randomUUID(), + turnId, + ts: Date.now(), + stopReason: 'end_turn', + }; + return; + } + if (input.text === FAKE_HOLD_OPEN_PROMPT || input.text === FAKE_HOLD_OPEN_REWRITE_PROMPT) { const rewriteTarget = input.text === FAKE_HOLD_OPEN_REWRITE_PROMPT; const waitingPrefix = rewriteTarget @@ -314,9 +378,6 @@ export class FakeBackend implements AgentBackend { } const ts = Date.now(); - const appendMessage = - this.ctx.appendMessage ?? - ((message: StoredMessage) => this.ctx.store.appendMessage(this.sessionId, message)); await appendMessage({ type: 'assistant', id: messageId, diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 8aea6a22be..0296b6e17a 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -501,6 +501,326 @@ describe('SqliteSessionMetadataStore', () => { } }); + test('indexes one durable position per user Turn without decoding transcript pages', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader({ id: 'session-turn-positions' })); + await store.appendMessages( + 'session-turn-positions', + [ + { + type: 'user', + id: 'message-turn-1-root', + turnId: 'turn-1', + ts: 10, + text: 'first prompt', + }, + { + type: 'assistant', + id: 'message-turn-1-assistant', + turnId: 'turn-1', + ts: 11, + text: 'first answer', + modelId: 'fake-model', + }, + { + type: 'user', + id: 'message-turn-1-steering', + turnId: 'turn-1', + ts: 12, + text: 'steering', + }, + { + type: 'user', + id: 'message-turn-2-root', + turnId: 'turn-2', + ts: 20, + text: 'second prompt', + }, + ], + { lastMessageAt: 20, lastMessagePreview: 'second prompt' }, + ); + + assert.deepEqual( + await store.readTurnPositions('session-turn-positions', { + direction: 'newer', + throughSequence: 3, + anchorSequence: null, + maxPositions: 128, + }), + { + kind: 'page', + throughSequence: 3, + revision: 0, + positions: [ + { turnId: 'turn-1', firstSequence: 0 }, + { turnId: 'turn-2', firstSequence: 3 }, + ], + hasOlder: false, + hasNewer: false, + }, + ); + } finally { + store.close(); + } + }); + + test('keeps a null position watermark empty after the first durable append', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader({ id: 'session-empty-position-watermark' })); + await store.appendMessages( + 'session-empty-position-watermark', + [ + { + type: 'user', + id: 'message-after-empty-watermark', + turnId: 'turn-after-empty-watermark', + ts: 1, + text: 'arrived after the subscription opened', + }, + ], + { lastMessageAt: 1, lastMessagePreview: 'arrived after the subscription opened' }, + ); + + assert.deepEqual( + await store.readTurnPositions('session-empty-position-watermark', { + direction: 'newer', + throughSequence: null, + anchorSequence: null, + maxPositions: 128, + }), + { + kind: 'page', + throughSequence: null, + revision: 0, + positions: [], + hasOlder: false, + hasNewer: false, + }, + ); + } finally { + store.close(); + } + }); + + test('backfills at most 1,024 legacy records per step and resumes after reopen', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-turn-position-backfill-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path); + try { + await setup.create(fullHeader({ id: 'session-position-backfill' })); + await setup.appendMessages( + 'session-position-backfill', + Array.from({ length: 1_025 }, (_, index) => ({ + type: 'user' as const, + id: `message-${index}`, + turnId: `turn-${index}`, + ts: index, + text: `prompt ${index}`, + })), + { lastMessageAt: 1_024, lastMessagePreview: 'prompt 1024' }, + ); + } finally { + setup.close(); + } + + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + DELETE FROM session_turn_positions; + UPDATE session_turn_position_state SET built_through_sequence = NULL; + `); + } finally { + legacy.close(); + } + + const firstPass = createSqliteSessionMetadataStore(path); + try { + assert.deepEqual( + await firstPass.readTurnPositions('session-position-backfill', { + direction: 'older', + throughSequence: 1_024, + anchorSequence: null, + maxPositions: 2, + }), + { + kind: 'building', + throughSequence: 1_024, + indexedThroughSequence: 1_023, + }, + ); + } finally { + firstPass.close(); + } + + const resumed = createSqliteSessionMetadataStore(path); + try { + assert.deepEqual( + await resumed.readTurnPositions('session-position-backfill', { + direction: 'older', + throughSequence: 1_024, + anchorSequence: null, + maxPositions: 2, + }), + { + kind: 'page', + throughSequence: 1_024, + revision: 0, + positions: [ + { turnId: 'turn-1023', firstSequence: 1_023 }, + { turnId: 'turn-1024', firstSequence: 1_024 }, + ], + hasOlder: true, + hasNewer: false, + }, + ); + } finally { + resumed.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('lets one oversized legacy record advance the position build watermark alone', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-turn-position-oversized-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path); + try { + await setup.create(fullHeader({ id: 'session-position-oversized' })); + await setup.appendMessages( + 'session-position-oversized', + [ + { + type: 'user', + id: 'message-oversized', + turnId: 'turn-oversized', + ts: 1, + text: 'x'.repeat(4 * 1024 * 1024 + 1), + }, + { + type: 'user', + id: 'message-after-oversized', + turnId: 'turn-after-oversized', + ts: 2, + text: 'after', + }, + ], + { lastMessageAt: 2, lastMessagePreview: 'after' }, + ); + } finally { + setup.close(); + } + + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + DELETE FROM session_turn_positions; + UPDATE session_turn_position_state SET built_through_sequence = NULL; + `); + } finally { + legacy.close(); + } + + const store = createSqliteSessionMetadataStore(path); + try { + assert.deepEqual( + await store.readTurnPositions('session-position-oversized', { + direction: 'newer', + throughSequence: 1, + anchorSequence: null, + maxPositions: 128, + }), + { + kind: 'building', + throughSequence: 1, + indexedThroughSequence: 0, + }, + ); + const completed = await store.readTurnPositions('session-position-oversized', { + direction: 'newer', + throughSequence: 1, + anchorSequence: null, + maxPositions: 128, + }); + assert.equal(completed.kind, 'page'); + assert.deepEqual(completed.kind === 'page' ? completed.positions : [], [ + { turnId: 'turn-oversized', firstSequence: 0 }, + { turnId: 'turn-after-oversized', firstSequence: 1 }, + ]); + } finally { + store.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('lazily indexes a v36 transcript record above the legacy 16 KiB bootstrap size', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-turn-position-v36-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path); + try { + await setup.create(fullHeader({ id: 'session-position-v36' })); + await setup.appendMessages( + 'session-position-v36', + [ + { + type: 'user', + id: 'message-v36', + turnId: 'turn-v36', + ts: 1, + text: 'v'.repeat(32 * 1024), + }, + ], + { lastMessageAt: 1, lastMessagePreview: 'legacy prompt' }, + ); + } finally { + setup.close(); + } + + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + DROP TABLE session_turn_position_state; + DROP TABLE session_turn_positions; + UPDATE session_metadata_schema SET version = 36 WHERE scope = 'session_metadata'; + `); + } finally { + legacy.close(); + } + + const migrated = createSqliteSessionMetadataStore(path); + try { + assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + assert.deepEqual( + await migrated.readTurnPositions('session-position-v36', { + direction: 'newer', + throughSequence: 0, + anchorSequence: null, + maxPositions: 128, + }), + { + kind: 'page', + throughSequence: 0, + revision: 0, + positions: [{ turnId: 'turn-v36', firstSequence: 0 }], + hasOlder: false, + hasNewer: false, + }, + ); + } finally { + migrated.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test('materializes a proven Root message when its admission is absent', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { @@ -573,6 +893,18 @@ describe('SqliteSessionMetadataStore', () => { { lastMessageAt: 30, lastMessagePreview: 'newest preview' }, ); + assert.equal( + ( + await store.readTurnPositions('session-legacy-order', { + direction: 'newer', + throughSequence: 2, + anchorSequence: null, + maxPositions: 128, + }) + ).kind, + 'page', + ); + await markMessagesHandedOffWithProvenRoots(store, { sessionId: 'session-legacy-order', messageIds: ['message-legacy-followup', 'message-legacy-steering'], @@ -614,6 +946,25 @@ describe('SqliteSessionMetadataStore', () => { (await store.readCatalogRecord('session-legacy-order')).lastMessagePreview, 'newest preview', ); + assert.deepEqual( + await store.readTurnPositions('session-legacy-order', { + direction: 'newer', + throughSequence: 4, + anchorSequence: null, + maxPositions: 128, + }), + { + kind: 'page', + throughSequence: 4, + revision: 1, + positions: [ + { turnId: 'turn-legacy-order', firstSequence: 1 }, + { turnId: 'turn-newer', firstSequence: 4 }, + ], + hasOlder: false, + hasNewer: false, + }, + ); const audit = new DatabaseSync(path, { readOnly: true }); try { assert.deepEqual(audit.prepare('PRAGMA foreign_key_check').all(), []); diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index dc62748e68..fc138ede1c 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -136,6 +136,9 @@ export type { SessionTranscriptRecordScanRequest, SessionTranscriptStoragePage, SessionTranscriptStorageFragment, + SessionTurnPosition, + SessionTurnPositionPageRequest, + SessionTurnPositionPageResult, } from './session-store.js'; export type ExecutionSessionWriter = SessionAuthorityStore; @@ -410,6 +413,8 @@ async function createExecutionStoresForWrite sessionStore.readTranscriptMessagesSnapshot(sessionId, request)), readTranscriptHighWaterSnapshot: (sessionId) => run(() => sessionStore.readTranscriptHighWaterSnapshot(sessionId)), + readTurnPositionsSnapshot: (sessionId, request) => + run(() => sessionStore.readTurnPositionsSnapshot(sessionId, request)), readTurnContributionsSnapshot: (sessionId, throughSequence, position, maxContributions) => run(() => sessionStore.readTurnContributionsSnapshot( diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index f51971958c..be3f3710cd 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -306,6 +306,34 @@ export interface SessionTurnLandmarkSnapshot { readonly landmarks: readonly SessionTurnLandmark[]; } +export interface SessionTurnPosition { + readonly turnId: string; + readonly firstSequence: number; +} + +export interface SessionTurnPositionPageRequest { + readonly direction: 'older' | 'newer'; + readonly throughSequence: number | null; + /** Exclusive sequence boundary. */ + readonly anchorSequence: number | null; + readonly maxPositions: number; +} + +export type SessionTurnPositionPageResult = + | { + readonly kind: 'page'; + readonly throughSequence: number | null; + readonly revision: number; + readonly positions: readonly SessionTurnPosition[]; + readonly hasOlder: boolean; + readonly hasNewer: boolean; + } + | { + readonly kind: 'building'; + readonly throughSequence: number; + readonly indexedThroughSequence: number | null; + }; + export interface SessionStore { create(input: CreateSessionInput, initialBoundary?: ExecutionBoundary): Promise; list(filter?: SessionListFilter): Promise; @@ -360,6 +388,10 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto sessionId: string, request: SessionTranscriptMessageLookupRequest, ): Promise; + readTurnPositionsSnapshot( + sessionId: string, + request: SessionTurnPositionPageRequest, + ): Promise; /** Observe successful durable ledger appends. Listeners must not throw. */ subscribeTranscriptChanges(listener: (sessionId: string) => void): () => void; /** Wait until the SQLite authority is ready for cross-domain transactions. */ @@ -980,6 +1012,14 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.readTranscriptHighWater(sessionId); } + async readTurnPositionsSnapshot( + sessionId: string, + request: SessionTurnPositionPageRequest, + ): Promise { + await this.ensureReady(); + return this.metadata.readTurnPositions(sessionId, request); + } + async readTurnContributionsSnapshot( sessionId: string, throughSequence: number | null, diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index b4e3c037a5..0e83a1dee4 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 36; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 37; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -1242,6 +1242,30 @@ const MIGRATIONS: ReadonlyMap = new Map([ SELECT 1; `, ], + [ + 37, + ` + CREATE TABLE IF NOT EXISTS session_turn_positions ( + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + first_sequence INTEGER NOT NULL CHECK (first_sequence >= 0), + PRIMARY KEY(session_id, turn_id), + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS session_turn_positions_by_sequence + ON session_turn_positions(session_id, first_sequence, turn_id); + + CREATE TABLE IF NOT EXISTS session_turn_position_state ( + session_id TEXT PRIMARY KEY, + built_through_sequence INTEGER CHECK ( + built_through_sequence IS NULL OR built_through_sequence >= 0 + ), + structural_revision INTEGER NOT NULL CHECK (structural_revision >= 0), + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + `, + ], ]); if (MIGRATIONS.size !== SQLITE_SESSION_METADATA_SCHEMA_VERSION) { diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 770147b5d3..c16b4e056b 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -144,6 +144,8 @@ import { type SessionTurnContribution, type SessionTurnContributionPage, type SessionTurnLandmarkSnapshot, + type SessionTurnPositionPageRequest, + type SessionTurnPositionPageResult, } from './session-store.js'; import { isDiscardableConversationCopy, @@ -167,12 +169,18 @@ import { sqliteOrdinarySessionRolePredicate, sqliteRecoverableSessionRolePredicate, } from './sqlite-session-role-scope.js'; +import { + initializeTurnPositionState, + readTurnPositions as readSqliteTurnPositions, + recordTurnPositions, + shiftTurnPositions, + SQLITE_TURN_POSITION_MAX_SOURCE_BYTES, + SQLITE_TURN_POSITION_MAX_SOURCE_MESSAGES, +} from './sqlite-session-turn-positions.js'; 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; function decodeStoredMessage(value: unknown): StoredMessage { @@ -2763,6 +2771,42 @@ export class SqliteSessionMetadataStore { return nullableStoredMessageSequence(row.high_water, sessionId); } + async readTurnPositions( + sessionId: string, + request: SessionTurnPositionPageRequest, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + if ( + (request.direction !== 'older' && request.direction !== 'newer') || + (request.throughSequence !== null && + (!Number.isSafeInteger(request.throughSequence) || request.throughSequence < 0)) || + (request.anchorSequence !== null && + (!Number.isSafeInteger(request.anchorSequence) || request.anchorSequence < 0)) || + !Number.isSafeInteger(request.maxPositions) || + request.maxPositions < 1 || + request.maxPositions > 128 + ) { + throw new Error('Invalid Session Turn position request'); + } + return this.transaction(() => { + if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); + return readSqliteTurnPositions(this.db, sessionId, request, (sequences) => { + const messages = new Map(); + for (const row of readStoredMessageRows(this.db, sessionId, sequences)) { + try { + messages.set(row.sequence, decodeStoredMessage(JSON.parse(row.recordJson) as unknown)); + } catch (error) { + throw new StoredSessionMessageIncompatibleError(sessionId, row.sequence, { + cause: error, + }); + } + } + return messages; + }); + }); + } + async readTurnContributions( sessionId: string, throughSequence: number | null, @@ -2823,8 +2867,8 @@ export class SqliteSessionMetadataStore { const recordBytes = storedMessageRecordBytes(row, sessionId, sequence); if ( sourceMessages > 0 && - (sourceMessages >= SQLITE_TURN_CONTRIBUTION_MAX_SOURCE_MESSAGES || - sourceBytes + recordBytes > SQLITE_TURN_CONTRIBUTION_MAX_SOURCE_BYTES) + (sourceMessages >= SQLITE_TURN_POSITION_MAX_SOURCE_MESSAGES || + sourceBytes + recordBytes > SQLITE_TURN_POSITION_MAX_SOURCE_BYTES) ) { return { throughSequence: fixedThrough, @@ -4753,6 +4797,7 @@ export class SqliteSessionMetadataStore { committedAt, ); if (result.changes !== 1) return undefined; + initializeTurnPositionState(this.db, header.id); this.options.failpoint?.('after_session_row_write'); this.ensureGenesisExecutionBoundary(header, initialBoundary); return { header, metadataVersion, committedAt }; @@ -5243,6 +5288,8 @@ export class SqliteSessionMetadataStore { } if (sequences.length === 0) return; + shiftTurnPositions(this.db, sessionId, firstSequence, amount); + this.db.exec('PRAGMA defer_foreign_keys = ON'); const moveChunks = this.db.prepare( 'UPDATE session_message_chunks SET sequence = ? WHERE session_id = ? AND sequence = ?', @@ -5330,6 +5377,7 @@ export class SqliteSessionMetadataStore { ); } } + recordTurnPositions(this.db, sessionId, firstSequence, entries); } private replaceSessionMessageSync( diff --git a/packages/storage/src/sqlite-session-turn-positions.ts b/packages/storage/src/sqlite-session-turn-positions.ts new file mode 100644 index 0000000000..2c88b67c61 --- /dev/null +++ b/packages/storage/src/sqlite-session-turn-positions.ts @@ -0,0 +1,355 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DatabaseSync } from 'node:sqlite'; +import type { StoredMessage } from '@maka/core/session'; +import type { + SessionTurnPositionPageRequest, + SessionTurnPositionPageResult, +} from './session-store.js'; + +export const SQLITE_TURN_POSITION_MAX_SOURCE_MESSAGES = 1_024; +export const SQLITE_TURN_POSITION_MAX_SOURCE_BYTES = 4 * 1024 * 1024; + +interface PositionStateRow { + readonly built_through_sequence?: unknown; + readonly structural_revision?: unknown; +} + +export interface TurnPositionSourceRow { + readonly sequence?: unknown; + readonly message_type?: unknown; + readonly stored_bytes?: unknown; + readonly admission_turn_id?: unknown; +} + +export function initializeTurnPositionState(db: DatabaseSync, sessionId: string): void { + db.prepare( + `INSERT INTO session_turn_position_state( + session_id, built_through_sequence, structural_revision + ) VALUES (?, NULL, 0) + ON CONFLICT(session_id) DO NOTHING`, + ).run(sessionId); +} + +export function recordTurnPositions( + db: DatabaseSync, + sessionId: string, + firstSequence: number, + entries: readonly { readonly message: StoredMessage }[], +): void { + initializeTurnPositionState(db, sessionId); + const upsert = db.prepare(` + INSERT INTO session_turn_positions(session_id, turn_id, first_sequence) + VALUES (?, ?, ?) + ON CONFLICT(session_id, turn_id) DO UPDATE SET + first_sequence = MIN(first_sequence, excluded.first_sequence) + `); + entries.forEach(({ message }, index) => { + if (message.type === 'user' && message.turnId.length > 0) { + upsert.run(sessionId, message.turnId, firstSequence + index); + } + }); + + if (entries.length === 0) return; + const state = readState(db, sessionId); + if ( + (state.builtThroughSequence === null && firstSequence === 0) || + state.builtThroughSequence === firstSequence - 1 + ) { + db.prepare( + `UPDATE session_turn_position_state + SET built_through_sequence = ? WHERE session_id = ?`, + ).run(firstSequence + entries.length - 1, sessionId); + } +} + +export function shiftTurnPositions( + db: DatabaseSync, + sessionId: string, + firstSequence: number, + amount: number, +): void { + initializeTurnPositionState(db, sessionId); + const positions = db + .prepare( + `SELECT turn_id, first_sequence FROM session_turn_positions + WHERE session_id = ? AND first_sequence >= ? + ORDER BY first_sequence DESC, turn_id DESC`, + ) + .all(sessionId, firstSequence) as Array<{ + readonly turn_id?: unknown; + readonly first_sequence?: unknown; + }>; + const move = db.prepare( + `UPDATE session_turn_positions SET first_sequence = ? + WHERE session_id = ? AND turn_id = ? AND first_sequence = ?`, + ); + for (const row of positions) { + if (typeof row.turn_id !== 'string' || !isCount(row.first_sequence)) { + throw new Error(`Invalid Session Turn position for ${sessionId}`); + } + move.run(row.first_sequence + amount, sessionId, row.turn_id, row.first_sequence); + } + const state = readState(db, sessionId); + if (state.structuralRevision === Number.MAX_SAFE_INTEGER) { + throw new Error(`Session Turn position revision overflow for ${sessionId}`); + } + db.prepare( + `UPDATE session_turn_position_state + SET built_through_sequence = CASE + WHEN built_through_sequence IS NOT NULL AND built_through_sequence >= ? + THEN built_through_sequence + ? + ELSE built_through_sequence + END, + structural_revision = structural_revision + 1 + WHERE session_id = ?`, + ).run(firstSequence, amount, sessionId); +} + +export function readTurnPositions( + db: DatabaseSync, + sessionId: string, + request: SessionTurnPositionPageRequest, + readMessages: (sequences: readonly number[]) => ReadonlyMap, +): SessionTurnPositionPageResult { + initializeTurnPositionState(db, sessionId); + const throughSequence = request.throughSequence; + if (throughSequence === null) { + const state = readState(db, sessionId); + return { + kind: 'page', + throughSequence: null, + revision: state.structuralRevision, + positions: [], + hasOlder: false, + hasNewer: false, + }; + } + const actualThrough = readHighWater(db, sessionId); + if (actualThrough === null || throughSequence > actualThrough) { + throw new Error(`Session Turn position watermark is ahead of durable storage: ${sessionId}`); + } + + let state = readState(db, sessionId); + if (state.builtThroughSequence === null || state.builtThroughSequence < throughSequence) { + buildTurnPositionStep(db, sessionId, state.builtThroughSequence, throughSequence, readMessages); + state = readState(db, sessionId); + if (state.builtThroughSequence === null || state.builtThroughSequence < throughSequence) { + return { + kind: 'building', + throughSequence, + indexedThroughSequence: state.builtThroughSequence, + }; + } + } + + const comparison = request.direction === 'older' ? '<' : '>'; + const order = request.direction === 'older' ? 'DESC' : 'ASC'; + const boundary = + request.anchorSequence ?? (request.direction === 'older' ? throughSequence + 1 : -1); + const raw = db + .prepare( + `SELECT turn_id, first_sequence FROM session_turn_positions + WHERE session_id = ? AND first_sequence <= ? AND first_sequence ${comparison} ? + ORDER BY first_sequence ${order}, turn_id ${order} + LIMIT ?`, + ) + .all(sessionId, throughSequence, boundary, request.maxPositions + 1) as Array<{ + readonly turn_id?: unknown; + readonly first_sequence?: unknown; + }>; + const selected = raw.slice(0, request.maxPositions).map((row) => { + if (typeof row.turn_id !== 'string' || !isCount(row.first_sequence)) { + throw new Error(`Invalid Session Turn position for ${sessionId}`); + } + return { turnId: row.turn_id, firstSequence: row.first_sequence }; + }); + if (request.direction === 'older') selected.reverse(); + const first = selected[0]?.firstSequence; + const last = selected.at(-1)?.firstSequence; + return { + kind: 'page', + throughSequence, + revision: state.structuralRevision, + positions: selected, + hasOlder: first === undefined ? false : hasPosition(db, sessionId, throughSequence, '<', first), + hasNewer: last === undefined ? false : hasPosition(db, sessionId, throughSequence, '>', last), + }; +} + +function buildTurnPositionStep( + db: DatabaseSync, + sessionId: string, + builtThroughSequence: number | null, + throughSequence: number, + readMessages: (sequences: readonly number[]) => ReadonlyMap, +): void { + const position = builtThroughSequence === null ? 0 : builtThroughSequence + 1; + const admissionTable = db + .prepare(`SELECT 1 AS found FROM sqlite_schema WHERE type = 'table' AND name = ?`) + .get('core_root_turn_admissions'); + const admissionProjection = admissionTable + ? `( + SELECT admission.turn_id FROM core_root_turn_admissions AS admission + WHERE admission.session_id = message.session_id + AND json_extract(admission.record_json, '$.userMessageId') = message.message_id + LIMIT 1 + )` + : 'NULL'; + const rows = db + .prepare( + `SELECT message.sequence, message.message_type, + coalesce(payload.record_bytes, length(CAST(message.record_json AS BLOB))) AS stored_bytes, + ${admissionProjection} AS admission_turn_id + 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_POSITION_MAX_SOURCE_MESSAGES}`, + ) + .all(sessionId, position, throughSequence) as TurnPositionSourceRow[]; + if (rows.length === 0) { + throw new Error(`Session Turn position build did not advance: ${sessionId}`); + } + + const upsert = db.prepare(` + INSERT INTO session_turn_positions(session_id, turn_id, first_sequence) + VALUES (?, ?, ?) + ON CONFLICT(session_id, turn_id) DO UPDATE SET + first_sequence = MIN(first_sequence, excluded.first_sequence) + `); + let sourceBytes = 0; + let lastSequence: number | null = null; + const selected: Array<{ + readonly sequence: number; + readonly messageType: unknown; + readonly admissionTurnId: unknown; + }> = []; + for (const row of rows) { + const sequence = requireCount(row.sequence, `Session Turn position sequence for ${sessionId}`); + const recordBytes = requireCount( + row.stored_bytes, + `Session Turn position bytes for ${sessionId}`, + ); + if ( + lastSequence !== null && + sourceBytes + recordBytes > SQLITE_TURN_POSITION_MAX_SOURCE_BYTES + ) { + break; + } + sourceBytes += recordBytes; + selected.push({ + sequence, + messageType: row.message_type, + admissionTurnId: row.admission_turn_id, + }); + lastSequence = sequence; + } + const legacyUserSequences = selected.flatMap((row) => + row.messageType === 'user' && typeof row.admissionTurnId !== 'string' ? [row.sequence] : [], + ); + const legacyMessages = readMessages(legacyUserSequences); + for (const row of selected) { + if (row.messageType === 'user') { + const turnId = + typeof row.admissionTurnId === 'string' + ? row.admissionTurnId + : (() => { + const message = legacyMessages.get(row.sequence); + if (message?.type !== 'user') { + throw new Error(`Session Turn position identity changed for ${sessionId}`); + } + return message.turnId; + })(); + if (turnId.length > 0) upsert.run(sessionId, turnId, row.sequence); + } + } + if (lastSequence === null) { + throw new Error(`Session Turn position build did not advance: ${sessionId}`); + } + db.prepare( + `UPDATE session_turn_position_state SET built_through_sequence = ? WHERE session_id = ?`, + ).run(lastSequence, sessionId); +} + +function readState( + db: DatabaseSync, + sessionId: string, +): { readonly builtThroughSequence: number | null; readonly structuralRevision: number } { + const row = db + .prepare( + `SELECT built_through_sequence, structural_revision + FROM session_turn_position_state WHERE session_id = ?`, + ) + .get(sessionId) as PositionStateRow | undefined; + if (!row) throw new Error(`Missing Session Turn position state for ${sessionId}`); + const builtThroughSequence = + row.built_through_sequence === null + ? null + : requireCount( + row.built_through_sequence, + `Session Turn position watermark for ${sessionId}`, + ); + return { + builtThroughSequence, + structuralRevision: requireCount( + row.structural_revision, + `Session Turn position revision for ${sessionId}`, + ), + }; +} + +function readHighWater(db: DatabaseSync, sessionId: string): number | null { + const row = db + .prepare('SELECT MAX(sequence) AS high_water FROM session_messages WHERE session_id = ?') + .get(sessionId) as { readonly high_water?: unknown }; + return row.high_water === null + ? null + : requireCount(row.high_water, `Session transcript watermark for ${sessionId}`); +} + +function hasPosition( + db: DatabaseSync, + sessionId: string, + throughSequence: number, + comparison: '<' | '>', + boundary: number, +): boolean { + return Boolean( + db + .prepare( + `SELECT 1 AS found FROM session_turn_positions + WHERE session_id = ? AND first_sequence <= ? AND first_sequence ${comparison} ? LIMIT 1`, + ) + .get(sessionId, throughSequence, boundary), + ); +} + +function requireCount(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`Invalid ${label}`); + } + return value; +} + +function isCount(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 179be9ea18..00e5083dbc 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -61,6 +61,30 @@ function timelineText(turn: ReturnType[number] | undefi ) ?? []; } +describe("bounded transcript metadata", () => { + test("does not materialize an empty Turn from a state record whose body is not resident", () => { + const turns = materializeTurns([ + { + type: "user", + id: "user-resident", + turnId: "turn-resident", + ts: 1, + text: "resident prompt", + }, + { + type: "turn_state", + id: "state-sparse", + turnId: "turn-sparse", + ts: 2, + status: "completed", + partialOutputRetained: false, + }, + ]); + + assert.deepEqual(turns.map((turn) => turn.turnId), ["turn-resident"]); + }); +}); + describe("steering timeline", () => { test("keeps a steering message at its conversational position", () => { const [turn] = materializeTurns([ diff --git a/packages/ui/src/__tests__/transcript-history-notice.test.tsx b/packages/ui/src/__tests__/transcript-history-notice.test.tsx index 2a26d78b30..541585d537 100644 --- a/packages/ui/src/__tests__/transcript-history-notice.test.tsx +++ b/packages/ui/src/__tests__/transcript-history-notice.test.tsx @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { renderToStaticMarkup } from 'react-dom/server'; -import { TranscriptHistoryNotice } from '../chat-view.js'; +import { TranscriptGapRow, TranscriptHistoryNotice } from '../chat-view.js'; function renderNotice(isPending: boolean): string { return renderToStaticMarkup( @@ -53,3 +53,22 @@ test('keeps the position status visible while return-to-latest is pending', () = assert.doesNotMatch(markup, /saved|loaded/); assert.match(markup, /disabled/); }); + +test('renders an unloaded range as one in-transcript row without scroll machinery', () => { + const markup = renderToStaticMarkup( + undefined} + />, + ); + + assert.match(markup, /data-transcript-gap="internal"/); + assert.match(markup, /data-missing-turn-count="2"/); + assert.match(markup, /2 unloaded turns/); + assert.match(markup, /Load this range/); + assert.doesNotMatch(markup, /height|resize|scroll/iu); +}); diff --git a/packages/ui/src/__tests__/transcript-row-projection.test.ts b/packages/ui/src/__tests__/transcript-row-projection.test.ts new file mode 100644 index 0000000000..c0f3d05ad4 --- /dev/null +++ b/packages/ui/src/__tests__/transcript-row-projection.test.ts @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { projectTranscriptRows } from '../transcript-row-projection.js'; + +const positionRange = { + state: 'ready' as const, + throughSequence: 8, + revision: 0, + positions: [ + { turnId: 'turn-1', firstSequence: 0 }, + { turnId: 'turn-2', firstSequence: 2 }, + { turnId: 'turn-3', firstSequence: 4 }, + { turnId: 'turn-4', firstSequence: 6 }, + ], + hasOlder: false, + hasNewer: false, +}; + +describe('bounded transcript row projection', () => { + test('keeps unloaded durable turns between an old range and the active turn visible as one gap', () => { + const rows = projectTranscriptRows({ + turns: [{ turnId: 'turn-1' }, { turnId: 'turn-4' }], + positionRange, + activeTurnId: 'turn-4', + }); + + assert.deepEqual(rows, [ + { kind: 'turn', turn: { turnId: 'turn-1' } }, + { + kind: 'gap', + direction: 'internal', + missingCount: 2, + firstMissing: { turnId: 'turn-2', firstSequence: 2 }, + }, + { kind: 'turn', turn: { turnId: 'turn-4' } }, + ]); + }); + + test('keeps one Turn identity when the active overlay settles to durable data', () => { + const first = { turnId: 'turn-1' }; + const active = { turnId: 'turn-4' }; + const liveRows = projectTranscriptRows({ + turns: [first, active], + positionRange, + activeTurnId: active.turnId, + }); + const settledRows = projectTranscriptRows({ + turns: [first, active], + positionRange, + }); + + assert.deepEqual(settledRows.map((row) => row.kind), ['turn', 'gap', 'turn']); + assert.strictEqual(liveRows[0]?.kind === 'turn' ? liveRows[0].turn : null, first); + assert.strictEqual(liveRows[2]?.kind === 'turn' ? liveRows[2].turn : null, active); + assert.strictEqual(settledRows[2]?.kind === 'turn' ? settledRows[2].turn : null, active); + assert.equal(settledRows.filter( + (row) => row.kind === 'turn' && row.turn.turnId === active.turnId, + ).length, 1); + }); + + test('uses one generic gap before an active overlay while positions are unavailable', () => { + const rows = projectTranscriptRows({ + turns: [{ turnId: 'turn-1' }, { turnId: 'turn-4' }], + positionRange: { + ...positionRange, + state: 'unavailable', + positions: [], + hasNewer: true, + }, + activeTurnId: 'turn-4', + }); + + assert.deepEqual(rows.map((row) => row.kind), ['turn', 'gap', 'turn']); + assert.deepEqual(rows[1], { + kind: 'gap', + direction: 'newer', + missingCount: null, + firstMissing: null, + }); + }); + + test('merges known missing positions with an adjacent unknown boundary', () => { + const rows = projectTranscriptRows({ + turns: [{ turnId: 'turn-2' }], + positionRange: { + ...positionRange, + positions: positionRange.positions.slice(0, 2), + hasOlder: true, + hasNewer: true, + }, + }); + + assert.equal(rows.filter((row) => row.kind === 'gap').length, 2); + assert.deepEqual(rows[0], { + kind: 'gap', + direction: 'older', + missingCount: null, + firstMissing: { turnId: 'turn-1', firstSequence: 0 }, + }); + assert.deepEqual(rows.at(-1), { + kind: 'gap', + direction: 'newer', + missingCount: null, + firstMissing: null, + }); + }); +}); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index ea812fe1be..8a026647e7 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -46,6 +46,11 @@ import { useChatLayoutContext } from '@astryxdesign/core/Chat'; import { useLayer } from '@astryxdesign/core/Layer'; import { materializeChat } from './materialize.js'; import { useTranscriptProjection } from './use-transcript-projection.js'; +import { + projectTranscriptRows, + type TranscriptPositionRange, + type TranscriptRow, +} from './transcript-row-projection.js'; import type { LiveTurnProjection } from './live-turn-projection.js'; import { ModelProviderRetryIndicator, @@ -152,6 +157,49 @@ export function TranscriptHistoryNotice({ ); } +export interface TranscriptGapRowProps { + direction: 'older' | 'internal' | 'newer'; + missingCount: number | null; + description: string; + actionLabel: string; + isPending: boolean; + onActivate(): Promise | void; +} + +/** The sole new visual primitive: a real row for a deliberately unloaded range. */ +export function TranscriptGapRow({ + direction, + missingCount, + description, + actionLabel, + isPending, + onActivate, +}: TranscriptGapRowProps) { + return ( + + {description} +