diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 40aaf79555..6cd73c90a0 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -610,7 +610,7 @@ "@maka/ui": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2725 + "nonTriviaTokens": 2687 }, "src/renderer/app-shell-session-start-actions.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index e79a36a1d3..06bc141298 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 @@ -153,7 +153,6 @@ for (const paged of [false, true]) { hostEpoch: 'host-1', durableThrough: 8, durable: turnC.map((message, index) => ({ sequence: index + 6, message })), - overlay: [], hasOlder: true, hasNewer: false, })) handler({ ...batch, deliverySequence: ++deliverySequence }); @@ -187,7 +186,6 @@ for (const paged of [false, true]) { hostEpoch: 'host-1', durableThrough: 8, durable: (paged ? turnB.slice(0, 2) : turnB).map((message, index) => ({ sequence: index + 3, message })), - overlay: [], hasOlder: true, hasNewer: true, }, navigation)) handler({ ...batch, deliverySequence: ++deliverySequence }); @@ -239,7 +237,6 @@ for (const hasSequence of [false, true]) { hostEpoch: 'host-1', durableThrough: 8, durable: tail.map((message, index) => ({ sequence: index + 7, message })), - overlay: [], hasOlder: true, hasNewer: false, })) handler({ ...batch, deliverySequence: ++deliverySequence }); @@ -269,7 +266,7 @@ for (const hasSequence of [false, true]) { }); } -test('moves a fragmented overlay record to durable storage without duplicating it', () => { +test('assembles a fragmented durable record once and ignores its replay', () => { const message = assistantMessage('x'.repeat(DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES * 2)); const identity = { sessionId: 'session-1', @@ -277,35 +274,30 @@ test('moves a fragmented overlay record to durable storage without duplicating i hostEpoch: 'host-1', }; const store = transcriptStore(); - const snapshot = [...encodeDesktopTranscriptSnapshot({ + for (const batch of encodeDesktopTranscriptSnapshot({ ...identity, durableThrough: null, durable: [], - overlay: [message], hasOlder: false, hasNewer: false, - })]; + })) store.accept(batch); - assert.ok(snapshot.length > 1); - for (const [index, batch] of snapshot.entries()) { + const change = [...encodeDesktopTranscriptChange(identity, { + coversFrom: null, + durableThrough: 4, + durableUpserts: [{ sequence: 4, message }], + })]; + assert.ok(change.length > 1); + for (const batch of change) { assert.ok( batch.fragments.reduce( (total, fragment) => total + fragment.data.byteLength, 0, ) <= DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, ); - assert.equal(store.accept(batch), index === snapshot.length - 1); + store.accept(batch); } assert.deepEqual(store.snapshot().messages, [message]); - assert.equal(store.hasDurableMessage(message.id), false); - - const change = [...encodeDesktopTranscriptChange(identity, { - coversFrom: null, - durableThrough: 4, - durableUpserts: [{ sequence: 4, message }], - })]; - for (const batch of change) store.accept(batch); - assert.deepEqual(store.snapshot().messages, [message]); assert.equal(store.hasDurableMessage(message.id), true); for (const batch of change) assert.equal(store.accept(batch), false); @@ -327,7 +319,6 @@ test('tracks the newest resident durable prompt as the window changes', () => { { sequence: 2, message: assistantMessage('answer') }, { sequence: 3, message: userMessage('newer', 'user-3') }, ], - overlay: [], hasOlder: false, hasNewer: false, })) store.accept(batch); @@ -353,7 +344,6 @@ test('drops stale transcript batches after a generation reset', () => { hostEpoch: 'host-1', durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('old') }], - overlay: [], hasOlder: false, hasNewer: false, })]; @@ -364,7 +354,6 @@ test('drops stale transcript batches after a generation reset', () => { hostEpoch: 'host-2', durableThrough: 2, durable: [{ sequence: 2, message: nextMessage }], - overlay: [], hasOlder: true, hasNewer: false, })]; @@ -395,7 +384,7 @@ test('cached reload snapshots allow the same live transcript generation to resum for (const batch of encodeDesktopTranscriptSnapshot({ ...identity, generation, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage(text) }], - overlay: [], hasOlder: false, hasNewer: false, + hasOlder: false, hasNewer: false, }, navigation)) deliveries.push({ generation, accepted: store.accept(batch) }); }; const controller = createDesktopTranscriptRangeController(store, async () => { @@ -448,7 +437,7 @@ test('a replacement live generation retires the previous replica through cached const snapshot = (generation: string) => [...encodeDesktopTranscriptSnapshot({ sessionId: 'session-1', generation, hostEpoch: 'host-1', durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage(generation) }], - overlay: [], hasOlder: false, hasNewer: false, + hasOlder: false, hasNewer: false, })]; const generations = ['previous-live', ...cachedGenerations, 'replacement-live']; for (const generation of generations) { @@ -482,7 +471,6 @@ test('keeps unchanged message references stable across immutable range snapshots ...identity, durableThrough: 1, durable: [{ sequence: 1, message: firstMessage }], - overlay: [], hasOlder: false, hasNewer: false, })) store.accept(batch); @@ -517,13 +505,7 @@ test('bounds the default active transcript range by Turn identities', async () = snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: messages.length - 1, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, messages.length - 1), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], + transcriptBootstrap: { durable: bootstrapPage }, decodeTranscriptPage: async () => ({ messages, nextCursor: null }), async close() {}, }); @@ -551,13 +533,7 @@ test('bounds the default active transcript range by presentation bytes', async ( snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: messages.length - 1, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, messages.length - 1), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], + transcriptBootstrap: { durable: bootstrapPage }, decodeTranscriptPage: async () => ({ messages, nextCursor: null }), async close() {}, }); @@ -589,13 +565,7 @@ test('keeps an oversized latest Turn visible after bootstrap eviction', async () snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: latest.identity, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, latest.identity), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], + transcriptBootstrap: { durable: bootstrapPage }, decodeTranscriptPage: async () => ({ messages: [older, latest], nextCursor: null }), async close() {}, }); @@ -629,18 +599,7 @@ test('keeps an oversized latest Turn visible before a trailing session note', as snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: trailingNote.identity, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { - ...bootstrapPage, - source: 'overlay', - rangeBoundarySequence: null, - protectedTurnSequence: null, - }, - }, - loadTranscriptOverlay: async () => [], + transcriptBootstrap: { durable: bootstrapPage }, decodeTranscriptPage: async () => ({ messages: [latest, trailingNote], nextCursor: null }), async close() {}, }); @@ -663,13 +622,7 @@ test('advances a projected transcript across hidden durable records', async () = snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 1, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, 1), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], + transcriptBootstrap: { durable: bootstrapPage }, decodeTranscriptPage: async (page) => ({ messages: page === bootstrapPage @@ -700,7 +653,7 @@ test('advances a projected transcript across hidden durable records', async () = ); }); -test('keeps an oversized streaming Turn visible when its overlay settles', async () => { +test('keeps an oversized Turn visible when the watermark advances onto it', async () => { const older = { identity: 0, message: { ...assistantMessage('older', 'assistant-0'), turnId: 'turn-0' }, @@ -719,13 +672,7 @@ test('keeps an oversized streaming Turn visible when its overlay settles', async snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: older.identity, - overlayMessageCount: 1, - durable: bootstrapPage, - overlay: { ...transcriptPage('older', null, older.identity), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [latest.message], + transcriptBootstrap: { durable: bootstrapPage }, decodeTranscriptPage: async (page) => page === bootstrapPage ? { messages: [older], nextCursor: null } : { messages: [latest], nextCursor: null }, @@ -733,13 +680,10 @@ test('keeps an oversized streaming Turn visible when its overlay settles', async async close() {}, }); const replica = await DesktopTranscriptReplica.prepare(handle); - assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), [latest.message.id]); await replica.advance(latest.identity); - const snapshot = replica.snapshot(); - assert.deepEqual(snapshot.durable.map(({ sequence }) => sequence), [latest.identity]); - assert.deepEqual(snapshot.overlay, []); + assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [latest.identity]); }); test('keeps an oversized settled Turn visible before a trailing session note', async () => { @@ -770,13 +714,7 @@ test('keeps an oversized settled Turn visible before a trailing session note', a snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: older.identity, - overlayMessageCount: 1, - durable: bootstrapPage, - overlay: { ...bootstrapPage, source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [latest.message], + transcriptBootstrap: { durable: bootstrapPage }, decodeTranscriptPage: async (page) => page === bootstrapPage ? { messages: [older], nextCursor: null } : { messages: [latest, trailingNote], nextCursor: null }, @@ -789,7 +727,6 @@ test('keeps an oversized settled Turn visible before a trailing session note', a const snapshot = replica.snapshot(); assert.ok(snapshot.durable.some(({ sequence }) => sequence === latest.identity)); - assert.deepEqual(snapshot.overlay, []); }); for (const direction of ['older', 'newer'] as const) { @@ -802,7 +739,6 @@ for (const direction of ['older', 'newer'] as const) { const page = (nextCursor: string | null) => ({ kind: 'page' as const, sessionId: 'session-1', - source: 'durable' as const, direction: 'older' as const, throughSequence: 4, rawBytes: 1, @@ -826,13 +762,7 @@ for (const direction of ['older', 'newer'] as const) { snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 4, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...page(null), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], + transcriptBootstrap: { durable: bootstrapPage }, decodeTranscriptPage: async (candidate) => candidate === bootstrapPage ? { messages: direction === 'older' ? messages.slice(4) : messages.slice(0, 1), nextCursor: 'older' } : { messages: messages.slice(2, 4), nextCursor: null }, @@ -879,7 +809,6 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is const page = (nextCursor: string | null, throughSequence: number) => ({ kind: 'page' as const, sessionId: 'session-1', - source: 'durable' as const, direction: 'newer' as const, throughSequence, rawBytes: 1, @@ -903,13 +832,7 @@ test('does not drive a discarded replica terminal when a contiguous catch-up is snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 4, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...page(null, 4), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], + transcriptBootstrap: { durable: bootstrapPage }, decodeTranscriptPage: async (candidate) => candidate === bootstrapPage ? { messages, nextCursor: null } : { messages: [appended], nextCursor: null }, @@ -958,7 +881,6 @@ test('a window opened between catch-up pages can join the change that follows', const page = (nextCursor: string | null, throughSequence: number) => ({ kind: 'page' as const, sessionId: 'session-1', - source: 'durable' as const, direction: 'newer' as const, throughSequence, rawBytes: 1, @@ -979,13 +901,7 @@ test('a window opened between catch-up pages can join the change that follows', snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 2, - overlayMessageCount: 0, - durable: bootstrapPage, - overlay: { ...page(null, 2), source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], + transcriptBootstrap: { durable: bootstrapPage }, decodeTranscriptPage: async (candidate) => candidate === bootstrapPage ? { messages: bootstrap, nextCursor: null } : candidate === first @@ -1024,41 +940,18 @@ test('a window opened between catch-up pages can join the change that follows', assert.equal(store.range().hasNewer, false); }); -test('rejects an overlay that exceeds its cache budget', async () => { - const messages = [ - assistantMessage('x'.repeat(700), 'overlay-1'), - assistantMessage('y'.repeat(700), 'overlay-2'), - ]; - const handle = runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - loadTranscriptOverlay: async () => messages, - async close() {}, - }); - - await assert.rejects( - DesktopTranscriptReplica.prepare(handle, { - maxResidentBytes: 1_024, - maxOverlayBytes: 1_024, - maxMessageBytes: 1_024, - }), - /overlay exceeds the session cache limit/, - ); -}); - test('transfers prepared transcript bytes into active replica accounting', async () => { - const message = assistantMessage('prepared', 'overlay-1'); + const message = assistantMessage('prepared', 'assistant-1'); const messageBytes = Buffer.byteLength(JSON.stringify(message), 'utf8'); let accountedBytes = 0; const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - loadTranscriptOverlay: async (_maxMessageBytes, accountAssemblyBytes) => { + decodeTranscriptPage: async (_page, _maxMessageBytes, accountAssemblyBytes) => { accountAssemblyBytes?.(messageBytes); accountAssemblyBytes?.(-messageBytes); - return [message]; + return { messages: [{ identity: 0, message }], nextCursor: null }; }, async close() {}, }); @@ -1076,13 +969,12 @@ test('transfers prepared transcript bytes into active replica accounting', async }); test('does not release resident bytes when preparation accounting rejects them', async () => { - const message = assistantMessage('prepared', 'overlay-1'); + const message = assistantMessage('prepared', 'assistant-1'); const deltas: number[] = []; const handle = runtimeHostSessionFixture({ snapshot: continuitySnapshot(), - transcript: Promise.resolve([]), + transcript: Promise.resolve([message]), events: { async *[Symbol.asyncIterator]() {} }, - loadTranscriptOverlay: async () => [message], async close() {}, }); @@ -1110,7 +1002,6 @@ test('reopens a failed transcript range with a fresh generation', async () => { hostEpoch: 'host-2', durableThrough: null, durable: [], - overlay: [], hasOlder: false, hasNewer: false, })) @@ -1193,7 +1084,6 @@ test('forwards a larger logical history range without changing batch size', asyn message: { ...assistantMessage('more', 'assistant-3'), turnId: 'turn-2' }, }, ], - overlay: [], hasOlder: true, hasNewer: true, }, store.navigate())) store.accept(batch); @@ -1236,7 +1126,6 @@ test('waits for the required durable message on the current transcript generatio ...identity, durableThrough: null, durable: [], - overlay: [], hasOlder: false, hasNewer: false, })) store.accept(batch); @@ -1254,7 +1143,7 @@ test('the window does not change while an answer is still being assembled', () = const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; for (const batch of encodeDesktopTranscriptSnapshot({ ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], - overlay: [], hasOlder: false, hasNewer: false, + hasOlder: false, hasNewer: false, })) store.accept(batch); const installed = store.snapshot(); @@ -1279,7 +1168,7 @@ for (const coversFrom of [7, undefined]) { const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; for (const batch of encodeDesktopTranscriptSnapshot({ ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], - overlay: [], hasOlder: false, hasNewer: false, + hasOlder: false, hasNewer: false, })) store.accept(batch); assert.equal(store.range().hasNewer, false); @@ -1300,7 +1189,7 @@ test('a reset the reader has navigated past moves the watermark and nothing else const identity = { sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1' }; for (const batch of encodeDesktopTranscriptSnapshot({ ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('first') }], - overlay: [], hasOlder: false, hasNewer: false, + hasOlder: false, hasNewer: false, })) store.accept(batch); const answer = [...encodeDesktopTranscriptSnapshot({ @@ -1309,7 +1198,7 @@ test('a reset the reader has navigated past moves the watermark and nothing else { sequence: 5, message: assistantMessage('x'.repeat(300 * 1024), 'assistant-5') }, { sequence: 6, message: assistantMessage('jumped', 'assistant-6') }, ], - overlay: [], hasOlder: true, hasNewer: false, + hasOlder: true, hasNewer: false, }, store.navigate())]; assert.ok(answer.length > 1); store.accept(answer[0]!); @@ -1338,7 +1227,7 @@ test('a fill is issued once per window and again as soon as the window moves', a })); for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: 2, hasOlder: true, hasNewer: false, overlay: [], + durableThrough: 2, hasOlder: true, hasNewer: false, durable: [ { sequence: 1, message: assistantMessage('first') }, { sequence: 2, message: assistantMessage('second', 'assistant-2') }, @@ -1371,7 +1260,7 @@ test('reports each tail the window reaches once, and none while it is parked', a // Opening a Session at the tail: the read marker still moves on open. for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 1, hasOlder: true, hasNewer: false, overlay: [], + ...identity, durableThrough: 1, hasOlder: true, hasNewer: false, durable: [{ sequence: 1, message: assistantMessage('first') }], })) store.accept(batch); await settle(); @@ -1454,7 +1343,6 @@ function transcriptPage( return { kind: 'page' as const, sessionId: 'session-1', - source: 'durable' as const, direction, throughSequence, rawBytes: 1, @@ -1526,7 +1414,7 @@ test('cached fallback remains readable and retries once per observation generati for (const batch of encodeDesktopTranscriptSnapshot({ ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage(online ? 'live' : 'cached') }], - overlay: [], hasOlder: false, hasNewer: false, + hasOlder: false, hasNewer: false, })) store.accept(batch); return { ...identity, readThroughMessageId: null, @@ -1567,7 +1455,7 @@ test('a read refused for a Host epoch that moved is superseded, not failed', asy for (const batch of encodeDesktopTranscriptSnapshot({ ...identity, durableThrough: 1, durable: [{ sequence: 1, message: assistantMessage('live') }], - overlay: [], hasOlder: true, hasNewer: false, + hasOlder: true, hasNewer: false, })) store.accept(batch); return { ...identity, readThroughMessageId: null, diff --git a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts index a1dc00a8e1..77f6ecd7fb 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client.test.ts @@ -151,12 +151,7 @@ function subscription( hostEpoch: 'host-1', subscriptionId: `subscription-${sessionId}`, activeAssistantStreams: [], - transcriptBootstrap: { - throughSequence: null, - overlayMessageCount: 0, - durable: emptyTranscriptPage(sessionId, 'durable'), - overlay: emptyTranscriptPage(sessionId, 'overlay'), - }, + transcriptBootstrap: { durable: emptyTranscriptPage(sessionId) }, snapshot: { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { @@ -176,13 +171,15 @@ function subscription( lifecycle.push(`${sessionId}:transcript`); return [] as T[]; }, - loadTranscriptOverlay: async (_decodeMessage: (value: unknown) => T) => [] as T[], decodeTranscriptPage: async () => { throw new Error('Fake subscription does not expose transcript pages'); }, loadTranscriptPage: async () => { throw new Error('Fake subscription does not expose transcript pages'); }, + ready: async () => { + lifecycle.push(`${sessionId}:ready`); + }, close: async () => { lifecycle.push(`${sessionId}:close`); }, @@ -190,11 +187,10 @@ function subscription( }; } -function emptyTranscriptPage(sessionId: string, source: 'durable' | 'overlay') { +function emptyTranscriptPage(sessionId: string) { return { kind: 'page' as const, sessionId, - source, direction: 'older' as const, throughSequence: null, rawBytes: 0, diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index 32e5575b09..7509f10fd9 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -1442,12 +1442,23 @@ function connectionHarness( if (options.subscriptionError) throw options.subscriptionError; const subscriptionFrames = new AsyncFrameQueue(); activeSubscriptionFrames = subscriptionFrames; - const closeSubscription = () => { subscriptionFrames.end(); ptyListeners.clear(); }; + // The Host holds a subscription's frames until the subscriber calls + // ready(), so handing them over earlier would let an ordering bug pass. + let releaseFrames = (): void => undefined; + const readyGate = new Promise((resolve) => { + releaseFrames = resolve; + }); + let closed = false; + const closeSubscription = () => { + closed = true; + subscriptionFrames.end(); + ptyListeners.clear(); + releaseFrames(); + }; closeSubscriptions.add(closeSubscription); const emptyPage = { kind: 'page' as const, sessionId, - source: 'durable' as const, direction: 'older' as const, throughSequence: null, rawBytes: 0, @@ -1468,15 +1479,17 @@ function connectionHarness( activeAssistantStreams: options.activeAssistantStreams ?? [], transcriptBootstrap: { throughSequence: null, - overlayMessageCount: 0, durable: emptyPage, - overlay: { ...emptyPage, source: 'overlay' }, }, loadTranscript: async () => [], - loadTranscriptOverlay: async () => [], decodeTranscriptPage: async () => ({ messages: [], nextCursor: null }), loadTranscriptPage: async () => emptyPage, - [Symbol.asyncIterator]: () => subscriptionFrames[Symbol.asyncIterator](), + [Symbol.asyncIterator]: async function* () { + await readyGate; + if (closed) return; + yield* subscriptionFrames; + }, + ready: async () => releaseFrames(), close: async () => closeSubscription(), }; }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 3c657aafb9..1d2e8c3c99 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 @@ -727,7 +727,6 @@ test('fences transcript range failures across same-source replica recovery', asy const bootstrap: SessionTranscriptPage = { kind: 'page', sessionId: 'session-1', - source: 'durable', direction: 'older', throughSequence: 1, rawBytes: 1, @@ -740,12 +739,7 @@ test('fences transcript range failures across same-source replica recovery', asy snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events, - transcriptBootstrap: { - throughSequence: 1, - overlayMessageCount: 0, - durable: bootstrap, - overlay: { ...bootstrap, source: 'overlay', nextCursor: null }, - }, + transcriptBootstrap: { durable: bootstrap }, decodeTranscriptPage: async (page) => ({ messages: page === bootstrap ? [{ identity: 1, message }] : [], nextCursor: page === bootstrap ? 'older' : null, @@ -873,7 +867,6 @@ test('broadcasts durable admission and transcript changes from the same message' loadTranscriptPage: async () => ({ kind: 'page', sessionId: 'session-1', - source: 'durable', direction: 'newer', throughSequence: 0, rawBytes: 1, @@ -1036,7 +1029,6 @@ test('moves the read marker only as far as the Renderer window reports reaching' loadTranscriptPage: async (input) => ({ kind: 'page', sessionId: 'session-1', - source: 'durable', direction: 'newer', throughSequence: input.throughSequence ?? null, rawBytes: 1, @@ -1132,6 +1124,19 @@ test('keeps a bounded transcript batch window in flight until the renderer ackno snapshot: continuitySnapshot(), transcript: Promise.resolve([message]), events, + transcriptBootstrap: { + durable: { + kind: 'page', + sessionId: 'session-1', + direction: 'older', + throughSequence: 0, + rawBytes: 1, + fragments: [], + rangeBoundarySequence: 0, + protectedTurnSequence: 0, + nextCursor: null, + }, + }, async close() { events.end(); }, @@ -1212,12 +1217,9 @@ test('finishes transcript open and replays a stale range request after replaceme transcript: Promise.resolve([message]), events, transcriptBootstrap: { - throughSequence: 0, - overlayMessageCount: 0, durable: { kind: 'page', sessionId: 'session-1', - source: 'durable', direction: 'older', throughSequence: 0, rawBytes: 1, @@ -1226,20 +1228,7 @@ test('finishes transcript open and replays a stale range request after replaceme protectedTurnSequence: null, nextCursor: 'older', }, - overlay: { - kind: 'page', - sessionId: 'session-1', - source: 'overlay', - direction: 'older', - throughSequence: null, - rawBytes: 0, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor: null, - }, }, - loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (page) => ({ messages: page.rawBytes === 1 ? [{ identity: 0, message }] : [], nextCursor: page.nextCursor, @@ -1250,7 +1239,6 @@ test('finishes transcript open and replays a stale range request after replaceme return { kind: 'page', sessionId: 'session-1', - source: input.source, direction: input.direction, throughSequence: input.throughSequence, rawBytes: 0, @@ -1398,7 +1386,6 @@ test('coalesces transcript changes into one bounded delta while renderer deliver loadTranscriptPage: async (input) => ({ kind: 'page', sessionId: 'session-1', - source: 'durable', direction: 'newer', throughSequence: input.throughSequence, rawBytes: 1, @@ -1506,7 +1493,6 @@ test('answers a window page read on its own navigation version and drops a stale const durablePage = (nextCursor: string | null): SessionTranscriptPage => ({ kind: 'page', sessionId: 'session-1', - source: 'durable', direction: 'older', throughSequence: 2, rawBytes: 1, @@ -1527,13 +1513,7 @@ test('answers a window page read on its own navigation version and drops a stale snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events, - transcriptBootstrap: { - throughSequence: 2, - overlayMessageCount: 0, - durable: bootstrap, - overlay: { ...bootstrap, source: 'overlay' }, - }, - loadTranscriptOverlay: async () => [], + transcriptBootstrap: { durable: bootstrap }, loadTranscriptPage: async (request) => { // `loadAround` probes one row older than its anchor to learn // whether history precedes it; that probe stays empty here. @@ -1618,7 +1598,6 @@ test('does not let one backpressured transcript consumer block another', async ( loadTranscriptPage: async (input) => ({ kind: 'page', sessionId: 'session-1', - source: 'durable', direction: 'newer', throughSequence: input.throughSequence, rawBytes: 1, @@ -1719,13 +1698,12 @@ test('keeps a transcript consumer available after a delivery fails', async () => client: { openSession: async () => runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), + snapshot: settledSnapshot(), transcript: Promise.resolve([]), events, loadTranscriptPage: async (input) => ({ kind: 'page', sessionId: 'session-1', - source: 'durable', direction: 'newer', throughSequence: input.throughSequence, rawBytes: 1, @@ -2242,7 +2220,6 @@ test("recovers when transcript paging loses the active subscription", async () = return { kind: "page", sessionId: "session-1", - source: "durable", direction: "newer", throughSequence: 0, rawBytes: 0, @@ -2285,7 +2262,6 @@ test('does not activate a refresh candidate that fails during commit preparation const accepted: SubscriptionFrame[] = []; let opens = 0; let preparations = 0; - let activated = false; let firstCloses = 0; const owner = new RuntimeHostSessionSubscriptionOwner({ client: { @@ -2325,20 +2301,11 @@ test('does not activate a refresh candidate that fails during commit preparation const refresh = owner.refresh(); await waitFor(() => preparations === 2); - secondEvents.push({ - kind: 'subscription.closed', - hostEpoch: 'host-1', - subscriptionId: 'subscription-2', - sequence: 1, - reason: 'slow_consumer', - }); - await assert.rejects(refresh, /slow consumer/); - activation.resolve(() => { - activated = true; - }); - await new Promise((resolve) => setImmediate(resolve)); + activation.reject(new Error('commit preparation failed')); + await assert.rejects(refresh, /commit preparation failed/); - assert.equal(activated, false); + // Retiring the previous subscription is the first thing activation does, so + // it still being open is how a skipped activation shows. assert.equal(firstCloses, 0); firstEvents.push(deltaFrame(1, 0, 'still live')); await waitFor(() => accepted.length === 1); @@ -2422,10 +2389,9 @@ test('lets an active recovery supersede a concurrent cold refresh', async () => await owner.close(); }); -test("retries an initial subscription closed before commit and resyncs once", async () => { +test("retries an initial subscription evicted before readiness and resyncs once", async () => { const firstEvents = new AsyncFrameQueue(); const secondEvents = new AsyncFrameQueue(); - const firstTranscript = deferred(); const secondTranscript = deferred(); const recoveredSessions: string[] = []; let openCount = 0; @@ -2437,7 +2403,7 @@ test("retries an initial subscription closed before commit and resyncs once", as return runtimeHostSessionFixture({ snapshot: continuitySnapshot(), activeAssistantStreams: [], - transcript: first ? firstTranscript.promise : secondTranscript.promise, + transcript: first ? Promise.resolve([]) : secondTranscript.promise, events: first ? firstEvents : secondEvents, async close() { (first ? firstEvents : secondEvents).end(); @@ -2450,16 +2416,8 @@ test("retries an initial subscription closed before commit and resyncs once", as recoveredSessions.push(sessionId); }, }); - let observingSettled = false; - const observing = observer.observe("session-1", "observer-1", eventTarget(16)); - void observing.then( - () => { - observingSettled = true; - }, - () => { - observingSettled = true; - }, - ); + // The Host evicts a subscriber that has not declared readiness by queueing + // this frame; it is released the moment readiness arrives. firstEvents.push({ kind: "subscription.closed", hostEpoch: "host-1", @@ -2467,14 +2425,15 @@ test("retries an initial subscription closed before commit and resyncs once", as sequence: 1, reason: "slow_consumer", }); - firstTranscript.resolve([]); + const observing = observer.observe("session-1", "observer-1", eventTarget(16)); await waitFor(() => openCount === 2); - assert.equal(observingSettled, false); assert.deepEqual(recoveredSessions, []); secondTranscript.resolve([]); + await waitFor(() => recoveredSessions.length === 1); await observing; assert.deepEqual(recoveredSessions, ["session-1"]); + assert.equal(openCount, 2); await observer.close(); }); @@ -2543,13 +2502,15 @@ test("finishes a watched predecessor after initial catch-up recovery", async () }); firstTranscript.resolve([]); await watching; - await waitFor(() => replacementCloseCount === 1); + await waitFor(() => finishedTurns.length === 1); assert.deepEqual(finishedTurns, [["session-1", "completed"]]); + // turn-2 is still running on the Host, so the replacement subscription stays. + assert.equal(replacementCloseCount, 0); await observer.close(); }); -test("keeps a joining observer pending across repeated catch-up eviction", async () => { +test("seeds a joining observer from the attempt that survives repeated catch-up eviction", async () => { const firstEvents = new AsyncFrameQueue(); const replacementEvents = new AsyncFrameQueue(); const finalEvents = new AsyncFrameQueue(); @@ -2613,15 +2574,8 @@ test("keeps a joining observer pending across repeated catch-up eviction", async "observer-2", joiningTarget, ); - let joiningSettled = false; - void joining.then( - () => { - joiningSettled = true; - }, - () => { - joiningSettled = true; - }, - ); + // Held until the replacement declares readiness, which it only does once its + // transcript lands — so the observer joins an attempt already evicted. replacementEvents.push({ kind: "subscription.closed", hostEpoch: "host-1", @@ -2631,8 +2585,6 @@ test("keeps a joining observer pending across repeated catch-up eviction", async }); replacementTranscript.resolve([]); await waitFor(() => openCount === 3); - await Promise.resolve(); - assert.equal(joiningSettled, false); finalTranscript.resolve([ { type: "assistant" as const, @@ -2644,6 +2596,11 @@ test("keeps a joining observer pending across repeated catch-up eviction", async }, ]); await joining; + await waitFor(() => + joiningTarget.events.some( + (event) => event.type === "text_delta" && event.text === "Hello", + ), + ); assert.equal(firstTarget.events.some((event) => event.type === "error"), false); assert.equal(joiningTarget.events.some((event) => event.type === "error"), false); @@ -2844,7 +2801,6 @@ test('replays durable admission before a terminal successor on subscription reco }, }), transcript: Promise.resolve(terminalTranscript), - loadTranscriptOverlay: async () => terminalTranscript, events: secondEvents, async close() { secondEvents.end(); @@ -2896,7 +2852,9 @@ test("shares one Host subscription and one delivery per renderer target", async openSession: async () => { openCount += 1; return runtimeHostSessionFixture({ - snapshot: continuitySnapshot(), + // Settled: releasing an idle subscription is only correct once the + // Host has nothing left to send. + snapshot: settledSnapshot(), activeAssistantStreams: [], transcript: Promise.resolve([]), events, @@ -3316,6 +3274,19 @@ function activeText(messageId: string, turnId = 'turn-1') { return { kind: 'text' as const, turnId, messageId }; } +/** A Session whose root Turn has ended, so nothing holds the subscription open. */ +function settledSnapshot(): SessionContinuitySnapshot { + return continuitySnapshot({ + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'completed', + terminalEventId: 'terminal-1', + }, + }); +} + function activeGoal() { return { goalId: "goal-1", @@ -3440,6 +3411,78 @@ async function waitFor(predicate: () => boolean): Promise { await pollFor(predicate, { attempts: 100, message: 'Timed out waiting for observer state' }); } +// #5365: leaving the conversation used to drop the subscription to a Turn the +// Host was still running, so coming back made it stream the whole answer again. +test('a running Turn keeps its subscription after the last viewer leaves', async () => { + const events = new AsyncFrameQueue(); + let opens = 0; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + activeAssistantStreams: [activeText('message-1')], + transcript: Promise.resolve([]), + events, + async close() { events.end(); }, + }); + const observer = new RuntimeHostSessionObserver({ + client: { openSession: async () => { opens += 1; return handle; } }, + emitSessionsChanged() {}, + }); + const target = eventTarget(1); + await observer.observe('session-1', 'conversation', target); + assert.equal(opens, 1); + + await observer.unobserve('conversation'); + // The Host keeps producing while nobody looks; the subscription has to be + // there to receive it, or the text below is lost and must be re-sent. + events.push(deltaFrame(1, 0, 'Written while away')); + await observer.observe('session-1', 'conversation-again', target); + + assert.equal(opens, 1); + const seed = target.observations.at(-1); + assert.equal(seed?.type, 'host_observation_seed'); + if (seed?.type === 'host_observation_seed') { + assert.ok(seed.events.some((event) => + event.type === 'text_delta' && event.text === 'Written while away')); + } + await observer.close(); +}); + +test('the subscription is released once the running Turn ends with no viewer', async () => { + const events = new AsyncFrameQueue(); + let closed = false; + const handle = runtimeHostSessionFixture({ + snapshot: continuitySnapshot(), + transcript: Promise.resolve([]), + events, + async close() { closed = true; events.end(); }, + }); + const observer = new RuntimeHostSessionObserver({ + client: { openSession: async () => handle }, + emitSessionsChanged() {}, + }); + await observer.observe('session-1', 'conversation', eventTarget(1)); + await observer.unobserve('conversation'); + assert.equal(closed, false); + + events.push({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 1, + snapshot: continuitySnapshot({ + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'completed', + terminalEventId: 'terminal-1', + }, + }), + }); + await waitFor(() => closed); + await observer.close(); +}); + test('a later observer in the same renderer receives the accumulated active stream', async () => { const events = new AsyncFrameQueue(); let opens = 0; diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts b/apps/desktop/src/main/__tests__/runtime-host-session-test-fixture.ts index 108d47bfb9..ff5ff646da 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 @@ -33,42 +33,55 @@ export function runtimeHostSessionFixture(input: { readonly transcript: Promise; readonly events: AsyncIterable; readonly transcriptBootstrap?: DesktopRuntimeHostSession['transcriptBootstrap']; - loadTranscriptOverlay?: DesktopRuntimeHostSession['loadTranscriptOverlay']; decodeTranscriptPage?: DesktopRuntimeHostSession['decodeTranscriptPage']; loadTranscriptPage?: DesktopRuntimeHostSession['loadTranscriptPage']; + ready?: DesktopRuntimeHostSession['ready']; close(): Promise; }): DesktopRuntimeHostSession { const sessionId = input.snapshot.session.sessionId; + const transcriptBootstrap = input.transcriptBootstrap ?? { + throughSequence: null, + durable: emptyPage(sessionId), + }; + // The Host holds a subscription's frames until the subscriber declares + // readiness, so a fixture that hands them over earlier would let an ordering + // bug pass. + let releaseFrames = (): void => undefined; + const readyGate = new Promise((resolve) => { + releaseFrames = resolve; + }); return { hostEpoch: 'host-1', subscriptionId: `subscription-${sessionId}`, snapshot: input.snapshot, activeAssistantStreams: input.activeAssistantStreams ?? [], - transcriptBootstrap: input.transcriptBootstrap ?? { - throughSequence: null, - overlayMessageCount: 0, - durable: emptyPage(sessionId, 'durable'), - overlay: emptyPage(sessionId, 'overlay'), - }, - events: input.events, + transcriptBootstrap, + events: (async function* () { + await readyGate; + yield* input.events; + })(), loadTranscript: () => input.transcript, - loadTranscriptOverlay: input.loadTranscriptOverlay ?? (() => input.transcript), decodeTranscriptPage: input.decodeTranscriptPage ?? - (async (): Promise> => ({ - messages: [], + (async (page): Promise> => ({ + messages: page === transcriptBootstrap.durable + ? (await input.transcript).map((message, identity) => ({ identity, message })) + : [], nextCursor: null, })), loadTranscriptPage: input.loadTranscriptPage ?? - (async () => emptyPage(sessionId, 'durable')), + (async () => emptyPage(sessionId)), + ready: async () => { + releaseFrames(); + await input.ready?.(); + }, close: input.close, }; } -function emptyPage(sessionId: string, source: 'durable' | 'overlay'): SessionTranscriptPage { +function emptyPage(sessionId: string): SessionTranscriptPage { return { kind: 'page', sessionId, - source, direction: 'older', throughSequence: null, rawBytes: 0, diff --git a/apps/desktop/src/main/__tests__/session-local.test.ts b/apps/desktop/src/main/__tests__/session-local.test.ts index 7e05626ee1..9d96c7dee7 100644 --- a/apps/desktop/src/main/__tests__/session-local.test.ts +++ b/apps/desktop/src/main/__tests__/session-local.test.ts @@ -482,7 +482,7 @@ test('a removed authority cannot be repopulated by an in-flight admission', asyn assert.deepEqual(store.list('authority'), []); }); -test('cache restoration never includes live overlay and expires independently of the outbox', async (t) => { +test('cache restoration expires independently of the outbox', async (t) => { let now = 1; const { store } = await database(t, () => now); store.enqueue('authority', intent()); @@ -497,11 +497,10 @@ test('cache restoration never includes live overlay and expires independently of message: { type: 'user', id: 'durable-1', turnId: 'turn', ts: 1, text: 'persisted' }, }, ], - overlay: [{ type: 'user', id: 'live-1', turnId: 'turn', ts: 2, text: 'in flight' }], hasOlder: false, hasNewer: false, }); - assert.deepEqual(store.transcript('authority', 'session-1')?.snapshot.overlay, []); + assert.equal(store.transcript('authority', 'session-1')?.snapshot.durableThrough, 1); assert.equal(store.transcript('different-authority', 'session-1'), undefined); now += 31 * 24 * 60 * 60 * 1000; assert.equal(store.transcript('authority', 'session-1'), undefined); @@ -551,7 +550,6 @@ test('durable Host evidence retires delivery independently of cache admission an }, }, ], - overlay: [], hasOlder: false, hasNewer: false, }; diff --git a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts index e867490437..a32eb391f2 100644 --- a/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts +++ b/apps/desktop/src/main/__tests__/session-workspace-action-identity.test.ts @@ -143,7 +143,7 @@ describe('session workspace action identity', () => { act(() => workspace.setActiveId(sessionC)); for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'c', generation: 'publication', hostEpoch: 'host', - durableThrough: null, durable: [], overlay: c, hasOlder: false, hasNewer: false, + durableThrough: 0, durable: c.map((message, sequence) => ({ sequence, message })), hasOlder: false, hasNewer: false, })) readerC.store.accept(batch); let blocked = true; let idle!: () => void; diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts index 859efc6285..349a2f32b5 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-pager.test.ts @@ -54,9 +54,6 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a assert.equal(replica.snapshot().hasOlder, false, 'sparse first-row sequence does not imply older history'); assert.equal(replica.snapshot().hasNewer, false, 'unused low watermark bits do not imply a newer row'); - // RuntimeEvent transcripts publish a Turn durably only after it ends. The - // running checkpoints below stay in the overlay, then this terminal event - // advances the actual Host watermark and exercises live-to-durable eviction. const completeThrough = await ledger.appendThrough('completed-b'); assert.ok(completeThrough !== null); const complete = await ledger.durableRecords(); @@ -106,26 +103,23 @@ test('keeps both Turns reachable when an oversized ledger Turn is followed by a }); for (const checkpoint of ['running-b', 'result-b'] as const) { - test(`retains the full oversized durable Turn while the running second Turn reaches ${checkpoint}`, async () => { + test(`serves the running second Turn through durable pages at ${checkpoint}`, async () => { const source = transcriptFixture(); const ledger = await openTranscriptNavigationLedger([...source.first, ...source.second]); let opened: Awaited> | undefined; try { const firstThrough = await ledger.appendThrough('completed-a'); - const first = await ledger.durableRecords(); - assert.equal(await ledger.appendThrough(checkpoint), firstThrough, - 'a running invocation changes its overlay, not the durable watermark'); - const rootTurn = { - sessionId: ledger.sessionId, turnId: 'b', - runId: 'run-b', status: 'running' as const, - }; - opened = await openReplica(ledger, firstThrough, rootTurn); + const runningThrough = await ledger.appendThrough(checkpoint); + assert.ok(firstThrough !== null && runningThrough !== null && runningThrough > firstThrough, + 'each committed RuntimeEvent of a running Turn advances the watermark'); + opened = await openReplica(ledger, runningThrough); const { replica } = opened; - assertRecords(replica, first); const expected = source.second.slice(0, source.second.findIndex(({ id }) => id === checkpoint) + 1) .filter((message) => message.type !== 'turn_state').map(({ id }) => id); - assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), expected); - assert.ok(replica.messages().some(({ id }) => id === expected.at(-1)), 'the running Turn remains reachable'); + assert.deepEqual( + replica.snapshot().durable.filter(({ message }) => message.turnId === 'b').map(({ message }) => message.id), + expected, + ); const throughSequence = await ledger.appendThrough('completed-b'); assert.ok(throughSequence !== null); @@ -137,8 +131,7 @@ for (const checkpoint of ['running-b', 'result-b'] as const) { }); assert.equal((await opened.subscription.next()).value?.kind, 'subscription.transcript_advanced'); await replica.advance(throughSequence); - assert.deepEqual(replica.snapshot().overlay, []); - const second = (await ledger.durableRecords()).filter(({ message }) => message.turnId === rootTurn.turnId); + const second = (await ledger.durableRecords()).filter(({ message }) => message.turnId === 'b'); assertRecords(replica, second); assert.equal(replica.snapshot().hasNewer, false); } finally { @@ -150,15 +143,11 @@ for (const checkpoint of ['running-b', 'result-b'] as const) { } type Ledger = Awaited>; -async function openReplica( - ledger: Ledger, - throughSequence: number | null, - rootTurn: { sessionId: string; turnId: string; runId: string; status: 'running' } | null = null, -) { +async function openReplica(ledger: Ledger, throughSequence: number | null) { const { reader, sessionId } = ledger; const opened = await createSessionTranscriptBootstrap({ - reader, sessionId, subscriptionId: SUBSCRIPTION_ID, throughSequence, rootTurn, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection: 'owner', + reader, sessionId, subscriptionId: SUBSCRIPTION_ID, throughSequence, + maxBytes: 16 * 1024, projection: 'owner', }); const subscription = new ClientSessionSubscription({ hostEpoch: HOST_EPOCH, subscriptionId: SUBSCRIPTION_ID, nextSequence: 1, @@ -166,16 +155,15 @@ async function openReplica( snapshot: { schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId, metadataRevision: 1, status: 'active', createdAt: 1, isArchived: false }, - projectionRevision: 1, rootTurn, goal: null, + projectionRevision: 1, rootTurn: null, goal: null, queue: { hostEpoch: HOST_EPOCH, queueRevision: 0, steering: [], followup: [] }, interactions: { pending: [] }, }, - }, async () => undefined, (request) => readSessionTranscriptPage({ reader, state: opened.state, request })); + }, async () => undefined, (request) => readSessionTranscriptPage({ reader, state: opened.state, request }), async () => undefined); const decodeMessage = (value: unknown) => decodeStoredMessage(markPersisted(value)); const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ snapshot: subscription.snapshot, transcript: Promise.resolve([]), events: subscription, transcriptBootstrap: opened.bootstrap, - loadTranscriptOverlay: (maxBytes, accountBytes) => subscription.loadTranscriptOverlay(decodeMessage, maxBytes, accountBytes), decodeTranscriptPage: (page, maxBytes, accountBytes) => subscription.decodeTranscriptPage(page, decodeMessage, maxBytes, accountBytes), loadTranscriptPage: (request) => subscription.loadTranscriptPage(request), close: () => subscription.close(), diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts index 896320173b..e5109dee23 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts @@ -44,10 +44,8 @@ for (const kind of ['before', 'around'] as const) { snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + durable: bootstrap, }, - loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (candidate) => ({ messages: candidate === bootstrap ? [latest] : [older], nextCursor: candidate === bootstrap ? 'older' : null, @@ -84,10 +82,8 @@ test('a global cache trim empties the tail without publishing or reading history snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + durable: bootstrap, }, - loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (candidate) => ({ messages: [decoded.get(candidate)!], nextCursor: null }), loadTranscriptPage: async (request) => { assert.ok(request.throughSequence !== null); @@ -123,10 +119,8 @@ test('a tail the global cache trim emptied is read back before it answers follow snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + durable: bootstrap, }, - loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (candidate) => ({ messages: [record(1)], nextCursor: candidate === bootstrap ? 'older' : null, }), @@ -171,10 +165,8 @@ test('return to latest answers with a tail after reclaim emptied the cache', asy snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + durable: bootstrap, }, - loadTranscriptOverlay: async () => [], // What global reclaim leaves behind: the watermark stands, the rows are gone. decodeTranscriptPage: async (candidate) => candidate === bootstrap ? { messages: [], nextCursor: 'older' } @@ -215,7 +207,7 @@ test('a superseded fragmented reset cannot clear or complete the next navigation const stale = [...encodeDesktopTranscriptSnapshot({ ...identity, durableThrough: 1, durable: [{ sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }], - overlay: [], hasOlder: false, hasNewer: true, + hasOlder: false, hasNewer: true, }, 1)]; assert.equal(store.accept(stale[0]!), false); store.navigate(); @@ -239,7 +231,7 @@ test('a replica replacement is admitted whole, however far the window has naviga { sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }, { sequence: 1, message: record(1).message }, ], - overlay: [], hasOlder: false, hasNewer: false, + hasOlder: false, hasNewer: false, })]; assert.ok(replacement.length > 1, 'the replacement has to span more than its reset batch'); for (const batch of replacement) store.accept(batch); @@ -333,7 +325,7 @@ test('a navigation outlives the band trimming the window it was issued under', ( { sequence: 0, message: { ...record(0).message, text: 'A'.repeat(300 * 1024) } as StoredMessage }, { sequence: 1, message: record(1).message }, ], - overlay: [], hasOlder: false, hasNewer: false, + hasOlder: false, hasNewer: false, }, navigating)]; assert.ok(replacement.length > 1, 'the replacement has to span more than its reset batch'); for (const batch of replacement) store.accept(batch); @@ -433,10 +425,8 @@ test('superseded batches remain ACKable and cannot reset the latest window while snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + durable: bootstrap, }, - loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (candidate) => ({ messages: candidate === historyPage ? [largeOld] : [latest], nextCursor: candidate === historyPage ? 'newer' : 'older', @@ -493,10 +483,8 @@ test('a fill in flight does not discard the replacement it was issued under', as snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, transcriptBootstrap: { - throughSequence: 1, overlayMessageCount: 0, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, + durable: bootstrap, }, - loadTranscriptOverlay: async () => [], decodeTranscriptPage: async (candidate) => ({ messages: [candidate === bootstrap ? record(1) : record(0)], nextCursor: null, }), @@ -540,7 +528,7 @@ function acceptSnapshot(store: DesktopTranscriptRangeStore, navigation: number | for (const batch of encodeDesktopTranscriptSnapshot({ ...identity, generation, durableThrough: 1, durable: records.map(({ identity: sequence, message }) => ({ sequence, message })), - overlay: [], hasOlder: true, hasNewer: false, + hasOlder: true, hasNewer: false, }, navigation)) store.accept(batch); } function record(identity: number) { @@ -548,7 +536,7 @@ function record(identity: number) { return { identity, message }; } function page(throughSequence: number): SessionTranscriptPage { - return { kind: 'page', sessionId: 'session-1', source: 'durable', direction: 'older', throughSequence, + return { kind: 'page', sessionId: 'session-1', direction: 'older', throughSequence, rawBytes: 1, fragments: [], rangeBoundarySequence: null, protectedTurnSequence: null, nextCursor: null }; } function continuitySnapshot() { diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts index ae626e671d..a9125af9a4 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-regression.test.ts @@ -301,17 +301,14 @@ async function settleRestore(): Promise { await new Promise((resolve) => setImmediate(resolve)); } -test('a live second Turn remains reachable after persistence evicts the oversized first Turn', async () => { +test('a second Turn reached by advancing evicts the oversized first Turn', async () => { const fixture = await oversizedHistoryFixture({ live: true }); try { assert.deepEqual(sequences(fixture.replica), [0, 1]); - assert.deepEqual(fixture.replica.snapshot().overlay.map(({ id }) => id), ['user-b', 'assistant-b']); await fixture.replica.advance(3); assert.deepEqual(sequences(fixture.replica), [2, 3]); - assert.deepEqual(fixture.replica.snapshot().overlay, []); - assert.equal(fixture.replica.messages().filter(({ id }) => id === 'assistant-b').length, 1); const answer = fixture.replica.messages().at(-1); assert.equal(answer?.type, 'assistant'); assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'Second answer, persisted completely.'); @@ -347,7 +344,6 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) { const result: SessionTranscriptPage = { kind: 'page', sessionId: 'session-1', - source: 'durable', direction: input.direction, throughSequence: input.through, rawBytes: input.records.reduce((bytes, record) => bytes + Buffer.byteLength(JSON.stringify(record.message)), 0), @@ -384,13 +380,7 @@ async function oversizedHistoryFixture(options: { live?: boolean } = {}) { }, transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: through, - overlayMessageCount: options.live ? 2 : 0, - durable: bootstrapPage, - overlay: { ...bootstrapPage, source: 'overlay', nextCursor: null }, - }, - loadTranscriptOverlay: async () => options.live ? records.slice(2, 4).map(({ message }) => message) : [], + transcriptBootstrap: { durable: bootstrapPage }, decodeTranscriptPage: async (candidate) => { const decoded = decodedPages.get(candidate); assert.ok(decoded, 'the replica must decode the page returned by its Host request'); diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts b/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts index 34b46ea5fd..af4fafdb8b 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-test-fixture.ts @@ -34,8 +34,7 @@ const FIXTURE_EPOCH = Date.UTC(2026, 0, 2, 3, 4, 5); /** * The real SQLite ledger and Host reader used by the navigation regressions. * Legacy-shaped input keeps the payload fixture legible, but every page is - * projected by the production RuntimeEvent reader. Running Turns live only in - * the active overlay; their rows acquire sparse durable sequences on ending. + * projected by the production RuntimeEvent reader. */ export async function openTranscriptNavigationLedger(messages: readonly StoredMessage[]) { const base = await mkdtemp(join(tmpdir(), 'maka-transcript-navigation-')); @@ -107,16 +106,6 @@ export async function openTranscriptNavigationLedger(messages: readonly StoredMe appendedThrough = index; return reader.readDurableHighWater(sessionId); }, - async appendPartialAssistant(turnId: string, messageId: string, text: string) { - const runId = `run-${turnId}`; - assert.ok(opened.has(turnId)); - await runtimeEventStore.appendRuntimeEvent(sessionId, runId, { - id: `partial-${messageId}`, sessionId, runId, invocationId: runId, turnId, - ts: FIXTURE_EPOCH + appendedThrough + 0.5, - partial: true, role: 'model', author: 'agent', - content: { kind: 'text', text }, refs: { providerEventId: messageId }, - }); - }, async durableRecords() { const result = await reader.readDurableRecords(sessionId, { direction: 'newer', maxMessages: 1_000, maxStoredBytes: 16 * 1024 * 1024, diff --git a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts deleted file mode 100644 index ba7289008f..0000000000 --- a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { markPersisted } from '@maka/core/persisted-value'; -import { decodeStoredMessage, type StoredMessage } from '@maka/core/session'; -import { - SESSION_CONTINUITY_SCHEMA_VERSION, - type SessionTranscriptPage, - type SessionTranscriptPageInput, -} from '@maka/runtime-host/protocol'; -import { ClientSessionSubscription } from '../../../../../packages/runtime-host/dist/client/session-subscription.js'; -import { - createSessionTranscriptBootstrap, - readSessionTranscriptPage, - updateSubscriberTranscriptHighWater, -} from '../../../../../packages/runtime-host/dist/server/session-transcript-pager.js'; -import { DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; -import { - encodeDesktopTranscriptChange, - encodeDesktopTranscriptPage, - encodeDesktopTranscriptSnapshot, -} from '../desktop-transcript-ipc.js'; -import { DesktopTranscriptReplica, type DesktopTranscriptReplicaChange } from '../desktop-transcript-replica.js'; -import { runtimeHostSessionFixture } from './runtime-host-session-test-fixture.js'; -import { openTranscriptNavigationLedger } from './transcript-navigation-test-fixture.js'; - -const HOST_EPOCH = 'host-1'; -const SUBSCRIPTION_ID = 'overlay-settlement-subscription'; -const PAGE_BYTES = 128 * 1024; -const BOOTSTRAP_THROUGH = 'running-b'; -const B_STEERING_THROUGH = 'steering-b'; -const B_COMPLETED_THROUGH = 'completed-b'; -const C_COMPLETED_THROUGH = 'completed-c'; - -for (const coalesced of [false, true]) { - test(`settles a bootstrap overlay through ${coalesced ? 'a coalesced B+C watermark' : 'separate B and C watermarks'}`, async () => { - const fixture = await openFixture(); - try { - const { replica, renderer } = fixture; - assert.equal(replica.snapshot().overlay.find(({ id }) => id === 'answer-b')?.id, 'answer-b'); - - if (!coalesced) { - await fixture.advance(B_COMPLETED_THROUGH); - assert.deepEqual(replica.snapshot().overlay, []); - } - await fixture.advance(C_COMPLETED_THROUGH); - assert.equal(replica.durableThrough, fixture.watermark(C_COMPLETED_THROUGH)); - assert.deepEqual(replica.snapshot().overlay, []); - assert.deepEqual(replica.snapshot().durable.map(({ message }) => message.id), - ['user-c', 'answer-c', 'completed-c']); - assert.equal( - renderer.snapshot().messages.some((message) => message.type === 'assistant' && message.text === 'B partial'), - false, - 'the durable row replaced the partial overlay answer', - ); - assert.ok(renderer.snapshot().messages.some(({ id }) => id === 'answer-c')); - } finally { - await fixture.close(); - } - }); -} - -test('a window parked off the tail reads the completed Turn back through its own edge', async () => { - const fixture = await openFixture(); - try { - const { replica, renderer } = fixture; - // Reading history: the window dropped the newest rows to meet its budget, - // so its newer edge is a gap and tail growth is no longer its business. - const oldest = renderer.range().oldestSequence; - assert.ok(oldest !== null); - renderer.retain(oldest, oldest); - assert.equal(renderer.range().hasNewer, true); - assert.equal( - renderer.snapshot().messages.some(({ id }) => id === 'answer-b'), false, - 'the overlay is a fact about the tail, and this window no longer reaches it', - ); - - await fixture.advance(B_COMPLETED_THROUGH); - await fixture.advance(C_COMPLETED_THROUGH); - - assert.deepEqual( - renderer.durableEntries().map(({ sequence }) => sequence), [oldest], - 'tail growth has nothing to join onto, so the window stays the range it was trimmed to', - ); - assert.equal(renderer.range().hasNewer, true); - - // Paging back: each read is anchored on the edge the last one left, which - // is the only thing that makes the rows spliceable. - for (let read = 0; read < 8 && renderer.range().hasNewer; read += 1) { - const anchor = renderer.range().newestSequence; - const page = await replica.loadAfter(anchor, PAGE_BYTES); - assert.ok(page); - for (const batch of encodeDesktopTranscriptPage({ - sessionId: replica.sessionId, - generation: replica.generation, - hostEpoch: replica.hostEpoch, - }, page, { direction: 'newer', anchor })) renderer.accept(batch); - } - - assert.deepEqual( - renderer.snapshot().messages.flatMap((message) => - message.type === 'assistant' && message.turnId === 'b' ? [message.text] : []), - ['B partial and completed answer'], - 'reading forward from the edge brings the completed body back', - ); - assert.equal(replica.snapshot().overlay.length, 0); - } finally { - await fixture.close(); - } -}); - -test('a completed live answer remains unique after a fresh transcript subscription', async () => { - const fixture = await openFixture(); - let reopened: Awaited> | undefined; - const assertAnswer = (messages: readonly StoredMessage[]) => { - const answers = messages.flatMap((message) => message.type === 'assistant' && message.turnId === 'b' - ? [{ id: message.id, text: message.text }] : []); - assert.deepEqual(answers, [ - { id: 'answer-b', text: 'B partial and completed answer' }, - ]); - }; - try { - await fixture.advance(B_COMPLETED_THROUGH); - assert.deepEqual(fixture.replica.snapshot().overlay, []); - assertAnswer(fixture.renderer.snapshot().messages); - assertAnswer((await fixture.ledger.durableRecords()).map(({ message }) => message)); - - reopened = await openSettledReplica(fixture.ledger); - const renderer = new DesktopTranscriptRangeStore(JSON.stringify(['local', fixture.ledger.sessionId])); - for (const batch of encodeDesktopTranscriptSnapshot(reopened.replica.snapshot())) renderer.accept(batch); - assert.deepEqual(reopened.replica.snapshot().overlay, []); - assertAnswer(renderer.snapshot().messages); - } finally { - await reopened?.close(); - await fixture.close(); - } -}); - -test('retains an unfinished overlay through runtime checkpoints', async () => { - const fixture = await openFixture(); - try { - const { replica, renderer } = fixture; - await fixture.advance(B_STEERING_THROUGH); - assert.equal(replica.durableThrough, fixture.bootstrapThrough, 'running B has no durable ending yet'); - const unfinished = replica.snapshot().overlay.find(({ id }) => id === 'answer-b'); - assert.equal(unfinished?.type === 'assistant' ? unfinished.text : undefined, 'B partial'); - - await fixture.advance(B_COMPLETED_THROUGH); - assert.deepEqual( - replica.snapshot().overlay, - [], - 'the Turn ending retires exactly the overlay rows it made durable', - ); - assert.deepEqual( - renderer.snapshot().messages.flatMap((message) => - message.type === 'assistant' && message.turnId === 'b' ? [message.text] : []), - ['B partial and completed answer'], - ); - } finally { - await fixture.close(); - } -}); - -test('catch-up retires the completed overlay in one notification', async () => { - const fixture = await openFixture(); - try { - const { replica, changes, requests } = fixture; - const before = requests.length; - await fixture.advance(B_COMPLETED_THROUGH); - const settled = changes.filter((change) => - change.durableUpserts.some(({ message }) => message.id === 'answer-b')); - assert.equal(settled.length, 1); - const answer = replica.messages().find(({ id }) => id === 'answer-b'); - assert.equal(answer?.type === 'assistant' ? answer.text : undefined, 'B partial and completed answer'); - assert.equal(requests.length - before, 1, 'catch-up settles through the same page it installs'); - assert.deepEqual(replica.snapshot().overlay, []); - } finally { - await fixture.close(); - } -}); - -test('a window page read retires the tail overlay copy without notifying other windows', async () => { - const older: StoredMessage = { - type: 'assistant', id: 'answer-older', turnId: 'older', ts: 1, - text: 'Older answer', modelId: 'fixture-model', - }; - const newest: StoredMessage = { - type: 'assistant', id: 'answer-newest', turnId: 'newest', ts: 2, - text: 'Newest answer', modelId: 'fixture-model', - }; - const durablePage = (): SessionTranscriptPage => ({ - kind: 'page', sessionId: 'session-1', source: 'durable', direction: 'older', - throughSequence: 2, rawBytes: 1, fragments: [], rangeBoundarySequence: null, - protectedTurnSequence: null, nextCursor: null, - }); - const bootstrap = durablePage(); - const decoded = new Map; - nextCursor: string | null; - }>([[bootstrap, { messages: [{ identity: 2, message: newest }], nextCursor: null }]]); - const changes: DesktopTranscriptReplicaChange[] = []; - const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ - snapshot: { - schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, - session: { sessionId: 'session-1', metadataRevision: 1, status: 'active', createdAt: 1, isArchived: false }, - projectionRevision: 1, rootTurn: null, goal: null, - queue: { hostEpoch: HOST_EPOCH, queueRevision: 0, steering: [], followup: [] }, - interactions: { pending: [] }, - }, - transcript: Promise.resolve([]), - events: { async *[Symbol.asyncIterator]() {} }, - transcriptBootstrap: { - throughSequence: 2, overlayMessageCount: 1, - durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' }, - }, - // The Host was still streaming the older answer when the subscription - // opened, so bootstrap holds an overlay copy of an already durable row. - loadTranscriptOverlay: async () => [older], - loadTranscriptPage: async () => { - const page = durablePage(); - decoded.set(page, { messages: [{ identity: 1, message: older }], nextCursor: null }); - return page; - }, - decodeTranscriptPage: async (page) => decoded.get(page)!, - async close() {}, - }), { onChange: (_replica, change) => changes.push(change) }); - try { - assert.deepEqual(replica.snapshot().overlay.map(({ id }) => id), ['answer-older']); - - const page = await replica.loadBefore(2, PAGE_BYTES); - - assert.ok(page); - assert.deepEqual(page.durable.map(({ message }) => message.id), ['answer-older']); - assert.deepEqual(replica.snapshot().overlay, [], 'the durable row retires the tail overlay copy'); - assert.deepEqual(changes, [], 'a window page changes nothing another window holds'); - assert.deepEqual(replica.snapshot().durable.map(({ sequence }) => sequence), [2]); - } finally { - replica.close(); - } -}); - -async function openFixture(beforePage?: (request: SessionTranscriptPageInput) => Promise) { - const messages: StoredMessage[] = [ - user('a'), assistant('a', 'A'.repeat(600 * 1024)), turnState('a', 'completed'), - user('b'), turnState('b', 'running'), - { ...user('b'), id: 'steering-b', steeringEventId: 'steering-event-b', text: 'Continue B' }, - assistant('b', 'B partial and completed answer'), turnState('b', 'completed'), - user('c'), turnState('c', 'running'), assistant('c', 'C'.repeat(600 * 1024)), turnState('c', 'completed'), - ]; - const ledger = await openTranscriptNavigationLedger(messages); - const { reader, sessionId } = ledger; - const bootstrapThrough = await ledger.appendThrough(BOOTSTRAP_THROUGH); - assert.ok(bootstrapThrough !== null); - const history = await ledger.durableRecords(); - await ledger.appendPartialAssistant('b', 'answer-b', 'B partial'); - const rootTurn = { sessionId, turnId: 'b', runId: 'run-b', status: 'running' as const }; - const activeAssistantStreams = [{ turnId: 'b', messageId: 'answer-b', kind: 'text' as const, text: 'B partial' }]; - const opened = await createSessionTranscriptBootstrap({ - reader, sessionId, subscriptionId: SUBSCRIPTION_ID, - throughSequence: bootstrapThrough, rootTurn, activeAssistantStreams, - maxBytes: 16 * 1024, projection: 'owner', - }); - const requests: SessionTranscriptPageInput[] = []; - const subscription = new ClientSessionSubscription({ - hostEpoch: HOST_EPOCH, subscriptionId: SUBSCRIPTION_ID, nextSequence: 1, - activeAssistantStreams, transcript: opened.bootstrap, - snapshot: { - schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, - session: { sessionId, metadataRevision: 1, status: 'running', createdAt: 1, isArchived: false }, - projectionRevision: 1, rootTurn, goal: null, - queue: { hostEpoch: HOST_EPOCH, queueRevision: 0, steering: [], followup: [] }, - interactions: { pending: [] }, - }, - }, async () => undefined, async (request) => { - requests.push(request); - await beforePage?.(request); - return readSessionTranscriptPage({ reader, state: opened.state, request }); - }); - const decodeMessage = (value: unknown) => decodeStoredMessage(markPersisted(value)); - const changes: DesktopTranscriptReplicaChange[] = []; - const renderer = new DesktopTranscriptRangeStore(JSON.stringify(['local', sessionId])); - const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ - snapshot: subscription.snapshot, activeAssistantStreams, events: subscription, - transcript: Promise.resolve([]), transcriptBootstrap: opened.bootstrap, - loadTranscriptOverlay: (maxMessageBytes, accountAssemblyBytes) => - subscription.loadTranscriptOverlay(decodeMessage, maxMessageBytes, accountAssemblyBytes), - decodeTranscriptPage: (page, maxMessageBytes, accountAssemblyBytes) => - subscription.decodeTranscriptPage(page, decodeMessage, maxMessageBytes, accountAssemblyBytes), - loadTranscriptPage: (request) => subscription.loadTranscriptPage(request), - close: () => subscription.close(), - }), { - onChange: (current, change) => { - changes.push(change); - // Tail growth is broadcast to every consumer and carries no navigation. - const identity = { - sessionId: current.sessionId, - generation: current.generation, - hostEpoch: current.hostEpoch, - }; - for (const batch of encodeDesktopTranscriptChange(identity, change)) renderer.accept(batch); - }, - }); - for (const batch of encodeDesktopTranscriptSnapshot(replica.snapshot())) renderer.accept(batch); - const watermarks = new Map(); - let frameSequence = 0; - const announce = async (checkpoint: string) => { - const throughSequence = await ledger.appendThrough(checkpoint); - assert.ok(throughSequence !== null); - watermarks.set(checkpoint, throughSequence); - const advanced = updateSubscriberTranscriptHighWater(opened.state, throughSequence); - if (checkpoint === B_STEERING_THROUGH) { - assert.equal(advanced, false, 'persisting a running Turn does not publish durable rows'); - return; - } - assert.equal(advanced, true); - subscription.accept({ - kind: 'subscription.transcript_advanced', hostEpoch: HOST_EPOCH, - subscriptionId: SUBSCRIPTION_ID, sequence: ++frameSequence, sessionId, throughSequence, - }); - const frame = await subscription.next(); - assert.equal(frame.done, false); - assert.equal(frame.value?.kind, 'subscription.transcript_advanced'); - }; - return { - replica, renderer, changes, requests, announce, history, bootstrapThrough, ledger, - watermark: (checkpoint: string) => { - const value = watermarks.get(checkpoint); - assert.notEqual(value, undefined); - return value!; - }, - async advance(messageId: string) { - await announce(messageId); - await replica.advance(watermarks.get(messageId) ?? bootstrapThrough); - }, - async close() { - replica.close(); - await subscription.close(); - await ledger.close(); - }, - }; -} - -async function openSettledReplica(ledger: Awaited>) { - const { sessionId, reader } = ledger; - const opened = await createSessionTranscriptBootstrap({ - reader, sessionId, subscriptionId: `${SUBSCRIPTION_ID}-reopened`, - throughSequence: await reader.readDurableHighWater(sessionId), rootTurn: null, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection: 'owner', - }); - const requests: SessionTranscriptPageInput[] = []; - const subscription = new ClientSessionSubscription({ - hostEpoch: HOST_EPOCH, subscriptionId: `${SUBSCRIPTION_ID}-reopened`, nextSequence: 1, - activeAssistantStreams: [], transcript: opened.bootstrap, - snapshot: { - schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, - session: { sessionId, metadataRevision: 1, status: 'active', createdAt: 1, isArchived: false }, - projectionRevision: 1, rootTurn: null, goal: null, - queue: { hostEpoch: HOST_EPOCH, queueRevision: 0, steering: [], followup: [] }, - interactions: { pending: [] }, - }, - }, async () => undefined, (request) => { - requests.push(request); - return readSessionTranscriptPage({ reader, state: opened.state, request }); - }); - const decodeMessage = (value: unknown) => decodeStoredMessage(markPersisted(value)); - const replica = await DesktopTranscriptReplica.prepare(runtimeHostSessionFixture({ - snapshot: subscription.snapshot, events: subscription, transcript: Promise.resolve([]), - transcriptBootstrap: opened.bootstrap, - loadTranscriptOverlay: (maxBytes, accountBytes) => subscription.loadTranscriptOverlay(decodeMessage, maxBytes, accountBytes), - decodeTranscriptPage: (page, maxBytes, accountBytes) => subscription.decodeTranscriptPage(page, decodeMessage, maxBytes, accountBytes), - loadTranscriptPage: (request) => subscription.loadTranscriptPage(request), - close: () => subscription.close(), - })); - return { replica, requests, async close() { replica.close(); await subscription.close(); } }; -} - -function user(turnId: string): Extract { - return { type: 'user', id: `user-${turnId}`, turnId, text: turnId, ts: 1 }; -} - -function assistant(turnId: string, text: string): Extract { - return { type: 'assistant', id: `answer-${turnId}`, turnId, text, ts: 1, modelId: 'fixture-model' }; -} - -function turnState(turnId: string, status: 'running' | 'completed'): StoredMessage { - return { type: 'turn_state', id: `${status}-${turnId}`, turnId, ts: 1, status }; -} diff --git a/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts b/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts index 413fd09ff8..f0c4b0a0a5 100644 --- a/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-parked-completion-probe.test.ts @@ -33,12 +33,11 @@ const message = (sequence: number, text = String(sequence)): StoredMessage => test('a Turn completing at the tail does not splice into a window parked far from it', () => { const store = new DesktopTranscriptRangeStore(JSON.stringify(['host-1', 'session-1'])); - // A jump to 5 during a run: loadAround's snapshot carries the live overlay (21). const navigation = store.navigate(); for (const batch of encodeDesktopTranscriptSnapshot({ ...identity, durableThrough: 20, durable: [{ sequence: 5, message: message(5) }, { sequence: 6, message: message(6) }], - overlay: [message(21, 'partial')], hasOlder: true, hasNewer: true, + hasOlder: true, hasNewer: true, }, navigation)) store.accept(batch); // 21 completes; the tail broadcast carries its durable row. for (const batch of encodeDesktopTranscriptChange(identity, { @@ -49,9 +48,6 @@ test('a Turn completing at the tail does not splice into a window parked far fro assert.deepEqual(store.durableEntries().map(({ sequence }) => sequence), [5, 6]); assert.deepEqual(store.snapshot().messages.map(({ id }) => id), ['message-5', 'message-6']); - // Reading forward to the tail is what brings 21 in. It arrives once, as the - // completed durable row: seeing that row retired the overlay the jump - // installed, even though the window could not keep it at the time. for (const batch of encodeDesktopTranscriptPage(identity, { durableThrough: 21, hasOlder: true, hasNewer: false, durable: Array.from({ length: 15 }, (_, index) => ({ @@ -61,5 +57,5 @@ test('a Turn completing at the tail does not splice into a window parked far fro }, { direction: 'newer', anchor: 6 })) store.accept(batch); const ids = store.snapshot().messages.map(({ id }) => id); assert.deepEqual(ids.slice(-2), ['message-20', 'message-21']); - assert.equal(ids.length, 17, 'the settled overlay is gone, so 21 is shown once'); + assert.equal(ids.length, 17, 'reading forward brings 21 in once'); }); diff --git a/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts b/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts index 935ec21439..de518085da 100644 --- a/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-pending-jump-probe.test.ts @@ -45,8 +45,7 @@ async function harness() { client: { openSession: async () => runtimeHostSessionFixture({ snapshot: continuitySnapshot(), transcript: Promise.resolve([]), events: { async *[Symbol.asyncIterator]() { await eventsClosed.promise; } }, - transcriptBootstrap: { throughSequence: THROUGH, overlayMessageCount: 0, durable: bootstrap, overlay: { ...bootstrap, source: 'overlay' } }, - loadTranscriptOverlay: async () => [], + transcriptBootstrap: { durable: bootstrap }, decodeTranscriptPage: async (candidate) => decoded.get(candidate)!, loadTranscriptPage: async (request) => { const candidate = page(); @@ -115,7 +114,7 @@ function record(identity: number) { return { identity, message }; } function page(): SessionTranscriptPage { - return { kind: 'page', sessionId: 'session-1', source: 'durable', direction: 'older', throughSequence: THROUGH, + return { kind: 'page', sessionId: 'session-1', direction: 'older', throughSequence: THROUGH, rawBytes: 1, fragments: [], rangeBoundarySequence: null, protectedTurnSequence: null, nextCursor: null }; } function continuitySnapshot() { diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index 4451617294..cb683a6fad 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -21,7 +21,6 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; import { act, createElement, createRef, type ComponentProps } from 'react'; import { deferred } from '@maka/core/test-only/async-primitives'; -import type { StoredMessage } from '@maka/core/session'; import type { DesktopTranscriptHandle } from '../../preload/transcript-contract.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; @@ -51,7 +50,7 @@ test('a resident navigation supersedes a pending history replacement without rer sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', durableThrough: 20, durable: [{ sequence, message: { type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - } }], overlay: [], hasOlder: true, hasNewer: true, + } }], hasOlder: true, hasNewer: true, }, navigation)) store.accept(batch); }; publish(20); @@ -104,7 +103,7 @@ test('sending before transcript open completes supersedes the queued bookmark wi durableThrough: 20, durable: [{ sequence: sequence ?? 20, message: { type: 'assistant', id: `answer-${turnId}`, turnId, text: turnId, ts: 1, modelId: 'fixture', - } }], overlay: [], hasOlder: true, hasNewer: sequence !== null, + } }], hasOlder: true, hasNewer: sequence !== null, }, navigation)) store.accept(batch); }; const handle: DesktopTranscriptHandle = { @@ -143,7 +142,7 @@ test('sending before transcript open completes supersedes the queued bookmark wi durableThrough: 20, durable: [{ sequence: 10, message: { type: 'assistant', id: 'answer-a', turnId: 'a', text: 'a', ts: 1, modelId: 'fixture', - } }], overlay: [], hasOlder: false, hasNewer: true, + } }], hasOlder: false, hasNewer: true, }, 1)) assert.equal(store.accept(batch), false); assert.strictEqual(store.snapshot(), latest, 'a late history response must not replace the latest range'); } finally { @@ -152,44 +151,6 @@ test('sending before transcript open completes supersedes the queued bookmark wi } }); -test('an overlay-only bookmark stays available without loading another range', async () => { - const sessionId = JSON.stringify(['host-1', 'session-1']); - const store = new DesktopTranscriptRangeStore(sessionId); - const overlay: StoredMessage = { - type: 'assistant', id: 'answer-b', turnId: 'b', text: 'partial B', ts: 1, modelId: 'fixture', - }; - for (const batch of encodeDesktopTranscriptSnapshot({ - sessionId: 'session-1', generation: 'generation-1', hostEpoch: 'host-1', - durableThrough: null, durable: [], overlay: [overlay], hasOlder: false, hasNewer: false, - })) store.accept(batch); - const controller = createDesktopTranscriptRangeController(store, async () => ({ - sessionId, generation: 'generation-1', hostEpoch: 'host-1', readThroughMessageId: null, - acknowledgeTail: async () => {}, - loadBefore: async () => {}, loadAfter: async () => {}, close: async () => {}, - loadAround: async () => assert.fail('an overlay-only bookmark has no page to load'), - loadLatest: async () => assert.fail('an overlay-only bookmark has no page to load'), - })); - const lifecycle = createTranscriptRestoreLifecycle(); - let unavailable = 0; - let cleared = 0; - const restore = () => restoreSessionTranscriptRange({ - lifecycle, sessionId, controller, readingAnchor: { turnId: 'b' }, - isCurrent: () => true, - setReadingAnchor: (_sessionId, anchor) => { if (!anchor) cleared += 1; }, - onRestoreUnavailable: () => { unavailable += 1; }, onError: (error) => assert.fail(String(error)), - }); - try { - restore(); - await new Promise((resolve) => setImmediate(resolve)); - restore(); - assert.equal(store.sequenceForTurn('b'), null); - assert.equal(unavailable, 0); - assert.equal(cleared, 0); - } finally { - await controller.close(); - } -}); - test('a failed return to the tail reports to its own Session', async () => { const fixture = controllerFixture(); const errors: string[] = []; diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index 2f6559df9c..da7431f17f 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -157,7 +157,7 @@ test('WorkHub projects the exact delegated Turn status and bounded assistant res async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { const snapshot = { sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 1, overlay: [], hasOlder: false, hasNewer: false, + durableThrough: 1, hasOlder: false, hasNewer: false, }; for (const batch of encodeDesktopTranscriptSnapshot({ ...snapshot, durable: [{ sequence: 1, message: result }], @@ -219,7 +219,7 @@ test('delegation feedback does not advance the target Session read marker', asyn await Promise.resolve(); const snapshot = { sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 2, overlay: [], hasOlder: false, hasNewer: false, + durableThrough: 2, hasOlder: false, hasNewer: false, }; for (const batch of encodeDesktopTranscriptSnapshot({ ...snapshot, @@ -292,7 +292,7 @@ test('WorkHub proves a long historical Turn tail before caching its final result ) => { for (const batch of encodeDesktopTranscriptSnapshot({ sessionId: 'target-session', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 4, overlay: [], hasOlder, hasNewer, durable, + durableThrough: 4, hasOlder, hasNewer, durable, }, navigation)) onBatch({ ...batch, deliverySequence: ++deliverySequence }); }; emit(undefined, [{ sequence: 4, message: { ...next, id: 'tail', ts: 4 } }], true, false); @@ -407,7 +407,7 @@ test('WorkHub tail navigation converges through the preload with a fragmented sp const sessionId = desktopSessionKey({ hostId: owner.hostId, sessionId: 'coordination' }); const snapshot = { sessionId: 'coordination', generation: 'generation-1', hostEpoch: 'epoch-1', - durableThrough: 8, overlay: [], hasOlder: true, hasNewer: false, + durableThrough: 8, hasOlder: true, hasNewer: false, }; const message: StoredMessage = { type: 'user', id: 'latest-message', turnId: 'latest-turn', ts: 7, @@ -566,7 +566,7 @@ for (const initial of ['failure-before-ready', 'failure-after-ready', 'cached'] const cached = attempt === 1; const snapshot = { sessionId: 'coordination', generation: cached ? 'cached:epoch-1' : `live-${attempt}`, - hostEpoch: 'epoch-1', durableThrough: 1, overlay: [], hasOlder: false, hasNewer: false, + hostEpoch: 'epoch-1', durableThrough: 1, hasOlder: false, hasNewer: false, }; const deliver = (navigation?: number) => { for (const batch of encodeDesktopTranscriptSnapshot({ @@ -627,7 +627,7 @@ test('WorkHub fills and trims its transcript window through the reader band', as transcripts: { async open(_sessionId: string, onBatch: (batch: DesktopTranscriptBatch) => void) { for (const batch of encodeDesktopTranscriptSnapshot({ - ...identity, durableThrough: 4, overlay: [], hasOlder: true, hasNewer: true, + ...identity, durableThrough: 4, hasOlder: true, hasNewer: true, durable: [row(2, 'turn-a'), row(3, 'turn-b')], })) onBatch({ ...batch, deliverySequence: ++deliverySequence }); return { diff --git a/apps/desktop/src/main/desktop-transcript-ipc.ts b/apps/desktop/src/main/desktop-transcript-ipc.ts index 1559c7abf5..a3aa995b2d 100644 --- a/apps/desktop/src/main/desktop-transcript-ipc.ts +++ b/apps/desktop/src/main/desktop-transcript-ipc.ts @@ -41,7 +41,6 @@ interface TranscriptBatchIdentity { interface TranscriptBatchContent { readonly durableThrough: number | null; readonly durable: readonly DesktopSequencedTranscriptMessage[]; - readonly overlay: readonly StoredMessage[]; readonly hasOlder?: boolean; readonly hasNewer?: boolean; readonly extends?: DesktopTranscriptExtension; @@ -56,7 +55,6 @@ export function encodeDesktopTranscriptSnapshot( return encodeDesktopTranscriptBatches({ ...snapshot, navigation }, { durableThrough: snapshot.durableThrough, durable: snapshot.durable, - overlay: snapshot.overlay, hasOlder: snapshot.hasOlder, hasNewer: snapshot.hasNewer, reset: true, @@ -71,7 +69,6 @@ export function encodeDesktopTranscriptPage( return encodeDesktopTranscriptBatches(identity, { durableThrough: page.durableThrough, durable: page.durable, - overlay: [], hasOlder: page.hasOlder, hasNewer: page.hasNewer, extends: extension, @@ -88,7 +85,6 @@ export function encodeDesktopTranscriptChange( return encodeDesktopTranscriptBatches(identity, { durableThrough: change.durableThrough, durable: change.durableUpserts, - overlay: [], coversFrom: change.coversFrom, reset: false, }); @@ -132,27 +128,18 @@ function* encodeDesktopTranscriptBatches( } function* encodeMessages(content: TranscriptBatchContent): Generator { - for (const entry of content.durable) { - yield* encodeMessage('durable', entry.sequence, null, entry.message); - } - for (const [order, message] of content.overlay.entries()) { - yield* encodeMessage('overlay', message.id, order, message); - } + for (const entry of content.durable) yield* encodeMessage(entry.sequence, entry.message); } function* encodeMessage( - source: 'durable' | 'overlay', - identity: number | string, - order: number | null, + sequence: number, message: StoredMessage, ): Generator { const bytes = Buffer.from(JSON.stringify(message), 'utf8'); for (let byteOffset = 0; byteOffset < bytes.byteLength; ) { const end = Math.min(byteOffset + DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES, bytes.byteLength); yield { - source, - identity, - order, + sequence, byteOffset, totalBytes: bytes.byteLength, data: Uint8Array.from(bytes.subarray(byteOffset, end)), diff --git a/apps/desktop/src/main/desktop-transcript-replica.ts b/apps/desktop/src/main/desktop-transcript-replica.ts index 09513df657..4187605510 100644 --- a/apps/desktop/src/main/desktop-transcript-replica.ts +++ b/apps/desktop/src/main/desktop-transcript-replica.ts @@ -29,7 +29,6 @@ import { type SessionTranscriptPage, } from '@maka/runtime-host/protocol'; import { - DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES, DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES, DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS, } from '../preload/transcript-contract.js'; @@ -40,7 +39,6 @@ export interface DesktopTranscriptReplicaOptions { readonly maxMessageBytes?: number; readonly maxResidentBytes?: number; readonly maxResidentTurns?: number; - readonly maxOverlayBytes?: number; readonly accountPreparationBytes?: (deltaBytes: number) => void; readonly onChange?: ( replica: DesktopTranscriptReplica, @@ -59,7 +57,6 @@ export interface DesktopTranscriptReplicaSnapshot { readonly hostEpoch: string; readonly durableThrough: number | null; readonly durable: readonly DesktopSequencedTranscriptMessage[]; - readonly overlay: readonly StoredMessage[]; readonly hasOlder: boolean; readonly hasNewer: boolean; } @@ -88,8 +85,8 @@ interface ResidentMessage extends DesktopSequencedTranscriptMessage { } /** - * Main's view of one Session transcript: the durable tail the projector needs, - * the overlay of not-yet-durable messages, and a pass-through pager for the + * Main's view of one Session transcript: the durable tail the projector needs + * and a pass-through pager for the * Renderer's own window. The Renderer decides what it holds; this class only * keeps the tail current and answers page reads. */ @@ -100,7 +97,6 @@ export class DesktopTranscriptReplica { readonly #handle: DesktopRuntimeHostSession; readonly #maxResidentBytes: number; readonly #maxResidentTurns: number; - readonly #maxOverlayBytes: number; readonly #maxMessageBytes: number; readonly #accountPreparationBytes: (deltaBytes: number) => void; readonly #onChange: ( @@ -108,9 +104,7 @@ export class DesktopTranscriptReplica { change: DesktopTranscriptReplicaChange, ) => void; readonly #durable = new Map(); - readonly #overlay = new Map(); #residentBytes = 0; - #overlayBytes = 0; #durableThrough: number | null; #targetThrough: number | null; #hasOlder: boolean; @@ -132,12 +126,10 @@ export class DesktopTranscriptReplica { options.maxResidentBytes ?? DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES; this.#maxResidentTurns = options.maxResidentTurns ?? DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS; - this.#maxOverlayBytes = - options.maxOverlayBytes ?? DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES; this.#maxMessageBytes = options.maxMessageBytes ?? SESSION_TRANSCRIPT_RANGE_MAX_BYTES; this.#accountPreparationBytes = options.accountPreparationBytes ?? (() => undefined); this.#onChange = options.onChange ?? (() => undefined); - this.#durableThrough = handle.transcriptBootstrap.throughSequence; + this.#durableThrough = handle.transcriptBootstrap.durable.throughSequence; this.#targetThrough = this.#durableThrough; this.#hasOlder = handle.transcriptBootstrap.durable.nextCursor !== null; } @@ -148,11 +140,6 @@ export class DesktopTranscriptReplica { ): Promise { const replica = new DesktopTranscriptReplica(handle, options); try { - await replica.#withAssembly(async (accountAssemblyBytes) => { - replica.#installOverlay( - await handle.loadTranscriptOverlay(replica.#maxMessageBytes, accountAssemblyBytes), - ); - }); await replica.#withDecodedPage(handle.transcriptBootstrap.durable, (durable) => { replica.#installDurable(durable.messages); replica.#hasOlder = durable.nextCursor !== null; @@ -163,9 +150,6 @@ export class DesktopTranscriptReplica { replica.#durableThrough ?? undefined, ); - if (replica.#overlayBytes > replica.#maxOverlayBytes) { - throw new RangeError('Desktop transcript overlay exceeds the session cache limit'); - } return replica; } catch (error) { replica.close(); @@ -205,7 +189,6 @@ export class DesktopTranscriptReplica { hostEpoch: this.hostEpoch, durableThrough: this.#durableThrough, durable: this.#orderedDurable(false), - overlay: [...this.#overlay.values()], hasOlder: this.#hasOlder, hasNewer: false, }; @@ -214,9 +197,7 @@ export class DesktopTranscriptReplica { messages(): StoredMessage[] { this.#assertOpen(); this.#assertResident(); - return this.#orderedDurable() - .map((entry) => entry.message) - .concat([...this.#overlay.values()].map((message) => structuredClone(message))); + return this.#orderedDurable().map((entry) => entry.message); } messagesForTurn(turnId: string): StoredMessage[] { @@ -264,7 +245,6 @@ export class DesktopTranscriptReplica { const throughSequence = this.#durableThrough; if (throughSequence === null) return undefined; const page = await this.#handle.loadTranscriptPage({ - source: 'durable', direction, throughSequence, cursor: null, @@ -283,7 +263,6 @@ export class DesktopTranscriptReplica { ) { throw correlationError(`Desktop transcript ${direction} page did not meet its anchor`); } - this.#completeOverlay(decoded.messages); return { durableThrough: throughSequence, durable: decoded.messages.map((entry) => ({ @@ -309,7 +288,6 @@ export class DesktopTranscriptReplica { const throughSequence = this.#durableThrough; if (throughSequence === null) return; const page = await this.#handle.loadTranscriptPage({ - source: 'durable', direction: 'older', throughSequence, cursor: null, @@ -351,7 +329,6 @@ export class DesktopTranscriptReplica { const throughSequence = this.#durableThrough; if (throughSequence === null || sequence > throughSequence) return undefined; const page = await this.#handle.loadTranscriptPage({ - source: 'durable', direction: 'newer', throughSequence, cursor: null, @@ -363,7 +340,6 @@ export class DesktopTranscriptReplica { // row of a Session is at no fixed number and `sequence > 0` cannot answer // whether anything precedes the anchor. Ask for one row older instead. const older = await this.#handle.loadTranscriptPage({ - source: 'durable', direction: 'older', throughSequence, cursor: null, @@ -376,7 +352,6 @@ export class DesktopTranscriptReplica { if (decoded.messages.length > 0 && decoded.messages[0]!.identity !== sequence) { throw correlationError('Desktop transcript range did not meet its anchor'); } - this.#completeOverlay(decoded.messages); return { sessionId: this.sessionId, generation: this.generation, @@ -386,7 +361,6 @@ export class DesktopTranscriptReplica { sequence: entry.identity, message: entry.message, })), - overlay: [...this.#overlay.values()], hasOlder: older.fragments.length > 0, hasNewer: decoded.nextCursor !== null, }; @@ -427,19 +401,12 @@ export class DesktopTranscriptReplica { if (!this.#resident) return; this.#resident = false; this.#clearDurable(); - for (const message of this.#overlay.values()) { - this.#adjustOverlayBytes(-encodedMessageBytes(message)); - } - this.#overlay.clear(); - this.#overlayBytes = 0; } close(): void { this.#closed = true; this.#resident = false; this.#durable.clear(); - this.#overlay.clear(); - this.#overlayBytes = 0; if (this.#residentExternallyAccounted) { this.#accountPreparationBytes(-this.#residentBytes); } @@ -460,7 +427,6 @@ export class DesktopTranscriptReplica { do { if (!this.#isLive()) return; const page: SessionTranscriptPage = await this.#handle.loadTranscriptPage({ - source: 'durable', direction: 'newer', throughSequence: target, cursor, @@ -506,15 +472,6 @@ export class DesktopTranscriptReplica { } } - #installOverlay(messages: readonly StoredMessage[]): void { - for (const message of messages) { - const previous = this.#overlay.get(message.id); - if (previous) this.#adjustOverlayBytes(-encodedMessageBytes(previous)); - this.#overlay.set(message.id, message); - this.#adjustOverlayBytes(encodedMessageBytes(message)); - } - } - #installDurable( messages: readonly { readonly identity: number; @@ -536,21 +493,6 @@ export class DesktopTranscriptReplica { }); this.#adjustResidentBytes(encodedBytes); } - this.#completeOverlay(messages); - } - - /** - * The durable row settles the overlay it replaces, so the tail cache stops - * carrying both. Each window retires its own overlay when it installs the - * row; a window that never installs it keeps showing what it has. - */ - #completeOverlay(messages: readonly { readonly message: StoredMessage }[]): void { - for (const { message } of messages) { - const overlay = this.#overlay.get(message.id); - if (!overlay) continue; - this.#overlay.delete(message.id); - this.#adjustOverlayBytes(-encodedMessageBytes(overlay)); - } } #acceptRange( @@ -603,7 +545,7 @@ export class DesktopTranscriptReplica { budget: number | undefined = undefined, protectedSequence?: number, ): void { - const residentBudget = budget ?? this.#maxResidentBytes + this.#overlayBytes; + const residentBudget = budget ?? this.#maxResidentBytes; const turns = new Map(); for (const sequence of [...this.#durable.keys()].sort((left, right) => left - right)) { const key = residentTurnKey(this.#durable.get(sequence)!); @@ -649,11 +591,6 @@ export class DesktopTranscriptReplica { this.#residentBytes += deltaBytes; } - #adjustOverlayBytes(deltaBytes: number): void { - this.#adjustResidentBytes(deltaBytes); - this.#overlayBytes += deltaBytes; - } - async #withDecodedPage( page: SessionTranscriptPage, accept: ( diff --git a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts index 9f7fdfc275..e9beb307a4 100644 --- a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts +++ b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts @@ -140,6 +140,9 @@ export function createRuntimeHostBotSessionAdapter( void completion.catch(() => undefined); try { try { + // The Host holds this subscription's frames until here, so the reply + // has to be collectable before the Turn that produces it starts. + await session.ready(); const started = await deps.client.startTurn({ sessionId, turnId, diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ab21f66cd7..0a5731f857 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -212,11 +212,9 @@ export interface DesktopRuntimeHostSession { readonly activeAssistantStreams: readonly SessionAssistantStreamIdentity[]; readonly transcriptBootstrap: SessionTranscriptBootstrap; readonly events: AsyncIterable; + /** Frames are held by the Host until this resolves. */ + ready(): Promise; loadTranscript(): Promise; - loadTranscriptOverlay( - maxMessageBytes?: number, - accountAssemblyBytes?: (deltaBytes: number) => void, - ): Promise; decodeTranscriptPage( page: SessionTranscriptPage, maxMessageBytes?: number, @@ -1853,6 +1851,10 @@ class DesktopSessionHandle implements DesktopRuntimeHostSession { this.events = subscription; } + ready(): Promise { + return this.subscription.ready(); + } + loadTranscript(): Promise { this.#transcriptTask ??= this.subscription.loadTranscript(decodeStoredMessage); return this.#transcriptTask; @@ -1862,17 +1864,6 @@ class DesktopSessionHandle implements DesktopRuntimeHostSession { return this.subscription.subscribePtyData(listener); } - loadTranscriptOverlay( - maxMessageBytes?: number, - accountAssemblyBytes?: (deltaBytes: number) => void, - ): Promise { - return this.subscription.loadTranscriptOverlay( - decodeStoredMessage, - maxMessageBytes, - accountAssemblyBytes, - ); - } - decodeTranscriptPage( page: SessionTranscriptPage, maxMessageBytes?: number, diff --git a/apps/desktop/src/main/runtime-host-session-observer.ts b/apps/desktop/src/main/runtime-host-session-observer.ts index 1ca5390129..407a61a592 100644 --- a/apps/desktop/src/main/runtime-host-session-observer.ts +++ b/apps/desktop/src/main/runtime-host-session-observer.ts @@ -396,7 +396,7 @@ export class RuntimeHostSessionObserver { ); return snapshot && { batches: encodeDesktopTranscriptSnapshot(snapshot, request.navigation), - bytes: [...snapshot.durable, ...snapshot.overlay.map((message) => ({ message }))], + bytes: snapshot.durable, }; }); } @@ -1212,23 +1212,29 @@ export class RuntimeHostSessionObserver { } async #closeIfIdle(state: ObservedSessionState): Promise { - if ( - state.targets.size > 0 || - state.watchedTurnIds.size > 0 || - state.transcriptConsumers.size > 0 || - state.pendingTranscriptConsumers > 0 - ) return; + if (this.#isRetained(state)) return; await Promise.resolve(); - if ( - state.targets.size === 0 && - state.watchedTurnIds.size === 0 && - state.transcriptConsumers.size === 0 && - state.pendingTranscriptConsumers === 0 - ) { + if (!this.#isRetained(state)) { await this.#closeState(state); } } + /** + * A running root Turn keeps the subscription whether or not anyone is + * looking: the Host goes on producing either way, and letting go here only + * makes the next viewer ask it to send everything a second time. + */ + #isRetained(state: ObservedSessionState): boolean { + const root = state.snapshot?.rootTurn; + return ( + state.targets.size > 0 || + state.watchedTurnIds.size > 0 || + state.transcriptConsumers.size > 0 || + state.pendingTranscriptConsumers > 0 || + (root !== null && root !== undefined && !isTerminalTurn(root)) + ); + } + #finishWatchedTurn( state: ObservedSessionState, turnId: string, diff --git a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts index e2304bc4ad..eb03a9a1ac 100644 --- a/apps/desktop/src/main/runtime-host-session-subscription-owner.ts +++ b/apps/desktop/src/main/runtime-host-session-subscription-owner.ts @@ -170,9 +170,9 @@ export class RuntimeHostSessionSubscriptionOwner { this.#attempt = attempt; previous.replica?.close(); await previous.handle.close().catch(() => undefined); - await this.#drainPendingFrames(attempt); attempt.phase = 'active'; attempt.preparationFailure = undefined; + await attempt.handle.ready(); } catch (error) { const failure = asError(error); if (attempt && this.#attempt === attempt) { @@ -252,9 +252,9 @@ export class RuntimeHostSessionSubscriptionOwner { activate(); this.#candidate = undefined; this.#attempt = attempt; - await this.#drainPendingFrames(attempt); attempt.phase = "active"; attempt.preparationFailure = undefined; + await attempt.handle.ready(); } catch (error) { if (this.#candidate === attempt) this.#candidate = undefined; if (this.#attempt === attempt) this.#attempt = undefined; @@ -367,7 +367,12 @@ export class RuntimeHostSessionSubscriptionOwner { if (frame.kind === "subscription.closed") { throw subscriptionClosedError(frame.reason); } - if (attempt.phase !== 'active') { + if (attempt.phase === 'preparing') { + // The Host holds frames until `ready()`, so one arriving here is a + // broken contract rather than a consumer falling behind. + throw new Error('Runtime Host sent a Session frame before the subscriber was ready'); + } + if (attempt.phase === 'retiring') { const frameBytes = Buffer.byteLength(JSON.stringify(frame), 'utf8'); if ( attempt.pendingFrames.length >= MAX_PENDING_FRAMES || @@ -451,14 +456,13 @@ function subscriptionClosedError( function isRecoverableSubscriptionFailure(error: unknown): boolean { if (error instanceof RuntimeHostOperationError) { - return error.operation === "session.transcript.page" && error.code === "not_found"; + return error.operation === 'session.transcript.page' && error.code === 'not_found'; } if (!(error instanceof RuntimeHostSubscriptionError)) return false; return ( - error.reason === "slow_consumer" || - error.reason === "sequence_gap" || - error.reason === "projection_revision_invalid" || - error.reason === "transcript_release_failed" + error.reason === 'slow_consumer' || + error.reason === 'sequence_gap' || + error.reason === 'projection_revision_invalid' ); } diff --git a/apps/desktop/src/main/session-local-store.ts b/apps/desktop/src/main/session-local-store.ts index 005836bb42..8658b8abd5 100644 --- a/apps/desktop/src/main/session-local-store.ts +++ b/apps/desktop/src/main/session-local-store.ts @@ -348,9 +348,7 @@ export class DesktopSessionLocalStore { } saveTranscript(partition: string, snapshot: DesktopTranscriptReplicaSnapshot): void { - // Persist durable evidence only. Live assistant fragments and old running - // claims must not masquerade as current execution after restart. - const payload = JSON.stringify({ ...snapshot, overlay: [] }); + const payload = JSON.stringify(snapshot); if (Buffer.byteLength(payload) > MAX_CACHE_SESSION_BYTES) return; this.#transaction(() => { this.#db diff --git a/apps/desktop/src/preload/transcript-contract.ts b/apps/desktop/src/preload/transcript-contract.ts index 515d3f0b51..94de166fb5 100644 --- a/apps/desktop/src/preload/transcript-contract.ts +++ b/apps/desktop/src/preload/transcript-contract.ts @@ -21,7 +21,6 @@ export const DESKTOP_TRANSCRIPT_FRAGMENT_MAX_BYTES = 128 * 1024; export const DESKTOP_TRANSCRIPT_RANGE_MAX_BYTES = 512 * 1024; /** Turns the Main tail cache keeps for the projector and for the tail the Renderer opens with. */ export const DESKTOP_TRANSCRIPT_TAIL_MAX_TURNS = 10; -export const DESKTOP_TRANSCRIPT_OVERLAY_CACHE_MAX_BYTES = 16 * 1024 * 1024; /** * Main rejects a read whose Host epoch moved under it. `ipcRenderer.invoke` * carries nothing across but the Error's message, so both sides name the @@ -31,9 +30,7 @@ export const DESKTOP_TRANSCRIPT_HOST_EPOCH_CHANGED_CODE = 'DESKTOP_TRANSCRIPT_HO export const DESKTOP_TRANSCRIPT_GLOBAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; export interface DesktopTranscriptFragment { - readonly source: 'durable' | 'overlay'; - readonly identity: number | string; - readonly order: number | null; + readonly sequence: number; readonly byteOffset: number; readonly totalBytes: number; readonly data: Uint8Array; @@ -140,13 +137,7 @@ export function assertDesktopTranscriptBatch(value: unknown): DesktopTranscriptB !value || typeof value !== 'object' || Array.isArray(value) || - (fragment.source !== 'durable' && fragment.source !== 'overlay') || - (fragment.source === 'durable' - ? !isSequence(fragment.identity) - : typeof fragment.identity !== 'string' || fragment.identity.length === 0) || - (fragment.source === 'overlay' - ? !isSequence(fragment.order) - : fragment.order !== null) || + !isSequence(fragment.sequence) || !isSequence(fragment.byteOffset) || !isSequence(fragment.totalBytes) || (fragment.totalBytes as number) < 1 || diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 6f82936890..efd4cc0d9b 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -340,9 +340,6 @@ export function createAppShellSessionEventHandlers(options: { return next; }); break; - case 'text_complete': - void refreshMessages(sessionId, { requiredAssistantMessageId: event.messageId }).catch(() => false); - break; case 'sandbox_boundary_request': case 'client_capability_request': case 'user_question_request': @@ -365,9 +362,6 @@ export function createAppShellSessionEventHandlers(options: { // had before the user granted more. onExecutionBoundaryChanged?.(sessionId); break; - case 'tool_result': - void refreshMessages(sessionId); - break; case 'error': onInteractionChanged?.(sessionId); if (activeIdRef.current === sessionId) { diff --git a/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts b/apps/desktop/src/renderer/features/conversation/controller/transcript-reading-position.ts index 09ed6eddcb..d30182f7ae 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 @@ -306,8 +306,9 @@ export function restoreSessionTranscriptRange(options: { message !== null && typeof message === 'object' && 'turnId' in message && message.turnId === target.turnId, )) { - // Active Turns are overlay-only in the RuntimeEvent projection. Their - // bookmark is already visible even though no durable sequence exists. + // A live row can be on screen before the transcript assigns it a + // sequence. Its bookmark is already visible, so there is nothing to + // page in. return false; } return restoringReadingAnchor; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 24a76738e0..d9f734f3a5 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -805,6 +805,17 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan } else { recordOwnedTurn(event.turnId, event.messageId); } + // Admission proves the transcript carries this message, and the Host + // does not echo a message it already wrote. Read it now so a message + // steered into the running Turn shows up there and its optimistic + // entry retires through the shared durable rule, instead of both + // waiting for the Turn to settle. + void sideChat.readSettledMessages(forkId) + .then(({ messages }) => { + if (!mountedRef.current || companionIdRef.current !== forkId) return; + mergeDurableMessages(messages); + }) + .catch(() => undefined); return; } if (admission) { diff --git a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts index 4ff6bb44de..5f154c3fc8 100644 --- a/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts +++ b/apps/desktop/src/renderer/platform/desktop/desktop-transcript-range-store.ts @@ -349,9 +349,6 @@ export function createRecoveringDesktopTranscriptRangeController( } interface PendingRecord { - readonly source: 'durable' | 'overlay'; - readonly identity: number | string; - readonly order: number | null; readonly totalBytes: number; readonly bytes: Uint8Array; receivedBytes: number; @@ -362,10 +359,6 @@ interface StoredRecord { readonly encoded: string; } -interface OverlayRecord extends StoredRecord { - readonly order: number; -} - export interface DesktopTranscriptRangeState { readonly sessionId: string; readonly generation: string; @@ -396,13 +389,6 @@ interface TranscriptWindow { readonly newestUserSequence: number | null; } -/** The Host tail, which is not a window member: the view shows it only at the tail. */ -interface TranscriptTail { - readonly through: number | null; - readonly overlay: ReadonlyMap; - readonly overlayOrder: readonly string[]; -} - /** * One answer under construction. Batches accumulate here so that `#window` * changes exactly once per answer, from one complete value to the next. @@ -420,9 +406,8 @@ interface TranscriptAssembly { durableThrough: number | null; hasOlder: boolean | undefined; hasNewer: boolean | undefined; - readonly fragments: Map; + readonly fragments: Map; readonly rows: Map; - readonly overlay: Map; } const EMPTY_WINDOW: TranscriptWindow = { @@ -434,14 +419,13 @@ const EMPTY_WINDOW: TranscriptWindow = { newestUserSequence: null, }; -const EMPTY_TAIL: TranscriptTail = { through: null, overlay: new Map(), overlayOrder: [] }; - export class DesktopTranscriptRangeStore { readonly sessionId: string; readonly #hostId: string; readonly #expectedSessionId: string; #window: TranscriptWindow = EMPTY_WINDOW; - #tail: TranscriptTail = EMPTY_TAIL; + /** The Host's durable watermark, which the window may not reach. */ + #durableThrough: number | null = null; #assembly: TranscriptAssembly | undefined; #pendingNavigation: number | undefined; #navigations = 0; @@ -520,17 +504,13 @@ export class DesktopTranscriptRangeStore { navigation: batch.navigation, extension: batch.extends, coversFrom: batch.coversFrom, - // A tail answer this window cannot join contributes nothing but its - // watermark, unless an overlay row is waiting to learn it has settled. - collects: kind !== 'tail' || - this.#tail.overlay.size > 0 || - this.#joinsTail(batch.coversFrom), + // A tail answer this window cannot join contributes nothing but its watermark. + collects: kind !== 'tail' || this.#joinsTail(batch.coversFrom), durableThrough: batch.durableThrough, hasOlder: undefined, hasNewer: undefined, fragments: new Map(), rows: new Map(), - overlay: new Map(), }; this.#assembly = assembly; } @@ -549,26 +529,13 @@ export class DesktopTranscriptRangeStore { * still the edge it was read from, because nothing else proves them adjacent. */ #apply(answer: TranscriptAssembly): boolean { - const tail = this.#tail; + const durableThrough = this.#durableThrough; const window = this.#window; - let overlay = answer.kind === 'replace' ? answer.overlay : tail.overlay; - let overlayOrder = answer.kind === 'replace' ? orderOverlay(answer.overlay) : tail.overlayOrder; - // A durable row retires the overlay it settles, whether or not this window - // keeps the row: seeing it is what proves the overlay obsolete. - if (overlay.size > 0) { - const settled = new Map(overlay); - for (const record of answer.rows.values()) settled.delete(record.message.id); - if (settled.size !== overlay.size) { - overlay = settled; - overlayOrder = overlayOrder.filter((messageId) => settled.has(messageId)); - } - } - const through = answer.durableThrough !== null && - (tail.through === null || answer.durableThrough > tail.through) - ? answer.durableThrough - : tail.through; - if (through !== tail.through || overlay !== tail.overlay) { - this.#tail = { through, overlay, overlayOrder }; + if ( + answer.durableThrough !== null && + (durableThrough === null || answer.durableThrough > durableThrough) + ) { + this.#durableThrough = answer.durableThrough; } const installed = this.#install(answer); if (installed) this.#window = installed; @@ -577,7 +544,7 @@ export class DesktopTranscriptRangeStore { this.#pendingNavigation = undefined; } const ready = this.#ready || (answer.kind === 'replace' && installed !== undefined); - const changed = this.#tail !== tail || this.#window !== window || ready !== this.#ready; + const changed = this.#durableThrough !== durableThrough || this.#window !== window || ready !== this.#ready; this.#ready = ready; if (changed) this.#commit(); for (const notify of this.#durableWaiters) notify(); @@ -698,7 +665,7 @@ export class DesktopTranscriptRangeStore { sessionId: this.sessionId, generation: this.#generation, hostEpoch: this.#hostEpoch, - durableThrough: this.#tail.through, + durableThrough: this.#durableThrough, oldestSequence: window.order[0] ?? null, newestSequence: window.order.at(-1) ?? null, hasOlder: window.hasOlder, @@ -711,8 +678,8 @@ export class DesktopTranscriptRangeStore { #hasNewer(): boolean { const window = this.#window; return window.hasNewerAtThrough || - (this.#tail.through !== null && - (window.through === null || this.#tail.through > window.through)); + (this.#durableThrough !== null && + (window.through === null || this.#durableThrough > window.through)); } hasDurableMessage(messageId: string): boolean { @@ -772,25 +739,17 @@ export class DesktopTranscriptRangeStore { fragment: DesktopTranscriptFragment, collects: boolean, ): void { - const key = `${fragment.source}:${typeof fragment.identity}:${fragment.identity}`; - let pending = assembly.fragments.get(key); + const sequence = fragment.sequence; + let pending = assembly.fragments.get(sequence); if (!pending) { pending = { - source: fragment.source, - identity: fragment.identity, - order: fragment.order, totalBytes: fragment.totalBytes, bytes: new Uint8Array(fragment.totalBytes), receivedBytes: 0, }; - assembly.fragments.set(key, pending); + assembly.fragments.set(sequence, pending); } - if ( - pending.source !== fragment.source || - pending.identity !== fragment.identity || - pending.order !== fragment.order || - pending.totalBytes !== fragment.totalBytes - ) { + if (pending.totalBytes !== fragment.totalBytes) { throw new Error('Desktop transcript fragment identity changed'); } const bytes = fragment.data; @@ -806,41 +765,21 @@ export class DesktopTranscriptRangeStore { pending.bytes.set(bytes, fragment.byteOffset); pending.receivedBytes += bytes.byteLength; if (pending.receivedBytes < pending.totalBytes) return; - assembly.fragments.delete(key); + assembly.fragments.delete(sequence); if (!collects) return; const encoded = new TextDecoder('utf-8', { fatal: true }).decode(pending.bytes); const message = freezeTranscriptValue(projectDesktopStoredMessage( { hostId: this.#hostId }, decodeStoredMessage(markPersisted(JSON.parse(encoded))), )); - const projected = JSON.stringify(message); - if (pending.source === 'durable') { - if (!Number.isSafeInteger(pending.identity) || (pending.identity as number) < 0) { - throw new Error('Invalid Desktop transcript durable identity'); - } - assembly.rows.set(pending.identity as number, { message, encoded: projected }); - return; - } - if (typeof pending.identity !== 'string' || message.id !== pending.identity) { - throw new Error('Desktop transcript overlay identity changed'); - } - if (pending.order === null || !Number.isSafeInteger(pending.order) || pending.order < 0) { - throw new Error('Invalid Desktop transcript overlay order'); - } - assembly.overlay.set(pending.identity, { message, encoded: projected, order: pending.order }); + assembly.rows.set(sequence, { message, encoded: JSON.stringify(message) }); } #createSnapshot(): DesktopTranscriptRangeSnapshot { const window = this.#window; - const tail = this.#tail; - const messages = Object.freeze([ - ...window.order.map((sequence) => window.rows.get(sequence)!.message), - // The overlay is a fact about the tail, so it belongs to the view only - // while the window is at the tail. - ...(this.#hasNewer() - ? [] - : tail.overlayOrder.map((messageId) => tail.overlay.get(messageId)!.message)), - ]); + const messages = Object.freeze( + window.order.map((sequence) => window.rows.get(sequence)!.message), + ); return Object.freeze({ ...this.range(), messages, @@ -907,13 +846,6 @@ function sameWindow(current: TranscriptWindow, candidate: TranscriptWindow): Tra return current; } -function orderOverlay(overlay: ReadonlyMap): string[] { - return [...overlay.keys()].sort((left, right) => { - const order = overlay.get(left)!.order - overlay.get(right)!.order; - return order === 0 ? left.localeCompare(right) : order; - }); -} - 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/docs/architecture/runtime-host-architecture.md b/docs/architecture/runtime-host-architecture.md index c2703b7439..57d104e741 100644 --- a/docs/architecture/runtime-host-architecture.md +++ b/docs/architecture/runtime-host-architecture.md @@ -213,7 +213,7 @@ Ordinary Runtime Session transcripts are projected from durable RuntimeEvents. S Opening a Session subscription atomically obtains a canonical snapshot, `nextSeq`, and active stream IDs. The open response is written before subsequent subscription frames. Live sequence belongs to the connection observation protocol; transcript cursors and durable event/message ordinals identify pagination. They are neither one counter nor necessarily consecutive integers. -Larger transcripts use bounded pagination. Cursors bind the subscription, Session, source, direction, and watermark with integrity checks. Bootstrap, pages, per-Turn projection work, and active overlays have separate bounds. Sequence gaps, HostEpoch changes, subscription loss, and invalid cursors require reopening and rereading canonical state. PTY has separate backpressure/subscription boundaries so it cannot overwhelm ordinary Session observation. +Larger transcripts use bounded pagination. Cursors bind the subscription, Session, direction, and watermark with integrity checks. Bootstrap, pages, and per-Turn projection work have separate bounds. Sequence gaps, HostEpoch changes, subscription loss, and invalid cursors require reopening and rereading canonical state. PTY has separate backpressure/subscription boundaries so it cannot overwhelm ordinary Session observation. For delivery, reconnection does not imply permission to execute a command again. Queries may retry under their read-only contract. A sent command without a received result retains an unknown outcome and reconciles through its Domain's exact request IDs, admission, or result records. Desktop's durable outbox preserves message and attachment identity, restoring crash-time `sending` as `unknown`; this is not generic transport replay. diff --git a/docs/architecture/runtime-host-architecture.zh-CN.md b/docs/architecture/runtime-host-architecture.zh-CN.md index fb3e769c78..20fcd2ea4d 100644 --- a/docs/architecture/runtime-host-architecture.zh-CN.md +++ b/docs/architecture/runtime-host-architecture.zh-CN.md @@ -213,7 +213,7 @@ child Session / Graph lineage:仍走自己的执行和 lineage 约束 打开 Session subscription 时原子地取得 canonical snapshot、`nextSeq` 和 active stream IDs。open response 先于后续 subscription frames 写出。Live sequence 是连接观察协议;transcript cursor 与持久 event/message ordinal 是分页身份,不能假设它们是同一计数器或都连续加一。 -较大的 transcript 通过有界分页读取。Cursor 绑定 subscription、Session、来源、方向与 watermark,并校验完整性;bootstrap、page、单 Turn 投影工作量和 active overlay 分别有界。Client 遇到 sequence gap、HostEpoch 变化、subscription 丢失或 cursor 失效时重新打开并读取 canonical state。PTY 有独立的背压/订阅边界,不应拖垮普通 Session 观察。 +较大的 transcript 通过有界分页读取。Cursor 绑定 subscription、Session、方向与 watermark,并校验完整性;bootstrap、page 和单 Turn 投影工作量分别有界。Client 遇到 sequence gap、HostEpoch 变化、subscription 丢失或 cursor 失效时重新打开并读取 canonical state。PTY 有独立的背压/订阅边界,不应拖垮普通 Session 观察。 对发送侧,连接恢复不等于可以重新执行 command。Query 可按自己的只读契约重试;command 已发送但没收到结果时,保留 outcome unknown,通过该 Domain 的确切请求 ID、admission 或结果记录协调。Desktop 的 durable outbox 保留消息与附件身份,并把崩溃时的 `sending` 恢复为 `unknown`;这不是传输层的通用重放。 diff --git a/packages/cli/src/__tests__/acp-session-registry.test.ts b/packages/cli/src/__tests__/acp-session-registry.test.ts index 3d1c5e8ea6..3be0df0bd9 100644 --- a/packages/cli/src/__tests__/acp-session-registry.test.ts +++ b/packages/cli/src/__tests__/acp-session-registry.test.ts @@ -2786,6 +2786,11 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< resolve(result: IteratorResult): void; reject(error: Error): void; }> = []; + #readied = false; + #openGate: () => void = () => undefined; + readonly #readyGate = new Promise((resolve) => { + this.#openGate = resolve; + }); #sequence = 0; #closed = false; #failure: Error | undefined; @@ -2811,8 +2816,19 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< return this; } + async ready(): Promise { + this.#readied = true; + this.#openGate(); + } + next(): Promise> { this.nextCalls += 1; + // The Host holds frames until the subscriber declares readiness, so a fake + // that hands them over earlier would let an ordering bug pass. + return this.#readied ? this.#deliver() : this.#readyGate.then(() => this.#deliver()); + } + + #deliver(): Promise> { const frame = this.#frames.shift(); if (frame) return Promise.resolve({ done: false, value: frame }); if (this.#failure) return Promise.reject(this.#failure); @@ -2885,10 +2901,6 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< return (await this.transcript).map(decodeMessage); } - async loadTranscriptOverlay(_decodeMessage: (value: unknown) => T): Promise { - return []; - } - async decodeTranscriptPage(): Promise { throw new Error('Fake subscription does not expose transcript pages'); } diff --git a/packages/cli/src/__tests__/acp-stdio-server.test.ts b/packages/cli/src/__tests__/acp-stdio-server.test.ts index 8129e0d8eb..145b5cb486 100644 --- a/packages/cli/src/__tests__/acp-stdio-server.test.ts +++ b/packages/cli/src/__tests__/acp-stdio-server.test.ts @@ -1161,6 +1161,11 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< }> = []; nextCalls = 0; closeCalls = 0; + #readied = false; + #openGate: () => void = () => undefined; + readonly #readyGate = new Promise((resolve) => { + this.#openGate = resolve; + }); #closed = false; #failure: Error | undefined; @@ -1176,8 +1181,19 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< return this; } + async ready(): Promise { + this.#readied = true; + this.#openGate(); + } + next(): Promise> { this.nextCalls += 1; + // The Host holds frames until the subscriber declares readiness, so a fake + // that hands them over earlier would let an ordering bug pass. + return this.#readied ? this.#deliver() : this.#readyGate.then(() => this.#deliver()); + } + + #deliver(): Promise> { const frame = this.#frames.shift(); if (frame) return Promise.resolve({ done: false, value: frame }); if (this.#failure) return Promise.reject(this.#failure); @@ -1203,10 +1219,6 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< return (await this.transcript).map(decodeMessage); } - async loadTranscriptOverlay(_decodeMessage: (value: unknown) => T): Promise { - return []; - } - async decodeTranscriptPage(): Promise { throw new Error('Fake subscription does not expose transcript pages'); } diff --git a/packages/cli/src/__tests__/runtime-host-run-command.test.ts b/packages/cli/src/__tests__/runtime-host-run-command.test.ts index 26b45e7a3c..7e69be8465 100644 --- a/packages/cli/src/__tests__/runtime-host-run-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-run-command.test.ts @@ -374,6 +374,64 @@ describe('Runtime Host maka run adapter', () => { assert.equal(exitCode, 1); }); + test('prints the transcript answer when the live stream never carried one', async () => { + const stdout: string[] = []; + let publishReplacement = () => {}; + const fixture = runFixture({ + turnEvents: eventsWithoutStreamedAnswer(() => publishReplacement()), + }); + publishReplacement = () => + fixture.publishTranscriptReplacement( + 'turn-1', + [ + { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 1, + text: 'Answer only the transcript saw', + modelId: 'gpt-5', + }, + ], + 'reconnect', + ); + + const exitCode = await runFixtureCommand(fixture, ['reattach'], (text) => stdout.push(text)); + + assert.equal(exitCode, 0); + assert.equal(stdout.join(''), 'Answer only the transcript saw\n'); + }); + + test('prints the answer when a durable transcript read lands after it', async () => { + const stdout: string[] = []; + let publishReplacement = () => {}; + const fixture = runFixture({ + turnEvents: eventsWithLateTranscriptRead(() => publishReplacement()), + }); + publishReplacement = () => + fixture.publishTranscriptReplacement( + 'turn-1', + [ + { + type: 'assistant', + id: 'assistant-step-1', + turnId: 'turn-1', + ts: 1, + text: 'Reading the file', + modelId: 'gpt-5', + }, + storedToolCall('turn-1', 'tool-2', 'step-1', 1), + successfulToolResult('turn-1', 2), + ], + 'reconcile', + ); + + const exitCode = await runFixtureCommand(fixture, ['answer once'], (text) => stdout.push(text)); + + assert.equal(exitCode, 0); + assert.equal(stdout.join(''), 'Host answer\n'); + }); + test('returns exit code 1 when one retry follows two sandbox failures', async () => { const fixture = runFixture({ turnEvents: multipleSandboxFailureEvents('turn-1'), @@ -1594,6 +1652,45 @@ async function* eventsFor(turnId: string, text: string, ts = 1): AsyncIterable void): AsyncIterable { + yield toolStart('turn-1', 'tool-2', 'step-1', 1); + yield successfulToolResult('turn-1', 2); + yield { + type: 'text_complete', + id: 'turn-1-text', + turnId: 'turn-1', + messageId: 'turn-1-message', + ts: 3, + text: 'Host answer', + }; + // The read this tool result triggered only reaches the transcript as far as + // the Host had committed it, which is behind the answer that just streamed. + publish(); + yield { + type: 'complete', + id: 'turn-1-complete', + turnId: 'turn-1', + ts: 4, + stopReason: 'end_turn', + }; +} + +// Reattaching to a Turn whose answer was produced before this client arrived: +// only the transcript can supply it, so a stored answer has to be able to set +// the final output when the stream never delivered one. +async function* eventsWithoutStreamedAnswer(publish: () => void): AsyncIterable { + yield toolStart('turn-1', 'tool-1', 'step-1', 1); + yield successfulToolResult('turn-1', 2); + publish(); + yield { + type: 'complete', + id: 'turn-1-complete', + turnId: 'turn-1', + ts: 3, + stopReason: 'end_turn', + }; +} + async function* eventsAfterTranscriptReplacement(publish: () => void): AsyncIterable { publish(); yield* eventsFor('turn-1', 'Incomplete answer', 3); 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 7c066b7790..336c0afc70 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1286,7 +1286,7 @@ describe('Runtime Host Maka Session driver', () => { }); assert.equal((await nextEvent(initial.activeTurn.events)).type, 'complete'); assert.equal((await initial.activeTurn.events[Symbol.asyncIterator]().next()).done, true); - await waitFor(() => refresh.nextCalls > 0); + await waitFor(() => refresh.transcriptCalls > 0); first.push({ kind: 'subscription.session_projection', hostEpoch: 'host-1', @@ -1373,7 +1373,7 @@ describe('Runtime Host Maka Session driver', () => { }); assert.equal((await nextEvent(initial.activeTurn.events)).type, 'complete'); assert.equal((await initial.activeTurn.events[Symbol.asyncIterator]().next()).done, true); - await waitFor(() => refresh.nextCalls > 0); + await waitFor(() => refresh.transcriptCalls > 0); first.push({ kind: 'subscription.session_projection', hostEpoch: 'host-1', @@ -1439,7 +1439,7 @@ describe('Runtime Host Maka Session driver', () => { }); assert.equal((await nextEvent(switched.activeTurn.events)).type, 'complete'); assert.equal((await switched.activeTurn.events[Symbol.asyncIterator]().next()).done, true); - await waitFor(() => refresh.nextCalls > 0); + await waitFor(() => refresh.transcriptCalls > 0); first.push({ kind: 'subscription.session_projection', hostEpoch: 'host-1', @@ -3112,6 +3112,12 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< reject(error: Error): void; }> = []; nextCalls = 0; + transcriptCalls = 0; + #readied = false; + #openGate: () => void = () => undefined; + readonly #readyGate = new Promise((resolve) => { + this.#openGate = resolve; + }); #closed = false; #failure: Error | undefined; @@ -3127,8 +3133,19 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< return this; } + async ready(): Promise { + this.#readied = true; + this.#openGate(); + } + next(): Promise> { this.nextCalls += 1; + // The Host holds frames until the subscriber declares readiness, so a fake + // that hands them over earlier would let an ordering bug pass. + return this.#readied ? this.#deliver() : this.#readyGate.then(() => this.#deliver()); + } + + #deliver(): Promise> { const frame = this.#frames.shift(); if (frame) return Promise.resolve({ done: false, value: frame }); if (this.#failure) return Promise.reject(this.#failure); @@ -3151,13 +3168,10 @@ class FakeSubscription implements RuntimeHostSessionSubscription, AsyncIterator< } async loadTranscript(decodeMessage: (value: unknown) => T): Promise { + this.transcriptCalls += 1; return (await this.transcript).map(decodeMessage); } - async loadTranscriptOverlay(_decodeMessage: (value: unknown) => T): Promise { - return []; - } - async decodeTranscriptPage(): Promise { throw new Error('Fake subscription does not expose transcript pages'); } @@ -3755,7 +3769,7 @@ describe('turn consumer lag recovery (#3180)', () => { assert.equal((await nextEvent(switched.activeTurn.events)).text, '!'); }); - test('recovers when slow-consumer closure is buffered during initial hydration', async () => { + test('recovers from a slow-consumer closure the Host held until hydration declared readiness', async () => { const transcript = deferred(); const initial = new FakeSubscription(continuitySnapshot(), transcript.promise); const replacement = new FakeSubscription( @@ -3782,12 +3796,11 @@ describe('turn consumer lag recovery (#3180)', () => { sequence: 1, reason: 'slow_consumer', }); - await waitFor(() => initial.nextCalls === 2); transcript.resolve([assistantMessage('turn-1', 'Hello')]); const switched = await switching; assert.ok(switched.activeTurn); - assert.equal(connection.openedSubscriptions, 2); + await waitForSubscriptions(connection, 2); replacement.push(deltaFrame(1, 'turn-1', 11, '!', 'subscription-2')); assert.equal((await nextEvent(switched.activeTurn.events)).text, '!'); }); @@ -3822,18 +3835,16 @@ describe('turn consumer lag recovery (#3180)', () => { }); const switched = await driver.switchSession('session-1'); assert.ok(switched.activeTurn); - const resynced = deferred(); - driver.subscribeTranscriptReplacements!((_sessionId, _turnId, _messages, reason) => { - if (reason === 'reconnect') resynced.resolve(); - }); await initial.close(); await waitForSubscriptions(connection, 2); await delay(5); assert.equal(connection.openedSubscriptions, 2, 'the first repeated EOF is backoff-gated'); - await resynced.promise; - assert.equal(connection.openedSubscriptions, 5); + // Each replacement only ends once this Client declares readiness, so a + // dead one costs a whole recovery round rather than being spotted while + // its transcript loads. + await waitForSubscriptions(connection, 5); stable.push(deltaFrame(1, 'turn-1', 11, '!', 'subscription-5')); assert.equal((await nextEvent(switched.activeTurn.events)).text, '!'); }); diff --git a/packages/cli/src/runtime-host-run-command.ts b/packages/cli/src/runtime-host-run-command.ts index 7af73261c2..7277411455 100644 --- a/packages/cli/src/runtime-host-run-command.ts +++ b/packages/cli/src/runtime-host-run-command.ts @@ -502,7 +502,10 @@ class RuntimeHostRunRuntime implements MakaRunRuntime { ): void { const active = this.#activeTurn; if (!active || active.sessionId !== sessionId || active.turnId !== turnId) return; - active.outcome = classifierFromStoredTurn(messages, turnId, active.runId); + // A read of a running Turn stops wherever the transcript has been + // committed, so it can restore what the live stream missed but never + // proves that what the stream already delivered is gone. + acceptStoredTurn(active.outcome, messages, turnId); } #waitForGraphTurnTerminal(turnId: string): Promise { @@ -562,7 +565,7 @@ function runtimeHostSessionSummaries(items: readonly SessionCatalogItem[]): Sess } type TurnOutcomeObservation = - | { readonly kind: 'output'; readonly text: string } + | { readonly kind: 'output'; readonly text: string; readonly source: 'live' | 'stored' } | { readonly kind: 'terminal'; readonly update: 'replace' | 'if_unset'; @@ -586,6 +589,7 @@ class TurnOutcomeClassifier { readonly #outcomeId: string; readonly #unresolvedSandboxFailures = new Set(); #finalOutput: string | undefined; + #finalOutputFromLive = false; #terminal: TerminalOutcomeObservation | undefined; constructor(outcomeId: string) { @@ -597,7 +601,13 @@ class TurnOutcomeClassifier { case undefined: return; case 'output': + // A subscriber is delivered in Host order, so the stream's last answer + // is the Turn's last answer. A transcript read stops wherever the Host + // had committed, so it can supply an answer the stream never carried + // but can never overrule one it did. + if (observation.source === 'stored' && this.#finalOutputFromLive) return; this.#finalOutput = observation.text; + this.#finalOutputFromLive = observation.source === 'live'; return; case 'terminal': if (observation.update === 'replace' || this.#terminal === undefined) { @@ -642,7 +652,7 @@ class TurnOutcomeClassifier { function observationFromSessionEvent(event: SessionEvent): TurnOutcomeObservation | undefined { if (event.type === 'text_complete' && event.text.trim().length > 0) { - return { kind: 'output', text: event.text }; + return { kind: 'output', text: event.text, source: 'live' }; } if (event.type === 'error') { return { @@ -668,7 +678,7 @@ function observationFromSessionEvent(event: SessionEvent): TurnOutcomeObservatio function observationFromStoredMessage(message: StoredMessage): TurnOutcomeObservation | undefined { if (message.type === 'assistant' && message.text.trim().length > 0) { - return { kind: 'output', text: message.text }; + return { kind: 'output', text: message.text, source: 'stored' }; } if (message.type === 'turn_state' && message.status === 'completed') { return { kind: 'terminal', update: 'replace', status: 'completed' }; @@ -767,10 +777,18 @@ function classifierFromStoredTurn( outcomeId: string, ): TurnOutcomeClassifier { const classifier = new TurnOutcomeClassifier(outcomeId); + acceptStoredTurn(classifier, messages, turnId); + return classifier; +} + +function acceptStoredTurn( + classifier: TurnOutcomeClassifier, + messages: readonly StoredMessage[], + turnId: string, +): void { for (const message of messages) { if (message.turnId === turnId) classifier.accept(observationFromStoredMessage(message)); } - return classifier; } class NonInteractiveInteractionController { diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 6460b83697..2e8a5e052b 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -51,7 +51,6 @@ import type { MakaPreparedSessionTurn } from './session-driver.js'; const decodeStoredMessage = (value: unknown): StoredMessage => decodePersistedStoredMessage(markPersisted(value)); -const MAX_PENDING_FRAMES = 512; const MAX_PENDING_EVENTS_PER_TURN = 1_024; const LAG_REARM_PENDING_EVENTS = MAX_PENDING_EVENTS_PER_TURN / 2; const MAX_RECOVERY_ATTEMPTS_WITHOUT_LIVE_FRAME = 8; @@ -112,7 +111,6 @@ export class RuntimeHostSessionChannel { readonly #onFailed: ((error: Error) => void) | undefined; readonly #onRecovered: () => void; readonly #turns = new Map(); - readonly #pendingFrames: SubscriptionFrame[] = []; readonly #pendingStartedTurns = new Map(); readonly #pendingOpenedInteractions: InteractionPendingSnapshot[] = []; readonly #pendingResolvedInteractions: InteractionPendingSnapshot[] = []; @@ -232,7 +230,7 @@ export class RuntimeHostSessionChannel { this.#acceptCanonicalReplacement(messages ?? []); this.#ready = true; try { - for (const frame of this.#pendingFrames.splice(0)) this.#accept(frame); + await subscription.ready(); } catch (error) { if (!this.#canRecover(error)) throw error; this.#failedSubscriptions.add(subscription); @@ -360,18 +358,13 @@ export class RuntimeHostSessionChannel { try { for await (const frame of subscription) { if (this.#closing || this.#subscription !== subscription) return; + // The Host holds frames until `ready()`, which this channel calls only + // once the transcript it folds them onto is in place. if (!this.#ready) { - if (this.#pendingFrames.length >= MAX_PENDING_FRAMES) { - throw new RuntimeHostSubscriptionError( - 'slow_consumer', - 'Runtime Host transcript could not keep up with live Session events', - ); - } - this.#pendingFrames.push(frame); - } else { - this.#accept(frame); - if (frame.kind !== 'subscription.closed') this.#observeRecoveryLiveFrame(subscription); + throw new Error('Runtime Host sent a Session frame before the subscriber was ready'); } + this.#accept(frame); + if (frame.kind !== 'subscription.closed') this.#observeRecoveryLiveFrame(subscription); } // A stream that ends without a subscription.closed frame is a broken // live channel, not a terminal state: the Host may have torn the @@ -460,7 +453,6 @@ export class RuntimeHostSessionChannel { this.#subscription = replacement; this.#subscribeSessionDomainChanges(replacement); this.#ready = false; - this.#pendingFrames.length = 0; void this.#pump(replacement); try { const messages = await runChannelOperation( @@ -477,7 +469,7 @@ export class RuntimeHostSessionChannel { const replacedLiveState = this.#acceptCanonicalReplacement(messages); this.#recoveryAwaitingLiveFrame = replacement; this.#ready = true; - for (const frame of this.#pendingFrames.splice(0)) this.#accept(frame); + await replacement.ready(); if (replacedLiveState) this.#onRecovered(); return; } catch (error) { @@ -631,7 +623,6 @@ export class RuntimeHostSessionChannel { (error.reason === 'connection_closed' || error.reason === 'sequence_gap' || error.reason === 'projection_revision_invalid' || - error.reason === 'transcript_release_failed' || error.reason === 'slow_consumer') ); } diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 773a6eb29a..15c048af48 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -1482,11 +1482,6 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { sessionId, transcript: { kind: 'none' }, }); - const draining = (async () => { - for await (const _frame of subscription) { - // Keep the bounded subscription healthy until turn.stop settles. - } - })(); try { const turn = subscription.snapshot.rootTurn; if (!turn || isTerminalTurn(turn)) return; @@ -1497,7 +1492,6 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { }); } finally { await subscription.close().catch(() => undefined); - await draining.catch(() => undefined); } } @@ -1864,16 +1858,11 @@ async function loadCurrentMessages( sessionId, transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, }); - const draining = (async () => { - for await (const _frame of subscription) { - // The transcript is pinned to the subscription snapshot. Drain newer - // frames only to preserve the bounded transport while the read runs. - } - })(); + // This read never declares readiness, so the Host holds every frame instead + // of queueing them against a consumer that will not take them. try { return await subscription.loadTranscript(decodeStoredMessage); } finally { await subscription.close().catch(() => undefined); - await draining.catch(() => undefined); } } diff --git a/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs b/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs index 5832696c60..b76f718457 100644 --- a/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs +++ b/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs @@ -67,13 +67,12 @@ async function runFixture(fixture) { sessionId: session.id, subscriptionId: `benchmark-${fixture.name}`, throughSequence, - rootTurn: null, - activeAssistantStreams: [], maxBytes: BOOTSTRAP_BYTES, + projection: 'owner', }); const bootstrapCpuMs = performance.now() - openedAt; let pageRequests = 0; - let transferredRawBytes = bootstrap.durable.rawBytes + bootstrap.overlay.rawBytes; + let transferredRawBytes = bootstrap.durable.rawBytes; const subscription = new ClientSessionSubscription( { hostEpoch: 'benchmark-host', @@ -123,7 +122,7 @@ async function runFixture(fixture) { fixture: fixture.name, messages: messages.length, rawMiB: decimalMiB(transferredRawBytes), - bootstrapKiB: decimalKiB(bootstrap.durable.rawBytes + bootstrap.overlay.rawBytes), + bootstrapKiB: decimalKiB(bootstrap.durable.rawBytes), wireRequests, pageRequests, setupMs: setupMs.toFixed(1), @@ -163,9 +162,6 @@ function sqliteReader(store) { return { readDurableHighWater: (sessionId) => store.readTranscriptHighWaterSnapshot(sessionId), readDurablePage: (sessionId, request) => store.readTranscriptPageSnapshot(sessionId, request), - readDurableMessagesById: (sessionId, messageIds, throughSequence) => - store.readTranscriptMessagesSnapshot(sessionId, messageIds, throughSequence), - readActiveOverlay: async () => [], }; } diff --git a/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts index 26b08781eb..3feb7011f3 100644 --- a/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/agent-graph-two-client-uds.test.ts @@ -123,6 +123,7 @@ test('two Clients query and control one Agent graph through Session invalidation sessionId: ROOT_SESSION_ID, transcript: { kind: 'none' }, }); + await subscription.ready(); const [desktopSnapshot, tuiSnapshot] = await Promise.all([ desktop.request('agent.graph.query', { rootSessionId: ROOT_SESSION_ID }), diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts index e2cb002e9d..dfc022e4b2 100644 --- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts +++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts @@ -260,6 +260,7 @@ test('one Local IPC owner and one authenticated WebSocket Client control the sam sessionId: 'shared-session', transcript: { kind: 'none' }, }); + await guestSubscription.ready(); const observationGrant = preparedGuest.grants.find( (grant) => grant.kind === 'session_observation', )!; @@ -1439,7 +1440,6 @@ test('migrates the released transcript query grant when opening an existing acce assert.deepEqual(authority.authenticate(credential)?.operationGrants, [ 'host.status', 'session.transcript.page', - 'session.transcript.overlay.release', ]); } finally { await rm(directory, { recursive: true, force: true }); diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts index b1626d7995..dca0f8ab4b 100644 --- a/packages/runtime-host/src/__tests__/connection-session.test.ts +++ b/packages/runtime-host/src/__tests__/connection-session.test.ts @@ -175,7 +175,6 @@ test('transcript pages are serialized per connection before their responses are result: { kind: 'page', sessionId: 'session-1', - source: input.source, direction: input.direction, throughSequence: input.throughSequence, rawBytes: 0, @@ -207,7 +206,6 @@ test('transcript pages are serialized per connection before their responses are operation: 'session.transcript.page', input: { subscriptionId: 'subscription-1', - source: 'durable', direction: 'older', throughSequence: null, cursor: null, @@ -438,7 +436,7 @@ test('serial outbound writer reports its 2 MiB byte bound before its frame bound } }); -test('flushes concurrent subscription opens before activating their live frame streams', async () => { +test('flushes concurrent subscription opens before the frames their readiness starts', async () => { const releaseWrites = deferred(); const requestsEntered = deferred(); const allWrites = deferred(); @@ -447,6 +445,13 @@ test('flushes concurrent subscription opens before activating their live frame s operation: 'subscription.open', input: { sessionId: `session-${index}`, transcript: { kind: 'none' } }, })); + // A Client learns a subscriptionId from the open result, so it cannot ask for + // frames before that result reaches it. These follow the same way. + const followUp = Array.from({ length: 16 }, (_, index) => ({ + requestId: `ready-${index}`, + operation: 'subscription.ready', + input: { subscriptionId: `subscription-session-${index}` }, + })); const written: EncodedProtocolMessage[] = []; let aborted = false; let resolveClosed!: () => void; @@ -459,6 +464,9 @@ test('flushes concurrent subscription opens before activating their live frame s read: async () => { const frame = inbound.shift(); if (frame) return frame; + await releaseWrites.promise; + const next = followUp.shift(); + if (next) return next; return new Promise((_resolve, reject) => { rejectRead = reject; }); @@ -466,7 +474,7 @@ test('flushes concurrent subscription opens before activating their live frame s write: async (message) => { await releaseWrites.promise; written.push(message); - if (written.length === 32) allWrites.resolve(); + if (written.length === 48) allWrites.resolve(); }, closeAfterFlush: () => { resolveClosed(); @@ -527,10 +535,19 @@ test('flushes concurrent subscription opens before activating their live frame s ok: true, result: { subscriptionId: input.subscriptionId }, }), - 'session.transcript.overlay.release': async () => ({ - ok: false, - error: { code: 'operation_unavailable', message: 'not used' }, - }), + 'subscription.ready': async (input) => { + const sessionId = input.subscriptionId.slice('subscription-'.length); + void sink + ?.send({ + kind: 'subscription.session_projection', + hostEpoch: 'host-epoch', + subscriptionId: input.subscriptionId, + sequence: 1, + snapshot: largeSnapshot(sessionId), + }) + .catch(() => undefined); + return { ok: true, result: { subscriptionId: input.subscriptionId } }; + }, 'session.transcript.page': async () => ({ ok: false, error: { code: 'operation_unavailable', message: 'not used' }, @@ -538,22 +555,7 @@ test('flushes concurrent subscription opens before activating their live frame s }, attachConnection: (_connectionId, attachedSink) => { sink = attachedSink; - return { - activate: (subscriptionId) => { - const sessionId = subscriptionId.slice('subscription-'.length); - void sink - ?.send({ - kind: 'subscription.session_projection', - hostEpoch: 'host-epoch', - subscriptionId, - sequence: 1, - snapshot: largeSnapshot(sessionId), - }) - .catch(() => undefined); - }, - abort() {}, - close() {}, - }; + return { abort() {}, close() {} }; }, }; const handlers: OperationHandlerMap = { @@ -1741,6 +1743,17 @@ async function openSubscription(transport: FramedTransport, sessionId: string, r if ('kind' in response || response.operation !== 'subscription.open' || !response.ok) { throw new Error(`Unable to open ${sessionId} subscription`); } + // Frames start where the subscriber says it can take them, which is what a + // Client does once it has the open result in hand. + await writeProtocolFrame(transport, { + requestId: `${requestId}-ready`, + operation: 'subscription.ready', + input: { subscriptionId: response.result.subscriptionId }, + }); + const ready = decodeHostFrame(await transport.read(1_000)); + if ('kind' in ready || ready.operation !== 'subscription.ready' || !ready.ok) { + throw new Error(`Unable to start ${sessionId} subscription frames`); + } return response.result; } @@ -1780,18 +1793,14 @@ function canonicalProjection(sessionId: string): CanonicalSessionProjection { function transcriptBootstrapFor(sessionId: string) { const contents = Buffer.from('t'.repeat(16 * 1024)); return { - throughSequence: 0, - overlayMessageCount: 0, durable: { kind: 'page' as const, sessionId, - source: 'durable' as const, direction: 'older' as const, throughSequence: 0, rawBytes: contents.byteLength, fragments: [ { - kind: 'durable' as const, sequence: 0, byteOffset: 0, totalBytes: contents.byteLength, @@ -1803,18 +1812,6 @@ function transcriptBootstrapFor(sessionId: string) { protectedTurnSequence: null, nextCursor: null, }, - overlay: { - kind: 'page' as const, - sessionId, - source: 'overlay' as const, - direction: 'older' as const, - throughSequence: 0, - rawBytes: 0, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor: null, - }, }; } diff --git a/packages/runtime-host/src/__tests__/execution-host-message.test.ts b/packages/runtime-host/src/__tests__/execution-host-message.test.ts index e3d544a23d..0f6624b204 100644 --- a/packages/runtime-host/src/__tests__/execution-host-message.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-message.test.ts @@ -114,6 +114,7 @@ test('subscribed Clients receive the durable steering echo as a session event', sessionId: fixture.sessionId, transcript: { kind: 'none' }, }); + await subscription.ready(); const probe = new SubscriptionProbe(subscription); const turnId = randomUUID(); @@ -224,6 +225,7 @@ test('steering becomes durable and ordered followups automatically start the nex sessionId: fixture.sessionId, transcript: { kind: 'none' }, }); + await queueSubscription.ready(); const queuedFollowups = queueSubscription.snapshot.queue.followup; assert.deepEqual( queuedFollowups.map((entry) => entry.messageId), diff --git a/packages/runtime-host/src/__tests__/execution-host-queue.test.ts b/packages/runtime-host/src/__tests__/execution-host-queue.test.ts index 5bc6d651bf..b6791deeee 100644 --- a/packages/runtime-host/src/__tests__/execution-host-queue.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-queue.test.ts @@ -130,6 +130,8 @@ test('subscribed Clients share one canonical queue and ordered root handoff', as sessionId: fixture.sessionId, transcript: { kind: 'none' }, }); + await desktopSubscription.ready(); + await tuiSubscription.ready(); const desktopProbe = new SubscriptionProbe(desktopSubscription); const tuiProbe = new SubscriptionProbe(tuiSubscription); for (const subscription of [desktopSubscription, tuiSubscription]) { @@ -239,12 +241,12 @@ test('a quote-only queued message survives the wire snapshot, the admission chai await withExecutionRoot(async (fixture) => { const host = await fixture.startHost(); const client = await connectClient(fixture.root); - const probe = new SubscriptionProbe( - await client.openSessionSubscription({ - sessionId: fixture.sessionId, - transcript: { kind: 'none' }, - }), - ); + const subscription = await client.openSessionSubscription({ + sessionId: fixture.sessionId, + transcript: { kind: 'none' }, + }); + await subscription.ready(); + const probe = new SubscriptionProbe(subscription); // The root turn occupies the session so the quote-only submit queues as // a follow-up instead of opening a successor. @@ -295,6 +297,7 @@ test('a quote-only queued message survives the wire snapshot, the admission chai sessionId: fixture.sessionId, transcript: { kind: 'none' }, }); + await recoveredSubscription.ready(); // The restart promotes the queued follow-up into a successor root Turn // that runs to completion; the durable user message must carry the // quote excerpt — the full submit -> admission -> wire snapshot -> @@ -499,6 +502,7 @@ test('a Host crash after queue admission recovers the durable successor once', a sessionId: fixture.sessionId, transcript: { kind: 'none' }, }); + await subscription.ready(); const probe = new SubscriptionProbe(subscription); const successor = await probe.waitFor( (frame) => @@ -634,6 +638,7 @@ test('a killed Host is recovered exactly once before its successor becomes ready sessionId: fixture.sessionId, transcript: { kind: 'none' }, }); + await firstSubscription.ready(); const firstProbe = new SubscriptionProbe(firstSubscription); const turnId = randomUUID(); const started = requireStartedTurn( @@ -666,6 +671,7 @@ test('a killed Host is recovered exactly once before its successor becomes ready sessionId: fixture.sessionId, transcript: { kind: 'none' }, }); + await recoveredSubscription.ready(); const recovered = await second.request('turn.query', { sessionId: fixture.sessionId, turnId, @@ -739,6 +745,7 @@ test('graceful Host shutdown stops and drains an active Turn before releasing ow sessionId: fixture.sessionId, transcript: { kind: 'none' }, }); + await subscription.ready(); const probe = new SubscriptionProbe(subscription); const turnId = randomUUID(); const started = requireStartedTurn( @@ -788,6 +795,7 @@ test('Host shutdown contains a user-question admission rejected by Interaction d sessionId: fixture.sessionId, transcript: { kind: 'none' }, }); + await subscription.ready(); const probe = new SubscriptionProbe(subscription); const turnId = randomUUID(); const started = requireStartedTurn( diff --git a/packages/runtime-host/src/__tests__/execution-host.test.ts b/packages/runtime-host/src/__tests__/execution-host.test.ts index 096479011b..cf1d1c5321 100644 --- a/packages/runtime-host/src/__tests__/execution-host.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host.test.ts @@ -731,6 +731,7 @@ test('two Clients share one execution after the starting Client disconnects', as sessionId: fixture.sessionId, transcript: { kind: 'tail', maxBytes: 16 * 1024 }, }); + await secondSubscription.ready(); const transcript = await secondSubscription.loadTranscript(decodeStoredMessage); assert.ok( transcript.some( @@ -1066,6 +1067,7 @@ test('a disconnected Client leaves a durable Interaction that another Client can sessionId: fixture.sessionId, transcript: { kind: 'none' }, }); + await subscription.ready(); const probe = new SubscriptionProbe(subscription); const pending = await waitForPendingInteraction(subscription, probe, started.runId); assert.equal(pending.sessionId, fixture.sessionId); @@ -1166,6 +1168,7 @@ test('two UDS Clients settle one hosted sandbox boundary and resume its exact Ru sessionId: fixture.sessionId, transcript: { kind: 'none' }, }); + await subscription.ready(); const probe = new SubscriptionProbe(subscription); const turnId = randomUUID(); const started = requireStartedTurn( diff --git a/packages/runtime-host/src/__tests__/fixtures/client-session-subscription.ts b/packages/runtime-host/src/__tests__/fixtures/client-session-subscription.ts new file mode 100644 index 0000000000..5370828588 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/client-session-subscription.ts @@ -0,0 +1,42 @@ +/* + * 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 { ClientSessionSubscription } from '../../client/session-subscription.js'; + +type Args = ConstructorParameters; + +/** + * A subscription whose subject is what it decodes, not when it starts. + * + * Declaring readiness reaches the Host over the connection these subjects do + * not have, so it is a no-op here. A test about when frames start builds its + * subscription against a real coordinator instead. + */ +export function clientSubscription( + result: Args[0], + requestClose: Args[1], + readTranscriptPage: Args[2], +): ClientSessionSubscription { + return new ClientSessionSubscription( + result, + requestClose, + readTranscriptPage, + async () => undefined, + ); +} diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index dc61582009..3817d05bdc 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -1558,6 +1558,7 @@ export async function waitForTerminalTurn( { sessionId, transcript: { kind: 'none' } }, PROCESS_TIMEOUT_MS, ); + await subscription.ready(); try { return await withTimeout( (async () => { diff --git a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts index 7bad8c5fc9..d572e4ddbe 100644 --- a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts +++ b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts @@ -24,7 +24,6 @@ import type { SessionTranscriptReader } from '../../server/session-transcript-re export function transcriptReader( durable: readonly StoredMessage[], - overlay: readonly StoredMessage[] = [], sequenceStride = 1, ): SessionTranscriptReader { const durableRecords = () => @@ -136,14 +135,6 @@ export function transcriptReader( records.length < candidates.length ? candidates[records.length]!.sequence : null, }; }, - readDurableMessagesById: async (_sessionId, request) => - request.throughSequence === null - ? [] - : durableRecords().flatMap(({ sequence, message }) => - sequence <= request.throughSequence! && request.messageIds.includes(message.id) - ? [message] - : [], - ), readDurableTurnContributions: async ( _sessionId, throughSequence, @@ -186,6 +177,5 @@ export function transcriptReader( } return { throughSequence: watermark, landmarks }; }, - readActiveOverlay: async () => overlay, }; } diff --git a/packages/runtime-host/src/__tests__/peer-session-collaboration.test.ts b/packages/runtime-host/src/__tests__/peer-session-collaboration.test.ts index 8ab1409613..a9debae8eb 100644 --- a/packages/runtime-host/src/__tests__/peer-session-collaboration.test.ts +++ b/packages/runtime-host/src/__tests__/peer-session-collaboration.test.ts @@ -228,6 +228,7 @@ test('production collaboration retains distinct Guest mounts, exact requests and sessionId: sessionIds[0]!, transcript: { kind: 'none' }, }); + await subscription.ready(); const frames: SubscriptionFrame[] = []; const events = (async () => { for await (const frame of subscription) frames.push(frame); diff --git a/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts index 51baf5d0ca..f1939b0876 100644 --- a/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/plan-two-client-uds.test.ts @@ -106,6 +106,7 @@ test('two Clients and a restarted production Host share one retry-safe Plan auth sessionId: session.id, transcript: { kind: 'none' }, }); + await subscription.ready(); const first = await desktop.request('plan.query', { kind: 'list_start', diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index efa126d013..e69ae4fd8b 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -617,7 +617,10 @@ test('startup recovery replays one admitted safe-boundary continuation without a assert.equal(opened.ok, true, JSON.stringify(opened)); if (!opened.ok) assert.fail('Unable to observe the recovered Session'); observeTerminal(opened.result.snapshot); - observer.activate(opened.result.subscriptionId); + await continuity.handlers['subscription.ready']( + { subscriptionId: opened.result.subscriptionId }, + operationContext(fixture.hostEpoch, fixture.acquireResidency, connectionId), + ); await recovery.recover(); assert.equal( @@ -3061,7 +3064,10 @@ test('hosted linked child roots share admission, message, terminal, and stop aut ); assert.equal(parentOpened.ok, true); if (!parentOpened.ok) return; - parentConnection.activate(parentOpened.result.subscriptionId); + await continuity.handlers['subscription.ready']( + { subscriptionId: parentOpened.result.subscriptionId }, + operationContext(hostEpoch, acquireResidency, parentConnectionId), + ); const parentTurnId = randomUUID(); const parentStarted = await interactiveTurns.handlers['turn.start']( @@ -3122,7 +3128,10 @@ test('hosted linked child roots share admission, message, terminal, and stop aut ); assert.equal(opened.ok, true); if (!opened.ok) throw new Error('Unable to subscribe to hosted linked child'); - connection.activate(opened.result.subscriptionId); + await childContinuity.handlers['subscription.ready']( + { subscriptionId: opened.result.subscriptionId }, + operationContext(hostEpoch, acquireResidency, childConnectionId), + ); closeChildContinuity = () => connection.close(); }, onEvent: () => { @@ -5921,15 +5930,19 @@ test('repeated handoffs preserve one logical admission, decreasing budget and ex stores: fixture.stores, canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, }); - const overlay = await transcript.readActiveOverlay(fixture.sessionId, { - sessionId: fixture.sessionId, - turnId: 'repeated-handoff', - runId: rootRunId, - status: 'running', + const page = await transcript.readDurablePage(fixture.sessionId, { + direction: 'newer', + throughSequence: await transcript.readDurableHighWater(fixture.sessionId), + maxBytes: 512 * 1024, + maxMessages: 256, }); + assert.equal(page.next, null); assert.deepEqual( - overlay.filter((message) => message.type === 'assistant').map((message) => message.id), - ['assistant-0', 'assistant-1', 'assistant-2'], + page.fragments + .map((fragment) => JSON.parse(Buffer.from(fragment.data).toString('utf8'))) + .filter((message) => message.type === 'assistant') + .map((message) => message.id), + ['assistant-0', 'assistant-1'], ); } const submitted = await fixture.messages.handlers['turn.message.submit']( diff --git a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts index dc689b52d9..0c833bfc05 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts @@ -245,6 +245,7 @@ test('two Clients share stable Session creation, CAS configuration, and catalog sessionId: created.id, transcript: { kind: 'none' }, }); + await subscription.ready(); const iterator = subscription[Symbol.asyncIterator](); assert.equal(subscription.snapshot.session.metadataRevision, created.revision); await assert.rejects( @@ -477,6 +478,7 @@ test('two Clients share stable Session creation, CAS configuration, and catalog sessionId: created.id, transcript: { kind: 'none' }, }); + await retirementSubscription.ready(); const retirementIterator = retirementSubscription[Symbol.asyncIterator](); const beforeArchive = await querySession(desktop, created.id); assert.equal(beforeArchive.status, 'active'); diff --git a/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts index 838bbbd98c..60791034b6 100644 --- a/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts +++ b/packages/runtime-host/src/__tests__/session-collaboration-authority.test.ts @@ -128,8 +128,8 @@ test('Session Guest invitation, grants, and revocation form one durable authorit 'subscription.open', 'subscription.close', 'subscription.pty_interest.set', + 'subscription.ready', 'session.transcript.page', - 'session.transcript.overlay.release', 'access.credential.finalize', ]); assert.ok(activeGuest); diff --git a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts index 74b0109e90..2637f4fd27 100644 --- a/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts @@ -43,7 +43,7 @@ import { import { SessionAdmissionGate } from '../server/session-admission-gate.js'; import type { SessionContinuityFrameSink } from '../server/session-continuity-service.js'; import type { SessionTranscriptReader } from '../server/session-transcript-reader.js'; -import { ClientSessionSubscription } from '../client/session-subscription.js'; +import { clientSubscription } from './fixtures/client-session-subscription.js'; import { transcriptReader } from './fixtures/session-transcript-reader.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; @@ -68,7 +68,7 @@ test('open is an inactive publication barrier and live sequence starts at nextSe new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opening = coordinator.handlers['subscription.open']( { sessionId: SESSION_ID, transcript: { kind: 'none' } }, @@ -126,7 +126,7 @@ test('Guest revocation wins a concurrent subscription open', async () => { }, }, ); - coordinator.attachConnection('guest-connection', new RecordingSink()); + attachTestConnection(coordinator, 'guest-connection', new RecordingSink()); const opening = coordinator.handlers['subscription.open']( { sessionId: SESSION_ID, transcript: { kind: 'none' } }, @@ -154,7 +154,7 @@ test('forwards the durable steering echo to subscribers as a session event', asy async () => canonical(), new SessionAdmissionGate(), ); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); await delayImmediate(); @@ -217,7 +217,7 @@ test('projects model-only user content out of Guest queue and steering frames', subscribeGrantRevocations: () => () => undefined, }, ); - const connection = coordinator.attachConnection('guest-connection', sink); + const connection = attachTestConnection(coordinator, 'guest-connection', sink); const opened = await open( coordinator, 'guest-connection', @@ -267,7 +267,7 @@ test('open snapshot includes pending Interactions from the canonical projection' async () => canonical({ interactions: { pending: [pending] } }), new SessionAdmissionGate(), ); - const connection = coordinator.attachConnection('connection-1', new RecordingSink()); + const connection = attachTestConnection(coordinator, 'connection-1', new RecordingSink()); const opened = await open(coordinator, 'connection-1'); assert.deepEqual(opened.snapshot.interactions, { pending: [pending] }); @@ -282,7 +282,7 @@ test('open identifies every assistant stream that is still active and round-trip async () => canonical(), new SessionAdmissionGate(), ); - coordinator.attachConnection('connection-1', new RecordingSink()); + attachTestConnection(coordinator, 'connection-1', new RecordingSink()); await open(coordinator, 'connection-1'); await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(1)); await coordinator.acceptRuntimeEvent( @@ -295,7 +295,7 @@ test('open identifies every assistant stream that is still active and round-trip messageId: 'message-3', }); - coordinator.attachConnection('connection-2', new RecordingSink()); + attachTestConnection(coordinator, 'connection-2', new RecordingSink()); const active = await open(coordinator, 'connection-2'); assert.deepEqual(active.activeAssistantStreams, [ { kind: 'text', turnId: 'turn-1', messageId: 'message-1' }, @@ -322,7 +322,7 @@ test('open identifies every assistant stream that is still active and round-trip 'run-1', textCompleteEvent('message-1', 'chunk-1'), ); - coordinator.attachConnection('connection-3', new RecordingSink()); + attachTestConnection(coordinator, 'connection-3', new RecordingSink()); const remaining = await open(coordinator, 'connection-3'); assert.deepEqual(remaining.activeAssistantStreams, [ { kind: 'thinking', turnId: 'turn-1', messageId: 'message-2' }, @@ -340,7 +340,7 @@ test('open identifies every assistant stream that is still active and round-trip 'run-1', textCompleteEvent('message-3', 'chunk-2'), ); - coordinator.attachConnection('connection-4', new RecordingSink()); + attachTestConnection(coordinator, 'connection-4', new RecordingSink()); const completed = await open(coordinator, 'connection-4'); assert.deepEqual(completed.activeAssistantStreams, []); coordinator.close(); @@ -353,7 +353,7 @@ test('publishes a non-prefix final value as an authoritative replacement', async new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); @@ -400,7 +400,7 @@ test('coalesces reasoning parts and completes the step before later steps contin new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); @@ -463,7 +463,7 @@ test('terminal fence suppresses ordinary refresh until the exact terminal cut pu new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); @@ -493,72 +493,6 @@ test('terminal fence suppresses ordinary refresh until the exact terminal cut pu coordinator.close(); }); -test('does not reuse an active transcript overlay after terminal publication', async () => { - let projection = canonical(); - const partial = assistantMessage('partial'); - const baseReader = transcriptReader([], [partial]); - let overlayReads = 0; - const reader: SessionTranscriptReader = { - ...baseReader, - readActiveOverlay: async (sessionId, rootTurn) => { - overlayReads += 1; - if ( - !rootTurn || - rootTurn.status === 'completed' || - rootTurn.status === 'failed' || - rootTurn.status === 'cancelled' - ) { - return []; - } - return baseReader.readActiveOverlay(sessionId, rootTurn); - }, - }; - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async () => projection, - new SessionAdmissionGate(), - undefined, - reader, - ); - await coordinator.holdTerminalPublication(SESSION_ID, 'turn-1', 'run-1'); - coordinator.attachConnection('connection-active-overlay', new RecordingSink()); - const active = await open(coordinator, 'connection-active-overlay', { - kind: 'tail', - maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, - }); - assert.ok((active.transcript?.overlay.rawBytes ?? 0) > 0); - assert.equal(overlayReads, 1); - - projection = canonical({ - rootTurn: { - sessionId: SESSION_ID, - turnId: 'turn-1', - runId: 'run-1', - status: 'completed', - terminalEventId: 'event-terminal', - }, - }); - await coordinator.publishTerminalProjection(SESSION_ID, 'turn-1', 'run-1'); - - coordinator.attachConnection('connection-terminal-overlay', new RecordingSink()); - const terminal = await open(coordinator, 'connection-terminal-overlay', { - kind: 'tail', - maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, - }); - assert.equal(terminal.snapshot.rootTurn?.status, 'completed'); - assert.equal(terminal.transcript?.overlay.rawBytes, 0); - assert.equal(overlayReads, 1); - const client = new ClientSessionSubscription( - terminal, - async () => undefined, - async () => { - throw new Error('empty terminal transcript unexpectedly requested another page'); - }, - ); - assert.deepEqual(await client.loadTranscript((value) => value), []); - coordinator.close(); -}); - test('detached canonical refreshes coalesce before Store I/O', async () => { let projection = canonical(); let reads = 0; @@ -577,7 +511,7 @@ test('detached canonical refreshes coalesce before Store I/O', async () => { new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); @@ -606,7 +540,7 @@ test('in-flight canonical refresh observes an invalidation after its first read' new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); @@ -641,7 +575,7 @@ test('reports a detached canonical publication failure to the Host lifecycle', a new SessionAdmissionGate(), (error) => observed.resolve(error), ); - const connection = coordinator.attachConnection('connection-1', new RecordingSink()); + const connection = attachTestConnection(coordinator, 'connection-1', new RecordingSink()); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); @@ -657,7 +591,7 @@ test('rejects a live event that is not owned by the canonical root', async () => async () => canonical(), new SessionAdmissionGate(), ); - const connection = coordinator.attachConnection('connection-1', new RecordingSink()); + const connection = attachTestConnection(coordinator, 'connection-1', new RecordingSink()); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); @@ -675,7 +609,7 @@ test('coalesces Agent graph invalidations onto the Session subscription sequence new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); @@ -714,7 +648,7 @@ test('coalesces typed domain invalidations without publishing continuity project new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); @@ -761,7 +695,7 @@ test('fans one bounded Runtime Resource burst out to an inherited Session view', }, ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const outcome = await coordinator.handlers['subscription.open']( { sessionId: childSessionId, transcript: { kind: 'none' } }, connectionContext('connection-1'), @@ -770,7 +704,7 @@ test('fans one bounded Runtime Resource burst out to an inherited Session view', if (!outcome.ok) return; connection.activate(outcome.result.subscriptionId); const guestSink = new RecordingSink(); - const guestConnection = coordinator.attachConnection('guest-connection', guestSink); + const guestConnection = attachTestConnection(coordinator, 'guest-connection', guestSink); const guestOutcome = await coordinator.handlers['subscription.open']( { sessionId: childSessionId, transcript: { kind: 'none' } }, connectionContext('guest-connection', { @@ -830,7 +764,7 @@ test('publishes live PTY bytes independently of the Session continuity sequence' new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); @@ -901,7 +835,7 @@ test('PTY overflow is bounded and requests terminal-only recovery while Session ); const blocked = deferred(); const frames: SubscriptionFrame[] = []; - const connection = coordinator.attachConnection('connection-1', { + const connection = attachTestConnection(coordinator, 'connection-1', { async send(frame) { frames.push(frame); if (frame.kind === 'subscription.runtime_resource_pty_data' && frames.length === 1) @@ -944,8 +878,8 @@ test('slow subscriber receives a terminal eviction without delaying another subs ); const slowSink = new RecordingSink(); const fastSink = new RecordingSink(); - const slowConnection = coordinator.attachConnection('connection-slow', slowSink); - const fastConnection = coordinator.attachConnection('connection-fast', fastSink); + const slowConnection = attachTestConnection(coordinator, 'connection-slow', slowSink); + const fastConnection = attachTestConnection(coordinator, 'connection-fast', fastSink); const slow = await open(coordinator, 'connection-slow'); const fast = await open(coordinator, 'connection-fast'); fastConnection.activate(fast.subscriptionId); @@ -984,8 +918,8 @@ test('coalesces queued assistant deltas instead of evicting a slow subscriber', ); const slowSink = new RecordingSink(); const fastSink = new RecordingSink(); - const slowConnection = coordinator.attachConnection('connection-slow', slowSink); - const fastConnection = coordinator.attachConnection('connection-fast', fastSink); + const slowConnection = attachTestConnection(coordinator, 'connection-slow', slowSink); + const fastConnection = attachTestConnection(coordinator, 'connection-fast', fastSink); const slow = await open(coordinator, 'connection-slow'); const fast = await open(coordinator, 'connection-fast'); fastConnection.activate(fast.subscriptionId); @@ -1031,7 +965,7 @@ test('keeps stream, kind, and completion boundaries when coalescing deltas', asy new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(1, 'message-1')); @@ -1095,7 +1029,7 @@ test('keeps coalesced deltas within the protocol text and frame limits', async ( new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); // Each delta is individually protocol-valid, but merging the two would @@ -1140,8 +1074,8 @@ test('removal closes every Session subscriber at the admitted sequence boundary' ); const desktopSink = new RecordingSink(); const tuiSink = new RecordingSink(); - const desktop = coordinator.attachConnection('connection-desktop', desktopSink); - const tui = coordinator.attachConnection('connection-tui', tuiSink); + const desktop = attachTestConnection(coordinator, 'connection-desktop', desktopSink); + const tui = attachTestConnection(coordinator, 'connection-tui', tuiSink); const desktopSubscription = await open(coordinator, 'connection-desktop'); const tuiSubscription = await open(coordinator, 'connection-tui'); desktop.activate(desktopSubscription.subscriptionId); @@ -1171,17 +1105,16 @@ test('removal closes every Session subscriber at the admitted sequence boundary' coordinator.close(); }); -test('open returns a bounded durable tail and an immutable active overlay', async () => { +test('open returns a bounded immutable durable tail', async () => { const durable: StoredMessage[] = [assistantMessage('durable')]; - const overlay = [assistantMessage('live overlay')]; const coordinator = new SessionContinuityCoordinator( HOST_EPOCH, async () => canonical(), new SessionAdmissionGate(), undefined, - transcriptReader(durable, overlay), + transcriptReader(durable), ); - const connection = coordinator.attachConnection('connection-1', new RecordingSink()); + const connection = attachTestConnection(coordinator, 'connection-1', new RecordingSink()); const opened = await open(coordinator, 'connection-1', { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, @@ -1189,7 +1122,7 @@ test('open returns a bounded durable tail and an immutable active overlay', asyn connection.activate(opened.subscriptionId); assert.ok(opened.transcript); if (!opened.transcript) return; - const client = new ClientSessionSubscription( + const client = clientSubscription( opened, async () => undefined, async () => { @@ -1197,241 +1130,18 @@ test('open returns a bounded durable tail and an immutable active overlay', asyn }, ); durable[0] = assistantMessage('mutated after open'); - overlay[0] = assistantMessage('mutated after open'); - assert.deepEqual(await client.loadTranscript((value) => value), [ - assistantMessage('live overlay'), - ]); + assert.deepEqual(await client.loadTranscript((value) => value), [assistantMessage('durable')]); coordinator.close(); }); -test('shares one immutable active overlay preparation until the Session changes', async () => { - const baseReader = transcriptReader( - [assistantMessage('durable')], - [assistantMessage('overlay'.repeat(8 * 1024))], - ); - let overlayReads = 0; +test('reports a durable bootstrap read failure as unavailable persistence', async () => { const reader: SessionTranscriptReader = { - ...baseReader, - readActiveOverlay: async (...args) => { - overlayReads += 1; - return baseReader.readActiveOverlay(...args); - }, - }; - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async () => canonical(), - new SessionAdmissionGate(), - undefined, - reader, - ); - coordinator.attachConnection('connection-overlay-1', new RecordingSink()); - coordinator.attachConnection('connection-overlay-2', new RecordingSink()); - await open(coordinator, 'connection-overlay-1', { - kind: 'tail', - maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, - }); - await open(coordinator, 'connection-overlay-2', { - kind: 'tail', - maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, - }); - assert.equal(overlayReads, 1); - - await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(1)); - coordinator.attachConnection('connection-overlay-3', new RecordingSink()); - await open(coordinator, 'connection-overlay-3', { - kind: 'tail', - maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, - }); - assert.equal(overlayReads, 2); - coordinator.close(); -}); - -test('queues concurrent overlay preparation instead of rejecting an empty overlay', async () => { - const firstRead = deferred(); - let reads = 0; - const reader = transcriptReader([]); - const boundedReader: SessionTranscriptReader = { - ...reader, - readActiveOverlay: async () => { - reads += 1; - if (reads === 1) await firstRead.promise; - return []; - }, - }; - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async (sessionId) => canonicalFor(sessionId), - new SessionAdmissionGate(), - undefined, - boundedReader, - ); - coordinator.attachConnection('connection-queued-1', new RecordingSink()); - coordinator.attachConnection('connection-queued-2', new RecordingSink()); - - const first = openForSession(coordinator, 'connection-queued-1', 'session-queued-1'); - await waitFor(() => reads === 1); - const second = openForSession(coordinator, 'connection-queued-2', 'session-queued-2'); - await delayImmediate(); - assert.equal(reads, 1); - let runtimeEventAccepted = false; - const acceptRuntimeEvent = coordinator - .acceptRuntimeEvent('session-queued-2', 'run-1', previewEvent()) - .finally(() => { - runtimeEventAccepted = true; - }); - await delayImmediate(); - assert.equal(runtimeEventAccepted, true); - await acceptRuntimeEvent; - - firstRead.resolve(); - const [firstResult, secondResult] = await Promise.all([first, second]); - assert.ok(firstResult.transcript); - assert.ok(secondResult.transcript); - assert.equal(reads, 2); - coordinator.close(); -}); - -test('rechecks a reusable overlay cache after queued preparation reaches capacity', async () => { - const firstCanonicalRead = deferred(); - let canonicalReads = 0; - let overlayReads = 0; - const baseReader = transcriptReader([]); - const reader: SessionTranscriptReader = { - ...baseReader, - readActiveOverlay: async (sessionId) => { - overlayReads += 1; - return [ - assistantMessage( - sessionId === 'session-retained' - ? 'x'.repeat(2 * 1024 * 1024) - : 'x'.repeat(15 * 1024 * 1024), - ), - ]; - }, - }; - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async (sessionId) => { - if (sessionId === 'session-shared' && canonicalReads++ === 0) { - await firstCanonicalRead.promise; - } - return canonicalFor(sessionId); - }, - new SessionAdmissionGate(), - undefined, - reader, - ); - coordinator.attachConnection('connection-retained', new RecordingSink()); - coordinator.attachConnection('connection-shared-1', new RecordingSink()); - coordinator.attachConnection('connection-shared-2', new RecordingSink()); - await openForSession(coordinator, 'connection-retained', 'session-retained'); - - const first = openForSession(coordinator, 'connection-shared-1', 'session-shared'); - await delayImmediate(); - const second = openForSession(coordinator, 'connection-shared-2', 'session-shared'); - firstCanonicalRead.resolve(); - - const [firstResult, secondResult] = await Promise.all([first, second]); - assert.ok(firstResult.transcript); - assert.ok(secondResult.transcript); - assert.equal(overlayReads, 2); - coordinator.close(); -}); - -test('keeps a shared overlay retained while another subscription open consumes it', async () => { - const durable = [ - { - type: 'user' as const, - id: 'durable-1', - turnId: 'turn-1', - ts: 1, - text: 'history', - }, - ]; - const baseReader = transcriptReader(durable, [assistantMessage('partial')]); - const secondPageStarted = deferred(); - const continueSecondPage = deferred(); - let pageReads = 0; - const reader: SessionTranscriptReader = { - ...baseReader, - readDurablePage: async (...args) => { - pageReads += 1; - if (pageReads === 2) { - secondPageStarted.resolve(); - await continueSecondPage.promise; - } - return baseReader.readDurablePage(...args); - }, - }; - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async () => canonical(), - new SessionAdmissionGate(), - undefined, - reader, - ); - coordinator.attachConnection('connection-shared-overlay-1', new RecordingSink()); - coordinator.attachConnection('connection-shared-overlay-2', new RecordingSink()); - const first = await openForSession(coordinator, 'connection-shared-overlay-1', SESSION_ID); - const second = openForSession(coordinator, 'connection-shared-overlay-2', SESSION_ID); - await secondPageStarted.promise; - - assert.deepEqual( - await coordinator.handlers['session.transcript.overlay.release']( - { subscriptionId: first.subscriptionId }, - connectionContext('connection-shared-overlay-1'), - ), - { ok: true, result: { subscriptionId: first.subscriptionId } }, - ); - continueSecondPage.resolve(); - assert.ok((await second).transcript); - coordinator.close(); -}); - -test('cancels a queued overlay preparation when its connection closes', async () => { - const firstRead = deferred(); - let reads = 0; - const reader = transcriptReader([]); - const boundedReader: SessionTranscriptReader = { - ...reader, - readActiveOverlay: async () => { - reads += 1; - if (reads === 1) await firstRead.promise; - return []; + ...transcriptReader([]), + readDurableHighWater: async () => 0, + readDurablePage: async () => { + throw new Error('injected durable bootstrap failure'); }, }; - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async (sessionId) => canonicalFor(sessionId), - new SessionAdmissionGate(), - undefined, - boundedReader, - ); - coordinator.attachConnection('connection-cancel-1', new RecordingSink()); - const queuedConnection = coordinator.attachConnection('connection-cancel-2', new RecordingSink()); - - const first = openForSession(coordinator, 'connection-cancel-1', 'session-cancel-1'); - await waitFor(() => reads === 1); - const queued = openForSession(coordinator, 'connection-cancel-2', 'session-cancel-2'); - await delayImmediate(); - queuedConnection.close(); - await assert.rejects(queued, /Session transcript is unavailable/); - assert.equal(reads, 1); - - firstRead.resolve(); - assert.ok((await first).transcript); - coordinator.close(); -}); - -test('fails fast when retained active overlays leave no preparation capacity', async () => { - let generation = 0; - const baseReader = transcriptReader([]); - const reader: SessionTranscriptReader = { - ...baseReader, - readActiveOverlay: async () => [ - assistantMessage(`${generation}:${'x'.repeat(15 * 1024 * 1024)}`), - ], - }; const coordinator = new SessionContinuityCoordinator( HOST_EPOCH, async () => canonical(), @@ -1439,204 +1149,24 @@ test('fails fast when retained active overlays leave no preparation capacity', a undefined, reader, ); - const opened: Array<{ abort(subscriptionId: string): void; subscriptionId: string }> = []; - for (let index = 0; index < 2; index += 1) { - const connection = coordinator.attachConnection( - `connection-overlay-budget-${index}`, - new RecordingSink(), - ); - const result = await open(coordinator, `connection-overlay-budget-${index}`, { - kind: 'tail', - maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, - }); - opened.push({ abort: connection.abort, subscriptionId: result.subscriptionId }); - generation += 1; - await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(index + 1)); - } - - coordinator.attachConnection('connection-overlay-budget-full', new RecordingSink()); - const unavailable = await coordinator.handlers['subscription.open']( + attachTestConnection(coordinator, 'connection-failed-bootstrap', new RecordingSink()); + const outcome = await coordinator.handlers['subscription.open']( { sessionId: SESSION_ID, transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, }, - connectionContext('connection-overlay-budget-full'), + connectionContext('connection-failed-bootstrap'), ); - assert.deepEqual(unavailable, { + assert.deepEqual(outcome, { ok: false, - error: { - code: 'operation_unavailable', - message: 'Runtime Host transcript overlay capacity reached', - }, - }); - - opened[0]!.abort(opened[0]!.subscriptionId); - const recovered = await open(coordinator, 'connection-overlay-budget-full', { - kind: 'tail', - maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, + error: { code: 'persistence_failed', message: 'Session transcript is unavailable' }, }); - assert.ok(recovered.transcript); coordinator.close(); }); -test('releases retained overlays after their materialization is acknowledged', async () => { - let generation = 0; - const baseReader = transcriptReader([]); - const reader: SessionTranscriptReader = { - ...baseReader, - readActiveOverlay: async () => [ - assistantMessage(`${generation}:${'x'.repeat(15 * 1024 * 1024)}`), - ], - }; - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async () => canonical(), - new SessionAdmissionGate(), - undefined, - reader, - ); - - for (let index = 0; index < 3; index += 1) { - const connectionId = `connection-consumed-overlay-${index}`; - coordinator.attachConnection(connectionId, new RecordingSink()); - const opened = await open(coordinator, connectionId, { - kind: 'tail', - maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, - }); - await consumeBootstrapOverlay(coordinator, connectionId, opened); - generation += 1; - await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(index + 1)); - } - coordinator.close(); -}); - -test('treats overlay release as idempotent after teardown without crossing connection ownership', async () => { - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async () => canonical(), - new SessionAdmissionGate(), - undefined, - transcriptReader([], [assistantMessage('partial')]), - ); - const owner = coordinator.attachConnection('connection-release-owner', new RecordingSink()); - coordinator.attachConnection('connection-release-foreign', new RecordingSink()); - const opened = await openForSession(coordinator, 'connection-release-owner', SESSION_ID); - const input = { subscriptionId: opened.subscriptionId }; - - assert.deepEqual( - await coordinator.handlers['session.transcript.overlay.release']( - input, - connectionContext('connection-release-foreign'), - ), - { - ok: false, - error: { code: 'not_found', message: 'Session subscription was not found' }, - }, - ); - owner.abort(opened.subscriptionId); - assert.deepEqual( - await coordinator.handlers['session.transcript.overlay.release']( - input, - connectionContext('connection-release-owner'), - ), - { ok: true, result: input }, - ); - coordinator.close(); -}); - -test('opens a terminal Session without reserving active overlay preparation capacity', async () => { - const overlay = assistantMessage('x'.repeat(9 * 1024 * 1024)); - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async (sessionId) => - sessionId === 'session-terminal' - ? canonicalFor(sessionId, { rootTurn: null }) - : canonicalFor(sessionId), - new SessionAdmissionGate(), - undefined, - transcriptReader([], [overlay]), - ); - for (const index of [1, 2]) { - const connectionId = `connection-retained-${index}`; - coordinator.attachConnection(connectionId, new RecordingSink()); - await openForSession(coordinator, connectionId, `session-retained-${index}`); - } - coordinator.attachConnection('connection-terminal', new RecordingSink()); - - const opened = await openForSession(coordinator, 'connection-terminal', 'session-terminal'); - - assert.equal(opened.transcript?.overlayMessageCount, 0); - coordinator.close(); -}); - -test('releases the active overlay budget after the last transcript subscriber leaves', async () => { - const overlay = assistantMessage('x'.repeat(15 * 1024 * 1024)); - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async (sessionId) => canonicalFor(sessionId), - new SessionAdmissionGate(), - undefined, - transcriptReader([], [overlay]), - ); - - for (let index = 0; index < 5; index += 1) { - const connectionId = `connection-idle-overlay-${index}`; - const sessionId = `session-idle-overlay-${index}`; - const connection = coordinator.attachConnection(connectionId, new RecordingSink()); - const outcome = await coordinator.handlers['subscription.open']( - { - sessionId, - transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, - }, - connectionContext(connectionId), - ); - assert.equal(outcome.ok, true); - if (!outcome.ok) continue; - connection.abort(outcome.result.subscriptionId); - } - coordinator.close(); -}); - -test('releases the active overlay budget when subscription open fails after preparation', async () => { - const overlay = assistantMessage('x'.repeat(15 * 1024 * 1024)); - const baseReader = transcriptReader([], [overlay]); - const reader: SessionTranscriptReader = { - ...baseReader, - readDurableHighWater: async () => 0, - readDurablePage: async () => { - throw new Error('injected durable bootstrap failure'); - }, - }; - const coordinator = new SessionContinuityCoordinator( - HOST_EPOCH, - async (sessionId) => canonicalFor(sessionId), - new SessionAdmissionGate(), - undefined, - reader, - ); - - for (let index = 0; index < 5; index += 1) { - const connectionId = `connection-failed-overlay-${index}`; - coordinator.attachConnection(connectionId, new RecordingSink()); - const outcome = await coordinator.handlers['subscription.open']( - { - sessionId: `session-failed-overlay-${index}`, - transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, - }, - connectionContext(connectionId), - ); - assert.deepEqual(outcome, { - ok: false, - error: { code: 'persistence_failed', message: 'Session transcript is unavailable' }, - }); - } - coordinator.close(); -}); - -test('releases the active overlay budget when its connection closes during open', async () => { +test('rejects a subscription open whose connection closes during transcript bootstrap', async () => { const durable = [assistantMessage('durable')]; - const overlay = assistantMessage('x'.repeat(15 * 1024 * 1024)); - const baseReader = transcriptReader(durable, [overlay]); + const baseReader = transcriptReader(durable); const pageStarted = deferred(); const continuePage = deferred(); let delayFirstPage = true; @@ -1658,34 +1188,22 @@ test('releases the active overlay budget when its connection closes during open' undefined, reader, ); - const interrupted = coordinator.attachConnection( - 'connection-interrupted-overlay', + const interrupted = attachTestConnection( + coordinator, + 'connection-interrupted', new RecordingSink(), ); const opening = coordinator.handlers['subscription.open']( { - sessionId: 'session-interrupted-overlay', + sessionId: 'session-interrupted', transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, }, - connectionContext('connection-interrupted-overlay'), + connectionContext('connection-interrupted'), ); await pageStarted.promise; interrupted.close(); continuePage.resolve(); await assert.rejects(opening, /connection closed during subscription open/); - - for (let index = 0; index < 2; index += 1) { - const connectionId = `connection-after-interruption-${index}`; - coordinator.attachConnection(connectionId, new RecordingSink()); - const outcome = await coordinator.handlers['subscription.open']( - { - sessionId: `session-after-interruption-${index}`, - transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, - }, - connectionContext(connectionId), - ); - assert.equal(outcome.ok, true); - } coordinator.close(); }); @@ -1722,7 +1240,7 @@ test('fits a transcript bootstrap inside a near-limit subscription open response undefined, transcriptReader(durable), ); - coordinator.attachConnection('connection-open-budget', new RecordingSink()); + attachTestConnection(coordinator, 'connection-open-budget', new RecordingSink()); const opened = await open(coordinator, 'connection-open-budget', { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, @@ -1738,102 +1256,342 @@ test('fits a transcript bootstrap inside a near-limit subscription open response coordinator.close(); }); -test('open reconciles the active assistant prefix into its overlay seed', async () => { +test('a running Turn row committed after open reaches the subscriber as transcript progress', async () => { + const prompt: StoredMessage = { + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'hello', + }; + const durable: StoredMessage[] = [prompt]; const coordinator = new SessionContinuityCoordinator( HOST_EPOCH, async () => canonical(), new SessionAdmissionGate(), undefined, - transcriptReader([assistantMessage('chunk-1')], [assistantMessage('chunk-1')]), + transcriptReader(durable), ); - await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(1)); - await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(2)); - coordinator.attachConnection('connection-live-prefix', new RecordingSink()); - const opened = await open(coordinator, 'connection-live-prefix', { + const sink = new RecordingSink(); + const connection = attachTestConnection(coordinator, 'connection-running-row', sink); + const opened = await open(coordinator, 'connection-running-row', { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, }); - const client = new ClientSessionSubscription( - opened, - async () => undefined, - async () => { - throw new Error('bounded bootstrap unexpectedly required continuation'); + connection.activate(opened.subscriptionId); + assert.equal(opened.snapshot.rootTurn?.status, 'running'); + assert.equal(opened.transcript?.durable.throughSequence, 0); + + durable.push(assistantMessage('first step')); + coordinator.enqueueTranscriptAdvanced(SESSION_ID); + await waitFor(() => sink.frames.length === 1); + + const frame = sink.frames[0]; + assert.equal(frame?.kind, 'subscription.transcript_advanced'); + if (frame?.kind !== 'subscription.transcript_advanced') return; + assert.equal(frame.throughSequence, 1); + const page = await coordinator.handlers['session.transcript.page']( + { + subscriptionId: opened.subscriptionId, + direction: 'newer', + throughSequence: frame.throughSequence, + cursor: null, + anchorSequence: 0, + maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, }, + connectionContext('connection-running-row'), + ); + assert.ok(page.ok); + if (!page.ok) return; + assert.deepEqual( + page.result.fragments.map((fragment) => + JSON.parse(Buffer.from(fragment.data, 'base64').toString('utf8')), + ), + [assistantMessage('first step')], + ); + coordinator.close(); +}); + +test('opening mid-stream pays out the streamed prefix before live deltas without a gap', async () => { + const coordinator = new SessionContinuityCoordinator( + HOST_EPOCH, + async () => canonical(), + new SessionAdmissionGate(), ); - assert.deepEqual(await client.loadTranscript((value) => value), [ - assistantMessage('chunk-1chunk-2'), + let streamed = ''; + for (let index = 0; index < 24; index += 1) { + const text = `${index}:${'x'.repeat(8 * 1024)}`; + streamed += text; + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { ...textEvent(index), text }); + } + const sink = new RecordingSink(); + const connection = attachTestConnection(coordinator, 'connection-mid-stream', sink); + const opened = await open(coordinator, 'connection-mid-stream'); + assert.deepEqual(opened.activeAssistantStreams, [ + { kind: 'text', turnId: 'turn-1', messageId: 'message-1' }, ]); + connection.activate(opened.subscriptionId); + for (const text of ['live-1', 'live-2']) { + streamed += text; + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { ...textEvent(99), text }); + } + await coordinator.acceptRuntimeEvent( + SESSION_ID, + 'run-1', + textCompleteEvent('message-1', streamed), + ); + await waitFor(() => + sink.frames.some( + (frame) => frame.kind === 'subscription.session_delta' && frame.delta.complete, + ), + ); + + let received = ''; + for (const [index, frame] of sink.frames.entries()) { + assert.equal(frame.kind, 'subscription.session_delta'); + if (frame.kind !== 'subscription.session_delta') return; + assert.equal(frame.sequence, opened.nextSequence + index); + decodeSubscriptionFrame(JSON.parse(encodeProtocolMessage(frame).toString('utf8'))); + assert.equal(frame.delta.reset, undefined); + assert.equal(frame.delta.startOffset, received.length); + received += frame.delta.text; + if (frame.delta.complete) assert.equal(frame, sink.frames.at(-1)); + } + assert.equal(received, streamed); coordinator.close(); }); -test('a non-prefix durable final replaces a stale active assistant draft', async () => { +// #5365: the in-flight answer a mid-stream subscriber has not seen is as large +// as the answer. Delivering it as soon as the open result flushed handed it to +// a Client that was still assembling the state those frames apply to, whose +// preparation buffer is sized for live traffic, not for a whole answer. +test('holds every frame, including the in-flight answer, until the subscriber declares readiness', async () => { const coordinator = new SessionContinuityCoordinator( HOST_EPOCH, async () => canonical(), new SessionAdmissionGate(), - undefined, - transcriptReader([assistantMessage('authoritative final')], [assistantMessage('draft')]), ); + let streamed = ''; + for (let index = 0; index < 24; index += 1) { + const text = `${index}:${'x'.repeat(8 * 1024)}`; + streamed += text; + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { ...textEvent(index), text }); + } + const sink = new RecordingSink(); + const connection = attachTestConnection(coordinator, 'connection-unready', sink); + const opened = await open(coordinator, 'connection-unready'); + streamed += 'live-1'; + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { ...textEvent(99), text: 'live-1' }); + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(sink.frames.length, 0); + + connection.activate(opened.subscriptionId); + await coordinator.acceptRuntimeEvent( + SESSION_ID, + 'run-1', + textCompleteEvent('message-1', streamed), + ); + await waitFor(() => + sink.frames.some( + (frame) => frame.kind === 'subscription.session_delta' && frame.delta.complete, + ), + ); + let received = ''; + for (const frame of sink.frames) { + assert.equal(frame.kind, 'subscription.session_delta'); + if (frame.kind !== 'subscription.session_delta') return; + received += frame.delta.text; + } + assert.equal(received, streamed); + coordinator.close(); +}); + +// #5365: a subscriber has one delivery order. A message that needs no catch-up +// used to be delivered and completed while an earlier message was still being +// paid out, so the answers arrived in the opposite order to the one the Host +// produced and a reader picking "the last answer" picked the earlier one. +test('a later message cannot complete ahead of the prefix a subscriber is still being paid', async () => { + const coordinator = new SessionContinuityCoordinator( + HOST_EPOCH, + async () => canonical(), + new SessionAdmissionGate(), + ); + let first = ''; + for (let index = 0; index < 24; index += 1) { + const text = `${index}:${'x'.repeat(8 * 1024)}`; + first += text; + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { ...textEvent(index), text }); + } + const sink = new GatedSink(); + const connection = attachTestConnection(coordinator, 'connection-order', sink); + const opened = await open(coordinator, 'connection-order'); + connection.activate(opened.subscriptionId); + + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textCompleteEvent('message-1', first)); await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { - ...textEvent(1), - text: 'draft', + ...textEvent(0, 'message-2'), + text: 'FINAL ANSWER', }); - coordinator.attachConnection('connection-final-handoff', new RecordingSink()); - const opened = await open(coordinator, 'connection-final-handoff', { - kind: 'tail', - maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { + ...textCompleteEvent('message-2', 'FINAL ANSWER'), + messageId: 'message-2', }); - const client = new ClientSessionSubscription( - opened, - async () => undefined, - async () => { - throw new Error('bounded bootstrap unexpectedly required continuation'); - }, - ); - assert.deepEqual(await client.loadTranscript((value) => value), [ - assistantMessage('authoritative final'), - ]); + sink.release(); + await waitFor(() => completionOrder(sink).length === 2); + + assert.deepEqual(completionOrder(sink), ['message-1', 'message-2']); coordinator.close(); }); -test('binds durable handoff reconciliation to the bootstrap watermark', async () => { - const durable: StoredMessage[] = []; - const partial = assistantMessage('part'); - const final = assistantMessage('partial complete'); - const baseReader = transcriptReader(durable, [partial]); - const reader: SessionTranscriptReader = { - ...baseReader, - readDurableMessagesById: async (...args) => { - const messages = await baseReader.readDurableMessagesById(...args); - durable.push(final); - return messages; +// #5365: the Turn ending does not unsay what the Host already streamed. The +// terminal publication used to drop every unpaid backlog, so a subscriber was +// left holding a truncated answer that the client then reported as complete. +test('a terminal publication finishes the prefix it found unpaid instead of dropping it', async () => { + let projection = canonical({ + rootTurn: { sessionId: SESSION_ID, turnId: 'turn-1', runId: 'run-1', status: 'running' }, + }); + const coordinator = new SessionContinuityCoordinator( + HOST_EPOCH, + async () => projection, + new SessionAdmissionGate(), + ); + let streamed = ''; + for (let index = 0; index < 24; index += 1) { + const text = `${index}:${'x'.repeat(8 * 1024)}`; + streamed += text; + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { ...textEvent(index), text }); + } + const sink = new GatedSink(); + const connection = attachTestConnection(coordinator, 'connection-terminal', sink); + const opened = await open(coordinator, 'connection-terminal'); + connection.activate(opened.subscriptionId); + await coordinator.acceptRuntimeEvent( + SESSION_ID, + 'run-1', + textCompleteEvent('message-1', streamed), + ); + + await coordinator.holdTerminalPublication(SESSION_ID, 'turn-1', 'run-1'); + projection = canonical({ + rootTurn: { + sessionId: SESSION_ID, + turnId: 'turn-1', + runId: 'run-1', + status: 'completed', + terminalEventId: 'event-terminal', }, - }; + }); + await coordinator.publishTerminalProjection(SESSION_ID, 'turn-1', 'run-1'); + sink.release(); + await waitFor(() => completionOrder(sink).length === 1); + + let received = ''; + for (const frame of sink.frames) { + if (frame.kind !== 'subscription.session_delta') continue; + assert.equal(frame.delta.startOffset, received.length); + received += frame.delta.text; + } + assert.equal(received, streamed); + coordinator.close(); +}); + +test('a stream completing before a mid-stream subscriber catches up is still paid out in full', async () => { const coordinator = new SessionContinuityCoordinator( HOST_EPOCH, async () => canonical(), new SessionAdmissionGate(), + ); + let streamed = ''; + for (let index = 0; index < 48; index += 1) { + const text = `${index}:${'x'.repeat(8 * 1024)}`; + streamed += text; + await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { ...textEvent(index), text }); + } + const sink = new RecordingSink(); + const connection = attachTestConnection(coordinator, 'connection-late-complete', sink); + const opened = await open(coordinator, 'connection-late-complete'); + await coordinator.acceptRuntimeEvent( + SESSION_ID, + 'run-1', + textCompleteEvent('message-1', streamed), + ); + connection.activate(opened.subscriptionId); + await waitFor(() => + sink.frames.some( + (frame) => + frame.kind === 'subscription.closed' || + (frame.kind === 'subscription.session_delta' && frame.delta.complete), + ), + ); + + let received = ''; + for (const frame of sink.frames) { + assert.equal(frame.kind, 'subscription.session_delta'); + if (frame.kind !== 'subscription.session_delta') return; + assert.equal(frame.delta.startOffset, received.length); + received += frame.delta.text; + } + assert.equal(received, streamed); + coordinator.close(); +}); + +test('commit-driven transcript progress waits for a fenced terminal publication', async () => { + let projection = canonical(); + const durable: StoredMessage[] = [assistantMessage('first')]; + const reader = transcriptReader(durable); + let highWaterReads = 0; + const admission = new SessionAdmissionGate(); + const coordinator = new SessionContinuityCoordinator( + HOST_EPOCH, + async () => projection, + admission, undefined, - reader, + { + ...reader, + readDurableHighWater: async (sessionId) => { + highWaterReads += 1; + return reader.readDurableHighWater(sessionId); + }, + }, ); - await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', { - ...textEvent(1), - text: partial.text, - }); - coordinator.attachConnection('connection-fixed-handoff', new RecordingSink()); - const opened = await open(coordinator, 'connection-fixed-handoff', { + const sink = new RecordingSink(); + const connection = attachTestConnection(coordinator, 'connection-fenced', sink); + const opened = await open(coordinator, 'connection-fenced', { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, }); - assert.equal(opened.transcript?.throughSequence, null); - const client = new ClientSessionSubscription( - opened, - async () => undefined, - async () => { - throw new Error('the fixed empty watermark unexpectedly exposed a later durable row'); + connection.activate(opened.subscriptionId); + await coordinator.holdTerminalPublication(SESSION_ID, 'turn-1', 'run-1'); + const readsBeforeCommit = highWaterReads; + + durable.push({ + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 2, + status: 'completed', + }); + coordinator.enqueueTranscriptAdvanced(SESSION_ID); + await admission.run(SESSION_ID, async () => undefined); + await delayImmediate(); + assert.equal(highWaterReads, readsBeforeCommit); + assert.equal(sink.frames.length, 0); + + projection = canonical({ + rootTurn: { + sessionId: SESSION_ID, + turnId: 'turn-1', + runId: 'run-1', + status: 'completed', + terminalEventId: 'event-terminal', }, + }); + await coordinator.publishTerminalProjection(SESSION_ID, 'turn-1', 'run-1'); + await waitFor(() => sink.frames.length === 2); + assert.deepEqual( + sink.frames.map((frame) => frame.kind), + ['subscription.transcript_advanced', 'subscription.session_projection'], ); - assert.deepEqual(await client.loadTranscript((value) => value), [partial]); coordinator.close(); }); @@ -1846,8 +1604,8 @@ test('large transcript messages are paged and cursors remain subscription-owned' undefined, transcriptReader([message]), ); - const owner = coordinator.attachConnection('connection-owner', new RecordingSink()); - const sibling = coordinator.attachConnection('connection-sibling', new RecordingSink()); + const owner = attachTestConnection(coordinator, 'connection-owner', new RecordingSink()); + const sibling = attachTestConnection(coordinator, 'connection-sibling', new RecordingSink()); const opened = await open(coordinator, 'connection-owner', { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, @@ -1855,7 +1613,7 @@ test('large transcript messages are paged and cursors remain subscription-owned' assert.ok(opened.transcript?.durable.nextCursor); if (!opened.transcript?.durable.nextCursor) return; const firstCursor = opened.transcript.durable.nextCursor; - const client = new ClientSessionSubscription( + const client = clientSubscription( opened, async () => undefined, async (input) => { @@ -1872,9 +1630,8 @@ test('large transcript messages are paged and cursors remain subscription-owned' const foreign = await coordinator.handlers['session.transcript.page']( { subscriptionId: opened.subscriptionId, - source: 'durable', direction: 'older', - throughSequence: opened.transcript.throughSequence, + throughSequence: opened.transcript.durable.throughSequence, cursor: firstCursor, anchorSequence: null, maxBytes: 1024, @@ -1910,7 +1667,7 @@ test('an in-flight transcript page cannot outlive its owning connection', async undefined, reader, ); - const connection = coordinator.attachConnection('connection-1', new RecordingSink()); + const connection = attachTestConnection(coordinator, 'connection-1', new RecordingSink()); const opened = await open(coordinator, 'connection-1', { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, @@ -1921,9 +1678,8 @@ test('an in-flight transcript page cannot outlive its owning connection', async const reading = coordinator.handlers['session.transcript.page']( { subscriptionId: opened.subscriptionId, - source: 'durable', direction: 'older', - throughSequence: opened.transcript.throughSequence, + throughSequence: opened.transcript.durable.throughSequence, cursor, anchorSequence: null, maxBytes: 1024, @@ -1976,7 +1732,7 @@ test('an in-flight transcript page cannot outlive its Guest observation grant', }, }, ); - coordinator.attachConnection('guest-connection', new RecordingSink()); + attachTestConnection(coordinator, 'guest-connection', new RecordingSink()); const opened = await open( coordinator, 'guest-connection', @@ -1997,9 +1753,8 @@ test('an in-flight transcript page cannot outlive its Guest observation grant', const reading = coordinator.handlers['session.transcript.page']( { subscriptionId: opened.subscriptionId, - source: 'durable', direction: 'older', - throughSequence: opened.transcript.throughSequence, + throughSequence: opened.transcript.durable.throughSequence, cursor, anchorSequence: null, maxBytes: 1024, @@ -2032,7 +1787,7 @@ test('a durable append refresh advances transcript before its completion event', reader, ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1', { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES, @@ -2057,7 +1812,11 @@ test('absolute live offsets survive a gap with no connected subscribers', async async () => canonical(), new SessionAdmissionGate(), ); - const firstConnection = coordinator.attachConnection('connection-first', new RecordingSink()); + const firstConnection = attachTestConnection( + coordinator, + 'connection-first', + new RecordingSink(), + ); const first = await open(coordinator, 'connection-first'); firstConnection.activate(first.subscriptionId); await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(1)); @@ -2066,17 +1825,23 @@ test('absolute live offsets survive a gap with no connected subscribers', async await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(2)); const sink = new RecordingSink(); - const secondConnection = coordinator.attachConnection('connection-second', sink); + const secondConnection = attachTestConnection(coordinator, 'connection-second', sink); const second = await open(coordinator, 'connection-second'); secondConnection.activate(second.subscriptionId); await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(3)); - await waitFor(() => sink.frames.length === 1); + await waitFor(() => sink.frames.length === 2); - const frame = sink.frames[0]; - assert.equal(frame?.kind, 'subscription.session_delta'); - if (frame?.kind === 'subscription.session_delta') { - assert.equal(frame.delta.startOffset, 'chunk-1'.length + 'chunk-2'.length); - } + assert.deepEqual( + sink.frames.map((frame) => + frame.kind === 'subscription.session_delta' + ? { startOffset: frame.delta.startOffset, text: frame.delta.text } + : frame.kind, + ), + [ + { startOffset: 0, text: 'chunk-1chunk-2' }, + { startOffset: 'chunk-1chunk-2'.length, text: 'chunk-3' }, + ], + ); coordinator.close(); }); @@ -2087,7 +1852,7 @@ test('keeps the current provider retry on the live Turn until the next content e new SessionAdmissionGate(), ); const liveSink = new RecordingSink(); - const live = coordinator.attachConnection('connection-live', liveSink); + const live = attachTestConnection(coordinator, 'connection-live', liveSink); const opened = await open(coordinator, 'connection-live'); assert.equal(opened.snapshot.rootTurn && 'providerRetry' in opened.snapshot.rootTurn, false); live.activate(opened.subscriptionId); @@ -2114,7 +1879,7 @@ test('keeps the current provider retry on the live Turn until the next content e ts: 1, reason: 'rate_limit' as const, }; - coordinator.attachConnection('connection-remount', new RecordingSink()); + attachTestConnection(coordinator, 'connection-remount', new RecordingSink()); const remounted = await open(coordinator, 'connection-remount'); assert.deepEqual( remounted.snapshot.rootTurn && 'providerRetry' in remounted.snapshot.rootTurn @@ -2133,7 +1898,7 @@ test('keeps the current provider retry on the live Turn until the next content e ); await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', textEvent(1)); - coordinator.attachConnection('connection-after-text', new RecordingSink()); + attachTestConnection(coordinator, 'connection-after-text', new RecordingSink()); const afterText = await open(coordinator, 'connection-after-text'); assert.equal( afterText.snapshot.rootTurn && 'providerRetry' in afterText.snapshot.rootTurn, @@ -2153,7 +1918,7 @@ test('rejoin seeds tool_result_preview at the open nextSequence without sequence await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', previewEvent()); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-rejoin', sink); + const connection = attachTestConnection(coordinator, 'connection-rejoin', sink); const opened = await open(coordinator, 'connection-rejoin'); assert.equal(opened.nextSequence, 1); @@ -2165,7 +1930,7 @@ test('rejoin seeds tool_result_preview at the open nextSequence without sequence assert.equal(sink.frames[0].sequence, 1); assert.equal(sink.frames[0].event.type, 'tool_result_preview'); - const client = new ClientSessionSubscription( + const client = clientSubscription( opened, async () => {}, async () => { @@ -2185,7 +1950,7 @@ test('live tool_start projects intent and a bounded args preview, never full arg new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-tool-start', sink); + const connection = attachTestConnection(coordinator, 'connection-tool-start', sink); const opened = await open(coordinator, 'connection-tool-start'); connection.activate(opened.subscriptionId); @@ -2221,7 +1986,7 @@ test('live tool_start never forwards a generic input payload as argsPreview', as new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-tool-input', sink); + const connection = attachTestConnection(coordinator, 'connection-tool-input', sink); const opened = await open(coordinator, 'connection-tool-input'); connection.activate(opened.subscriptionId); @@ -2272,7 +2037,7 @@ test('tool_result clears retained tool_result_preview so a later open does not s }); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-after-settle', sink); + const connection = attachTestConnection(coordinator, 'connection-after-settle', sink); const opened = await open(coordinator, 'connection-after-settle'); assert.equal(opened.nextSequence, 1); connection.activate(opened.subscriptionId); @@ -2296,7 +2061,7 @@ test('publishes only the minimal sandbox failure reason from a tool result', asy new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); @@ -2339,7 +2104,7 @@ test('publishes only the bounded shell-run correlation from poll args', async () new SessionAdmissionGate(), ); const sink = new RecordingSink(); - const connection = coordinator.attachConnection('connection-1', sink); + const connection = attachTestConnection(coordinator, 'connection-1', sink); const opened = await open(coordinator, 'connection-1'); connection.activate(opened.subscriptionId); const ref = 'maka://runtime/background-tasks/bg-1'; @@ -2372,6 +2137,29 @@ test('publishes only the bounded shell-run correlation from poll args', async () coordinator.close(); }); +/** + * A connection whose `activate` is the Client's own `subscription.ready`. + * + * Frames start where a subscriber says it can take them, so a test that starts + * them any other way is not exercising the path a Client uses. + */ +function attachTestConnection( + coordinator: SessionContinuityCoordinator, + connectionId: string, + sink: SessionContinuityFrameSink, + identity?: TestIdentity, +) { + const connection = coordinator.attachConnection(connectionId, sink); + return { + ...connection, + activate: (subscriptionId: string) => + coordinator.handlers['subscription.ready']( + { subscriptionId }, + connectionContext(connectionId, identity), + ), + }; +} + class RecordingSink implements SessionContinuityFrameSink { readonly frames: SubscriptionFrame[] = []; @@ -2380,6 +2168,34 @@ class RecordingSink implements SessionContinuityFrameSink { } } +/** Records frames but holds each send until released, so a backlog stays unpaid. */ +class GatedSink implements SessionContinuityFrameSink { + readonly frames: SubscriptionFrame[] = []; + #held: Array<() => void> = []; + #open = false; + + async send(frame: SubscriptionFrame): Promise { + this.frames.push(frame); + if (this.#open) return; + await new Promise((resolve) => this.#held.push(resolve)); + } + + release(): void { + this.#open = true; + const held = this.#held; + this.#held = []; + for (const resume of held) resume(); + } +} + +function completionOrder(sink: { frames: SubscriptionFrame[] }): string[] { + return sink.frames.flatMap((frame) => + frame.kind === 'subscription.session_delta' && frame.delta.complete + ? [frame.delta.messageId] + : [], + ); +} + function textCompleteEvent(messageId: string, text: string) { return { type: 'text_complete' as const, @@ -2408,101 +2224,6 @@ async function open( return outcome.result; } -async function openForSession( - coordinator: SessionContinuityCoordinator, - connectionId: string, - sessionId: string, -) { - const outcome = await coordinator.handlers['subscription.open']( - { - sessionId, - transcript: { kind: 'tail', maxBytes: SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES }, - }, - connectionContext(connectionId), - ); - if (!outcome.ok) throw new Error(outcome.error.message); - return outcome.result; -} - -async function consumeBootstrapOverlay( - coordinator: SessionContinuityCoordinator, - connectionId: string, - opened: Awaited>, -): Promise { - assert.ok(opened.transcript); - let cursor = opened.transcript.overlay.nextCursor; - let finalCursor: string | null = null; - while (cursor !== null) { - finalCursor = cursor; - const outcome = await coordinator.handlers['session.transcript.page']( - { - subscriptionId: opened.subscriptionId, - source: 'overlay', - direction: 'older', - throughSequence: opened.transcript.throughSequence, - cursor, - anchorSequence: null, - maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, - }, - connectionContext(connectionId), - ); - assert.equal(outcome.ok, true); - if (!outcome.ok) throw new Error('Transcript overlay page failed'); - if (outcome.result.nextCursor === null) { - assert.deepEqual( - await coordinator.handlers['session.transcript.page']( - { - subscriptionId: opened.subscriptionId, - source: 'overlay', - direction: 'older', - throughSequence: opened.transcript.throughSequence, - cursor, - anchorSequence: null, - maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, - }, - connectionContext(connectionId), - ), - outcome, - ); - } - cursor = outcome.result.nextCursor; - } - const released = await coordinator.handlers['session.transcript.overlay.release']( - { subscriptionId: opened.subscriptionId }, - connectionContext(connectionId), - ); - assert.deepEqual(released, { - ok: true, - result: { subscriptionId: opened.subscriptionId }, - }); - assert.deepEqual( - await coordinator.handlers['session.transcript.overlay.release']( - { subscriptionId: opened.subscriptionId }, - connectionContext(connectionId), - ), - released, - ); - assert.ok(finalCursor); - assert.deepEqual( - await coordinator.handlers['session.transcript.page']( - { - subscriptionId: opened.subscriptionId, - source: 'overlay', - direction: 'older', - throughSequence: opened.transcript.throughSequence, - cursor: finalCursor, - anchorSequence: null, - maxBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, - }, - connectionContext(connectionId), - ), - { - ok: false, - error: { code: 'invalid_request', message: 'Transcript overlay has been released' }, - }, - ); -} - function connectionContext( connectionId: string, identity: TestIdentity = TEST_OWNER_IDENTITY, diff --git a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts index 9afe6bb6e1..ab8e7936a5 100644 --- a/packages/runtime-host/src/__tests__/session-subscription-client.test.ts +++ b/packages/runtime-host/src/__tests__/session-subscription-client.test.ts @@ -40,7 +40,7 @@ import { RuntimeHostSubscriptionError, type RuntimeHostConnection, } from '../client/index.js'; -import { ClientSessionSubscription } from '../client/session-subscription.js'; +import { clientSubscription } from './fixtures/client-session-subscription.js'; import { prepareRuntimeHostEndpoint } from '../control/endpoint.js'; import { removeHostRegistration, writeHostRegistration } from '../control/registration.js'; import { @@ -146,7 +146,7 @@ test('unobserved PTY bytes do not consume the Session iterator or sequence', asy }); test('PTY callbacks bypass a stalled Session iterator and isolate consumer failures', async () => { - const subscription = new ClientSessionSubscription( + const subscription = clientSubscription( openResult('host-1', 'subscription-1'), async () => undefined, async () => { @@ -182,7 +182,7 @@ test('PTY callbacks bypass a stalled Session iterator and isolate consumer failu }); test('domain callbacks validate identity, support unsubscribe, and stop on close', async () => { - const subscription = new ClientSessionSubscription( + const subscription = clientSubscription( openResult('host-1', 'subscription-domain'), async () => undefined, async () => { @@ -536,13 +536,10 @@ test('reassembles bounded backward pages with a timeout independent of index pre assert.ok(!('kind' in next) && next.operation === 'subscription.open'); openRequest = next; const opened = openResult(hostEpoch, 'subscription-fragmented', { - throughSequence: 0, - overlayMessageCount: 0, durable: transcriptPage({ rawBytes: encoded.byteLength - splitAt, fragments: [ { - kind: 'durable', sequence: 0, byteOffset: splitAt, totalBytes: encoded.byteLength, @@ -552,7 +549,6 @@ test('reassembles bounded backward pages with a timeout independent of index pre ], nextCursor: 'cursor-1', }), - overlay: transcriptPage({ source: 'overlay' }), }); await writeProtocolFrame(transport, { requestId: openRequest.requestId, @@ -565,7 +561,6 @@ test('reassembles bounded backward pages with a timeout independent of index pre assert.equal(continuationRequest.operation, 'session.transcript.page'); assert.deepEqual(continuationRequest.input, { subscriptionId: opened.subscriptionId, - source: 'durable', direction: 'older', throughSequence: 0, cursor: 'cursor-1', @@ -581,7 +576,6 @@ test('reassembles bounded backward pages with a timeout independent of index pre rawBytes: splitAt, fragments: [ { - kind: 'durable', sequence: 0, byteOffset: 0, totalBytes: encoded.byteLength, @@ -618,16 +612,13 @@ test('decodes one bounded page without walking the remaining transcript', async const encoded = Buffer.from(JSON.stringify(message), 'utf8'); const splitAt = Math.floor(encoded.byteLength / 2); const requests: Array<{ cursor: string | null; maxBytes: number }> = []; - const subscription = new ClientSessionSubscription( + const subscription = clientSubscription( openResult('host-1', 'subscription-bounded-page', { - throughSequence: 4, - overlayMessageCount: 0, durable: { ...transcriptPage({ rawBytes: encoded.byteLength - splitAt, fragments: [ { - kind: 'durable', sequence: 4, byteOffset: splitAt, totalBytes: encoded.byteLength, @@ -639,7 +630,6 @@ test('decodes one bounded page without walking the remaining transcript', async }), throughSequence: 4, }, - overlay: { ...transcriptPage({ source: 'overlay' }), throughSequence: 4 }, }), async () => undefined, async (input) => { @@ -649,7 +639,6 @@ test('decodes one bounded page without walking the remaining transcript', async rawBytes: splitAt, fragments: [ { - kind: 'durable', sequence: 4, byteOffset: 0, totalBytes: encoded.byteLength, @@ -715,7 +704,6 @@ test('assembles the complete edge Turn while paging newer transcript', async () rawBytes: promptBytes.byteLength, fragments: [ { - kind: 'durable', sequence: 0, byteOffset: 0, totalBytes: promptBytes.byteLength, @@ -730,12 +718,9 @@ test('assembles the complete edge Turn while paging newer transcript', async () rangeBoundarySequence: 1, protectedTurnSequence: 1, }; - const subscription = new ClientSessionSubscription( + const subscription = clientSubscription( openResult('host-1', 'subscription-newer-turn', { - throughSequence: 1, - overlayMessageCount: 0, durable: initial, - overlay: { ...transcriptPage({ source: 'overlay' }), throughSequence: 1 }, }), async () => undefined, async (input) => { @@ -745,7 +730,6 @@ test('assembles the complete edge Turn while paging newer transcript', async () rawBytes: answerBytes.byteLength, fragments: [ { - kind: 'durable', sequence: 1, byteOffset: 0, totalBytes: answerBytes.byteLength, @@ -774,253 +758,6 @@ test('assembles the complete edge Turn while paging newer transcript', async () assert.deepEqual(requests, ['answer']); }); -test('loads and releases only the active overlay', async () => { - const overlay = { - type: 'user' as const, - id: 'user-1', - turnId: 'turn-1', - ts: 1, - text: 'active', - }; - const bytes = Buffer.from(JSON.stringify(overlay), 'utf8'); - let releases = 0; - const subscription = new ClientSessionSubscription( - openResult('host-1', 'subscription-overlay-only', overlayBootstrap(bytes)), - async () => undefined, - async () => { - throw new Error('durable transcript must not be read'); - }, - async () => { - releases += 1; - }, - ); - - assert.deepEqual(await subscription.loadTranscriptOverlay(decodeStoredMessage), [overlay]); - assert.equal(releases, 1); - await assert.rejects( - subscription.loadTranscriptOverlay(decodeStoredMessage), - hasSubscriptionReason('correlation_changed'), - ); - assert.equal(releases, 1); -}); - -test('rejects an oversized active overlay before releasing it', async () => { - const overlay = { - type: 'user' as const, - id: 'user-1', - turnId: 'turn-1', - ts: 1, - text: 'active', - }; - const bytes = Buffer.from(JSON.stringify(overlay), 'utf8'); - let releases = 0; - const subscription = new ClientSessionSubscription( - openResult('host-1', 'subscription-overlay-limit', overlayBootstrap(bytes)), - async () => undefined, - async () => { - throw new Error('durable transcript must not be read'); - }, - async () => { - releases += 1; - }, - ); - - await assert.rejects( - subscription.loadTranscriptOverlay(decodeStoredMessage, bytes.byteLength - 1), - RangeError, - ); - assert.equal(releases, 0); -}); - -test('releases a materialized overlay through the connection-bound control operation', async () => { - const message = Buffer.from( - JSON.stringify({ - type: 'user', - id: 'user-1', - turnId: 'turn-1', - ts: 1, - text: 'overlay', - }), - 'utf8', - ); - await withProtocolPeer( - async (transport, hostEpoch, rootId) => { - const openRequest = await acceptConnectionAndReadOpen(transport, hostEpoch, rootId); - const opened = openResult( - hostEpoch, - 'subscription-overlay-release', - overlayBootstrap(message), - ); - await writeProtocolFrame(transport, { - requestId: openRequest.requestId, - operation: 'subscription.open', - ok: true, - result: opened, - }); - const release = decodeClientFrame(await transport.read(1_000)); - assert.ok(!('kind' in release)); - assert.equal(release.operation, 'session.transcript.overlay.release'); - assert.deepEqual(release.input, { - subscriptionId: opened.subscriptionId, - }); - await writeProtocolFrame(transport, { - requestId: release.requestId, - operation: 'session.transcript.overlay.release', - ok: true, - result: { subscriptionId: opened.subscriptionId }, - }); - await answerClose(transport, opened.subscriptionId); - }, - async (connection) => { - const subscription = await connection.openSessionSubscription({ - sessionId: 'session-1', - transcript: { kind: 'tail', maxBytes: 16 * 1024 }, - }); - assert.deepEqual(await subscription.loadTranscript(decodeStoredMessage), [ - { - type: 'user', - id: 'user-1', - turnId: 'turn-1', - ts: 1, - text: 'overlay', - }, - ]); - await subscription.close(); - }, - ); -}); - -test('fails the connection when overlay release is not confirmed', async () => { - const message = Buffer.from( - JSON.stringify({ - type: 'user', - id: 'user-1', - turnId: 'turn-1', - ts: 1, - text: 'overlay', - }), - 'utf8', - ); - await withProtocolPeer( - async (transport, hostEpoch, rootId) => { - const openRequest = await acceptConnectionAndReadOpen(transport, hostEpoch, rootId); - const opened = openResult( - hostEpoch, - 'subscription-overlay-release-failure', - overlayBootstrap(message), - ); - await writeProtocolFrame(transport, { - requestId: openRequest.requestId, - operation: 'subscription.open', - ok: true, - result: opened, - }); - const release = decodeClientFrame(await transport.read(1_000)); - assert.ok(!('kind' in release)); - assert.equal(release.operation, 'session.transcript.overlay.release'); - await writeProtocolFrame(transport, { - requestId: release.requestId, - operation: 'session.transcript.overlay.release', - ok: false, - error: { code: 'internal_failure', message: 'release failed' }, - }); - }, - async (connection) => { - const subscription = await connection.openSessionSubscription({ - sessionId: 'session-1', - transcript: { kind: 'tail', maxBytes: 16 * 1024 }, - }); - await assert.rejects( - () => subscription.loadTranscript(decodeStoredMessage), - hasSubscriptionReason('transcript_release_failed'), - ); - await assert.rejects(() => connection.status()); - }, - ); -}); - -test('keeps the connection usable when close wins the overlay release race', async () => { - const message = Buffer.from( - JSON.stringify({ - type: 'user', - id: 'user-1', - turnId: 'turn-1', - ts: 1, - text: 'overlay', - }), - 'utf8', - ); - await withProtocolPeer( - async (transport, hostEpoch, rootId) => { - const openRequest = await acceptConnectionAndReadOpen(transport, hostEpoch, rootId); - const opened = openResult( - hostEpoch, - 'subscription-overlay-release-after-close', - overlayBootstrap(message), - ); - await writeProtocolFrame(transport, { - requestId: openRequest.requestId, - operation: 'subscription.open', - ok: true, - result: opened, - }); - const close = decodeClientFrame(await transport.read(1_000)); - assert.ok(!('kind' in close)); - assert.equal(close.operation, 'subscription.close'); - await writeProtocolFrame(transport, { - requestId: close.requestId, - operation: 'subscription.close', - ok: true, - result: { subscriptionId: opened.subscriptionId }, - }); - const release = decodeClientFrame(await transport.read(1_000)); - assert.ok(!('kind' in release)); - assert.equal(release.operation, 'session.transcript.overlay.release'); - await writeProtocolFrame(transport, { - requestId: release.requestId, - operation: 'session.transcript.overlay.release', - ok: true, - result: { subscriptionId: opened.subscriptionId }, - }); - const status = decodeClientFrame(await transport.read(1_000)); - assert.ok(!('kind' in status)); - assert.equal(status.operation, 'host.status'); - await writeProtocolFrame(transport, { - requestId: status.requestId, - operation: 'host.status', - ok: true, - result: { - hostEpoch, - compositionId: 'maka.interactive', - compositionRevision: '1', - state: 'ready', - connections: 1, - activeOperations: 1, - activeResidencies: 0, - }, - }); - }, - async (connection) => { - const subscription = await connection.openSessionSubscription({ - sessionId: 'session-1', - transcript: { kind: 'tail', maxBytes: 16 * 1024 }, - }); - const loading = subscription.loadTranscript(decodeStoredMessage); - await subscription.close(); - assert.deepEqual(await loading, [ - { - type: 'user', - id: 'user-1', - turnId: 'turn-1', - ts: 1, - text: 'overlay', - }, - ]); - assert.equal((await connection.status()).hostEpoch, connection.hostEpoch); - }, - ); -}); - test('loads a durable transcript whose sequences are sparse', async () => { const messages = [0, 2].map((sequence) => Buffer.from( @@ -1034,16 +771,13 @@ test('loads a durable transcript whose sequences are sparse', async () => { 'utf8', ), ); - const subscription = new ClientSessionSubscription( + const subscription = clientSubscription( openResult('host-1', 'subscription-projected', { - throughSequence: 2, - overlayMessageCount: 0, durable: { ...transcriptPage({ rawBytes: messages.reduce((total, message) => total + message.byteLength, 0), fragments: messages .map((message, index) => ({ - kind: 'durable' as const, sequence: index * 2, byteOffset: 0, totalBytes: message.byteLength, @@ -1054,7 +788,6 @@ test('loads a durable transcript whose sequences are sparse', async () => { }), throughSequence: 2, }, - overlay: { ...transcriptPage({ source: 'overlay' }), throughSequence: 2 }, }), async () => undefined, async () => { @@ -1079,15 +812,12 @@ test('rejects a durable message that does not match its payload digest', async ( }), 'utf8', ); - const subscription = new ClientSessionSubscription( + const subscription = clientSubscription( openResult('host-1', 'subscription-digest-mismatch', { - throughSequence: 0, - overlayMessageCount: 0, durable: transcriptPage({ rawBytes: message.byteLength, fragments: [ { - kind: 'durable', sequence: 0, byteOffset: 0, totalBytes: message.byteLength, @@ -1096,7 +826,6 @@ test('rejects a durable message that does not match its payload digest', async ( }, ], }), - overlay: transcriptPage({ source: 'overlay' }), }), async () => undefined, async () => { @@ -1134,7 +863,6 @@ test('rejects a transcript cursor that does not advance', async () => { nextCursor: 'stuck-cursor', fragments: [ { - kind: 'durable', sequence: 0, byteOffset: 0, totalBytes: message.byteLength, @@ -1143,12 +871,9 @@ test('rejects a transcript cursor that does not advance', async () => { }, ], }); - const subscription = new ClientSessionSubscription( + const subscription = clientSubscription( openResult('host-1', 'subscription-stuck-cursor', { - throughSequence: 0, - overlayMessageCount: 0, durable: repeated, - overlay: transcriptPage({ source: 'overlay' }), }), async () => undefined, async () => repeated, @@ -1161,168 +886,6 @@ test('rejects a transcript cursor that does not advance', async () => { }); }); -test('rejects an overlay that terminates before its declared high-water', async () => { - const message = Buffer.from( - JSON.stringify({ - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 1, - text: '', - }), - 'utf8', - ); - const subscription = new ClientSessionSubscription( - openResult('host-1', 'subscription-truncated-overlay', { - throughSequence: null, - overlayMessageCount: 2, - durable: { ...transcriptPage(), throughSequence: null }, - overlay: { - ...transcriptPage({ - source: 'overlay', - rawBytes: message.byteLength, - fragments: [ - { - kind: 'overlay', - messageIndex: 0, - byteOffset: 0, - totalBytes: message.byteLength, - data: message.toString('base64'), - }, - ], - }), - throughSequence: null, - }, - }), - async () => undefined, - async () => { - throw new Error('unexpected page request'); - }, - ); - - await assert.rejects( - () => subscription.loadTranscript((value) => value), - hasSubscriptionReason('correlation_changed'), - ); -}); - -test('acknowledges the overlay only after complete materialization', async () => { - const message = Buffer.from( - JSON.stringify({ - type: 'user', - id: 'user-1', - turnId: 'turn-1', - ts: 1, - text: 'ok', - }), - 'utf8', - ); - let releases = 0; - const subscription = new ClientSessionSubscription( - openResult('host-1', 'subscription-overlay-release', overlayBootstrap(message)), - async () => undefined, - async () => { - throw new Error('unexpected page request'); - }, - async () => { - releases += 1; - }, - ); - - assert.deepEqual(await subscription.loadTranscript(decodeStoredMessage), [ - { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'ok' }, - ]); - assert.equal(releases, 1); - await subscription.loadTranscript(decodeStoredMessage); - assert.equal(releases, 1); -}); - -test('acknowledges a complete overlay before waiting for durable continuation pages', async () => { - const durableMessage = Buffer.from( - JSON.stringify({ - type: 'user', - id: 'user-1', - turnId: 'turn-1', - ts: 1, - text: 'history', - }), - 'utf8', - ); - const overlayMessage = Buffer.from( - JSON.stringify({ - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 2, - text: 'partial', - modelId: 'test-model', - }), - 'utf8', - ); - const split = Math.floor(durableMessage.byteLength / 2); - const durablePageStarted = deferred(); - const continueDurablePage = deferred(); - let releases = 0; - const subscription = new ClientSessionSubscription( - openResult('host-1', 'subscription-overlay-release-before-durable', { - throughSequence: 0, - overlayMessageCount: 1, - durable: transcriptPage({ - rawBytes: durableMessage.byteLength - split, - fragments: [ - { - kind: 'durable', - sequence: 0, - byteOffset: split, - totalBytes: durableMessage.byteLength, - payloadDigest: null, - data: durableMessage.subarray(split).toString('base64'), - }, - ], - nextCursor: 'durable-cursor-1', - }), - overlay: overlayBootstrap(overlayMessage).overlay, - }), - async () => undefined, - async () => { - durablePageStarted.resolve(); - await continueDurablePage.promise; - return transcriptPage({ - rawBytes: split, - fragments: [ - { - kind: 'durable', - sequence: 0, - byteOffset: 0, - totalBytes: durableMessage.byteLength, - payloadDigest: null, - data: durableMessage.subarray(0, split).toString('base64'), - }, - ], - }); - }, - async () => { - releases += 1; - }, - ); - - const loading = subscription.loadTranscript(decodeStoredMessage); - await durablePageStarted.promise; - assert.equal(releases, 1); - continueDurablePage.resolve(); - assert.deepEqual(await loading, [ - { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'history' }, - { - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 2, - text: 'partial', - modelId: 'test-model', - }, - ]); -}); - test('close stops transcript pagination after the in-flight page', async () => { const message = Buffer.from( JSON.stringify({ @@ -1336,15 +899,12 @@ test('close stops transcript pagination after the in-flight page', async () => { ); const page = deferred>(); let pageRequests = 0; - const subscription = new ClientSessionSubscription( + const subscription = clientSubscription( openResult('host-1', 'subscription-closing', { - throughSequence: 0, - overlayMessageCount: 0, durable: transcriptPage({ rawBytes: Math.floor(message.byteLength / 2), fragments: [ { - kind: 'durable', sequence: 0, byteOffset: Math.ceil(message.byteLength / 2), totalBytes: message.byteLength, @@ -1354,7 +914,6 @@ test('close stops transcript pagination after the in-flight page', async () => { ], nextCursor: 'cursor-1', }), - overlay: transcriptPage({ source: 'overlay' }), }), async () => undefined, async () => { @@ -1704,13 +1263,10 @@ function openResult( function transcriptBootstrap(message: Buffer): SessionTranscriptBootstrap { return { - throughSequence: 0, - overlayMessageCount: 0, durable: transcriptPage({ rawBytes: message.byteLength, fragments: [ { - kind: 'durable', sequence: 0, byteOffset: 0, totalBytes: message.byteLength, @@ -1719,37 +1275,11 @@ function transcriptBootstrap(message: Buffer): SessionTranscriptBootstrap { }, ], }), - overlay: transcriptPage({ source: 'overlay' }), - }; -} - -function overlayBootstrap(message: Buffer): SessionTranscriptBootstrap { - return { - throughSequence: null, - overlayMessageCount: 1, - durable: { ...transcriptPage(), throughSequence: null }, - overlay: { - ...transcriptPage({ - source: 'overlay', - rawBytes: message.byteLength, - fragments: [ - { - kind: 'overlay', - messageIndex: 0, - byteOffset: 0, - totalBytes: message.byteLength, - data: message.toString('base64'), - }, - ], - }), - throughSequence: null, - }, }; } function transcriptPage( options: { - source?: 'durable' | 'overlay'; rawBytes?: number; fragments?: readonly SessionTranscriptFragment[]; nextCursor?: string | null; @@ -1758,7 +1288,6 @@ function transcriptPage( return { kind: 'page', sessionId: 'session-1', - source: options.source ?? 'durable', direction: 'older', throughSequence: 0, rawBytes: options.rawBytes ?? 0, diff --git a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts index 13ce9dacdb..790034d94d 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts @@ -24,16 +24,14 @@ import { type StoredMessage, } from '@maka/core/session'; import { markPersisted } from '@maka/core/persisted-value'; -import { ClientSessionSubscription } from '../client/session-subscription.js'; +import { clientSubscription } from './fixtures/client-session-subscription.js'; import { SESSION_CONTINUITY_SCHEMA_VERSION } from '../protocol/index.js'; import { createSessionTranscriptBootstrap, - prepareSessionTranscriptOverlay, readSessionTranscriptPage, TranscriptPageRequestError, updateSubscriberTranscriptHighWater, } from '../server/session-transcript-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'; @@ -45,12 +43,10 @@ test('reads newly durable messages forward from an announced watermark', async ( sessionId: 'session-1', subscriptionId: 'subscription-1', throughSequence: 1, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 1024, projection: 'owner', }); - assert.equal(bootstrap.throughSequence, 1); + assert.equal(bootstrap.durable.throughSequence, 1); durable.push(userMessage(2), userMessage(3)); assert.equal(updateSubscriberTranscriptHighWater(state, 3), true); @@ -59,7 +55,6 @@ test('reads newly durable messages forward from an announced watermark', async ( state, request: { subscriptionId: 'subscription-1', - source: 'durable', direction: 'newer', throughSequence: 3, cursor: null, @@ -68,9 +63,7 @@ test('reads newly durable messages forward from an announced watermark', async ( }, }); assert.deepEqual( - page.fragments.map((fragment) => - fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex, - ), + page.fragments.map((fragment) => fragment.sequence), [2, 3], ); assert.equal(page.rangeBoundarySequence, 3); @@ -97,8 +90,6 @@ test('preserves the canonical retry decision in shared bootstrap and later pages sessionId: 'session-1', subscriptionId: 'shared', throughSequence: 0, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 1024, projection: 'shared', }); @@ -123,7 +114,6 @@ test('preserves the canonical retry decision in shared bootstrap and later pages state, request: { subscriptionId: 'shared', - source: 'durable', direction: 'newer', throughSequence: 1, cursor: null, @@ -135,7 +125,7 @@ test('preserves the canonical retry decision in shared bootstrap and later pages assert.deepEqual(decodeBootstrap(page)[0]?.retry, { decision: 'exhausted', attempts: 2 }); }); -test('projects durable and active transcript records before sharing them', async () => { +test('projects durable transcript records before sharing them', async () => { const durable: StoredMessage[] = [ { ...assistantMessage(0), @@ -190,20 +180,12 @@ test('projects durable and active transcript records before sharing them', async ], }, ]; - const overlay: StoredMessage[] = [ - { - ...assistantMessage(1), - providerOptions: { replay: 'private' }, - }, - ]; - const reader = transcriptReader(durable, overlay); + const reader = transcriptReader(durable); const owner = await createSessionTranscriptBootstrap({ reader, sessionId: 'session-1', subscriptionId: 'owner', throughSequence: 2, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection: 'owner', }); @@ -212,8 +194,6 @@ test('projects durable and active transcript records before sharing them', async sessionId: 'session-1', subscriptionId: 'shared', throughSequence: 3, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection: 'shared', }); @@ -222,11 +202,6 @@ test('projects durable and active transcript records before sharing them', async (decodeBootstrap(owner.bootstrap.durable)[0] as { data?: unknown }).data !== undefined, true, ); - assert.equal( - (decodeBootstrap(owner.bootstrap.overlay)[0] as { providerOptions?: unknown }) - .providerOptions !== undefined, - true, - ); const sharedDurable = decodeBootstrap(shared.bootstrap.durable); assert.deepEqual( sharedDurable.map((message) => message.type), @@ -245,7 +220,6 @@ test('projects durable and active transcript records before sharing them', async assert.equal('providerOutput' in sharedDurable[1]!, false); assert.equal('providerOptions' in sharedDurable[2]!, false); assert.deepEqual(sharedDurable[2]!.thinking, { text: 'visible thought' }); - assert.equal('providerOptions' in decodeBootstrap(shared.bootstrap.overlay)[0]!, false); const projectedState = projectSharedSessionTranscriptMessage( { type: 'turn_state', @@ -286,8 +260,6 @@ test('rejects cursor tampering and cross-subscription replay', async () => { sessionId: 'session-1', subscriptionId: 'subscription-1', throughSequence: 0, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 128, projection: 'owner', }); @@ -296,8 +268,6 @@ test('rejects cursor tampering and cross-subscription replay', async () => { sessionId: 'session-1', subscriptionId: 'subscription-2', throughSequence: 0, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 128, projection: 'owner', }); @@ -307,7 +277,6 @@ test('rejects cursor tampering and cross-subscription replay', async () => { const tampered = `${cursor.slice(0, -1)}${cursor.endsWith('A') ? 'B' : 'A'}`; const request = { subscriptionId: 'subscription-1', - source: 'durable' as const, direction: 'older' as const, throughSequence: 0, cursor, @@ -329,23 +298,6 @@ test('rejects cursor tampering and cross-subscription replay', async () => { ); }); -test('keeps a durable continuation when overlay bytes reduce the bootstrap budget', async () => { - const durable = [userMessage(0, 'a'.repeat(240)), userMessage(1, 'b'.repeat(240))]; - const reader = transcriptReader(durable, [userMessage(0, 'overlay'.repeat(40))]); - const { bootstrap } = await createSessionTranscriptBootstrap({ - reader, - sessionId: 'session-1', - subscriptionId: 'subscription-1', - throughSequence: 1, - rootTurn: null, - activeAssistantStreams: [], - maxBytes: Buffer.byteLength(JSON.stringify(durable[0]), 'utf8') * 2, - projection: 'owner', - }); - assert.ok(bootstrap.overlay.rawBytes > 0); - assert.ok(bootstrap.durable.nextCursor); -}); - test('opens the complete latest Turn when bootstrap starts inside its assistant', async () => { const prompt = { ...userMessage(0, 'hello'), turnId: 'turn-1' }; const assistant = { @@ -359,13 +311,6 @@ test('opens the complete latest Turn when bootstrap starts inside its assistant' sessionId: 'session-1', subscriptionId: 'subscription-1', throughSequence: 1, - rootTurn: { - sessionId: 'session-1', - turnId: 'turn-1', - runId: 'run-1', - status: 'running', - }, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection: 'owner', }); @@ -373,7 +318,7 @@ test('opens the complete latest Turn when bootstrap starts inside its assistant' assert.equal(bootstrap.durable.rangeBoundarySequence, 0); assert.equal(bootstrap.durable.protectedTurnSequence, 1); assert.ok(bootstrap.durable.nextCursor); - const subscription = new ClientSessionSubscription( + const subscription = clientSubscription( { hostEpoch: 'host-1', subscriptionId: 'subscription-1', @@ -507,14 +452,6 @@ test('pages through a terminal Turn that exceeds the Host range message bound', sessionId: 'session-1', subscriptionId: 'subscription-1', throughSequence: durable.length - 1, - rootTurn: { - sessionId: 'session-1', - turnId: 'turn-1', - runId: 'run-1', - status: 'completed', - terminalEventId: 'terminal-1', - }, - activeAssistantStreams: [], maxBytes: 512 * 1024, projection: 'owner', }); @@ -524,7 +461,7 @@ test('pages through a terminal Turn that exceeds the Host range message bound', assert.equal(bootstrap.durable.protectedTurnSequence, null); assert.ok(bootstrap.durable.nextCursor); - const subscription = new ClientSessionSubscription( + const subscription = clientSubscription( { hostEpoch: 'host-1', subscriptionId: 'subscription-1', @@ -575,7 +512,6 @@ test('pages through a terminal Turn that exceeds the Host range message bound', state, request: { subscriptionId: 'subscription-1', - source: 'durable', direction: 'older', throughSequence: durable.length - 1, cursor: decoded.nextCursor, @@ -603,13 +539,6 @@ test('degrades the range boundary for an oversized running Turn', async () => { sessionId: 'session-1', subscriptionId: 'subscription-1', throughSequence: durable.length - 1, - rootTurn: { - sessionId: 'session-1', - turnId: 'turn-1', - runId: 'run-1', - status: 'running', - }, - activeAssistantStreams: [], maxBytes: 512 * 1024, projection: 'owner', }); @@ -631,14 +560,6 @@ test('degrades the range boundary for a Turn that exceeds the byte bound', async sessionId: 'session-1', subscriptionId: 'subscription-1', throughSequence: durable.length - 1, - rootTurn: { - sessionId: 'session-1', - turnId: 'turn-1', - runId: 'run-1', - status: 'completed', - terminalEventId: 'terminal-1', - }, - activeAssistantStreams: [], maxBytes: 512 * 1024, projection: 'owner', }); @@ -665,13 +586,6 @@ test('admits a latest Turn exactly at the Host range message bound', async () => sessionId: 'session-1', subscriptionId: `subscription-${projection}`, throughSequence: durable.length - 1, - rootTurn: { - sessionId: 'session-1', - turnId: 'turn-before', - runId: 'run-1', - status: 'running', - }, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection, }); @@ -695,14 +609,12 @@ test('excludes a partial far-edge Turn when the complete range would exceed its sessionId: 'session-1', subscriptionId: 'subscription-1', throughSequence: durable.length - 1, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 512 * 1024, projection: 'owner', }); assert.deepEqual( - bootstrap.durable.fragments.map((fragment) => fragment.kind === 'durable' && fragment.sequence), + bootstrap.durable.fragments.map((fragment) => fragment.sequence), Array.from({ length: 254 }, (_, index) => 256 - index), ); assert.equal(bootstrap.durable.rangeBoundarySequence, 3); @@ -714,7 +626,6 @@ test('excludes a partial far-edge Turn when the complete range would exceed its state, request: { subscriptionId: 'subscription-1', - source: 'durable', direction: 'older', throughSequence: durable.length - 1, cursor: bootstrap.durable.nextCursor, @@ -723,7 +634,7 @@ test('excludes a partial far-edge Turn when the complete range would exceed its }, }); assert.deepEqual( - page.fragments.map((fragment) => fragment.kind === 'durable' && fragment.sequence), + page.fragments.map((fragment) => fragment.sequence), [2, 1, 0], ); assert.equal(page.rangeBoundarySequence, 0); @@ -740,8 +651,6 @@ test('admits a forward Turn exactly at the Host range message bound', async () = sessionId: 'session-1', subscriptionId, throughSequence: 0, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection, }); @@ -759,7 +668,6 @@ test('admits a forward Turn exactly at the Host range message bound', async () = state, request: { subscriptionId, - source: 'durable', direction: 'newer', throughSequence: durable.length - 1, cursor: null, @@ -784,8 +692,6 @@ test('defers a partial forward edge Turn to the next complete range', async () = sessionId: 'session-1', subscriptionId, throughSequence: 0, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection, }); @@ -803,7 +709,6 @@ test('defers a partial forward edge Turn to the next complete range', async () = state, request: { subscriptionId, - source: 'durable', direction: 'newer', throughSequence: durable.length - 1, cursor: null, @@ -812,7 +717,7 @@ test('defers a partial forward edge Turn to the next complete range', async () = }, }); assert.deepEqual( - first.fragments.map((fragment) => fragment.kind === 'durable' && fragment.sequence), + first.fragments.map((fragment) => fragment.sequence), Array.from({ length: 254 }, (_, index) => index + 1), ); assert.equal(first.rangeBoundarySequence, 254); @@ -824,7 +729,6 @@ test('defers a partial forward edge Turn to the next complete range', async () = state, request: { subscriptionId, - source: 'durable', direction: 'newer', throughSequence: durable.length - 1, cursor: first.nextCursor, @@ -833,7 +737,7 @@ test('defers a partial forward edge Turn to the next complete range', async () = }, }); assert.deepEqual( - second.fragments.map((fragment) => fragment.kind === 'durable' && fragment.sequence), + second.fragments.map((fragment) => fragment.sequence), [255, 256, 257], ); assert.equal(second.rangeBoundarySequence, 257); @@ -849,8 +753,6 @@ test('protects the latest Turn when a forward page ends in a session note', asyn sessionId: 'session-1', subscriptionId: 'subscription-1', throughSequence: 0, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection: 'owner', }); @@ -865,7 +767,6 @@ test('protects the latest Turn when a forward page ends in a session note', asyn state, request: { subscriptionId: 'subscription-1', - source: 'durable', direction: 'newer', throughSequence: 2, cursor: null, @@ -897,8 +798,6 @@ test('shared paging skips a full hidden storage batch before a visible message', sessionId: 'session-1', subscriptionId: 'subscription-1', throughSequence: hidden.length, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection: 'shared', }); @@ -933,8 +832,6 @@ test('shared range edges cross a hidden storage batch between visible messages', sessionId: 'session-1', subscriptionId: 'subscription-1', throughSequence: durable.length - 1, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection: 'shared', }); @@ -947,7 +844,6 @@ test('shared range edges cross a hidden storage batch between visible messages', state, request: { subscriptionId: 'subscription-1', - source: 'durable', direction: 'newer', throughSequence: durable.length - 1, cursor: null, @@ -966,8 +862,6 @@ test('shrinks the raw bootstrap until it fits its aggregate encoded budget', asy sessionId: 'session-1', subscriptionId: 'subscription-1', throughSequence: durable.length - 1, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 16 * 1024, maxEncodedBytes: 4 * 1024, projection: 'owner', @@ -976,67 +870,6 @@ test('shrinks the raw bootstrap until it fits its aggregate encoded budget', asy assert.ok(bootstrap.durable.nextCursor); }); -test('rejects an active overlay that exceeds its retained message bound', async () => { - const overlay = Array.from({ length: 4_097 }, (_, index) => userMessage(index)); - await assert.rejects( - prepareSessionTranscriptOverlay({ - reader: transcriptReader([], overlay), - sessionId: 'session-1', - throughSequence: null, - rootTurn: null, - activeAssistantStreams: [], - }), - /overlay exceeds its message limit/, - ); -}); - -test('delegates one deduplicated and bounded durable reconciliation request', async () => { - const messages = Array.from({ length: 257 }, (_, index) => assistantMessage(index)); - const requests: Parameters[1][] = []; - const base = transcriptReader(messages, messages); - const reader: SessionTranscriptReader = { - ...base, - readDurableMessagesById: async (_sessionId, request) => { - requests.push(request); - return messages.filter((message) => request.messageIds.includes(message.id)); - }, - }; - const activeAssistantStreams = messages.flatMap((message, index) => [ - { - turnId: message.turnId, - messageId: message.id, - kind: 'text' as const, - text: message.text, - }, - ...(index === 0 - ? [ - { - turnId: message.turnId, - messageId: message.id, - kind: 'thinking' as const, - text: message.thinking!.text, - }, - ] - : []), - ]); - - const overlay = await prepareSessionTranscriptOverlay({ - reader, - sessionId: 'session-1', - throughSequence: 256, - rootTurn: null, - activeAssistantStreams, - }); - - assert.equal(overlay.length, 257); - assert.equal(requests.length, 1); - assert.equal(requests[0]?.messageIds.length, 257); - assert.equal(new Set(requests[0]?.messageIds).size, 257); - assert.equal(requests[0]?.throughSequence, 256); - assert.equal(requests[0]?.maxMessages, 4_096); - assert.equal(requests[0]?.maxBytes, 16 * 1024 * 1024); -}); - function userMessage(index: number, text = `message-${index}`): StoredMessage { return { type: 'user', @@ -1072,7 +905,7 @@ async function decodeSparseTranscriptRanges( direction: 'older' | 'newer', projection: 'owner' | 'shared', ): Promise> { - const reader = transcriptReader(durable, [], 8); + const reader = transcriptReader(durable, 8); const throughSequence = (durable.length - 1) * 8 + 7; const subscriptionId = `subscription-${projection}-${direction}`; const { bootstrap, state } = await createSessionTranscriptBootstrap({ @@ -1080,12 +913,10 @@ async function decodeSparseTranscriptRanges( sessionId: 'session-1', subscriptionId, throughSequence, - rootTurn: null, - activeAssistantStreams: [], maxBytes: 16 * 1024, projection, }); - const subscription = new ClientSessionSubscription( + const subscription = clientSubscription( { hostEpoch: 'host-1', subscriptionId, @@ -1121,7 +952,6 @@ async function decodeSparseTranscriptRanges( state, request: { subscriptionId, - source: 'durable', direction, throughSequence, cursor, diff --git a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts index a2d25e713c..47d16bf590 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-protocol.test.ts @@ -32,7 +32,6 @@ import { const input = { subscriptionId: 'subscription-1', - source: 'durable' as const, direction: 'older' as const, throughSequence: 3, cursor: null, @@ -44,13 +43,11 @@ const payloadDigest = `sha256:${'a'.repeat(64)}` as const; const page = { kind: 'page' as const, sessionId: 'session-1', - source: 'durable' as const, direction: 'older' as const, throughSequence: 3, rawBytes: 4, fragments: [ { - kind: 'durable' as const, sequence: 2, byteOffset: 0, totalBytes: 4, @@ -70,37 +67,8 @@ test('Session transcript protocol accepts bounded correlated pages and bootstrap HOST_OPERATION_SPECS['session.transcript.page'].assertOutputForInput?.(input, page), ); - const bootstrap = { - throughSequence: 3, - overlayMessageCount: 0, - durable: { ...page, direction: 'older' as const }, - overlay: { - kind: 'page' as const, - sessionId: 'session-1', - source: 'overlay' as const, - direction: 'older' as const, - throughSequence: 3, - rawBytes: 0, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: null, - nextCursor: null, - }, - }; + const bootstrap = { durable: page }; assert.deepEqual(decodeSessionTranscriptBootstrap(bootstrap), bootstrap); - assert.throws( - () => decodeSessionTranscriptBootstrap({ ...bootstrap, overlayMessageCount: 4_097 }), - isProtocolError, - ); - const release = { subscriptionId: 'subscription-1' }; - assert.deepEqual( - HOST_OPERATION_SPECS['session.transcript.overlay.release'].decodeInput(release), - release, - ); - assert.deepEqual( - HOST_OPERATION_SPECS['session.transcript.overlay.release'].decodeOutput(release), - release, - ); }); test('a maximum single-fragment continuation remains transport safe', () => { @@ -111,7 +79,6 @@ test('a maximum single-fragment continuation remains transport safe', () => { rawBytes: data.byteLength, fragments: [ { - kind: 'durable' as const, sequence: 2, byteOffset: 1, totalBytes: data.byteLength + 1, @@ -140,7 +107,6 @@ test('a maximum multi-message page remains transport safe', () => { throughSequence: Number.MAX_SAFE_INTEGER, rawBytes: SESSION_TRANSCRIPT_PAGE_MAX_BYTES, fragments: Array.from({ length: 256 }, (_, sequence) => ({ - kind: 'durable' as const, sequence, byteOffset: 0, totalBytes: fragmentBytes, @@ -173,19 +139,6 @@ test('Session transcript protocol rejects malformed and uncorrelated values', () () => decodeSessionTranscriptPage({ ...page, protectedTurnSequence: 4 }), isProtocolError, ); - assert.throws( - () => - decodeSessionTranscriptPage({ - ...page, - source: 'overlay', - rawBytes: 0, - fragments: [], - rangeBoundarySequence: null, - protectedTurnSequence: 2, - nextCursor: null, - }), - isProtocolError, - ); assert.throws( () => decodeSessionTranscriptPage({ @@ -203,13 +156,7 @@ test('Session transcript protocol rejects malformed and uncorrelated values', () isProtocolError, ); assert.throws( - () => - decodeSessionTranscriptBootstrap({ - throughSequence: 3, - overlayMessageCount: 0, - durable: page, - overlay: { ...page, source: 'overlay', throughSequence: 2 }, - }), + () => decodeSessionTranscriptBootstrap({ durable: { ...page, direction: 'newer' as const } }), isProtocolError, ); }); diff --git a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts index f9b4e5a60f..caecee9a64 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -22,8 +22,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { seedInvocation, testInvocationOpening } from '@maka/runtime/test-only/invocation-fixture'; -import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import { seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { WORKHUB_COORDINATION_SESSION_ID, @@ -39,18 +38,15 @@ import { readPageSchema } from '@maka/runtime/read-page'; import { openToolResultArchiveEvidenceReader } from '@maka/storage/tool-result-archive-evidence'; import { foldTurnContribution } from '@maka/storage/session-message-projection'; import type { SessionTurnContribution } from '@maka/storage/execution-stores'; -import { - type ExecutionStoresWriter, - openInteractiveExecutionStoresForWrite, -} from '@maka/storage/execution-stores'; +import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; import { - ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, createSessionTranscriptReader, + TRANSCRIPT_TURN_MAX_BYTES, } from '../server/session-transcript-reader.js'; for (const coordination of [false, true]) - test(`keeps ${coordination ? 'WorkHub' : 'ordinary'} durable history separate from the canonical active overlay`, async () => { + test(`pages ${coordination ? 'WorkHub' : 'ordinary'} running Turn rows as their events commit`, async () => { const base = await mkdtemp(join(tmpdir(), 'maka-session-transcript-')); const capability = await resolveStorageRoot({ path: join(base, 'root'), @@ -84,8 +80,6 @@ for (const coordination of [false, true]) created && created.kind !== 'conflict' ? created.record.header : await stores.sessionStore.create(input); - // An ended Turn is what the durable half is made of; the running one below - // belongs to the overlay and must not appear in a durable page. await seedInvocation(stores.runtimeEventStore, { sessionId: session.id, runId: 'run-0', @@ -303,36 +297,32 @@ for (const coordination of [false, true]) stores, canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, }); - const messages = await read.readActiveOverlay(session.id, { - sessionId: session.id, - turnId: 'turn-1', - runId: 'run-1', - status: 'running', - }); + const messages: StoredMessage[] = []; + for (let position: number | null = 0; position !== null; ) { + const running: Awaited> = + await read.readDurableRecords(session.id, { + direction: 'newer', + position, + maxStoredBytes: TRANSCRIPT_TURN_MAX_BYTES, + maxMessages: 64, + }); + messages.push( + ...running.records + .map(({ message }) => message) + .filter((message) => message.turnId === 'turn-1'), + ); + position = running.nextPosition; + } assert.deepEqual( - messages.slice(0, 4).map((message) => ({ type: message.type, id: message.id })), + messages.slice(0, 2).map((message) => ({ type: message.type, id: message.id })), [ { type: 'user', id: 'user-1' }, - { type: 'assistant', id: 'assistant-1' }, - { type: 'assistant', id: 'assistant-2' }, { type: 'assistant', id: 'assistant-3' }, ], ); - assert.equal(messages.length, 4 + resultCount * 2); - const firstAssistant = messages[1]; - assert.equal(firstAssistant?.type, 'assistant'); - if (firstAssistant?.type === 'assistant') { - assert.equal(firstAssistant.text, 'still streaming'); - assert.equal(firstAssistant.thinking?.text, 'deep thought'); - } - const thinkingOnly = messages[2]; - assert.equal(thinkingOnly?.type, 'assistant'); - if (thinkingOnly?.type === 'assistant') { - assert.equal(thinkingOnly.text, ''); - assert.equal(thinkingOnly.thinking?.text, 'still reasoning'); - } - const completedAssistant = messages[3]; + assert.equal(messages.length, 2 + resultCount * 2); + const completedAssistant = messages[1]; assert.equal(completedAssistant?.type, 'assistant'); if (completedAssistant?.type === 'assistant') assert.equal(completedAssistant.text, 'final text'); @@ -363,9 +353,9 @@ for (const coordination of [false, true]) } const durable = await read.readDurablePage(session.id, { - direction: 'older', + direction: 'newer', maxBytes: 1024, - maxMessages: 10, + maxMessages: 3, }); assert.equal(durable.throughSequence, await read.readDurableHighWater(session.id)); assert.ok(durable.throughSequence !== null); @@ -375,8 +365,9 @@ for (const coordination of [false, true]) return { type: message.type, id: message.id }; }), [ - { type: 'turn_state', id: 'terminal-0' }, { type: 'user', id: 'user-0' }, + { type: 'turn_state', id: 'terminal-0' }, + { type: 'user', id: 'user-1' }, ], ); if (largeBash) { @@ -406,20 +397,9 @@ for (const coordination of [false, true]) }, }), ); - if (!coordination) { - // The handoff membership check must hash this large prefix without - // loading it as a second RuntimeEvent array. - const handoff = await read.readActiveOverlay(session.id, { - sessionId: session.id, - turnId: 'turn-1', - runId: 'run-1', - status: 'running', - }); - assertLargeBashResult(handoff, largeBash); - } const recovered = await read.readDurableRecords(session.id, { direction: 'older', - maxStoredBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, + maxStoredBytes: TRANSCRIPT_TURN_MAX_BYTES, maxMessages: 32, }); assertLargeBashResult( @@ -602,17 +582,6 @@ test('pages the ledger without materializing Turns it takes no rows from', async read.readDurablePage(session.id, { direction: 'newer', maxBytes: 1024, maxMessages: 1 }), ); assert.equal(JSON.parse(head.fragments[0]!.data.toString()).text, 'prompt 0'); - assert.deepEqual( - await decoding('lookup miss', ONE_BIG_TURN_BUDGET, () => - read.readDurableMessagesById(session.id, { - throughSequence: through, - messageIds: ['missing-stream'], - maxBytes: 1024, - maxMessages: 1, - }), - ), - [], - ); const landmarks = await decoding('landmarks', SMALL_TURN_BUDGET, () => read.readDurableTurnLandmarks(session.id, 3), ); @@ -662,15 +631,6 @@ test('pages the ledger without materializing Turns it takes no rows from', async assert.deepEqual(contributions, [...folded.values()]); const assistant = records.find((record) => record.message.id === 'assistant-final'); assert.ok(assistant); - assert.deepEqual( - await read.readDurableMessagesById(session.id, { - throughSequence: through, - messageIds: ['assistant-final'], - maxBytes: 4096, - maxMessages: 1, - }), - [assistant.message], - ); // Reassemble the same multibyte message in either direction, inside one row. for (const direction of ['older', 'newer'] as const) { let byteOffset: number | undefined; @@ -723,108 +683,144 @@ test('pages the ledger without materializing Turns it takes no rows from', async } }); -test('stops scanning a control-only ledger at the cumulative immutable event limit', async () => { - const sessionId = 'session-1'; - const events = Array.from({ length: 8_193 }, (_, index) => - runtimeEvent(sessionId, { - id: `artifact-${index}`, - ts: index + 1, - role: 'system', - author: 'system', - actions: { artifactDelta: { bytes: index } }, - }), - ); - let scanned = 0; - const stores = { - agentRunStore: {}, - runtimeEventStore: { - readRunInvocation: async () => testInvocation(sessionId), - readRuntimeEventsBounded: async () => ({ status: 'limit_exceeded' as const }), - scanRuntimeEvents: async ( - _sessionId: string, - _runId: string, - budget: { readonly maxImmutableRecords: number }, - visit: (batch: readonly RuntimeEvent[]) => void, - ) => { - for (let offset = 0; offset < events.length; offset += 128) { - const batch = events.slice(offset, offset + 128); - if (offset + batch.length > budget.maxImmutableRecords) { - return { status: 'limit_exceeded' as const }; - } - scanned += batch.length; - visit(batch); - } - return { status: 'complete' as const }; - }, - }, - } as unknown as ExecutionStoresWriter<'interactive'>; - const read = createSessionTranscriptReader({ - stores, - canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, - }); +/** Every row a reader may serve, as `[sequence, message id]`, in `newer` order. */ +type ExpectedRows = ReadonlyArray; - await assert.rejects( - read.readActiveOverlay(sessionId, { - sessionId, - turnId: 'turn-1', - runId: 'run-1', - status: 'running', - }), - /storage scan limit/, - ); - assert.equal(scanned, 8_192); -}); +/** + * Both directions serve exactly `expected`, monotone in sequence, whether the + * walk takes one row at a time or the whole transcript at once. + * + * Paging is not compared against a sweep: both run the same walk, so a walk + * that drops a Turn drops it from both and the comparison still passes. + */ +async function assertTranscriptRows( + read: ReturnType, + sessionId: string, + throughSequence: number, + expected: ExpectedRows, +): Promise { + for (const direction of ['older', 'newer'] as const) { + const wanted = direction === 'older' ? [...expected].reverse() : expected; + for (const maxMessages of [expected.length, 1]) { + const rows: Array = []; + let position: number | undefined; + for (let page = 0; page <= expected.length; page++) { + const result = await read.readDurableRecords(sessionId, { + direction, + throughSequence, + ...(position === undefined ? {} : { position }), + maxMessages, + maxStoredBytes: 1 << 20, + }); + rows.push( + ...result.records.map(({ sequence, message }) => [sequence, message.id] as const), + ); + if (result.nextPosition === null) break; + position = result.nextPosition; + } + assert.deepEqual(rows, wanted, `${direction} in pages of ${maxMessages}`); + for (let index = 1; index < rows.length; index++) { + const step = rows[index]![0] - rows[index - 1]![0]; + assert.ok(direction === 'older' ? step < 0 : step > 0, `${direction} is monotone`); + } + } + } +} -test('stops an oversized active projection before retaining the full RuntimeEvent ledger', async () => { - const sessionId = 'session-1'; - const events = Array.from({ length: 8_193 }, (_, index) => - runtimeEvent(sessionId, { - id: `user-${index}`, - ts: index + 1, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'x' }, - refs: { storedMessageId: `message-${index}` }, - }), - ); - let visited = 0; - const stores = { - agentRunStore: {}, - runtimeEventStore: { - readRunInvocation: async () => testInvocation(sessionId), - scanRuntimeEvents: async ( - _sessionId: string, - _runId: string, - _budget: unknown, - visit: (batch: readonly RuntimeEvent[]) => void, - ) => { - for (let offset = 0; offset < events.length; offset += 128) { - visited += Math.min(128, events.length - offset); - visit(events.slice(offset, offset + 128)); - } - return { status: 'complete' as const }; - }, - }, - } as unknown as ExecutionStoresWriter<'interactive'>; - const read = createSessionTranscriptReader({ - stores, - canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, +test('serves every row of a Turn nested inside another', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-nested-paging-')); + await withNestedTranscript(base, async (read, sessionId) => { + // outer opens first and ends last; inner opens and ends inside it, so the + // two Turns share a stretch of the Session's ordinals. + // + // outer: [1 ....................... 10] rows 2, 9, 10 + // inner: [3 ............. 8] rows 4, 5, 6, 7, 8 + await seed(read.stores, sessionId, 'outer'); + await read.text('outer', 'outer-before'); + await seed(read.stores, sessionId, 'inner'); + for (let index = 0; index < 4; index++) await read.text('inner', `inner-${index}`); + await read.end('inner', 'inner-end'); + await read.text('outer', 'outer-after'); + await read.end('outer', 'outer-end'); + + const throughSequence = (await read.readDurableHighWater(sessionId))!; + assert.equal(throughSequence, 10 * 8 + 7); + await assertTranscriptRows(read, sessionId, throughSequence, [ + [2 * 8, 'outer-before'], + [4 * 8, 'inner-0'], + [5 * 8, 'inner-1'], + [6 * 8, 'inner-2'], + [7 * 8, 'inner-3'], + [8 * 8, 'inner-end'], + [9 * 8, 'outer-after'], + [10 * 8, 'outer-end'], + ]); }); +}); - await assert.rejects( - read.readActiveOverlay(sessionId, { - sessionId, - turnId: 'turn-1', - runId: 'run-1', - status: 'running', - }), - /exceeds its presentation limit/, - ); - assert.ok(visited < events.length); +test('serves a running Turn that encloses two separated Turns', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-sibling-paging-')); + await withNestedTranscript(base, async (read, sessionId) => { + // Two siblings that do not touch each other, both inside one Turn. Meeting + // a Turn that does not overlap the one in hand proves nothing about the + // rest, so a walk that probes only its own edges loses whichever side it + // steps over. + // + // outer: [1 .............................. 11] rows 2, 6, 10, 11 + // first: [3 .. 5] rows 4, 5 + // second: [7 .. 9] rows 8, 9 + await seed(read.stores, sessionId, 'outer'); + await read.text('outer', 'outer-a'); + await seed(read.stores, sessionId, 'first'); + await read.text('first', 'first-a'); + await read.end('first', 'first-end'); + await read.text('outer', 'outer-b'); + await seed(read.stores, sessionId, 'second'); + await read.text('second', 'second-a'); + await read.end('second', 'second-end'); + await read.text('outer', 'outer-c'); + await read.end('outer', 'outer-end'); + + // A watermark inside the outer Turn: it is still running as of this read, + // so it has no ending to be reached through. + const throughSequence = 10 * 8 + 7; + assert.equal(await read.readDurableHighWater(sessionId), 11 * 8 + 7); + await assertTranscriptRows(read, sessionId, throughSequence, [ + [2 * 8, 'outer-a'], + [4 * 8, 'first-a'], + [5 * 8, 'first-end'], + [6 * 8, 'outer-b'], + [8 * 8, 'second-a'], + [9 * 8, 'second-end'], + [10 * 8, 'outer-c'], + ]); + }); }); -test('pages a nested Turn the same way a single sweep reads it', async () => { - const base = await mkdtemp(join(tmpdir(), 'maka-nested-paging-')); +const seed = ( + stores: Awaited>, + sessionId: string, + runId: string, +) => + seedInvocation(stores.runtimeEventStore, { + sessionId, + runId, + turnId: `turn-${runId}`, + openedAt: 0, + }); + +/** A reader over an empty Session, with the appenders these fixtures build from. */ +async function withNestedTranscript( + base: string, + body: ( + read: ReturnType & { + stores: Awaited>; + text(runId: string, id: string): Promise; + end(runId: string, id: string): Promise; + }, + sessionId: string, + ) => Promise, +): Promise { const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); const owner = await tryAcquireInteractiveRootOwner(capability); assert.ok(owner); @@ -837,79 +833,44 @@ test('pages a nested Turn the same way a single sweep reads it', async () => { model: 'fake-model', permissionMode: 'ask', }); - let counter = 0; - const append = (runId: string, overrides: Partial) => + let ts = 0; + const append = (runId: string, id: string, overrides: Partial) => stores.runtimeEventStore.appendRuntimeEvent( session.id, runId, runtimeEvent(session.id, { - id: `${runId}-event-${counter++}`, + id, invocationId: runId, runId, turnId: `turn-${runId}`, - ts: counter, + ts: ++ts, ...overrides, }), ); - const text = (runId: string, body: string) => - append(runId, { role: 'model', author: 'agent', content: { kind: 'text', text: body } }); - - // `outer` opens first and ends last; `inner` opens and ends inside it, so - // the two Turns share a stretch of the Session's ordinals. - await seedInvocation(stores.runtimeEventStore, { - sessionId: session.id, - runId: 'outer', - turnId: 'turn-outer', - openedAt: 0, - }); - await text('outer', 'outer before'); - await seedInvocation(stores.runtimeEventStore, { - sessionId: session.id, - runId: 'inner', - turnId: 'turn-inner', - openedAt: 1, - }); - for (let index = 0; index < 4; index++) await text('inner', `inner ${index}`); - await append('inner', { status: 'completed', actions: { endInvocation: true } }); - await text('outer', 'outer after'); - await append('outer', { status: 'completed', actions: { endInvocation: true } }); - - const read = createSessionTranscriptReader({ - stores, - canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, - }); - const throughSequence = await read.readDurableHighWater(session.id); - - for (const direction of ['older', 'newer'] as const) { - const sweep = await read.readDurablePage(session.id, { - direction, - throughSequence, - maxBytes: 1 << 20, - maxMessages: 64, - }); - const swept = sweep.fragments.map((fragment) => fragment.sequence); - - const paged: number[] = []; - let position: number | undefined; - for (let page = 0; page < 32; page++) { - const result = await read.readDurablePage(session.id, { - direction, - throughSequence, - ...(position === undefined ? {} : { position }), - maxBytes: 1 << 20, - maxMessages: 1, - }); - if (result.fragments.length === 0) break; - paged.push(...result.fragments.map((fragment) => fragment.sequence)); - if (result.next?.position === undefined || result.next.position === null) break; - position = result.next.position; - } - assert.deepEqual(paged, swept, direction); - } + await body( + { + ...createSessionTranscriptReader({ + stores, + canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, + }), + stores, + text: (runId, id) => + append(runId, id, { + role: 'model', + author: 'agent', + content: { kind: 'text', text: id }, + refs: { storedMessageId: id }, + }), + end: (runId, id) => + append(runId, id, { status: 'completed', actions: { endInvocation: true } }), + }, + session.id, + ); } finally { + await owner.close(); await rm(base, { recursive: true, force: true }); } -}); +} function runtimeEvent(sessionId: string, overrides: Partial): RuntimeEvent { return { @@ -934,9 +895,7 @@ function assertLargeBashResult( (message) => message.type === 'tool_result' && message.toolUseId === 'large-bash-0', ); assert.ok(result?.type === 'tool_result'); - assert.ok( - Buffer.byteLength(JSON.stringify(result), 'utf8') < ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, - ); + assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') < TRANSCRIPT_TURN_MAX_BYTES); assert.ok(result.content.kind === 'terminal'); assert.equal(result.content.status, 'failed'); assert.equal(result.content.exitCode, 7); @@ -950,14 +909,3 @@ function assertLargeBashResult( assert.equal(result.content.output.stdoutTruncated, true); assert.equal(result.content.output.stderrTruncated, true); } - -function testInvocation(sessionId: string): RuntimeInvocationRecord { - return { - sessionId, - invocationId: 'run-1', - runId: 'run-1', - turnId: 'turn-1', - openedAt: 1, - opening: testInvocationOpening(), - }; -} diff --git a/packages/runtime-host/src/__tests__/workhub-assignment-crash-recovery.test.ts b/packages/runtime-host/src/__tests__/workhub-assignment-crash-recovery.test.ts index 8479dbfd23..21fd5d83a8 100644 --- a/packages/runtime-host/src/__tests__/workhub-assignment-crash-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/workhub-assignment-crash-recovery.test.ts @@ -427,6 +427,7 @@ test('real Host uses the independent Memory provider for messages, history and W { sessionId: 'memory-task', transcript: { kind: 'tail', maxBytes: 16384 } }, TIMEOUT, ); + await subscription.ready(); try { const history = subscription.transcriptBootstrap; assert.ok(history); diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index 2f76e8eeda..4fa15a4cec 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -666,16 +666,11 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { () => this.#closeSessionSubscription(result.subscriptionId), (query) => this.request('session.transcript.page', query, requestTimeoutMs), async () => { - try { - await this.request( - 'session.transcript.overlay.release', - { subscriptionId: result.subscriptionId }, - requestTimeoutMs, - ); - } catch (error) { - this.#fail(asError(error)); - throw error; - } + await this.request( + 'subscription.ready', + { subscriptionId: result.subscriptionId }, + requestTimeoutMs, + ); }, ); this.#subscriptions.set(result.subscriptionId, subscription); diff --git a/packages/runtime-host/src/client/session-subscription.ts b/packages/runtime-host/src/client/session-subscription.ts index 7d2ae126e4..6c3ac87dd5 100644 --- a/packages/runtime-host/src/client/session-subscription.ts +++ b/packages/runtime-host/src/client/session-subscription.ts @@ -44,7 +44,6 @@ export type RuntimeHostSubscriptionFailureReason = | 'correlation_changed' | 'projection_revision_invalid' | 'slow_consumer' - | 'transcript_release_failed' | 'connection_closed'; export class RuntimeHostSubscriptionError extends Error { @@ -71,11 +70,6 @@ export interface RuntimeHostSessionSubscription extends AsyncIterable(decodeMessage: (value: unknown) => T): Promise; - loadTranscriptOverlay( - decodeMessage: (value: unknown) => T, - maxMessageBytes?: number, - accountAssemblyBytes?: (deltaBytes: number) => void, - ): Promise; decodeTranscriptPage( page: SessionTranscriptPage, decodeMessage: (value: unknown) => T, @@ -85,6 +79,8 @@ export interface RuntimeHostSessionSubscription extends AsyncIterable, ): Promise; + /** Frames are held by the Host until this resolves. */ + ready(): Promise; close(): Promise; } @@ -110,10 +106,11 @@ export class ClientSessionSubscription readonly activeAssistantStreams: readonly SessionAssistantStreamIdentity[]; readonly transcriptBootstrap: SessionTranscriptBootstrap | null; readonly #requestClose: () => Promise; + readonly #requestReady: () => Promise; + #readyTask: Promise | undefined; readonly #readTranscriptPage: ( input: SessionTranscriptPageInput, ) => Promise; - readonly #releaseTranscriptOverlay: () => Promise; readonly #expectedSessionId: string; readonly #queue: QueuedFrame[] = []; readonly #ptyListeners = new Set<(frame: SessionRuntimeResourcePtyDataFrame) => void>(); @@ -133,15 +130,13 @@ export class ClientSessionSubscription #closing = false; #closeTask: Promise | undefined; #transcriptTask: Promise | undefined; - #overlayTask: Promise> | undefined; - #overlayConsumed = false; #latestTranscriptThroughSequence: number | null; constructor( result: SubscriptionOpenResult, requestClose: () => Promise, readTranscriptPage: (input: SessionTranscriptPageInput) => Promise, - releaseTranscriptOverlay: () => Promise = async () => undefined, + requestReady: () => Promise, ) { this.hostEpoch = result.hostEpoch; this.subscriptionId = result.subscriptionId; @@ -151,10 +146,22 @@ export class ClientSessionSubscription this.#expectedSessionId = result.snapshot.session.sessionId; this.#expectedSequence = result.nextSequence; this.#latestProjectionRevision = result.snapshot.projectionRevision; - this.#latestTranscriptThroughSequence = result.transcript?.throughSequence ?? null; + this.#latestTranscriptThroughSequence = result.transcript?.durable.throughSequence ?? null; this.#requestClose = requestClose; this.#readTranscriptPage = readTranscriptPage; - this.#releaseTranscriptOverlay = releaseTranscriptOverlay; + this.#requestReady = requestReady; + } + + /** + * Take frames from here on. + * + * Until this is called the Host holds them, so a subscriber assembles the + * state frames apply to without an in-flight answer of any size arriving + * against a queue sized for live traffic. + */ + ready(): Promise { + this.#readyTask ??= this.#requestReady(); + return this.#readyTask; } [Symbol.asyncIterator](): AsyncIterator { @@ -214,26 +221,6 @@ export class ClientSessionSubscription return this.#transcriptTask.then((messages) => messages.map(decodeMessage)); } - loadTranscriptOverlay( - decodeMessage: (value: unknown) => T, - maxMessageBytes = Number.MAX_SAFE_INTEGER, - accountAssemblyBytes: (deltaBytes: number) => void = () => undefined, - ): Promise { - this.#assertTranscriptReadable(); - const bootstrap = this.transcriptBootstrap; - if (!bootstrap) { - return Promise.reject( - new RuntimeHostSubscriptionError( - 'correlation_changed', - 'Session subscription was opened without transcript access', - ), - ); - } - return this.#consumeTranscriptOverlay(bootstrap, maxMessageBytes, accountAssemblyBytes).then( - (messages) => messages.map((entry) => decodeMessage(entry.value)), - ); - } - async decodeTranscriptPage( page: SessionTranscriptPage, decodeMessage: (value: unknown) => T, @@ -242,13 +229,11 @@ export class ClientSessionSubscription ): Promise> { this.#assertTranscriptReadable(); this.#assertTranscriptPage(page, { - source: page.source, direction: page.direction, throughSequence: page.throughSequence, maxBytes: Math.max(1, page.rawBytes), }); const assembler = new TranscriptFragmentAssembler( - page.source, page.direction, maxMessageBytes, accountAssemblyBytes, @@ -257,11 +242,7 @@ export class ClientSessionSubscription assembler.accept(page.fragments); let cursor = page.nextCursor; let rangeBytes = page.fragments.reduce((total, fragment) => total + fragment.totalBytes, 0); - const rangeIdentities = new Set( - page.fragments.map((fragment) => - fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex, - ), - ); + const rangeIdentities = new Set(page.fragments.map((fragment) => fragment.sequence)); let reachedBoundary = page.rangeBoundarySequence === null || rangeIdentities.has(page.rangeBoundarySequence); while (assembler.continuationBytes !== null || !reachedBoundary) { @@ -273,7 +254,6 @@ export class ClientSessionSubscription } const requestedCursor = cursor; const continuation = await this.loadTranscriptPage({ - source: page.source, direction: page.direction, throughSequence: page.throughSequence, cursor, @@ -290,9 +270,8 @@ export class ClientSessionSubscription ); } for (const fragment of continuation.fragments) { - const identity = fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex; - if (!rangeIdentities.has(identity)) { - rangeIdentities.add(identity); + if (!rangeIdentities.has(fragment.sequence)) { + rangeIdentities.add(fragment.sequence); rangeBytes += fragment.totalBytes; } } @@ -362,96 +341,24 @@ export class ClientSessionSubscription 'Session subscription was opened without transcript access', ); } - const overlay = await this.#consumeTranscriptOverlay(bootstrap); const durable = await this.#loadTranscriptSource(bootstrap.durable); - const messages = durable.map((entry) => entry.value); - const indexById = new Map(); - for (const [index, message] of messages.entries()) { - const id = messageIdentity(message); - if (id) indexById.set(id, index); - } - for (const entry of overlay) { - const id = messageIdentity(entry.value); - const index = id ? indexById.get(id) : undefined; - if (index === undefined) { - if (id) indexById.set(id, messages.length); - messages.push(entry.value); - } else { - messages[index] = entry.value; - } - } - return messages; - } - - #consumeTranscriptOverlay( - bootstrap: SessionTranscriptBootstrap, - maxMessageBytes = Number.MAX_SAFE_INTEGER, - accountAssemblyBytes: (deltaBytes: number) => void = () => undefined, - ): Promise> { - if (this.#overlayConsumed && !this.#overlayTask) { - return Promise.reject( - new RuntimeHostSubscriptionError( - 'correlation_changed', - 'Session transcript overlay was already consumed', - ), - ); - } - this.#overlayTask ??= (async () => { - const overlay = await this.#loadTranscriptSource( - bootstrap.overlay, - maxMessageBytes, - accountAssemblyBytes, - ); - assertCompleteIdentities( - overlay, - bootstrap.overlayMessageCount === 0 ? null : bootstrap.overlayMessageCount - 1, - ); - if (bootstrap.overlayMessageCount === 0) { - this.#overlayConsumed = true; - return overlay; - } - try { - await this.#releaseTranscriptOverlay(); - } catch (cause) { - await this.close().catch(() => undefined); - throw new RuntimeHostSubscriptionError( - 'transcript_release_failed', - 'Runtime Host Session transcript overlay release was not confirmed', - { cause }, - ); - } - this.#overlayConsumed = true; - return overlay; - })(); - const task = this.#overlayTask; - return task.finally(() => { - if (this.#overlayTask === task) this.#overlayTask = undefined; - }); + return durable.map((entry) => entry.value); } async #loadTranscriptSource( initial: SessionTranscriptPage, - maxMessageBytes = Number.MAX_SAFE_INTEGER, - accountAssemblyBytes: (deltaBytes: number) => void = () => undefined, ): Promise> { this.#assertTranscriptPage(initial, { - source: initial.source, direction: initial.direction, throughSequence: initial.throughSequence, maxBytes: Math.max(1, initial.rawBytes), }); - const assembler = new TranscriptFragmentAssembler( - initial.source, - initial.direction, - maxMessageBytes, - accountAssemblyBytes, - ); + const assembler = new TranscriptFragmentAssembler(initial.direction); try { assembler.accept(initial.fragments); let cursor = initial.nextCursor; while (cursor !== null) { const page = await this.loadTranscriptPage({ - source: initial.source, direction: initial.direction, throughSequence: initial.throughSequence, cursor, @@ -484,14 +391,10 @@ export class ClientSessionSubscription #assertTranscriptPage( page: SessionTranscriptPage, - expected: Pick< - SessionTranscriptPageInput, - 'source' | 'direction' | 'throughSequence' | 'maxBytes' - >, + expected: Pick, ): void { if ( page.sessionId !== this.#expectedSessionId || - page.source !== expected.source || page.direction !== expected.direction || page.throughSequence !== expected.throughSequence || page.rawBytes > expected.maxBytes @@ -670,7 +573,6 @@ class TranscriptFragmentAssembler { #lastStartedIdentity: number | undefined; constructor( - private readonly source: 'durable' | 'overlay', private readonly direction: 'older' | 'newer', private readonly maxMessageBytes = Number.MAX_SAFE_INTEGER, private readonly accountAssemblyBytes: (deltaBytes: number) => void = () => undefined, @@ -704,15 +606,9 @@ class TranscriptFragmentAssembler { } #accept(fragment: SessionTranscriptFragment): void { - if (fragment.kind !== this.source) { - throw new RuntimeHostSubscriptionError( - 'correlation_changed', - 'Session transcript fragment source changed', - ); - } - const identity = fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex; + const identity = fragment.sequence; const bytes = Buffer.from(fragment.data, 'base64'); - const payloadDigest = fragment.kind === 'durable' ? fragment.payloadDigest : null; + const payloadDigest = fragment.payloadDigest; if (!this.#current) this.#start(identity, fragment.totalBytes, payloadDigest); if ( this.#current?.identity !== identity || @@ -804,33 +700,3 @@ class TranscriptFragmentAssembler { this.#current = undefined; } } - -function assertCompleteIdentities( - messages: readonly { identity: number }[], - throughIdentity: number | null, -): void { - if (throughIdentity === null) { - if (messages.length !== 0) { - throw new RuntimeHostSubscriptionError( - 'correlation_changed', - 'Session transcript contains messages without a watermark', - ); - } - return; - } - if ( - messages.length !== throughIdentity + 1 || - messages.some((message, index) => message.identity !== index) - ) { - throw new RuntimeHostSubscriptionError( - 'correlation_changed', - 'Session transcript has a message sequence gap', - ); - } -} - -function messageIdentity(value: unknown): string | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return; - const id = (value as Record).id; - return typeof id === 'string' ? id : undefined; -} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index bcb4370717..0c7b1b949f 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,8 @@ 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 = 157 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 158 as const; +// 158: Session transcripts advance per committed RuntimeEvent; the active overlay is gone. // 154: External Session import results distinguish committed Sessions from typed source limits. // 153: Sessions may select plugin executors and Plugin Platform queries expose them. // 152: Assistant completions and transcript rows preserve interrupted responses. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index deb62c8327..aa2f3718ab 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -334,7 +334,6 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'session.revision.abandon', 'session.revision.create', 'session.transcript.page', - 'session.transcript.overlay.release', 'session.turn_landmarks.query', 'session.turns.query', 'session.workspace.relocate', @@ -345,6 +344,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'subscription.close', 'subscription.open', 'subscription.pty_interest.set', + 'subscription.ready', 'session.todo.query', 'turn.interrupt', 'turn.message.execution.query', diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 7db2ae58f2..95bba3299f 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -367,8 +367,7 @@ export const SESSION_CONTINUITY_OPERATION_SPECS = { if ( input.transcript.kind === 'tail' && output.transcript && - output.transcript.durable.rawBytes + output.transcript.overlay.rawBytes > - input.transcript.maxBytes + output.transcript.durable.rawBytes > input.transcript.maxBytes ) { throw invalidProtocolFrame('Session transcript bootstrap exceeds requested byte limit'); } @@ -381,6 +380,21 @@ export const SESSION_CONTINUITY_OPERATION_SPECS = { decodeInput: decodeSubscriptionCloseInput, decodeOutput: decodeSubscriptionCloseResult, }), + /** + * The subscriber can take frames now. + * + * The Host holds a new subscription's frames until this arrives. What it + * holds includes the in-flight answer a mid-stream subscriber has not seen, + * which is as large as the answer and so cannot be handed to a client that + * is still assembling the state those frames apply to. + */ + 'subscription.ready': defineOperation({ + mode: 'control', + availability: 'ready', + errors: SUBSCRIPTION_CLOSE_ERRORS, + decodeInput: decodeSubscriptionCloseInput, + decodeOutput: decodeSubscriptionCloseResult, + }), } as const; export function decodeSubscriptionFrame(value: unknown): SubscriptionFrame { diff --git a/packages/runtime-host/src/protocol/session-transcript.ts b/packages/runtime-host/src/protocol/session-transcript.ts index 8cdfc8ebd7..1f93682b14 100644 --- a/packages/runtime-host/src/protocol/session-transcript.ts +++ b/packages/runtime-host/src/protocol/session-transcript.ts @@ -23,7 +23,6 @@ import { requireEntityId, requireExactRecord, requireId, - requireRecord, requireUtf8String, } from './codec.js'; import { invalidProtocolFrame } from './errors.js'; @@ -34,34 +33,22 @@ export const SESSION_TRANSCRIPT_PAGE_MAX_BYTES = 512 * 1024; export const SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES = 256; export const SESSION_TRANSCRIPT_RANGE_MAX_BYTES = 16 * 1024 * 1024; export const SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES = SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES; -export const SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES = 4_096; export const SESSION_TRANSCRIPT_PAGE_RESULT_MAX_BYTES = 744 * 1024; export const SESSION_TRANSCRIPT_CURSOR_MAX_BYTES = 1024; -export type SessionTranscriptPageSource = 'durable' | 'overlay'; export type SessionTranscriptPageDirection = 'older' | 'newer'; -export type SessionTranscriptFragment = - | { - readonly kind: 'durable'; - readonly sequence: number; - readonly byteOffset: number; - readonly totalBytes: number; - readonly payloadDigest: `sha256:${string}` | null; - readonly data: string; - } - | { - readonly kind: 'overlay'; - readonly messageIndex: number; - readonly byteOffset: number; - readonly totalBytes: number; - readonly data: string; - }; +export interface SessionTranscriptFragment { + readonly sequence: number; + readonly byteOffset: number; + readonly totalBytes: number; + readonly payloadDigest: `sha256:${string}` | null; + readonly data: string; +} export interface SessionTranscriptPage { readonly kind: 'page'; readonly sessionId: string; - readonly source: SessionTranscriptPageSource; readonly direction: SessionTranscriptPageDirection; readonly throughSequence: number | null; readonly rawBytes: number; @@ -74,15 +61,11 @@ export interface SessionTranscriptPage { } export interface SessionTranscriptBootstrap { - readonly throughSequence: number | null; - readonly overlayMessageCount: number; readonly durable: SessionTranscriptPage; - readonly overlay: SessionTranscriptPage; } export interface SessionTranscriptPageInput { readonly subscriptionId: string; - readonly source: SessionTranscriptPageSource; readonly direction: SessionTranscriptPageDirection; readonly throughSequence: number | null; readonly cursor: string | null; @@ -90,14 +73,6 @@ export interface SessionTranscriptPageInput { readonly maxBytes: number; } -export interface SessionTranscriptOverlayReleaseInput { - readonly subscriptionId: string; -} - -export interface SessionTranscriptOverlayReleaseResult { - readonly subscriptionId: string; -} - const QUERY_ERRORS = [ 'host_not_ready', 'host_draining', @@ -118,42 +93,11 @@ export const SESSION_TRANSCRIPT_OPERATION_SPECS = { decodeOutput: decodeSessionTranscriptPage, assertOutputForInput: assertSessionTranscriptPageOutput, }), - 'session.transcript.overlay.release': defineOperation({ - mode: 'control', - availability: 'ready', - errors: QUERY_ERRORS, - decodeInput: decodeSessionTranscriptOverlayReleaseInput, - decodeOutput: decodeSessionTranscriptOverlayReleaseResult, - assertOutputForInput: (input, output) => { - if (input.subscriptionId !== output.subscriptionId) { - throw invalidProtocolFrame('Session transcript overlay release identity changed'); - } - }, - }), } as const; -function decodeSessionTranscriptOverlayReleaseInput( - value: unknown, -): SessionTranscriptOverlayReleaseInput { - const input = requireExactRecord(value, 'Session transcript overlay release input', [ - 'subscriptionId', - ]); - return { subscriptionId: requireId(input.subscriptionId, 'subscriptionId') }; -} - -function decodeSessionTranscriptOverlayReleaseResult( - value: unknown, -): SessionTranscriptOverlayReleaseResult { - const result = requireExactRecord(value, 'Session transcript overlay release result', [ - 'subscriptionId', - ]); - return { subscriptionId: requireId(result.subscriptionId, 'subscriptionId') }; -} - export function decodeSessionTranscriptPageInput(value: unknown): SessionTranscriptPageInput { const input = requireExactRecord(value, 'Session transcript page input', [ 'subscriptionId', - 'source', 'direction', 'throughSequence', 'cursor', @@ -177,7 +121,6 @@ export function decodeSessionTranscriptPageInput(value: unknown): SessionTranscr } return { subscriptionId: requireId(input.subscriptionId, 'subscriptionId'), - source: decodeSource(input.source), direction: decodeDirection(input.direction), throughSequence: input.throughSequence === null @@ -190,44 +133,15 @@ export function decodeSessionTranscriptPageInput(value: unknown): SessionTranscr } export function decodeSessionTranscriptBootstrap(value: unknown): SessionTranscriptBootstrap { - const bootstrap = requireExactRecord(value, 'Session transcript bootstrap', [ - 'throughSequence', - 'overlayMessageCount', - 'durable', - 'overlay', - ]); - const throughSequence = - bootstrap.throughSequence === null - ? null - : requireCount(bootstrap.throughSequence, 'Session transcript watermark'); - const overlayMessageCount = requireCount( - bootstrap.overlayMessageCount, - 'Session transcript overlay message count', - ); - if (overlayMessageCount > SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES) { - throw invalidProtocolFrame('Session transcript overlay exceeds its message limit'); - } + const bootstrap = requireExactRecord(value, 'Session transcript bootstrap', ['durable']); const durable = decodeSessionTranscriptPage(bootstrap.durable); - const overlay = decodeSessionTranscriptPage(bootstrap.overlay); - if ( - durable.source !== 'durable' || - durable.direction !== 'older' || - overlay.source !== 'overlay' || - overlay.direction !== 'older' || - durable.throughSequence !== throughSequence || - overlay.throughSequence !== throughSequence - ) { + if (durable.direction !== 'older') { throw invalidProtocolFrame('Invalid Session transcript bootstrap correlation'); } - if (durable.rawBytes + overlay.rawBytes > SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES) { + if (durable.rawBytes > SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES) { throw invalidProtocolFrame('Session transcript bootstrap exceeds byte limit'); } - return { - throughSequence, - overlayMessageCount, - durable, - overlay, - }; + return { durable }; } export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPage { @@ -239,7 +153,6 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa const result = requireExactRecord(value, 'Session transcript page result', [ 'kind', 'sessionId', - 'source', 'direction', 'throughSequence', 'rawBytes', @@ -249,7 +162,6 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa 'nextCursor', ]); if (result.kind !== 'page') throw invalidProtocolFrame('Invalid Session transcript page kind'); - const source = decodeSource(result.source); const direction = decodeDirection(result.direction); const throughSequence = result.throughSequence === null @@ -262,7 +174,7 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa throw invalidProtocolFrame('Invalid Session transcript page fragments'); } const fragments = result.fragments.map((fragment) => - decodeSessionTranscriptFragment(fragment, source, throughSequence), + decodeSessionTranscriptFragment(fragment, throughSequence), ); assertFragmentOrder(fragments, direction); const rawBytes = requireCount(result.rawBytes, 'Session transcript page bytes'); @@ -290,16 +202,14 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa ? null : requireCount(result.protectedTurnSequence, 'Session transcript protected Turn sequence'); if ( - (rangeBoundarySequence !== null && source !== 'durable') || - (rangeBoundarySequence !== null && - (throughSequence === null || rangeBoundarySequence > throughSequence)) + rangeBoundarySequence !== null && + (throughSequence === null || rangeBoundarySequence > throughSequence) ) { throw invalidProtocolFrame('Invalid Session transcript range boundary'); } if ( - (protectedTurnSequence !== null && source !== 'durable') || - (protectedTurnSequence !== null && - (throughSequence === null || protectedTurnSequence > throughSequence)) + protectedTurnSequence !== null && + (throughSequence === null || protectedTurnSequence > throughSequence) ) { throw invalidProtocolFrame('Invalid Session transcript protected Turn sequence'); } @@ -309,7 +219,6 @@ export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPa return { kind: 'page', sessionId: requireEntityId(result.sessionId, 'sessionId'), - source, direction, throughSequence, rawBytes, @@ -325,36 +234,28 @@ function assertFragmentOrder( direction: SessionTranscriptPageDirection, ): void { let previous: number | undefined; - for (const fragment of fragments) { - const identity = fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex; + for (const { sequence } of fragments) { if ( previous !== undefined && - (direction === 'older' ? identity >= previous : identity <= previous) + (direction === 'older' ? sequence >= previous : sequence <= previous) ) { throw invalidProtocolFrame('Session transcript page fragment order changed'); } - previous = identity; + previous = sequence; } } function decodeSessionTranscriptFragment( value: unknown, - source: SessionTranscriptPageSource, throughSequence: number | null, ): SessionTranscriptFragment { - const fragment = requireRecord(value, 'Session transcript fragment'); - const identityKey = source === 'durable' ? 'sequence' : 'messageIndex'; - const exact = requireExactRecord(fragment, 'Session transcript fragment', [ - 'kind', - identityKey, + const exact = requireExactRecord(value, 'Session transcript fragment', [ + 'sequence', 'byteOffset', 'totalBytes', - ...(source === 'durable' ? ['payloadDigest'] : []), + 'payloadDigest', 'data', ]); - if (exact.kind !== source) { - throw invalidProtocolFrame('Session transcript fragment source changed'); - } const byteOffset = requireCount(exact.byteOffset, 'Session transcript fragment byte offset'); const totalBytes = requireCount(exact.totalBytes, 'Session transcript fragment total bytes'); const data = requireBase64Fragment(exact.data); @@ -367,24 +268,15 @@ function decodeSessionTranscriptFragment( ) { throw invalidProtocolFrame('Invalid Session transcript fragment bounds'); } - if (source === 'durable') { - const sequence = requireCount(exact.sequence, 'Session transcript message sequence'); - if (throughSequence === null || sequence > throughSequence) { - throw invalidProtocolFrame('Session transcript fragment exceeds watermark'); - } - const payloadDigest = - exact.payloadDigest === null - ? null - : requirePayloadDigest(exact.payloadDigest, 'Session transcript payload digest'); - return { kind: 'durable', sequence, byteOffset, totalBytes, payloadDigest, data }; + const sequence = requireCount(exact.sequence, 'Session transcript message sequence'); + if (throughSequence === null || sequence > throughSequence) { + throw invalidProtocolFrame('Session transcript fragment exceeds watermark'); } - return { - kind: 'overlay', - messageIndex: requireCount(exact.messageIndex, 'Session transcript overlay index'), - byteOffset, - totalBytes, - data, - }; + const payloadDigest = + exact.payloadDigest === null + ? null + : requirePayloadDigest(exact.payloadDigest, 'Session transcript payload digest'); + return { sequence, byteOffset, totalBytes, payloadDigest, data }; } function requirePayloadDigest(value: unknown, label: string): `sha256:${string}` { @@ -414,7 +306,6 @@ function assertSessionTranscriptPageOutput( output: SessionTranscriptPage, ): void { if ( - output.source !== input.source || output.direction !== input.direction || output.throughSequence !== input.throughSequence || output.rawBytes > input.maxBytes @@ -423,13 +314,6 @@ function assertSessionTranscriptPageOutput( } } -function decodeSource(value: unknown): SessionTranscriptPageSource { - if (value !== 'durable' && value !== 'overlay') { - throw invalidProtocolFrame('Invalid Session transcript page source'); - } - return value; -} - function decodeDirection(value: unknown): SessionTranscriptPageDirection { if (value !== 'older' && value !== 'newer') { throw invalidProtocolFrame('Invalid Session transcript page direction'); diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts index 9903bbb44d..30f270b73e 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -58,14 +58,9 @@ const PERSISTED_GRANT_MIGRATIONS: ReadonlyMap = string, PersistedGrantMigration >([ - // The transcript query split into paging and its overlay release. - [ - 'session.transcript.query', - { - kind: 'replace', - successors: ['session.transcript.page', 'session.transcript.overlay.release'], - }, - ], + ['session.transcript.query', { kind: 'replace', successors: ['session.transcript.page'] }], + // Retired with the active transcript overlay; pages alone carry a running Turn. + ['session.transcript.overlay.release', { kind: 'release' }], // The Turn query kept its name and gained a separate landmark query beside it. [ 'session.turns.query', @@ -104,8 +99,8 @@ export const SESSION_GUEST_OPERATION_GRANTS = Object.freeze([ 'subscription.open', 'subscription.close', 'subscription.pty_interest.set', + 'subscription.ready', 'session.transcript.page', - 'session.transcript.overlay.release', ] as const satisfies readonly OperationKey[]); // A Client Capability provider serves exactly this much and nothing else. It diff --git a/packages/runtime-host/src/server/connection-session.ts b/packages/runtime-host/src/server/connection-session.ts index cf0e4e5217..7c1de224f7 100644 --- a/packages/runtime-host/src/server/connection-session.ts +++ b/packages/runtime-host/src/server/connection-session.ts @@ -258,10 +258,6 @@ export class RuntimeHostConnectionSession { : undefined; try { await receipt.flushed; - // Subscriber-local queues retain pre-activation events. Expose them - // only after the open result leaves the connection-wide writer, or a - // restore fan-out can make legal responses and first frames overflow it. - if (openedSubscriptionId) continuity?.activate(openedSubscriptionId); } catch (error) { if (openedSubscriptionId) continuity?.abort(openedSubscriptionId); throw error; diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 0b4de75a66..671a77ef70 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -339,6 +339,7 @@ export async function createExecutionRuntimeHostComposition( let sessionEffects: HostSessionEffectCoordinator | undefined; let memoryExtraction: HostMemoryExtractionCoordinator | undefined; let unsubscribeTranscriptChanges: (() => void) | undefined; + let unsubscribeRuntimeEventCommits: (() => void) | undefined; let transcriptReader: SessionTranscriptReader | undefined; let unsubscribeUsageChanges: (() => void) | undefined; let workspaceExecution: RuntimeHostWorkspaceExecutionComposition | undefined; @@ -883,6 +884,9 @@ export async function createExecutionRuntimeHostComposition( unsubscribeTranscriptChanges = stores.sessionStore.subscribeTranscriptChanges((sessionId) => continuityCoordinator.enqueueCanonicalRefresh(sessionId), ); + unsubscribeRuntimeEventCommits = stores.runtimeEventStore.subscribeRuntimeEventCommits( + (sessionId) => continuityCoordinator.enqueueTranscriptAdvanced(sessionId), + ); unsubscribeUsageChanges = openedUsageStores.subscribeSessionUsageChanges((sessionId) => continuityCoordinator.enqueueSessionDomainChanged(sessionId, 'usage'), ); @@ -2595,6 +2599,7 @@ export async function createExecutionRuntimeHostComposition( () => externalAgentSetup?.close(), () => { unsubscribeTranscriptChanges?.(); + unsubscribeRuntimeEventCommits?.(); unsubscribeUsageChanges?.(); }, ], @@ -2919,6 +2924,7 @@ export async function createExecutionRuntimeHostComposition( } try { unsubscribeTranscriptChanges?.(); + unsubscribeRuntimeEventCommits?.(); unsubscribeUsageChanges?.(); } catch (closeError) { errors.push(closeError); diff --git a/packages/runtime-host/src/server/session-continuity-coordinator.ts b/packages/runtime-host/src/server/session-continuity-coordinator.ts index 1e1c8030a4..4e10ff14cf 100644 --- a/packages/runtime-host/src/server/session-continuity-coordinator.ts +++ b/packages/runtime-host/src/server/session-continuity-coordinator.ts @@ -73,21 +73,18 @@ import type { } from './session-continuity-service.js'; import { createSessionTranscriptBootstrap, - prepareSessionTranscriptOverlay, readSessionTranscriptPage, type SubscriberTranscriptState, TranscriptPageRequestError, updateSubscriberTranscriptHighWater, } from './session-transcript-pager.js'; -import { - ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, - type SessionTranscriptReader, -} from './session-transcript-reader.js'; +import type { SessionTranscriptReader } from './session-transcript-reader.js'; import { projectSharedSessionMessageContent } from './shared-session-transcript.js'; const MAX_CONNECTION_SUBSCRIPTIONS = 16; const MAX_SUBSCRIBER_QUEUED_FRAMES = 32; const MAX_SUBSCRIBER_QUEUED_BYTES = 256 * 1024; +const ASSISTANT_BACKLOG_CHUNK_CHARACTERS = 8 * 1024; export type { CanonicalSessionProjection } from './canonical-session-projection.js'; @@ -118,7 +115,6 @@ interface SessionProjectionState { revision: number; subscribers: Map; assistantStreams: Map; - transcriptOverlay?: CachedTranscriptOverlay; /** * Latest live tool_result_preview per toolUseId for the active turn. * Replace semantics; cleared on tool_result and terminal publication. @@ -148,7 +144,6 @@ interface ConnectionState { sink: SessionContinuityFrameSink; subscriptionIds: Set; pendingOpenCount: number; - readonly closed: AbortController; } interface QueuedSubscriptionFrame { @@ -176,47 +171,24 @@ interface Subscriber { ptyInterests: Set; terminalQueued: boolean; transcript?: SubscriberTranscriptState; - retainedTranscriptOverlay?: RetainedTranscriptOverlay; -} - -interface RetainedTranscriptOverlay { - readonly messages: readonly Buffer[]; - readonly bytes: number; - references: number; -} - -interface CachedTranscriptOverlay { - readonly throughSequence: number | null; - readonly prepared: Promise; - pendingConsumers: number; - cancelPreparation(): void; -} - -interface TranscriptOverlayPreparationWaiter { - cancelled: boolean; - granted: boolean; - resolve(release: () => void): void; - reject(error: Error): void; -} - -interface TranscriptOverlayPreparationPermit { - readonly waiter: TranscriptOverlayPreparationWaiter; - take(): () => void; - release(): void; -} - -const MAX_RETAINED_TRANSCRIPT_OVERLAY_BYTES = 64 * 1024 * 1024; -const MAX_TRANSCRIPT_OVERLAY_PREPARATION_WAITERS = 64; -// Preparation can retain the active projection, durable reconciliation, and -// final encoded snapshot at the same time; charge all three to the Host budget. -const MAX_TRANSCRIPT_OVERLAY_PREPARATION_BYTES = ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES * 3; - -class TranscriptOverlayCapacityError extends Error { - readonly name = 'TranscriptOverlayCapacityError'; + /** + * Streams that were already running when this subscriber opened. Their text + * is paid out as the queue drains, so a long stream cannot overflow the queue. + */ + assistantBacklog: Map; + /** + * Work that arrived while a backlog was still unpaid. A subscriber has one + * delivery order, so anything produced after the text it is catching up on + * waits here instead of overtaking it. + */ + deferred: Array<() => void>; } -class TranscriptOverlayPreparationRequired extends Error { - readonly name = 'TranscriptOverlayPreparationRequired'; +interface AssistantBacklog { + /** Characters of the stream this subscriber has been sent. */ + sent: number; + /** Set when the stream completed before the subscriber caught up. */ + completion?: { runId: string; stream: ActiveAssistantStream; interrupted?: true }; } interface PendingRefresh { @@ -277,23 +249,18 @@ export class SessionContinuityCoordinator implements SessionContinuityService { error: { code: 'not_found', message: 'Session subscription was not found' }, }; }, - 'session.transcript.page': (input, context) => - this.#readTranscriptPage(context.connectionId, input), - 'session.transcript.overlay.release': async (input, context) => { - const existing = this.#subscriptions.get(input.subscriptionId); - if (!existing) { - return { ok: true, result: { subscriptionId: input.subscriptionId } }; - } - const subscriber = this.#ownedSubscriber(context.connectionId, input.subscriptionId); - if (!subscriber) { + 'subscription.ready': async (input, context) => { + if (!this.#ownedSubscriber(context.connectionId, input.subscriptionId)) { return { ok: false, error: { code: 'not_found', message: 'Session subscription was not found' }, }; } - this.#releaseSubscriberTranscriptOverlay(subscriber); + this.#activate(context.connectionId, input.subscriptionId); return { ok: true, result: { subscriptionId: input.subscriptionId } }; }, + 'session.transcript.page': (input, context) => + this.#readTranscriptPage(context.connectionId, input), }; readonly #connections = new Map(); @@ -302,14 +269,11 @@ export class SessionContinuityCoordinator implements SessionContinuityService { readonly #pendingRefreshes = new Map(); readonly #pendingAgentGraphChanges = new Map(); readonly #pendingSessionDomainChanges = new Map(); - readonly #retainedTranscriptOverlays = new Map(); - readonly #transcriptOverlayPreparationWaiters: TranscriptOverlayPreparationWaiter[] = []; + readonly #pendingTranscriptAdvances = new Map(); readonly #hostEpoch: string; readonly #readCanonical: ReadCanonicalSessionProjection; readonly #transcriptReader: SessionTranscriptReader | undefined; #closed = false; - #preparingTranscriptOverlayBytes = 0; - #retainedTranscriptOverlayBytes = 0; readonly #sessionAccessAuthority: | Pick | undefined; @@ -358,13 +322,9 @@ export class SessionContinuityCoordinator implements SessionContinuityService { sink, subscriptionIds: new Set(), pendingOpenCount: 0, - closed: new AbortController(), }); let attached = true; return { - activate: (subscriptionId) => { - if (attached) this.#activate(connectionId, subscriptionId); - }, abort: (subscriptionId) => { if (attached) this.#abortSubscription(connectionId, subscriptionId); }, @@ -384,7 +344,6 @@ export class SessionContinuityCoordinator implements SessionContinuityService { if (this.#closed) return; const state = this.#sessions.get(sessionId); if (!state || (state.subscribers.size === 0 && !state.terminalPublicationFence)) return; - this.#invalidateTranscriptOverlay(state); const canonical = await this.#readCanonicalProjection(sessionId); if (this.#closed || !canonical) return; await this.#refreshTranscriptHighWater(sessionId, state); @@ -425,6 +384,44 @@ export class SessionContinuityCoordinator implements SessionContinuityService { ); } + /** Safe for synchronous commit hooks: publishes the transcript high water after RuntimeEvents commit. */ + enqueueTranscriptAdvanced(sessionId: string): void { + if (this.#closed || !this.#sessions.has(sessionId)) return; + const pending = this.#pendingTranscriptAdvances.get(sessionId); + if (pending) { + if (pending.inFlight) pending.dirty = true; + return; + } + const advance: PendingRefresh = { dirty: false, inFlight: false }; + this.#pendingTranscriptAdvances.set(sessionId, advance); + const run = async () => { + if (this.#closed) return; + const state = this.#sessions.get(sessionId); + // A fenced terminal publication advances the transcript together with + // the terminal projection, so a turn_state row never outruns its Turn. + if (!state || state.terminalPublicationFence) return; + await this.#refreshTranscriptHighWater(sessionId, state); + }; + void this.sessionAdmission + .enqueueDetached(sessionId, async () => { + advance.inFlight = true; + await run(); + if (!advance.dirty) return; + advance.dirty = false; + await run(); + }) + .then( + () => { + this.#pendingTranscriptAdvances.delete(sessionId); + if (advance.dirty) this.enqueueTranscriptAdvanced(sessionId); + }, + (error) => { + this.#pendingTranscriptAdvances.delete(sessionId); + this.onPublicationFailure(error); + }, + ); + } + /** Coalesce process-local graph invalidations onto the root Session sequence. */ enqueueAgentGraphChanged(event: { rootSessionId: string; @@ -667,17 +664,31 @@ export class SessionContinuityCoordinator implements SessionContinuityService { const nextRevision = state.revision + 1; const snapshot = createSessionContinuitySnapshot(canonical, nextRevision); - this.#invalidateTranscriptOverlay(state); state.canonical = canonical; state.revision = nextRevision; delete state.terminalPublicationFence; + // A subscriber still being paid a stream's prefix has not seen the end + // of it. The Turn ending does not make that text untrue, so each + // backlog keeps its own copy of the stream and finishes paying it; the + // terminal projection queues behind that, never over it. + for (const subscriber of state.subscribers.values()) { + for (const [key, backlog] of subscriber.assistantBacklog) { + if (backlog.completion) continue; + const stream = state.assistantStreams.get(key); + if (!stream) { + subscriber.assistantBacklog.delete(key); + continue; + } + backlog.completion = { runId: rootTurn.runId, stream: { ...stream } }; + } + } state.assistantStreams.clear(); state.toolResultPreviews.clear(); this.#broadcastProjection(state, snapshot); - if (state.subscribers.size === 0) { - this.#invalidateTranscriptOverlay(state); - this.#sessions.delete(sessionId); + for (const subscriber of state.subscribers.values()) { + this.#payAssistantBacklog(subscriber, state); } + if (state.subscribers.size === 0) this.#sessions.delete(sessionId); }, admission, ); @@ -708,7 +719,6 @@ export class SessionContinuityCoordinator implements SessionContinuityService { if (!canonical) throw new Error('Runtime event belongs to a missing Session'); state = this.#commitCanonical(sessionId, canonical).state; } - this.#invalidateTranscriptOverlay(state); const rootTurn = state.canonical.rootTurn; if ( !rootTurn || @@ -738,7 +748,15 @@ export class SessionContinuityCoordinator implements SessionContinuityService { text: (current?.text ?? '') + event.text, }); for (const subscriber of state.subscribers.values()) { - this.#enqueueAssistantDelta(subscriber, sessionId, runId, event, kind, startOffset); + if (subscriber.assistantBacklog.has(prefixKey)) { + // This text is part of the prefix still being paid out; the payout + // reads the accumulated stream, so it carries this delta already. + this.#payAssistantBacklog(subscriber, state); + } else { + this.#deliverInOrder(subscriber, () => + this.#enqueueAssistantDelta(subscriber, sessionId, runId, event, kind, startOffset), + ); + } } return; } @@ -761,12 +779,12 @@ export class SessionContinuityCoordinator implements SessionContinuityService { if (thinking) { const finalThinking = thinking.completedParts?.join('') ?? thinking.text; for (const subscriber of state.subscribers.values()) { - this.#enqueueAssistantCompletion( + this.#completeAssistantStream( subscriber, - sessionId, + state, runId, + thinkingKey, thinking, - 'thinking', finalThinking, ); } @@ -785,12 +803,12 @@ export class SessionContinuityCoordinator implements SessionContinuityService { text: '', } satisfies ActiveAssistantStream); for (const subscriber of state.subscribers.values()) { - this.#enqueueAssistantCompletion( + this.#completeAssistantStream( subscriber, - sessionId, + state, runId, + textKey, current, - 'text', event.text, event.interrupted, ); @@ -836,7 +854,6 @@ export class SessionContinuityCoordinator implements SessionContinuityService { for (const subscriber of state.subscribers.values()) { this.#enqueueSessionRemoved(subscriber); } - this.#invalidateTranscriptOverlay(state); this.#sessions.delete(sessionId); }, admission, @@ -848,12 +865,11 @@ export class SessionContinuityCoordinator implements SessionContinuityService { if (this.#closed) return; this.#closed = true; this.#unsubscribeGrantRevocations?.(); - this.#cancelTranscriptOverlayPreparationWaiters(); for (const connectionId of [...this.#connections.keys()]) this.#closeConnection(connectionId); - for (const state of this.#sessions.values()) this.#invalidateTranscriptOverlay(state); this.#sessions.clear(); this.#subscriptions.clear(); this.#pendingRefreshes.clear(); + this.#pendingTranscriptAdvances.clear(); this.#pendingAgentGraphChanges.clear(); this.#pendingSessionDomainChanges.clear(); } @@ -893,219 +909,68 @@ export class SessionContinuityCoordinator implements SessionContinuityService { }; } connection.pendingOpenCount += 1; - let preparationPermit: TranscriptOverlayPreparationPermit | undefined; - let retryAfterCapacity = false; try { - for (;;) { - try { - return await this.sessionAdmission.run(sessionId, async () => { - if (this.#connections.get(connectionId) !== connection) { - throw new Error('Runtime Host connection closed during subscription open'); - } - const canonical = await this.#readCanonicalProjection(sessionId); - if (this.#connections.get(connectionId) !== connection) { - throw new Error('Runtime Host connection closed during subscription open'); - } - if (!canonical) { - return { - ok: false as const, - code: 'not_found' as const, - message: 'Session was not found', - }; - } - const committed = this.#commitCanonical(sessionId, canonical); - if (committed.changed) { - this.#invalidateTranscriptOverlay(committed.state); - this.#broadcastProjection(committed.state, committed.value); - } - if (this.#connections.get(connectionId) !== connection) { - this.#scheduleInactiveStateCleanup(sessionId, committed.state); - throw new Error('Runtime Host connection closed during subscription open'); - } + return await this.sessionAdmission.run(sessionId, async () => { + if (this.#connections.get(connectionId) !== connection) { + throw new Error('Runtime Host connection closed during subscription open'); + } + const canonical = await this.#readCanonicalProjection(sessionId); + if (this.#connections.get(connectionId) !== connection) { + throw new Error('Runtime Host connection closed during subscription open'); + } + if (!canonical) { + return { + ok: false as const, + code: 'not_found' as const, + message: 'Session was not found', + }; + } + const committed = this.#commitCanonical(sessionId, canonical); + if (committed.changed) this.#broadcastProjection(committed.state, committed.value); + if (this.#connections.get(connectionId) !== connection) { + this.#scheduleInactiveStateCleanup(sessionId, committed.state); + throw new Error('Runtime Host connection closed during subscription open'); + } - const subscriptionId = randomUUID(); - const activeAssistantStreams = [...committed.state.assistantStreams.values()].map( - ({ kind, turnId, messageId }) => ({ kind, turnId, messageId }), - ); - let transcript: SubscriberTranscriptState | undefined; - let retainedTranscriptOverlay: RetainedTranscriptOverlay | undefined; - let cachedTranscriptOverlay: CachedTranscriptOverlay | undefined; - let transcriptSubscriberInstalled = false; - let transcriptBootstrap: SubscriptionOpenResult['transcript'] = null; - try { - if (input.transcript.kind === 'tail') { - if (!this.#transcriptReader) { - return { - ok: false as const, - code: 'operation_unavailable' as const, - message: 'Session transcript is unavailable', - }; - } - try { - const throughSequence = - await this.#transcriptReader.readDurableHighWater(sessionId); - cachedTranscriptOverlay = this.#prepareTranscriptOverlay( - committed.state, - sessionId, - throughSequence, - preparationPermit, - ); - cachedTranscriptOverlay.pendingConsumers += 1; - retainedTranscriptOverlay = await waitForConnectionOpen( - cachedTranscriptOverlay.prepared, - connection.closed.signal, - ); - const snapshot = projectSessionSnapshot(committed.value, identity.principalKind); - const created = await createSessionTranscriptBootstrap({ - reader: this.#transcriptReader, - sessionId, - subscriptionId, - throughSequence, - rootTurn: committed.state.canonical.rootTurn, - activeAssistantStreams: committed.state.assistantStreams.values(), - maxBytes: input.transcript.maxBytes, - preparedOverlayMessages: retainedTranscriptOverlay.messages, - projection: identity.principalKind === 'session_guest' ? 'shared' : 'owner', - maxEncodedBytes: subscriptionOpenTranscriptBudget({ - hostEpoch: this.#hostEpoch, - subscriptionId, - nextSequence: 1, - snapshot, - activeAssistantStreams, - transcript: null, - }), - }); - transcript = created.state; - transcriptBootstrap = created.bootstrap; - } catch (error) { - if (error instanceof TranscriptOverlayPreparationRequired) throw error; - return { - ok: false as const, - code: - error instanceof TranscriptOverlayCapacityError - ? ('operation_unavailable' as const) - : ('persistence_failed' as const), - message: - error instanceof TranscriptOverlayCapacityError - ? 'Runtime Host transcript overlay capacity reached' - : 'Session transcript is unavailable', - }; - } - } - if (this.#connections.get(connectionId) !== connection) { - this.#scheduleInactiveStateCleanup(sessionId, committed.state); - throw new Error('Runtime Host connection closed during subscription open'); - } - const openValue: SubscriptionOpenResult = { - hostEpoch: this.#hostEpoch, - subscriptionId, - nextSequence: 1, - snapshot: projectSessionSnapshot(committed.value, identity.principalKind), - activeAssistantStreams, - transcript: transcriptBootstrap, - }; - if ( - Buffer.byteLength(JSON.stringify(openValue), 'utf8') > - SUBSCRIPTION_OPEN_RESULT_MAX_BYTES - ) { - return { - ok: false as const, - code: 'operation_unavailable' as const, - message: 'Session subscription state exceeds the transport limit', - }; - } - if (!this.#canObserve(identity, sessionId)) { - return { - ok: false as const, - code: 'not_found' as const, - message: 'Session was not found', - }; - } - const subscriber: Subscriber = { - connectionId, - principalId: identity.principalId, - principalKind: identity.principalKind, - sessionId, - subscriptionId, - sink: connection.sink, - phase: 'open', - activated: false, - nextSequence: 1, - lastFlushedSequence: 0, - queue: [], - ptyQueue: [], - ptyInterests: new Set(), - ptyQueuedBytes: 0, - ptyPumping: false, - queuedBytes: 0, - pumping: false, - terminalQueued: false, - ...(transcript ? { transcript } : {}), - ...(retainedTranscriptOverlay ? { retainedTranscriptOverlay } : {}), - }; - if (subscriber.retainedTranscriptOverlay) - this.#retainTranscriptOverlay(subscriber.retainedTranscriptOverlay); - committed.state.subscribers.set(subscriptionId, subscriber); - this.#subscriptions.set(subscriptionId, subscriber); - connection.subscriptionIds.add(subscriptionId); - transcriptSubscriberInstalled = subscriber.retainedTranscriptOverlay !== undefined; - // Client expects the first delivered frame at nextSequence from the open - // result. Capture that before enqueueing retained previews — each - // #enqueue advances nextSequence. - const firstSequence = subscriber.nextSequence; - // Seed retained live previews so a mid-turn rejoin still has Open facts. - const rootTurn = committed.state.canonical.rootTurn; - if (rootTurn && !isTerminalTurn(rootTurn)) { - for (const preview of committed.state.toolResultPreviews.values()) { - if (preview.turnId !== rootTurn.turnId) continue; - const frame: SessionEventFrame = { - kind: 'subscription.session_event', - hostEpoch: this.#hostEpoch, - subscriptionId: subscriber.subscriptionId, - sequence: subscriber.nextSequence, - sessionId, - runId: rootTurn.runId, - event: projectSessionEvent( - preview, - sessionId, - subscriber.principalKind === 'session_guest', - ), - }; - this.#enqueue(subscriber, frame); - } - } - return { - ok: true as const, - value: { ...openValue, nextSequence: firstSequence }, - }; - } finally { - if (cachedTranscriptOverlay) cachedTranscriptOverlay.pendingConsumers -= 1; - if ( - cachedTranscriptOverlay && - !transcriptSubscriberInstalled && - cachedTranscriptOverlay.pendingConsumers === 0 && - !this.#hasTranscriptOverlayConsumer(committed.state) - ) { - this.#invalidateTranscriptOverlay(committed.state, cachedTranscriptOverlay); - } - } - }); - } catch (error) { - if (!(error instanceof TranscriptOverlayPreparationRequired)) throw error; - if (retryAfterCapacity) { + const subscriptionId = randomUUID(); + const activeAssistantStreams = [...committed.state.assistantStreams.values()].map( + ({ kind, turnId, messageId }) => ({ kind, turnId, messageId }), + ); + let transcript: SubscriberTranscriptState | undefined; + let transcriptBootstrap: SubscriptionOpenResult['transcript'] = null; + if (input.transcript.kind === 'tail') { + if (!this.#transcriptReader) { return { ok: false as const, code: 'operation_unavailable' as const, - message: 'Runtime Host transcript overlay capacity reached', + message: 'Session transcript is unavailable', }; } try { - preparationPermit = await this.#acquireTranscriptOverlayPreparation(connection); - } catch (acquireError) { - if (acquireError instanceof TranscriptOverlayCapacityError) { - retryAfterCapacity = true; - continue; - } + const throughSequence = await this.#transcriptReader.readDurableHighWater(sessionId); + const snapshot = projectSessionSnapshot(committed.value, identity.principalKind); + const created = await createSessionTranscriptBootstrap({ + reader: this.#transcriptReader, + sessionId, + subscriptionId, + throughSequence, + maxBytes: input.transcript.maxBytes, + projection: identity.principalKind === 'session_guest' ? 'shared' : 'owner', + maxEncodedBytes: subscriptionOpenTranscriptBudget({ + hostEpoch: this.#hostEpoch, + subscriptionId, + nextSequence: 1, + snapshot, + activeAssistantStreams, + transcript: null, + }), + }); + transcript = created.state; + transcriptBootstrap = created.bootstrap; + } catch (error) { + // The client can only retry, but a projection that outgrew its + // bounds is a Host defect and has to leave a trace here. + this.onPublicationFailure(error); return { ok: false as const, code: 'persistence_failed' as const, @@ -1113,9 +978,94 @@ export class SessionContinuityCoordinator implements SessionContinuityService { }; } } - } + if (this.#connections.get(connectionId) !== connection) { + this.#scheduleInactiveStateCleanup(sessionId, committed.state); + throw new Error('Runtime Host connection closed during subscription open'); + } + const openValue: SubscriptionOpenResult = { + hostEpoch: this.#hostEpoch, + subscriptionId, + nextSequence: 1, + snapshot: projectSessionSnapshot(committed.value, identity.principalKind), + activeAssistantStreams, + transcript: transcriptBootstrap, + }; + if ( + Buffer.byteLength(JSON.stringify(openValue), 'utf8') > SUBSCRIPTION_OPEN_RESULT_MAX_BYTES + ) { + return { + ok: false as const, + code: 'operation_unavailable' as const, + message: 'Session subscription state exceeds the transport limit', + }; + } + if (!this.#canObserve(identity, sessionId)) { + return { + ok: false as const, + code: 'not_found' as const, + message: 'Session was not found', + }; + } + const subscriber: Subscriber = { + connectionId, + principalId: identity.principalId, + principalKind: identity.principalKind, + sessionId, + subscriptionId, + sink: connection.sink, + phase: 'open', + activated: false, + nextSequence: 1, + lastFlushedSequence: 0, + queue: [], + ptyQueue: [], + ptyInterests: new Set(), + ptyQueuedBytes: 0, + ptyPumping: false, + queuedBytes: 0, + pumping: false, + terminalQueued: false, + assistantBacklog: new Map( + [...committed.state.assistantStreams.keys()].map((key) => [key, { sent: 0 }]), + ), + deferred: [], + ...(transcript ? { transcript } : {}), + }; + committed.state.subscribers.set(subscriptionId, subscriber); + this.#subscriptions.set(subscriptionId, subscriber); + connection.subscriptionIds.add(subscriptionId); + // Client expects the first delivered frame at nextSequence from the open + // result. Capture that before enqueueing retained previews — each + // #enqueue advances nextSequence. + const firstSequence = subscriber.nextSequence; + // Seed retained live previews so a mid-turn rejoin still has Open facts. + const rootTurn = committed.state.canonical.rootTurn; + if (rootTurn && !isTerminalTurn(rootTurn)) { + for (const preview of committed.state.toolResultPreviews.values()) { + if (preview.turnId !== rootTurn.turnId) continue; + const frame: SessionEventFrame = { + kind: 'subscription.session_event', + hostEpoch: this.#hostEpoch, + subscriptionId: subscriber.subscriptionId, + sequence: subscriber.nextSequence, + sessionId, + runId: rootTurn.runId, + event: projectSessionEvent( + preview, + sessionId, + subscriber.principalKind === 'session_guest', + ), + }; + this.#enqueue(subscriber, frame); + } + } + this.#payAssistantBacklog(subscriber, committed.state); + return { + ok: true as const, + value: { ...openValue, nextSequence: firstSequence }, + }; + }); } finally { - preparationPermit?.release(); connection.pendingOpenCount -= 1; } } @@ -1180,232 +1130,6 @@ export class SessionContinuityCoordinator implements SessionContinuityService { }); } - #prepareTranscriptOverlay( - state: SessionProjectionState, - sessionId: string, - throughSequence: number | null, - permit: TranscriptOverlayPreparationPermit | undefined, - ): CachedTranscriptOverlay { - if (!this.#transcriptReader) throw new Error('Session transcript is unavailable'); - const cached = state.transcriptOverlay; - if (cached?.throughSequence === throughSequence) return cached; - if (cached) this.#invalidateTranscriptOverlay(state, cached); - if (!state.canonical.rootTurn || isTerminalTurn(state.canonical.rootTurn)) { - const prepared = Promise.resolve(this.#registerTranscriptOverlay([])); - const entry = { - throughSequence, - prepared, - pendingConsumers: 0, - cancelPreparation: () => {}, - }; - state.transcriptOverlay = entry; - return entry; - } - if (!permit) throw new TranscriptOverlayPreparationRequired(); - const release = permit.take(); - const prepared = (async () => { - try { - if (permit.waiter.cancelled) { - throw new Error('Session transcript overlay preparation was cancelled'); - } - const messages = await prepareSessionTranscriptOverlay({ - reader: this.#transcriptReader!, - sessionId, - throughSequence, - rootTurn: state.canonical.rootTurn, - activeAssistantStreams: state.assistantStreams.values(), - }); - return this.#registerTranscriptOverlay(messages); - } finally { - release(); - } - })(); - const entry = { - throughSequence, - prepared, - pendingConsumers: 0, - cancelPreparation: () => this.#cancelTranscriptOverlayPreparation(permit.waiter), - }; - state.transcriptOverlay = entry; - void prepared.catch(() => { - if (state.transcriptOverlay === entry) state.transcriptOverlay = undefined; - }); - return entry; - } - - async #acquireTranscriptOverlayPreparation( - connection: ConnectionState, - ): Promise { - const ticket = this.#queueTranscriptOverlayPreparation(); - let release: () => void; - try { - release = await waitForConnectionOpen(ticket.ready, connection.closed.signal); - } catch (error) { - this.#cancelTranscriptOverlayPreparation(ticket.waiter); - throw error; - } - let available = true; - return { - waiter: ticket.waiter, - take: () => { - if (!available) { - throw new Error('Session transcript overlay preparation permit is unavailable'); - } - available = false; - return release; - }, - release: () => { - if (!available) return; - available = false; - release(); - }, - }; - } - - #queueTranscriptOverlayPreparation(): { - readonly waiter: TranscriptOverlayPreparationWaiter; - readonly ready: Promise<() => void>; - } { - if ( - this.#transcriptOverlayPreparationWaiters.length >= MAX_TRANSCRIPT_OVERLAY_PREPARATION_WAITERS - ) { - throw new TranscriptOverlayCapacityError( - 'Runtime Host transcript overlay preparation queue reached its limit', - ); - } - let resolve!: (release: () => void) => void; - let reject!: (error: Error) => void; - const ready = new Promise<() => void>((resolveReady, rejectReady) => { - resolve = resolveReady; - reject = rejectReady; - }); - const waiter: TranscriptOverlayPreparationWaiter = { - cancelled: false, - granted: false, - resolve, - reject, - }; - this.#transcriptOverlayPreparationWaiters.push(waiter); - this.#drainTranscriptOverlayPreparationWaiters(); - return { waiter, ready }; - } - - #releaseTranscriptOverlayPreparation(): void { - this.#preparingTranscriptOverlayBytes -= MAX_TRANSCRIPT_OVERLAY_PREPARATION_BYTES; - this.#drainTranscriptOverlayPreparationWaiters(); - } - - #drainTranscriptOverlayPreparationWaiters(): void { - if (this.#closed) return; - while (this.#transcriptOverlayPreparationWaiters.length > 0) { - const waiter = this.#transcriptOverlayPreparationWaiters[0]!; - if (waiter.cancelled) { - this.#transcriptOverlayPreparationWaiters.shift(); - continue; - } - if ( - this.#retainedTranscriptOverlayBytes + MAX_TRANSCRIPT_OVERLAY_PREPARATION_BYTES > - MAX_RETAINED_TRANSCRIPT_OVERLAY_BYTES - ) { - if (this.#preparingTranscriptOverlayBytes > 0) return; - this.#transcriptOverlayPreparationWaiters.shift(); - waiter.cancelled = true; - waiter.reject( - new TranscriptOverlayCapacityError('Runtime Host transcript overlay capacity reached'), - ); - continue; - } - if ( - this.#retainedTranscriptOverlayBytes + - this.#preparingTranscriptOverlayBytes + - MAX_TRANSCRIPT_OVERLAY_PREPARATION_BYTES > - MAX_RETAINED_TRANSCRIPT_OVERLAY_BYTES - ) { - return; - } - this.#transcriptOverlayPreparationWaiters.shift(); - waiter.granted = true; - this.#preparingTranscriptOverlayBytes += MAX_TRANSCRIPT_OVERLAY_PREPARATION_BYTES; - let released = false; - waiter.resolve(() => { - if (released) return; - released = true; - this.#releaseTranscriptOverlayPreparation(); - }); - } - } - - #cancelTranscriptOverlayPreparation(waiter: TranscriptOverlayPreparationWaiter): void { - if (waiter.cancelled) return; - waiter.cancelled = true; - if (!waiter.granted) { - const index = this.#transcriptOverlayPreparationWaiters.indexOf(waiter); - if (index >= 0) this.#transcriptOverlayPreparationWaiters.splice(index, 1); - waiter.reject(new Error('Session transcript overlay preparation was cancelled')); - this.#drainTranscriptOverlayPreparationWaiters(); - } - } - - #cancelTranscriptOverlayPreparationWaiters(): void { - for (const waiter of this.#transcriptOverlayPreparationWaiters.splice(0)) { - waiter.cancelled = true; - waiter.reject(new Error('Session continuity coordinator is closed')); - } - } - - #registerTranscriptOverlay(messages: readonly Buffer[]): RetainedTranscriptOverlay { - const bytes = messages.reduce((total, message) => total + message.byteLength, 0); - if (this.#retainedTranscriptOverlayBytes + bytes > MAX_RETAINED_TRANSCRIPT_OVERLAY_BYTES) { - throw new TranscriptOverlayCapacityError('Runtime Host transcript overlay capacity reached'); - } - const retained = { messages, bytes, references: 1 }; - this.#retainedTranscriptOverlays.set(messages, retained); - this.#retainedTranscriptOverlayBytes += bytes; - return retained; - } - - #retainTranscriptOverlay(retained: RetainedTranscriptOverlay): void { - if ( - retained.references < 1 || - this.#retainedTranscriptOverlays.get(retained.messages) !== retained - ) { - throw new Error('Session transcript overlay is no longer retained'); - } - retained.references += 1; - } - - #releaseTranscriptOverlay(retained: RetainedTranscriptOverlay): void { - if (retained.references < 1) return; - retained.references -= 1; - if (retained.references > 0) return; - this.#retainedTranscriptOverlays.delete(retained.messages); - this.#retainedTranscriptOverlayBytes -= retained.bytes; - this.#drainTranscriptOverlayPreparationWaiters(); - } - - #invalidateTranscriptOverlay( - state: SessionProjectionState, - expected?: CachedTranscriptOverlay, - ): void { - const cached = state.transcriptOverlay; - if (!cached || (expected && cached !== expected)) return; - state.transcriptOverlay = undefined; - cached.cancelPreparation(); - void cached.prepared.then( - (retained) => this.#releaseTranscriptOverlay(retained), - () => undefined, - ); - } - - #hasTranscriptOverlayConsumer(state: SessionProjectionState): boolean { - return ( - (state.transcriptOverlay?.pendingConsumers ?? 0) > 0 || - [...state.subscribers.values()].some( - (subscriber) => subscriber.retainedTranscriptOverlay !== undefined, - ) - ); - } - async #refreshTranscriptHighWater( sessionId: string, state: SessionProjectionState, @@ -1464,7 +1188,6 @@ export class SessionContinuityCoordinator implements SessionContinuityService { #closeConnection(connectionId: string): void { const connection = this.#connections.get(connectionId); if (!connection) return; - connection.closed.abort(new Error('Runtime Host connection closed during subscription open')); for (const subscriptionId of [...connection.subscriptionIds]) { const subscriber = this.#ownedSubscriber(connectionId, subscriptionId); if (subscriber) this.#removeSubscriber(subscriber); @@ -1774,6 +1497,10 @@ export class SessionContinuityCoordinator implements SessionContinuityService { this.#removeSubscriber(subscriber); return; } + if (subscriber.assistantBacklog.size > 0) { + const state = this.#sessions.get(subscriber.sessionId); + if (state) this.#payAssistantBacklog(subscriber, state); + } this.#pump(subscriber); }, () => this.#removeSubscriber(subscriber), @@ -1793,25 +1520,99 @@ export class SessionContinuityCoordinator implements SessionContinuityService { this.#connections .get(subscriber.connectionId) ?.subscriptionIds.delete(subscriber.subscriptionId); - this.#releaseSubscriberTranscriptOverlay(subscriber); - if (!this.#closed && state && removed) { - if (!this.#hasTranscriptOverlayConsumer(state)) this.#invalidateTranscriptOverlay(state); - if (state.subscribers.size === 0) { - this.#scheduleInactiveStateCleanup(subscriber.sessionId, state); + subscriber.assistantBacklog.clear(); + subscriber.deferred = []; + if (!this.#closed && state && removed && state.subscribers.size === 0) { + this.#scheduleInactiveStateCleanup(subscriber.sessionId, state); + } + } + + /** + * Sends a subscriber the in-flight assistant text it joined too late to see, + * one frame at a time while its queue has room. Live deltas for such a stream + * are withheld until the backlog catches up, so offsets stay contiguous. + */ + #payAssistantBacklog(subscriber: Subscriber, state: SessionProjectionState): void { + const rootTurn = state.canonical.rootTurn; + for (const [key, backlog] of subscriber.assistantBacklog) { + const { completion } = backlog; + const stream = completion?.stream ?? state.assistantStreams.get(key); + const runId = completion?.runId ?? rootTurn?.runId; + if (!stream || !runId || (!completion && stream.turnId !== rootTurn?.turnId)) { + subscriber.assistantBacklog.delete(key); + continue; + } + while (backlog.sent < stream.text.length) { + if ( + subscriber.phase !== 'open' || + subscriber.queue.length >= MAX_SUBSCRIBER_QUEUED_FRAMES / 2 || + subscriber.queuedBytes >= MAX_SUBSCRIBER_QUEUED_BYTES / 2 + ) { + return; + } + const chunk = stream.text.slice( + backlog.sent, + backlog.sent + ASSISTANT_BACKLOG_CHUNK_CHARACTERS, + ); + this.#enqueueAssistantText( + subscriber, + subscriber.sessionId, + runId, + stream, + stream.kind, + backlog.sent, + chunk, + ); + backlog.sent += chunk.length; + } + subscriber.assistantBacklog.delete(key); + if (completion) { + this.#enqueueAssistantCompletion( + subscriber, + subscriber.sessionId, + runId, + stream, + stream.kind, + stream.text, + completion.interrupted, + ); } } + this.#drainDeferred(subscriber); } - #releaseSubscriberTranscriptOverlay(subscriber: Subscriber): void { - if (subscriber.transcript) subscriber.transcript.overlayMessages = undefined; - const retained = subscriber.retainedTranscriptOverlay; - if (!retained) return; - subscriber.retainedTranscriptOverlay = undefined; - this.#releaseTranscriptOverlay(retained); - const state = this.#sessions.get(subscriber.sessionId); - if (state && !this.#hasTranscriptOverlayConsumer(state)) { - this.#invalidateTranscriptOverlay(state); + #completeAssistantStream( + subscriber: Subscriber, + state: SessionProjectionState, + runId: string, + key: string, + stream: ActiveAssistantStream, + finalText: string, + interrupted?: true, + ): void { + const backlog = subscriber.assistantBacklog.get(key); + const held = backlog ? stream.text.slice(0, backlog.sent) : stream.text; + if (backlog && finalText.startsWith(held)) { + backlog.completion = { + runId, + stream: { ...stream, text: finalText }, + ...(interrupted ? { interrupted } : {}), + }; + this.#payAssistantBacklog(subscriber, state); + return; } + subscriber.assistantBacklog.delete(key); + this.#deliverInOrder(subscriber, () => + this.#enqueueAssistantCompletion( + subscriber, + subscriber.sessionId, + runId, + { ...stream, text: held }, + stream.kind, + finalText, + interrupted, + ), + ); } #ownedSubscriber(connectionId: string, subscriptionId: string): Subscriber | undefined { @@ -1830,7 +1631,6 @@ export class SessionContinuityCoordinator implements SessionContinuityService { !state.terminalPublicationFence && (!state.canonical.rootTurn || isTerminalTurn(state.canonical.rootTurn)) ) { - this.#invalidateTranscriptOverlay(state); this.#sessions.delete(sessionId); } }); @@ -1903,16 +1703,41 @@ export class SessionContinuityCoordinator implements SessionContinuityService { #broadcastProjection(state: SessionProjectionState, snapshot: SessionContinuitySnapshot): void { for (const subscriber of state.subscribers.values()) { - this.#enqueue(subscriber, { - kind: 'subscription.session_projection', - hostEpoch: this.#hostEpoch, - subscriptionId: subscriber.subscriptionId, - sequence: subscriber.nextSequence, - snapshot: projectSessionSnapshot(snapshot, subscriber.principalKind), + this.#deliverInOrder(subscriber, () => { + this.#enqueue(subscriber, { + kind: 'subscription.session_projection', + hostEpoch: this.#hostEpoch, + subscriptionId: subscriber.subscriptionId, + sequence: subscriber.nextSequence, + snapshot: projectSessionSnapshot(snapshot, subscriber.principalKind), + }); }); } } + /** + * Runs `work` now, or behind whatever this subscriber is still catching up + * on. Every assistant frame and projection goes through here, so the order a + * subscriber sees is the order the Host produced. + */ + #deliverInOrder(subscriber: Subscriber, work: () => void): void { + if (subscriber.assistantBacklog.size === 0 && subscriber.deferred.length === 0) { + work(); + return; + } + subscriber.deferred.push(work); + } + + #drainDeferred(subscriber: Subscriber): void { + while ( + subscriber.assistantBacklog.size === 0 && + subscriber.deferred.length > 0 && + subscriber.phase === 'open' + ) { + subscriber.deferred.shift()?.(); + } + } + #runInSessionLane( sessionId: string, operation: () => Promise | T, @@ -2266,23 +2091,3 @@ function toolStartShellRunRef( return undefined; } } - -function waitForConnectionOpen(task: Promise, closed: AbortSignal): Promise { - // A race against a connection-lifetime Promise retains every winning overlay - // until disconnect. Remove the close listener as soon as this wait finishes. - return new Promise((resolve, reject) => { - const onClose = () => reject(closed.reason); - if (closed.aborted) onClose(); - else closed.addEventListener('abort', onClose, { once: true }); - void task.then( - (value) => { - closed.removeEventListener('abort', onClose); - resolve(value); - }, - (error: unknown) => { - closed.removeEventListener('abort', onClose); - reject(error); - }, - ); - }); -} diff --git a/packages/runtime-host/src/server/session-continuity-service.ts b/packages/runtime-host/src/server/session-continuity-service.ts index a830ff57ee..3aa7ab7ced 100644 --- a/packages/runtime-host/src/server/session-continuity-service.ts +++ b/packages/runtime-host/src/server/session-continuity-service.ts @@ -25,7 +25,6 @@ export interface SessionContinuityFrameSink { } export interface SessionContinuityConnection { - activate(subscriptionId: string): void; abort(subscriptionId: string): void; close(): void; } diff --git a/packages/runtime-host/src/server/session-transcript-pager.ts b/packages/runtime-host/src/server/session-transcript-pager.ts index 6b7fec2e35..4c13b6e10a 100644 --- a/packages/runtime-host/src/server/session-transcript-pager.ts +++ b/packages/runtime-host/src/server/session-transcript-pager.ts @@ -28,13 +28,10 @@ import { type SessionTranscriptPage, type SessionTranscriptPageDirection, type SessionTranscriptPageInput, - type SessionTranscriptPageSource, - type TurnSnapshot, } from '../protocol/index.js'; import { - ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, - ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES, type SessionTranscriptReader, + TRANSCRIPT_TURN_MAX_BYTES, } from './session-transcript-reader.js'; import { projectSharedSessionTranscriptMessage } from './shared-session-transcript.js'; @@ -44,7 +41,6 @@ interface TranscriptCursorState { readonly version: 1; readonly subscriptionId: string; readonly sessionId: string; - readonly source: SessionTranscriptPageSource; readonly direction: SessionTranscriptPageDirection; readonly throughSequence: number | null; readonly position: number; @@ -55,20 +51,11 @@ interface TranscriptCursorState { export interface SubscriberTranscriptState { readonly sessionId: string; readonly subscriptionId: string; - readonly openedThroughSequence: number | null; - overlayMessages: readonly Buffer[] | undefined; readonly cursorSecret: Buffer; durableThroughSequence: number | null; readonly projection: SessionTranscriptProjection; } -export interface ActiveTranscriptAssistantStream { - readonly turnId: string; - readonly messageId: string; - readonly kind: 'text' | 'thinking'; - readonly text: string; -} - interface SelectedFragments { readonly fragments: readonly SessionTranscriptFragment[]; readonly rawBytes: number; @@ -80,38 +67,18 @@ export async function createSessionTranscriptBootstrap(input: { sessionId: string; subscriptionId: string; throughSequence: number | null; - rootTurn: TurnSnapshot | null; - activeAssistantStreams: Iterable; maxBytes: number; maxEncodedBytes?: number; - preparedOverlayMessages?: readonly Buffer[]; projection: SessionTranscriptProjection; }): Promise<{ bootstrap: SessionTranscriptBootstrap; state: SubscriberTranscriptState }> { const projection = input.projection; - const preparedOverlayMessages = - input.preparedOverlayMessages ?? (await prepareSessionTranscriptOverlay(input)); - const overlayMessages = - projection === 'shared' - ? preparedOverlayMessages.flatMap((message) => - projectEncodedSharedMessage(message, input.sessionId), - ) - : preparedOverlayMessages; const cursorSecret = randomBytes(32); let rawBudget = input.maxBytes; for (;;) { - const overlayBudget = Math.min(8 * 1024, Math.max(1, Math.floor(rawBudget / 2))); - const selectedOverlay = selectOverlay( - overlayMessages, - 'older', - overlayMessages.length - 1, - null, - overlayBudget, - ); - const durableBudget = rawBudget - selectedOverlay.rawBytes; const durableRequest = { direction: 'older', throughSequence: input.throughSequence, - maxBytes: durableBudget, + maxBytes: rawBudget, maxMessages: SESSION_TRANSCRIPT_RANGE_MAX_MESSAGES, } as const; const durableStorage = @@ -124,9 +91,7 @@ export async function createSessionTranscriptBootstrap(input: { const state: SubscriberTranscriptState = { sessionId: input.sessionId, subscriptionId: input.subscriptionId, - openedThroughSequence: input.throughSequence, durableThroughSequence: input.throughSequence, - overlayMessages, cursorSecret, projection, }; @@ -139,18 +104,14 @@ export async function createSessionTranscriptBootstrap(input: { selected: durableSelection, }); const bootstrap: SessionTranscriptBootstrap = { - throughSequence: input.throughSequence, - overlayMessageCount: overlayMessages.length, durable: pageFromSelection( state, - 'durable', 'older', rangeEdges.selected, input.throughSequence, rangeEdges.rangeBoundarySequence, rangeEdges.protectedTurnSequence, ), - overlay: pageFromSelection(state, 'overlay', 'older', selectedOverlay), }; const encodedBytes = Buffer.byteLength(JSON.stringify(bootstrap), 'utf8'); if (input.maxEncodedBytes === undefined || encodedBytes <= input.maxEncodedBytes) { @@ -164,31 +125,6 @@ export async function createSessionTranscriptBootstrap(input: { } } -export async function prepareSessionTranscriptOverlay(input: { - reader: SessionTranscriptReader; - sessionId: string; - throughSequence: number | null; - rootTurn: TurnSnapshot | null; - activeAssistantStreams: Iterable; -}): Promise { - const activeAssistantStreams = [...input.activeAssistantStreams]; - const activeMessageIds = [...new Set(activeAssistantStreams.map((stream) => stream.messageId))]; - const activeOverlay = await input.reader.readActiveOverlay(input.sessionId, input.rootTurn); - const durableActiveMessages = await input.reader.readDurableMessagesById(input.sessionId, { - messageIds: activeMessageIds, - throughSequence: input.throughSequence, - maxBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, - maxMessages: ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES, - }); - const overlayMessages = mergeActiveAssistantStreams( - activeOverlay, - activeAssistantStreams, - durableActiveMessages, - ).map((message) => Buffer.from(JSON.stringify(message), 'utf8')); - assertOverlayRetainedBound(overlayMessages); - return overlayMessages; -} - export async function readSessionTranscriptPage(input: { reader: SessionTranscriptReader; state: SubscriberTranscriptState; @@ -202,33 +138,8 @@ export async function readSessionTranscriptPage(input: { ) { throw new TranscriptPageRequestError('Transcript watermark is not known to this subscription'); } - if (request.source === 'overlay' && request.throughSequence !== state.openedThroughSequence) { - throw new TranscriptPageRequestError('Transcript overlay watermark changed'); - } - if (request.source === 'overlay' && state.overlayMessages === undefined) { - throw new TranscriptPageRequestError('Transcript overlay has been released'); - } const position = resolvePosition(state, request); if (position === null) return emptyPage(state, request); - if (request.source === 'overlay') { - const overlayMessages = state.overlayMessages!; - const selected = selectOverlay( - overlayMessages, - request.direction, - position.position, - position.byteOffset, - request.maxBytes, - continuationMessageLimit(position), - ); - return pageFromSelection( - state, - 'overlay', - request.direction, - selected, - request.throughSequence, - ); - } - if (request.throughSequence === null) return emptyPage(state, request); const durableRequest = { direction: request.direction, throughSequence: request.throughSequence, @@ -260,7 +171,6 @@ export async function readSessionTranscriptPage(input: { }); return pageFromSelection( state, - 'durable', request.direction, rangeEdges.selected, request.throughSequence, @@ -287,9 +197,7 @@ async function readRangeEdges(input: { protectedTurnSequence: null, }; } - const selectedSequences = input.selected.fragments.flatMap((fragment) => - fragment.kind === 'durable' ? [fragment.sequence] : [], - ); + const selectedSequences = input.selected.fragments.map((fragment) => fragment.sequence); if (selectedSequences.length === 0) { return { selected: input.selected, @@ -430,8 +338,8 @@ async function readRangeEdges(input: { ? input.selected : (() => { const retainedSequences = new Set(retainedRecords.map((record) => record.sequence)); - const fragments = input.selected.fragments.filter( - (fragment) => fragment.kind === 'durable' && retainedSequences.has(fragment.sequence), + const fragments = input.selected.fragments.filter((fragment) => + retainedSequences.has(fragment.sequence), ); return { fragments, @@ -481,7 +389,7 @@ async function readSharedDurablePage( ? {} : { throughSequence: request.throughSequence }), position: scanPosition, - maxStoredBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, + maxStoredBytes: TRANSCRIPT_TURN_MAX_BYTES, maxMessages: SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, }); throughSequence = scanned.throughSequence; @@ -549,14 +457,6 @@ async function readSharedDurablePage( }; } -function projectEncodedSharedMessage(bytes: Buffer, sessionId: string): Buffer[] { - const projected = projectSharedSessionTranscriptMessage( - JSON.parse(bytes.toString('utf8')), - sessionId, - ); - return projected ? [Buffer.from(JSON.stringify(projected), 'utf8')] : []; -} - export function updateSubscriberTranscriptHighWater( state: SubscriberTranscriptState, throughSequence: number | null, @@ -586,7 +486,6 @@ function resolvePosition( if ( cursor.subscriptionId !== state.subscriptionId || cursor.sessionId !== state.sessionId || - cursor.source !== request.source || cursor.direction !== request.direction || cursor.throughSequence !== request.throughSequence ) { @@ -598,16 +497,6 @@ function resolvePosition( rangeBoundarySequence: cursor.rangeBoundarySequence, }; } - if (request.source === 'overlay') { - const overlayMessages = state.overlayMessages; - if (overlayMessages === undefined) return null; - const anchor = request.anchorSequence; - const position = - request.direction === 'older' ? (anchor ?? overlayMessages.length) - 1 : (anchor ?? -1) + 1; - return position < 0 || position >= overlayMessages.length - ? null - : { position, byteOffset: null, rangeBoundarySequence: null }; - } if (request.throughSequence === null) return null; const position = request.direction === 'older' @@ -635,7 +524,6 @@ function storageSelection( ): SelectedFragments { return { fragments: storage.fragments.map((fragment) => ({ - kind: 'durable' as const, sequence: fragment.sequence, byteOffset: fragment.byteOffset, totalBytes: fragment.totalBytes, @@ -655,18 +543,13 @@ function selectionThroughRangeBoundary( if (rangeBoundarySequence === null) return selected; // RuntimeEvent-backed message sequences are sparse, so a continuation's // message limit cannot infer how many records remain from sequence distance. - const firstOmittedIndex = selected.fragments.findIndex( - (fragment) => - fragment.kind === 'durable' && - (direction === 'older' - ? fragment.sequence < rangeBoundarySequence - : fragment.sequence > rangeBoundarySequence), + const firstOmittedIndex = selected.fragments.findIndex((fragment) => + direction === 'older' + ? fragment.sequence < rangeBoundarySequence + : fragment.sequence > rangeBoundarySequence, ); if (firstOmittedIndex === -1) return selected; const firstOmitted = selected.fragments[firstOmittedIndex]!; - if (firstOmitted.kind !== 'durable') { - throw new Error('Session transcript durable range contained an overlay fragment'); - } const fragments = selected.fragments.slice(0, firstOmittedIndex); return { fragments, @@ -678,52 +561,6 @@ function selectionThroughRangeBoundary( }; } -function selectOverlay( - messages: readonly Buffer[], - direction: SessionTranscriptPageDirection, - position: number, - byteOffset: number | null, - maxBytes: number, - maxMessages = SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES, -): SelectedFragments { - const fragments: SessionTranscriptFragment[] = []; - let rawBytes = 0; - let index = position; - let offset = byteOffset; - while ( - index >= 0 && - index < messages.length && - rawBytes < maxBytes && - fragments.length < maxMessages - ) { - const message = messages[index]!; - const selected = selectBuffer(message, direction, offset, maxBytes - rawBytes); - if (!selected) break; - fragments.push({ - kind: 'overlay', - messageIndex: index, - byteOffset: selected.byteOffset, - totalBytes: message.byteLength, - data: selected.data.toString('base64'), - }); - rawBytes += selected.data.byteLength; - if (!selected.complete) { - return { - fragments, - rawBytes, - next: { position: index, byteOffset: selected.nextOffset }, - }; - } - index += direction === 'older' ? -1 : 1; - offset = null; - } - return { - fragments, - rawBytes, - next: index >= 0 && index < messages.length ? { position: index, byteOffset: null } : null, - }; -} - function selectBuffer( bytes: Buffer, direction: SessionTranscriptPageDirection, @@ -763,12 +600,11 @@ function selectBuffer( function pageFromSelection( state: SubscriberTranscriptState, - source: SessionTranscriptPageSource, direction: SessionTranscriptPageDirection, selected: SelectedFragments, - throughSequence: number | null = state.openedThroughSequence, - rangeBoundarySequence: number | null = null, - protectedTurnSequence: number | null = null, + throughSequence: number | null, + rangeBoundarySequence: number | null, + protectedTurnSequence: number | null, ): SessionTranscriptPage { const cursorRangeBoundarySequence = selected.next !== null && @@ -781,7 +617,6 @@ function pageFromSelection( return { kind: 'page', sessionId: state.sessionId, - source, direction, throughSequence, rawBytes: selected.rawBytes, @@ -794,7 +629,6 @@ function pageFromSelection( version: 1, subscriptionId: state.subscriptionId, sessionId: state.sessionId, - source, direction, throughSequence, rangeBoundarySequence: cursorRangeBoundarySequence, @@ -813,7 +647,6 @@ function emptyPage( return { kind: 'page', sessionId: state.sessionId, - source: request.source, direction: request.direction, throughSequence: request.throughSequence, rawBytes: 0, @@ -858,7 +691,6 @@ function decodeCursor(value: string, secret: Buffer): TranscriptCursorState { 'version', 'subscriptionId', 'sessionId', - 'source', 'direction', 'throughSequence', 'position', @@ -875,7 +707,6 @@ function decodeCursor(value: string, secret: Buffer): TranscriptCursorState { cursor.version !== 1 || typeof cursor.subscriptionId !== 'string' || typeof cursor.sessionId !== 'string' || - (cursor.source !== 'durable' && cursor.source !== 'overlay') || (cursor.direction !== 'older' && cursor.direction !== 'newer') || (cursor.throughSequence !== null && !isCount(cursor.throughSequence)) || !isCount(cursor.position) || @@ -891,91 +722,6 @@ function signCursor(payload: string, secret: Buffer): Buffer { return createHmac('sha256', secret).update(payload, 'utf8').digest(); } -function mergeActiveAssistantStreams( - overlay: readonly StoredMessage[], - prefixes: Iterable, - durable: readonly StoredMessage[], -): StoredMessage[] { - const merged = [...overlay]; - const indices = new Map(merged.map((message, index) => [message.id, index])); - const durableById = new Map(); - for (const message of durable) durableById.set(message.id, message); - for (const prefix of prefixes) { - let index = indices.get(prefix.messageId); - const durableMessage = durableById.get(prefix.messageId); - if (index === undefined) { - if (!durableMessage) { - throw new Error('Active assistant prefix has no matching transcript message'); - } - index = merged.length; - indices.set(prefix.messageId, index); - merged.push(durableMessage); - } else if (durableMessage) { - const projected = merged[index]; - if (projected?.type !== 'assistant' || durableMessage.type !== 'assistant') { - throw new Error('Active assistant prefix has no matching transcript message'); - } - merged[index] = reconcileAssistantMessage(durableMessage, projected); - } - const message = merged[index]; - if (message?.type !== 'assistant' || message.turnId !== prefix.turnId) { - throw new Error('Active assistant prefix has no matching transcript message'); - } - if (prefix.kind === 'text') { - merged[index] = { ...message, text: reconcileAssistantText(message.text, prefix.text) }; - continue; - } - if (!message.thinking) { - throw new Error('Active thinking prefix has no matching transcript content'); - } - merged[index] = { - ...message, - thinking: { - ...message.thinking, - text: reconcileAssistantText(message.thinking.text, prefix.text), - }, - }; - } - return merged; -} - -function assertOverlayRetainedBound(messages: readonly Buffer[]): void { - if (messages.length > ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES) { - throw new Error('Active Session transcript overlay exceeds its message limit'); - } - let retainedBytes = 0; - for (const message of messages) { - retainedBytes += message.byteLength; - if (retainedBytes > ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES) { - throw new Error('Active Session transcript overlay exceeds its byte limit'); - } - } -} - -function reconcileAssistantMessage( - durable: Extract, - projected: Extract, -): Extract { - const thinking = - durable.thinking && projected.thinking - ? { - ...projected.thinking, - text: reconcileAssistantText(durable.thinking.text, projected.thinking.text), - } - : (projected.thinking ?? durable.thinking); - return { - ...projected, - text: reconcileAssistantText(durable.text, projected.text), - ...(thinking ? { thinking } : {}), - }; -} - -function reconcileAssistantText(projected: string, active: string): string { - if (active.startsWith(projected)) return active; - if (projected.startsWith(active)) return projected; - return projected; -} - function isCount(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) >= 0; } diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts index 9fa6b47802..752af84fa3 100644 --- a/packages/runtime-host/src/server/session-transcript-reader.ts +++ b/packages/runtime-host/src/server/session-transcript-reader.ts @@ -19,10 +19,7 @@ import type { RuntimeEvent } from '@maka/core/runtime-event'; import { DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES } from '@maka/core/durable-tool-result-projection'; -import { readRunInvocation } from '@maka/core/runtime-event-store'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; -import { runtimeHandoffPause } from '@maka/core/runtime-handoff'; -import { readLogicalRuntimeExecution } from '@maka/core/runtime-logical-execution'; import { WORKHUB_COORDINATION_SESSION_ID, type StoredMessage } from '@maka/core/session'; import { createRuntimeEventStoredMessageProjector, @@ -36,7 +33,6 @@ import { } from '@maka/runtime/interaction-authority'; import type { ExecutionStoresWriter, - SessionTranscriptMessageLookupRequest, SessionTranscriptPageRequest, SessionTranscriptRecordScanPage, SessionTranscriptRecordScanRequest, @@ -46,17 +42,16 @@ import type { SessionTurnContributionPage, SessionTurnLandmark, SessionTurnLandmarkSnapshot, - RuntimeTranscriptInvocationHeader, + RuntimeTranscriptRun, } from '@maka/storage/execution-stores'; import { foldTurnContribution } from '@maka/storage/session-message-projection'; -import { SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES, type TurnSnapshot } from '../protocol/index.js'; const PERMISSION_OUTCOME_READ_CONCURRENCY = 8; /** One event can emit content, a permission, usage, and terminal/notice rows. */ const EVENT_SEQUENCE_STRIDE = 8; -export const ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES = SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES; -export const ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES = 16 * 1024 * 1024; -const TRANSCRIPT_SOURCE_MAX_EVENTS = ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES * 2; +const TRANSCRIPT_TURN_MAX_MESSAGES = 4_096; +export const TRANSCRIPT_TURN_MAX_BYTES = 16 * 1024 * 1024; +const TRANSCRIPT_SOURCE_MAX_EVENTS = TRANSCRIPT_TURN_MAX_MESSAGES * 2; // One RuntimeEvent can carry both the raw Tool Result and its durable model projection. const TRANSCRIPT_SOURCE_MAX_RECORD_BYTES = DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES * 2 + 256 * 1024; @@ -64,16 +59,6 @@ const TRANSCRIPT_SOURCE_MAX_RECORD_BYTES = // separate from the bounded amount of immutable input it may visit. const TRANSCRIPT_SOURCE_MAX_BYTES = TRANSCRIPT_SOURCE_MAX_EVENTS * TRANSCRIPT_SOURCE_MAX_RECORD_BYTES; -const ACTIVE_TRANSCRIPT_SCAN_BATCH_MAX_BYTES = 256 * 1024; -/** Turns per storage round trip: one, so a page loads no Turn it cannot use. */ -const TRANSCRIPT_TURN_SCAN_LIMIT = 1; -/** - * How far back the live-to-durable handoff looks for a message id. The ids come - * from assistant streams the subscriber is still watching, so they are in the - * newest Turn or the one it continued from; an id that is in neither is treated - * as absent rather than searched for down the Session. - */ -const TRANSCRIPT_LOOKUP_MAX_TURNS = 2; export function createSessionTranscriptReader(input: { stores: ExecutionStoresWriter<'interactive'>; @@ -96,8 +81,6 @@ export function createSessionTranscriptReader(input: { (await prepared(sessionId)).readPage(sessionId, request), readDurableRecords: async (sessionId, request) => (await prepared(sessionId)).readRecords(sessionId, request), - readDurableMessagesById: async (sessionId, request) => - (await prepared(sessionId)).readMessagesById(sessionId, request), readDurableTurnContributions: async (sessionId, throughSequence, position, maxContributions) => (await prepared(sessionId)).readTurnContributions( sessionId, @@ -107,50 +90,6 @@ export function createSessionTranscriptReader(input: { ), readDurableTurnLandmarks: async (sessionId, maxLandmarks) => (await prepared(sessionId)).readTurnLandmarks(sessionId, maxLandmarks), - readActiveOverlay: async (sessionId, rootTurn) => { - if (!rootTurn || isTerminalTurn(rootTurn)) return []; - - const store = input.stores.runtimeEventStore; - const root = await readRunInvocation(store, sessionId, rootTurn.runId); - if (!root) return []; - const invocations = new Map([[root.runId, root]]); - let runIds: readonly string[] = [root.runId]; - if (root.terminalEvent && runtimeHandoffPause(root.terminalEvent)) { - const logical = await readLogicalRuntimeExecution( - { - ...store, - readRunInvocation: async (id, runId) => { - const run = await readRunInvocation(store, id, runId); - if (run) invocations.set(runId, run); - return run; - }, - readImmutableRuntimePrefixProof: (prefix) => - store.readImmutableRuntimePrefixProof(prefix, { - maxEvents: TRANSCRIPT_SOURCE_MAX_EVENTS, - maxBytes: TRANSCRIPT_SOURCE_MAX_BYTES, - maxRecordBytes: TRANSCRIPT_SOURCE_MAX_RECORD_BYTES, - }), - }, - { sessionId, turnId: rootTurn.turnId, runId: rootTurn.runId }, - root, - { mode: 'membership' }, - ); - if (!logical) return []; - runIds = logical.runIds; - } - const pending = createTranscriptProjection( - runIds.map((runId) => invocations.get(runId)!), - true, - ); - for (const runId of runIds) - await scanActiveRuntimeEvents(input.stores, sessionId, runId, pending.push); - const projected = await pending.finish(input.canonicalPermissionOutcomes); - if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) { - throw new Error('Active RuntimeEvent transcript projection is incomplete'); - } - assertActiveOverlayBounded(projected.messages); - return projected.messages; - }, }; } @@ -164,10 +103,6 @@ export interface SessionTranscriptReader { sessionId: string, request: SessionTranscriptRecordScanRequest, ): Promise; - readDurableMessagesById( - sessionId: string, - request: SessionTranscriptMessageLookupRequest, - ): Promise; readDurableTurnContributions( sessionId: string, throughSequence: number | null, @@ -178,10 +113,6 @@ export interface SessionTranscriptReader { sessionId: string, maxLandmarks: number, ): Promise; - readActiveOverlay( - sessionId: string, - rootTurn: TurnSnapshot | null, - ): Promise; } /** @@ -204,7 +135,7 @@ function createDurableLedgerTranscriptReader(input: { /** One Turn's rows, each at the sequence its own event sits at. */ const projectTurn = async ( - turn: PendingTranscriptTurn, + turn: PendingTranscriptRun, ): Promise<{ sequence: number; message: StoredMessage }[]> => { const projected = await turn.projection.finish(input.canonicalPermissionOutcomes); if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) { @@ -243,16 +174,14 @@ function createDurableLedgerTranscriptReader(input: { }); }; - const readTurns = async ( + const readRun = async ( sessionId: string, request: { direction: 'older' | 'newer'; throughOrdinal: number; position: number }, - limit = TRANSCRIPT_TURN_SCAN_LIMIT, - ): Promise => - store.readTranscriptInvocations( + ): Promise => + store.readTranscriptRun( sessionId, { ...request, - limit, maxEvents: TRANSCRIPT_SOURCE_MAX_EVENTS, maxBytes: TRANSCRIPT_SOURCE_MAX_BYTES, maxRecordBytes: TRANSCRIPT_SOURCE_MAX_RECORD_BYTES, @@ -274,8 +203,6 @@ function createDurableLedgerTranscriptReader(input: { direction: 'older' | 'newer'; throughSequence?: number | null; position?: number; - /** Stops the walk after this many Turns, for a read that may find nothing. */ - maxTurns?: number; }, ): AsyncGenerator<{ sequence: number; message: StoredMessage }> { const throughSequence = @@ -284,52 +211,31 @@ function createDurableLedgerTranscriptReader(input: { const position = request.position ?? (request.direction === 'older' ? throughSequence : 0); const throughOrdinal = ordinalOf(throughSequence); const older = request.direction === 'older'; - const readTurnAt = async (at: number): Promise => - at < 0 || at > throughOrdinal - ? undefined - : ( - await readTurns(sessionId, { - direction: request.direction, - throughOrdinal, - position: at, - }) - )[0]; let ordinal = ordinalOf(position); - let walked = 0; - let carried: PendingTranscriptTurn | undefined; while (ordinal >= 0 && ordinal <= throughOrdinal) { - const first = carried ?? (await readTurnAt(ordinal)); - carried = undefined; - if (first === undefined) return; - if (request.maxTurns !== undefined && walked >= request.maxTurns) return; // A page resumes from one record's sequence and drops everything the other - // side of it, so what this yields has to be monotone in sequence. Turns - // whose ordinal ranges overlap — a nested run inside its parent — are - // therefore drained together instead of one after the other. - const cluster = [first]; - let low = first.firstOrdinal; - let high = first.lastOrdinal; - for (;;) { - const next = await readTurnAt(older ? low - 1 : high + 1); - if (next === undefined) break; - if (older ? next.lastOrdinal < low : next.firstOrdinal > high) { - carried = next; - break; - } - cluster.push(next); - low = Math.min(low, next.firstOrdinal); - high = Math.max(high, next.lastOrdinal); - } - walked += cluster.length; - const records = (await Promise.all(cluster.map(projectTurn))) - .flat() + // side of it, so what this yields has to be monotone in sequence. Storage + // answers with a stretch of ordinals one invocation owns outright, so the + // rows yielded here are the only ones the Session has in that stretch — + // whatever the Turn is interleaved with outside it. + const run = await readRun(sessionId, { + direction: request.direction, + throughOrdinal, + position: ordinal, + }); + if (!run) return; + const from = run.firstOrdinal * EVENT_SEQUENCE_STRIDE; + const to = run.lastOrdinal * EVENT_SEQUENCE_STRIDE + EVENT_SEQUENCE_STRIDE - 1; + const records = (await projectTurn(run)) .filter( ({ sequence }) => - sequence <= throughSequence && (older ? sequence <= position : sequence >= position), + sequence >= from && + sequence <= Math.min(to, throughSequence) && + (older ? sequence <= position : sequence >= position), ) .sort((a, b) => (older ? b.sequence - a.sequence : a.sequence - b.sequence)); yield* records; - ordinal = older ? low - 1 : high + 1; + ordinal = older ? run.firstOrdinal - 1 : run.lastOrdinal + 1; } }; @@ -351,36 +257,38 @@ function createDurableLedgerTranscriptReader(input: { if (watermark === null) { return { throughSequence: null, contributions: [], nextPosition: null }; } - const turns = await readTurns( - sessionId, - { + const throughOrdinal = ordinalOf(watermark); + // A Turn interleaved with another owns several stretches of the Session, + // and this walk meets each one. Folding by Turn keeps that one summary. + const contributions = new Map(); + let nextPosition: number | null = null; + for (let ordinal = ordinalOf(position); ordinal <= throughOrdinal; ) { + const run = await readRun(sessionId, { direction: 'newer', - throughOrdinal: ordinalOf(watermark), - position: ordinalOf(position), - }, - maxContributions + 1, - ); - const contributions: SessionTurnContribution[] = []; - for (const turn of turns.slice(0, maxContributions)) { + throughOrdinal, + position: ordinal, + }); + if (!run) break; + const turnId = run.invocation.turnId; + if (!contributions.has(turnId) && contributions.size >= maxContributions) { + nextPosition = run.firstOrdinal * EVENT_SEQUENCE_STRIDE; + break; + } // Folded from the Turn's own rows, so `firstSequence` lands on its first // row rather than on the opening fact, which has no row at all. - let contribution: SessionTurnContribution | undefined; - for (const { sequence, message } of await projectTurn(turn)) { + for (const { sequence, message } of await projectTurn(run)) { if (sequence < position || sequence > watermark) continue; - contribution = foldTurnContribution( - contribution, - turn.invocation.turnId, - sequence, - message, + contributions.set( + turnId, + foldTurnContribution(contributions.get(turnId), turnId, sequence, message), ); } - if (contribution) contributions.push(contribution); + ordinal = run.lastOrdinal + 1; } - const next = turns[maxContributions]; return { throughSequence: watermark, - contributions, - nextPosition: next ? next.firstOrdinal * EVENT_SEQUENCE_STRIDE : null, + contributions: [...contributions.values()], + nextPosition, }; }, @@ -422,8 +330,6 @@ interface TranscriptRecordSource { direction: 'older' | 'newer'; throughSequence?: number | null; position?: number; - /** Stops the walk after this many Turns, for a read that may find nothing. */ - maxTurns?: number; }, ): AsyncGenerator<{ sequence: number; message: StoredMessage }>; } @@ -516,38 +422,6 @@ function pagedTranscriptReads(source: TranscriptRecordSource) { } return { throughSequence, records, nextPosition }; }, - - /** - * The durable rows behind a set of message ids. - * - * The ids come from the assistant streams a subscriber is still watching, - * so they belong to the Session's newest Turns. The scan walks back from - * the watermark a Turn at a time and stops as soon as every id is found, - * rather than keeping an index from message id to event. An id that is not - * there stops the walk after the newest Turns instead of reading the - * Session: the handoff shows what the tail holds, not everything it could. - */ - async readMessagesById( - sessionId: string, - request: SessionTranscriptMessageLookupRequest, - ): Promise { - if (request.throughSequence === null || request.messageIds.length === 0) return []; - const wanted = new Set(request.messageIds); - const found: Array<{ sequence: number; message: StoredMessage }> = []; - let bytes = 0; - for await (const record of source.scan(sessionId, { - direction: 'older', - throughSequence: request.throughSequence, - maxTurns: TRANSCRIPT_LOOKUP_MAX_TURNS, - })) { - if (!wanted.delete(record.message.id)) continue; - bytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); - if (found.length >= request.maxMessages || bytes > request.maxBytes) break; - found.push(record); - if (wanted.size === 0) break; - } - return found.sort((a, b) => a.sequence - b.sequence).map((record) => record.message); - }, }; } @@ -555,15 +429,15 @@ function ordinalOf(sequence: number): number { return Math.floor(sequence / EVENT_SEQUENCE_STRIDE); } -function assertActiveOverlayBounded(messages: readonly StoredMessage[]): void { - if (messages.length > ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES) { - throw new Error('Active Session transcript overlay exceeds its message limit'); +function assertTurnPresentationBounded(messages: readonly StoredMessage[]): void { + if (messages.length > TRANSCRIPT_TURN_MAX_MESSAGES) { + throw new Error('Session transcript Turn exceeds its message limit'); } let encodedBytes = 0; for (const message of messages) { encodedBytes += Buffer.byteLength(JSON.stringify(message), 'utf8'); - if (encodedBytes > ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES) { - throw new Error('Active Session transcript overlay exceeds its byte limit'); + if (encodedBytes > TRANSCRIPT_TURN_MAX_BYTES) { + throw new Error('Session transcript Turn exceeds its byte limit'); } } } @@ -585,8 +459,8 @@ async function readCanonicalPermissionOutcomes( for (const item of batch) { if (!item.outcome) continue; encodedBytes += Buffer.byteLength(JSON.stringify(item.outcome), 'utf8'); - if (encodedBytes > ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES) { - throw new Error('Active Session permission outcomes exceed the transcript byte limit'); + if (encodedBytes > TRANSCRIPT_TURN_MAX_BYTES) { + throw new Error('Session permission outcomes exceed the transcript byte limit'); } outcomes.set(item.requestId, item.outcome); } @@ -594,42 +468,13 @@ async function readCanonicalPermissionOutcomes( return outcomes; } -async function scanActiveRuntimeEvents( - stores: ExecutionStoresWriter<'interactive'>, - sessionId: string, - runId: string, - visit: (event: RuntimeEvent) => void, -): Promise { - const result = await stores.runtimeEventStore.scanRuntimeEvents( - sessionId, - runId, - { - maxBatchBytes: ACTIVE_TRANSCRIPT_SCAN_BATCH_MAX_BYTES, - maxRecordBytes: TRANSCRIPT_SOURCE_MAX_RECORD_BYTES, - maxImmutableRecords: TRANSCRIPT_SOURCE_MAX_EVENTS, - maxImmutableBytes: TRANSCRIPT_SOURCE_MAX_BYTES, - maxPartialRecords: TRANSCRIPT_SOURCE_MAX_EVENTS, - maxPartialBytes: ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES, - }, - (batch) => { - for (const event of batch) visit(event); - }, - ); - if (result.status === 'limit_exceeded') { - throw new Error('Active RuntimeEvent transcript exceeds its storage scan limit'); - } -} - -interface PendingTranscriptTurn extends RuntimeTranscriptInvocationHeader { +interface PendingTranscriptRun extends RuntimeTranscriptRun { projection: ReturnType; ordinals: Map; } /** Keep only presentation state while the storage snapshot visits complete facts. */ -function createTranscriptProjection( - invocations: readonly RuntimeInvocationRecord[], - active = false, -) { +function createTranscriptProjection(invocations: readonly RuntimeInvocationRecord[]) { const canonicalPermissionOutcomes = new Map(); let messageCount = 0; let messageBytes = 0; @@ -637,16 +482,12 @@ function createTranscriptProjection( let sourceBytes = 0; const projector = createRuntimeEventStoredMessageProjector({ invocations, - active, canonicalPermissionOutcomes, projectToolResult: projectTranscriptToolResult, onMessage: (message) => { messageCount += 1; messageBytes += Buffer.byteLength(JSON.stringify(message)); - if ( - messageCount > ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES || - messageBytes > ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES - ) + if (messageCount > TRANSCRIPT_TURN_MAX_MESSAGES || messageBytes > TRANSCRIPT_TURN_MAX_BYTES) throw new Error('Session transcript projection exceeds its presentation limit'); }, }); @@ -662,9 +503,9 @@ function createTranscriptProjection( : event; sourceBytes += Buffer.byteLength(JSON.stringify(measured)); if (eventCount > TRANSCRIPT_SOURCE_MAX_EVENTS) - throw new Error('Active RuntimeEvent transcript exceeds its event limit'); - if (sourceBytes > ACTIVE_TRANSCRIPT_OVERLAY_MAX_BYTES) - throw new Error('Active RuntimeEvent transcript exceeds its byte limit'); + throw new Error('RuntimeEvent transcript exceeds its event limit'); + if (sourceBytes > TRANSCRIPT_TURN_MAX_BYTES) + throw new Error('RuntimeEvent transcript exceeds its byte limit'); projector.push(event); }, async finish(reader: CanonicalPermissionOutcomeReader) { @@ -674,12 +515,8 @@ function createTranscriptProjection( ); for (const [id, outcome] of outcomes) canonicalPermissionOutcomes.set(id, outcome); const projected = projector.finish(); - assertActiveOverlayBounded(projected.messages); + assertTurnPresentationBounded(projected.messages); return projected; }, }; } - -function isTerminalTurn(turn: TurnSnapshot): boolean { - return turn.status === 'completed' || turn.status === 'failed' || turn.status === 'cancelled'; -} diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index ca9813b87c..e5b1dac4fb 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -28,7 +28,6 @@ import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@ import { deriveTurnRecords, decodeCanonicalMessage } from '@maka/core/session'; import type { CanonicalPermissionOutcomeRecord } from '../interaction-authority.js'; import { - activePresentationRuntimeEvents, createRuntimeEventStoredMessageProjector, isHardRuntimeEventReadModelDiagnostic, isUnclaimedRuntimeEventDiagnostic, @@ -305,36 +304,112 @@ describe('projectRuntimeEventsToStoredMessages', () => { ); }); - test('active streaming projection settles model partials and inserts thinking-only rows in source order', () => { + // A transcript page may cut an invocation at any committed event. Rows that + // appear for a prefix must be exactly the rows the whole invocation later + // attributes to those same events, or a page would change once the Turn ends. + test('every event prefix projects the rows the full ledger attributes to it', () => { const events = [ ev({ - id: 'evt-thinking-partial', + id: 'evt-prefix-user', ts: ts + 1, - partial: true, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'read the file' }, + }), + ev({ + id: 'evt-prefix-thinking', + ts: ts + 2, role: 'model', author: 'agent', - content: { kind: 'thinking', text: 'still thinking' }, - refs: { providerEventId: 'step-thinking-only' }, + content: { kind: 'thinking', text: 'look first', signature: 'sig' }, + refs: { providerEventId: 'step-1' }, }), ev({ - id: 'evt-later-user', - ts: ts + 2, - role: 'user', - author: 'user', - content: { kind: 'text', text: 'steer' }, + id: 'evt-prefix-text', + ts: ts + 3, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'Reading it.' }, + refs: { providerEventId: 'step-1' }, + }), + ev({ + id: 'evt-prefix-call', + ts: ts + 4, + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'tool-p', name: 'Read', args: { path: '/a' } }, + refs: { toolCallId: 'tool-p', providerEventId: 'step-1' }, + }), + ev({ + id: 'evt-prefix-result', + ts: ts + 5, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-p', + name: 'Read', + result: { kind: 'text', text: 'contents' }, + }, + refs: { toolCallId: 'tool-p' }, + }), + ev({ + id: 'evt-prefix-usage', + ts: ts + 6, + actions: { tokenUsage: { input: 10, output: 5 } }, + }), + ev({ + id: 'evt-prefix-ended', + ts: ts + 7, + status: 'completed', + actions: { endInvocation: true }, }), ]; - const streamed = createRuntimeEventStoredMessageProjector({ - invocations: [invocation], - active: true, + const project = (prefix: readonly RuntimeEvent[]) => + projectRuntimeEventsToStoredMessages(prefix, { invocations: [invocation] }); + const full = project(events); + assert.deepStrictEqual(full.diagnostics, []); + assert.deepStrictEqual( + full.messages.map((message) => message.type), + ['user', 'assistant', 'tool_call', 'tool_result', 'token_usage', 'turn_state'], + ); + + for (let k = 1; k <= events.length; k += 1) { + const seen = new Set(events.slice(0, k).map((event) => event.id)); + const expected = full.messages.filter((_, index) => seen.has(full.sourceEventIds[index]!)); + const prefix = project(events.slice(0, k)); + assert.deepStrictEqual(prefix.messages, expected, `prefix of ${k} events`); + assert.deepStrictEqual(prefix.diagnostics, [], `prefix of ${k} events`); + } + assert.deepStrictEqual( + project(events.slice(0, 2)).messages.map((message) => message.type), + ['user'], + ); + }); + + test('unclaimed thinking is a defect only once its invocation has ended', () => { + const thinking = ev({ + id: 'evt-orphan-thinking', + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'no answer followed' }, + refs: { providerEventId: 'step-orphan' }, + }); + const ended = ev({ + id: 'evt-orphan-ended', + status: 'completed', + actions: { endInvocation: true }, }); - for (const event of events) streamed.push(event); assert.deepStrictEqual( - streamed.finish(), - projectRuntimeEventsToStoredMessages(activePresentationRuntimeEvents(events), { + projectRuntimeEventsToStoredMessages([thinking], { invocations: [invocation] }).diagnostics, + [], + ); + assert.deepStrictEqual( + projectRuntimeEventsToStoredMessages([thinking, ended], { invocations: [invocation], - }), + }).diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.eventId]), + [['unsupported_event', 'evt-orphan-thinking']], ); }); @@ -1743,11 +1818,15 @@ describe('projectRuntimeEventsToStoredMessages', () => { result: 'plain string is not ToolResultContent', }, }), + ev({ id: 'evt-ended', status: 'completed', actions: { endInvocation: true } }), ], { invocations: [invocation] }, ); - assert.deepStrictEqual(out.messages, []); + assert.deepStrictEqual( + out.messages.map((message) => message.type), + ['turn_state'], + ); // The orphaned permission decision carries no content, so its catch-all is // the soft code — but the projector that tried to build its row and failed // still reports `incomplete_event`, which stays hard. Downgrading the diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 9201dbbb48..95a4127263 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -176,7 +176,6 @@ export interface ProjectRuntimeEventsToStoredMessagesOptions { | readonly RuntimeInvocationRecord[] | Readonly>; canonicalPermissionOutcomes?: ReadonlyMap; - active?: boolean; projectToolResult?: (event: RuntimeEvent, decoded: ToolResultContent) => ToolResultContent; onMessage?: (message: StoredMessage, sourceEventId: string) => void; } @@ -342,6 +341,7 @@ export function createRuntimeEventStoredMessageProjector( diagnosticPosition: number; }> = []; const permissionRequestIds = new Set(); + const endedInvocationIds = new Set(); let nextSourceOrder = 0; let finished: RuntimeEventReadModelProjection | undefined; const attributeEmitted = ( @@ -565,6 +565,7 @@ export function createRuntimeEventStoredMessageProjector( projected = projectTokenUsage(event, state, messages) || projected; } + if (isTerminalRuntimeEvent(event)) endedInvocationIds.add(event.invocationId); if (isTerminalRuntimeEvent(event) && !event.actions?.handoffPause) { projected = projectTerminalTurnState(event, state, messages) || projected; } @@ -598,8 +599,7 @@ export function createRuntimeEventStoredMessageProjector( const push = (event: RuntimeEvent): void => { if (finished) throw new Error('RuntimeEvent StoredMessage projector is already finished'); - const sourceOrder = nextSourceOrder++; - projectEvent(options.active ? settledPresentationEvent(event) : event, sourceOrder); + projectEvent(event, nextSourceOrder++); }; const finish = (): RuntimeEventReadModelProjection => { @@ -620,13 +620,6 @@ export function createRuntimeEventStoredMessageProjector( attributeDiagnostics(acceptance.sourceOrder, acceptance.diagnosticPosition); } - if (options.active) { - for (const pendingItems of [...state.thinkingByMessageId.values()]) { - const pending = pendingItems.at(-1); - if (pending) projectEvent(emptyAssistantText(pending.event), pending.sourceOrder); - } - } - const orderedDiagnostics = state.diagnostics .map((diagnostic, index) => ({ diagnostic, source: diagnosticSources[index]! })) .sort( @@ -641,16 +634,17 @@ export function createRuntimeEventStoredMessageProjector( ...orderedDiagnostics.map(({ diagnostic }) => diagnostic), ); - if (!options.active) { - for (const pendingItems of state.thinkingByMessageId.values()) { - for (const pending of pendingItems) { - diagnostic( - state, - pending.event, - 'unsupported_event', - 'thinking content has no assistant text row with a matching message id', - ); - } + // Before its invocation ends, thinking may still get its text in a later + // event, so an unended prefix leaves it without a row rather than a defect. + for (const pendingItems of state.thinkingByMessageId.values()) { + for (const pending of pendingItems) { + if (!endedInvocationIds.has(pending.event.invocationId)) continue; + diagnostic( + state, + pending.event, + 'unsupported_event', + 'thinking content has no assistant text row with a matching message id', + ); } } diff --git a/packages/runtime/src/runtime-read-model.ts b/packages/runtime/src/runtime-read-model.ts index d90d55e79a..221f260b02 100644 --- a/packages/runtime/src/runtime-read-model.ts +++ b/packages/runtime/src/runtime-read-model.ts @@ -124,8 +124,7 @@ export class RuntimeReadModel { // No terminal event yet: the invocation is still open, or the process died // holding it. Either way its own events are the whole truth about it, read - // as a running turn reads — the arriving text presented as settled. No - // durable ordinals exist for them yet, so they keep ledger order. + // as a running turn reads — the arriving text presented as settled. if (!invocation.terminalEvent) { inFlightTurnIds.add(invocation.turnId); appendOrderedEvents(ordered, activePresentationRuntimeEvents(runEvents), runIndex); diff --git a/packages/storage/src/__tests__/execution-provider-conformance.test.ts b/packages/storage/src/__tests__/execution-provider-conformance.test.ts index 2a58801ddd..598c731d90 100644 --- a/packages/storage/src/__tests__/execution-provider-conformance.test.ts +++ b/packages/storage/src/__tests__/execution-provider-conformance.test.ts @@ -233,7 +233,6 @@ for (const backend of ['Local', 'Memory'] as const) { direction: 'older' as const, throughOrdinal, position: throughOrdinal, - limit: 1, maxEvents: 10, maxBytes: 64 * 1024, maxRecordBytes: 16 * 1024, @@ -246,22 +245,19 @@ for (const backend of ['Local', 'Memory'] as const) { last: header.lastOrdinal, entries: [...entries].map(({ ordinal, event }) => ({ ordinal, id: event.id })), }); - const expected = await s.readTranscriptInvocations(run.sessionId, request, project); - assert.equal(expected.length, 1); + const expected = await s.readTranscriptRun(run.sessionId, request, project); + assert.ok(expected); assert.deepEqual( - expected[0]!.entries.map((e) => e.id), + expected.entries.map((e) => e.id), [opening.id, body.id, ending.id], ); - assert.equal(expected[0]!.last, throughOrdinal); - await s.readTranscriptInvocations(run.sessionId, request, (header, entries) => { + assert.equal(expected.last, throughOrdinal); + await s.readTranscriptRun(run.sessionId, request, (header, entries) => { header.invocation.opening.route.modelId = 'mutated'; for (const entry of entries) entry.event.author = 'user'; return null; }); - assert.deepEqual( - await s.readTranscriptInvocations(run.sessionId, request, project), - expected, - ); + assert.deepEqual(await s.readTranscriptRun(run.sessionId, request, project), expected); assert.equal( (await s.readRunInvocation(run.sessionId, run.runId))!.opening.route.modelId, 'fake-model', @@ -272,33 +268,168 @@ for (const backend of ['Local', 'Memory'] as const) { ); for (const field of ['maxEvents', 'maxBytes', 'maxRecordBytes'] as const) { await assert.rejects( - s.readTranscriptInvocations(run.sessionId, { ...request, [field]: 1 }, project), + s.readTranscriptRun(run.sessionId, { ...request, [field]: 1 }, project), RuntimeTranscriptOversizedTurnError, ); await assert.rejects( - s.readTranscriptInvocations(run.sessionId, { ...request, [field]: 0 }, project), + s.readTranscriptRun(run.sessionId, { ...request, [field]: 0 }, project), /Invalid/, ); } - const firstOnly = await s.readTranscriptInvocations( + const firstOnly = await s.readTranscriptRun( run.sessionId, { ...request, maxEvents: 1 }, (_header, entries) => entries[Symbol.iterator]().next().value?.event.id, ); - assert.deepEqual(firstOnly, [opening.id]); + assert.equal(firstOnly, opening.id); // Projectors own their results, which can contain live methods (the // production transcript reader returns a fold), not just cloneable data. const projected = { read: () => 'caller-owned projection' }; - const results = await s.readTranscriptInvocations( - run.sessionId, - request, - (_header, entries) => { - assert.equal([...entries].length, 3); - return projected; - }, + const result = await s.readTranscriptRun(run.sessionId, request, (_header, entries) => { + assert.equal([...entries].length, 3); + return projected; + }); + assert.equal(result, projected); + assert.equal(result!.read(), 'caller-owned projection'); + }); + }); + test(backend + ': transcript serves a running Turn up to the watermark', async () => { + await withProvider(make(), async ({ runtimeEventStore: s }) => { + const sessionId = 'watermark-session'; + const turn = (name: string) => ({ + sessionId, + runId: `${name}-run`, + turnId: `${name}-turn`, + invocationId: `${name}-invocation`, + }); + const settled = turn('settled'); + const running = turn('running'); + const opened = (run: typeof settled) => + buildInvocationOpenedEvent({ + id: `${run.invocationId}-opened`, + run, + openedAt: 1, + opening: invocationOpening(), + }); + const text = (run: typeof settled, id: string, role: 'user' | 'model'): RuntimeEvent => ({ + ...run, + id, + ts: 2, + partial: false, + role, + author: role === 'user' ? 'user' : 'agent', + content: { kind: 'text', text: id }, + }); + const ending = (run: typeof settled): RuntimeEvent => ({ + ...run, + id: `${run.invocationId}-ended`, + ts: 3, + partial: false, + role: 'system', + author: 'system', + actions: { endInvocation: true }, + status: 'completed', + }); + const commits: string[] = []; + const unsubscribe = s.subscribeRuntimeEventCommits((id) => commits.push(id)); + const highWaters: Array = [await s.readTranscriptHighWater(sessionId)]; + for (const event of [ + opened(settled), + text(settled, 'settled-prompt', 'user'), + ending(settled), + opened(running), + text(running, 'running-prompt', 'user'), + text(running, 'running-answer', 'model'), + ]) { + await s.appendRuntimeEvent(sessionId, event.runId, event); + highWaters.push(await s.readTranscriptHighWater(sessionId)); + } + assert.deepEqual(highWaters, [null, 1, 2, 3, 4, 5, 6]); + assert.deepEqual(commits, Array(6).fill(sessionId)); + await assert.rejects( + s.appendRuntimeEvent(sessionId, settled.runId, text(settled, 'late', 'model')), ); - assert.equal(results[0], projected); - assert.equal(results[0]!.read(), 'caller-owned projection'); + assert.equal(commits.length, 6); + + // One call answers with one run, so a walk is the whole sweep: step past + // the run until nothing is left. A run reaches the walk's own bound when + // no other Turn stops it, which is why the outermost run of each + // direction ends at 0 or at the watermark rather than at a Turn's edge. + const read = async ( + direction: 'older' | 'newer', + position: number, + throughOrdinal: number, + ) => { + const seen: Array<{ + invocationId: string; + first: number; + last: number; + ordinals: number[]; + }> = []; + for (let at = Math.min(position, throughOrdinal); at >= 0 && at <= throughOrdinal; ) { + const run = await s.readTranscriptRun( + sessionId, + { + direction, + position: at, + throughOrdinal, + maxEvents: 16, + maxBytes: 64 * 1024, + maxRecordBytes: 16 * 1024, + }, + (header, entries) => ({ + invocationId: header.invocation.invocationId, + first: header.firstOrdinal, + last: header.lastOrdinal, + ordinals: [...entries].map((entry) => entry.ordinal), + }), + ); + if (!run) break; + seen.push(run); + at = direction === 'older' ? run.first - 1 : run.last + 1; + } + return seen.sort((a, b) => a.first - b.first); + }; + assert.deepEqual(await read('older', 6, 6), [ + { invocationId: settled.invocationId, first: 0, last: 3, ordinals: [1, 2, 3] }, + { invocationId: running.invocationId, first: 4, last: 6, ordinals: [4, 5, 6] }, + ]); + assert.deepEqual(await read('newer', 1, 6), [ + { invocationId: settled.invocationId, first: 1, last: 3, ordinals: [1, 2, 3] }, + { invocationId: running.invocationId, first: 4, last: 6, ordinals: [4, 5, 6] }, + ]); + assert.deepEqual(await read('newer', 5, 6), [ + { invocationId: running.invocationId, first: 5, last: 6, ordinals: [4, 5, 6] }, + ]); + assert.deepEqual(await read('older', 6, 5), [ + { invocationId: settled.invocationId, first: 0, last: 3, ordinals: [1, 2, 3] }, + { invocationId: running.invocationId, first: 4, last: 5, ordinals: [4, 5] }, + ]); + assert.deepEqual(await read('newer', 1, 2), [ + { invocationId: settled.invocationId, first: 1, last: 2, ordinals: [1, 2] }, + ]); + assert.deepEqual( + (await s.readTranscriptLandmarks(sessionId, 6, 8)).map((landmark) => ({ + invocationId: landmark.invocation.invocationId, + first: landmark.firstOrdinal, + prompt: landmark.prompt?.event.id, + })), + [ + { invocationId: settled.invocationId, first: 1, prompt: 'settled-prompt' }, + { invocationId: running.invocationId, first: 4, prompt: 'running-prompt' }, + ], + ); + + await s.importConversationCopyRuntimeEvents(sessionId, [ + { + runId: 'copied-run', + events: [opened(turn('copied')), text(turn('copied'), 'copied-prompt', 'user')], + }, + ]); + assert.equal(commits.length, 7); + unsubscribe(); + await s.appendRuntimeEvent(sessionId, running.runId, ending(running)); + assert.equal(commits.length, 7); }); }); test(backend + ': steering reorder preserves unselected and followup queue slots', async () => { diff --git a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts index 0df4a15622..e3d444428a 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-store.test.ts @@ -34,6 +34,10 @@ import { RuntimeTranscriptQuery, } from '../runtime-transcript-query.js'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import { + acquireOperationalStateDatabase, + resolveOperationalStateDatabasePath, +} from '../operational-state-store.js'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; import { buildImmutableRuntimePrefix, @@ -190,40 +194,37 @@ describe('SqliteRuntimeStore', () => { direction: 'newer' as const, throughOrdinal: Number.MAX_SAFE_INTEGER, position: 1, - limit: 8, maxEvents: 64, maxRecordBytes: 64_000, }; await assert.rejects( - store.readTranscriptInvocations( - run.sessionId, - { ...request, maxBytes: 6_000 }, - (_turn, events) => [...events], - ), + store.readTranscriptRun(run.sessionId, { ...request, maxBytes: 6_000 }, (_run, events) => [ + ...events, + ]), (error: unknown) => error instanceof RuntimeTranscriptOversizedTurnError, ); await assert.rejects( - store.readTranscriptInvocations( + store.readTranscriptRun( run.sessionId, { ...request, maxBytes: 64_000, maxRecordBytes: 6_000 }, - (_turn, events) => [...events], + (_run, events) => [...events], ), (error: unknown) => error instanceof RuntimeTranscriptOversizedTurnError, ); await assert.rejects( - store.readTranscriptInvocations( + store.readTranscriptRun( run.sessionId, { ...request, maxEvents: 2, maxBytes: 64_000 }, - (_turn, events) => [...events], + (_run, events) => [...events], ), (error: unknown) => error instanceof RuntimeTranscriptOversizedTurnError, ); - const served = await store.readTranscriptInvocations( + const served = await store.readTranscriptRun( run.sessionId, { ...request, maxBytes: 64_000 }, - (_turn, events) => [...events], + (_run, events) => [...events], ); - assert.equal(served.length, 1); + assert.equal(served?.length, 3); }); }); @@ -231,40 +232,39 @@ describe('SqliteRuntimeStore', () => { await withStore(async (store) => { await appendSettledTurn(store, 1); - const projected = await store.readTranscriptInvocations( + const projected = await store.readTranscriptRun( 'session-1', { direction: 'newer', throughOrdinal: Number.MAX_SAFE_INTEGER, position: 1, - limit: 1, maxEvents: 3, maxBytes: 64_000, maxRecordBytes: 32_000, }, - (turn, events) => { + (run, events) => { assert.equal(Array.isArray(events), false); return { - invocationId: turn.invocation.invocationId, - firstOrdinal: turn.firstOrdinal, - lastOrdinal: turn.lastOrdinal, + invocationId: run.invocation.invocationId, + firstOrdinal: run.firstOrdinal, + lastOrdinal: run.lastOrdinal, rows: [...events].map(({ ordinal, event }) => ({ ordinal, eventId: event.id })), }; }, ); - assert.deepEqual(projected, [ - { - invocationId: 'invocation-1', - firstOrdinal: 1, - lastOrdinal: 3, - rows: [ - { ordinal: 1, eventId: 'opened-1' }, - { ordinal: 2, eventId: 'prompt-1' }, - { ordinal: 3, eventId: 'terminal-1' }, - ], - }, - ]); + assert.deepEqual(projected, { + invocationId: 'invocation-1', + firstOrdinal: 1, + // The Session holds nothing after this Turn, so its run reaches the + // read's own bound rather than stopping at another Turn's first event. + lastOrdinal: Number.MAX_SAFE_INTEGER, + rows: [ + { ordinal: 1, eventId: 'opened-1' }, + { ordinal: 2, eventId: 'prompt-1' }, + { ordinal: 3, eventId: 'terminal-1' }, + ], + }); }); }); @@ -285,18 +285,13 @@ describe('SqliteRuntimeStore', () => { const request = { throughOrdinal: Number.MAX_SAFE_INTEGER, position: 6, - limit: 1, maxEvents: 64, maxBytes: 64_000, maxRecordBytes: 64_000, }; query.highWater('session-1'); - query.invocations('session-1', { ...request, direction: 'older' }, (_turn, events) => [ - ...events, - ]); - query.invocations('session-1', { ...request, direction: 'newer' }, (_turn, events) => [ - ...events, - ]); + query.run('session-1', { ...request, direction: 'older' }, (_run, events) => [...events]); + query.run('session-1', { ...request, direction: 'newer' }, (_run, events) => [...events]); // A full scan is how a page starts costing the Session it sits in: the // rows it walks are every Turn's, not the page's. for (const { sql, bind } of executed) { @@ -354,6 +349,65 @@ describe('SqliteRuntimeStore', () => { } }); }); + it('publishes RuntimeEvent commits once, after a committed transaction', async () => { + await withStore(async (store) => { + const commits: string[] = []; + store.subscribeRuntimeEventCommits((sessionId) => commits.push(sessionId)); + const first = textEvent('batch-1'); + const second = textEvent('batch-2'); + await assert.rejects( + store.importRuntimeEventsBatch({ + sessionId: first.sessionId, + runId: first.runId, + events: [first, { ...first, ts: 99 }], + }), + ); + assert.deepEqual(await store.readSessionRuntimeEventEntries('session-1'), []); + assert.deepEqual(commits, []); + await store.importRuntimeEventsBatch({ + sessionId: first.sessionId, + runId: first.runId, + events: [first, second], + }); + assert.deepEqual(commits, ['session-1']); + }); + }); + + it('publishes leased RuntimeEvent commits when the outermost transaction settles', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-sqlite-runtime-lease-')); + const outer = acquireOperationalStateDatabase(root); + const store = createSqliteRuntimeStore(resolveOperationalStateDatabasePath(root), { + databaseLease: acquireOperationalStateDatabase(root), + }); + try { + const commits: string[] = []; + store.subscribeRuntimeEventCommits((sessionId) => commits.push(sessionId)); + const appends: Promise[] = []; + assert.throws(() => + outer.transaction('write', () => { + appends.push(store.appendRuntimeEvent('session-1', 'run-1', textEvent('lost'))); + throw new Error('roll back'); + }), + ); + await Promise.all(appends); + assert.deepEqual(commits, []); + assert.deepEqual(await store.readSessionRuntimeEventEntries('session-1'), []); + + outer.transaction('write', () => { + for (const id of ['kept-1', 'kept-2']) { + appends.push(store.appendRuntimeEvent('session-1', 'run-1', textEvent(id))); + } + assert.deepEqual(commits, []); + }); + assert.deepEqual(commits, ['session-1']); + await Promise.all(appends); + } finally { + store.close(); + outer.close(); + await rm(root, { recursive: true, force: true }); + } + }); + it('makes a raw canonical-equivalent terminal durability retry idempotent', async () => { await withStore(async (store) => { const terminal: RuntimeEvent = { @@ -2642,6 +2696,10 @@ async function appendSettledTurn(store: Store, index: number): Promise { }); } +function textEvent(id: string): RuntimeEvent { + return functionCallEvent({ id, content: { kind: 'text', text: id } }); +} + function functionCallEvent(overrides: Partial = {}): RuntimeEvent { return { id: 'call-event-1', diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 5ca63431f3..2277f1587b 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -157,8 +157,8 @@ export type { export type ExecutionSessionWriter = SessionAuthorityStore; export type { - RuntimeTranscriptInvocationHeader, RuntimeTranscriptLandmark, + RuntimeTranscriptRun, } from './runtime-transcript-query.js'; export type ExecutionAgentRunWriter = DurableAgentRunStore; export type ExecutionRuntimeEventWriter = DurableRuntimeEventStore & @@ -178,6 +178,8 @@ export type ExecutionRuntimeEventWriter = DurableRuntimeEventStore & events: readonly RuntimeEvent[], ): Promise; readSessionRuntimeEventEntries(sessionId: string): Promise; + /** Called once per Session after each write that committed RuntimeEvents to it. */ + subscribeRuntimeEventCommits(listener: (sessionId: string) => void): () => void; }; interface ExecutionStoresWriterBase { readonly kind: K; @@ -735,10 +737,22 @@ async function createExecutionStoresForWrite( run(() => runtimeEventStore.resequenceSessionEventOrdinals(sessionId)), readTranscriptHighWater: (sessionId) => run(() => runtimeEventStore.readTranscriptHighWater(sessionId)), - readTranscriptInvocations: (sessionId, request, project) => - run(() => runtimeEventStore.readTranscriptInvocations(sessionId, request, project)), + readTranscriptRun: (sessionId, request, project) => + run(() => runtimeEventStore.readTranscriptRun(sessionId, request, project)), readTranscriptLandmarks: (sessionId, throughOrdinal, limit) => run(() => runtimeEventStore.readTranscriptLandmarks(sessionId, throughOrdinal, limit)), + subscribeRuntimeEventCommits: (listener) => { + if (closed) throw invalidExecutionStores(kind, 'write'); + assertStorageRootLeaseActive(lease, kind, 'write'); + const unsubscribe = runtimeEventStore.subscribeRuntimeEventCommits((sessionId) => { + if (!closed) listener(sessionId); + }); + subscriptions.add(unsubscribe); + return () => { + subscriptions.delete(unsubscribe); + unsubscribe(); + }; + }, claimContinuation: (input) => run(() => runtimeEventStore.claimContinuation(input)), readContinuationClaimByBoundary: (boundaryDigest) => run(() => runtimeEventStore.readContinuationClaimByBoundary(boundaryDigest)), diff --git a/packages/storage/src/operational-state-store.ts b/packages/storage/src/operational-state-store.ts index 77def1c7b0..4f0e61a202 100644 --- a/packages/storage/src/operational-state-store.ts +++ b/packages/storage/src/operational-state-store.ts @@ -168,6 +168,11 @@ export interface OperationalStateDatabaseLease { readonly database: DatabaseSync; readonly databasePath: string; transaction(mode: 'read' | 'write', operation: () => T): T; + /** + * Called once the outermost open transaction commits or rolls back, which + * a nested `transaction` call cannot see for itself. + */ + onTransactionSettled(callback: (committed: boolean) => void): void; backup(destinationPath: string): Promise; close(): void; } @@ -197,6 +202,7 @@ class OperationalStateDatabaseOwner { private references = 0; private closed = false; private transactionDepth = 0; + private readonly settledCallbacks: Array<(committed: boolean) => void> = []; constructor( readonly databasePath: string, @@ -235,6 +241,11 @@ class OperationalStateDatabaseOwner { database: this.database, databasePath: this.databasePath, transaction: (mode, operation) => this.transaction(mode, operation), + onTransactionSettled: (callback) => { + if (this.transactionDepth === 0) + throw new Error('No operational state transaction is open'); + this.settledCallbacks.push(callback); + }, backup: (destinationPath) => this.backup(destinationPath), close: () => { if (released) return; @@ -278,16 +289,23 @@ class OperationalStateDatabaseOwner { if (this.transactionDepth > 0) return operation(); this.database.exec(mode === 'write' ? 'BEGIN IMMEDIATE' : 'BEGIN'); this.transactionDepth += 1; + let result: T; try { - const result = operation(); + result = operation(); this.database.exec('COMMIT'); - return result; } catch (error) { rollback(this.database); - throw error; - } finally { this.transactionDepth -= 1; + this.settle(false); + throw error; } + this.transactionDepth -= 1; + this.settle(true); + return result; + } + + private settle(committed: boolean): void { + for (const callback of this.settledCallbacks.splice(0)) callback(committed); } } diff --git a/packages/storage/src/runtime-transcript-query.ts b/packages/storage/src/runtime-transcript-query.ts index 3039394a56..09b7f4d662 100644 --- a/packages/storage/src/runtime-transcript-query.ts +++ b/packages/storage/src/runtime-transcript-query.ts @@ -39,14 +39,17 @@ export const TERMINAL_RUNTIME_EVENT_SQL = `( )`; /** - * One invocation's events, in ledger order, carrying the Session ordinal each - * one sits at. + * One unbroken stretch of Session ordinals owned by a single invocation. * - * The transcript rows of a Turn come from projecting these together: what a - * RuntimeEvent becomes is decided by the read model alone, so nothing here - * classifies an event or decides whether it produces a row. + * No other invocation has an event between `firstOrdinal` and `lastOrdinal`, + * so this invocation is the only one that can produce a row there. A Turn + * interleaved with another owns several runs rather than one. + * + * The transcript rows come from projecting the invocation's events together: + * what a RuntimeEvent becomes is decided by the read model alone, so nothing + * here classifies an event or decides whether it produces a row. */ -export interface RuntimeTranscriptInvocationHeader { +export interface RuntimeTranscriptRun { readonly invocation: RuntimeInvocationRecord; readonly firstOrdinal: number; readonly lastOrdinal: number; @@ -59,12 +62,11 @@ export interface RuntimeTranscriptLandmark { readonly prompt?: { readonly ordinal: number; readonly event: RuntimeEvent }; } -export interface RuntimeTranscriptInvocationRequest { +export interface RuntimeTranscriptRunRequest { readonly direction: 'older' | 'newer'; readonly throughOrdinal: number; /** Ordinal the walk starts from, inclusive, in `direction`. */ readonly position: number; - readonly limit: number; /** Refused rather than truncated: half a Turn projects to a wrong transcript. */ readonly maxEvents: number; readonly maxBytes: number; @@ -73,15 +75,24 @@ export interface RuntimeTranscriptInvocationRequest { export interface RuntimeTranscriptQueries { readTranscriptHighWater(sessionId: string): Promise; - readTranscriptInvocations( + /** + * The run the walk reaches from `position`, or `undefined` past the end. + * + * A caller that projects the invocation, yields the rows it produces inside + * the run, and resumes past the run is monotone in ordinal however the + * Session interleaved its Turns. The whole invocation is projected because a + * row is the read model's fold over the Turn's events, not a per-event map; + * only the rows inside the run are this walk's to yield. + */ + readTranscriptRun( sessionId: string, - request: RuntimeTranscriptInvocationRequest, + request: RuntimeTranscriptRunRequest, /** Consume `events` and return synchronously while the read transaction is open. */ project: ( - turn: RuntimeTranscriptInvocationHeader, + run: RuntimeTranscriptRun, events: Iterable<{ readonly ordinal: number; readonly event: RuntimeEvent }>, ) => T, - ): Promise; + ): Promise; readTranscriptLandmarks( sessionId: string, throughOrdinal: number, @@ -105,12 +116,6 @@ const visibleOpening = (payload: string) => ` AND (json_extract(${payload}, '$.lineage.parentRunId') IS NULL OR (json_extract(${payload}, '$.source.kind') <> 'fresh' AND json_extract(${payload}, '$.lineage.agentId') IS NULL)))`; -/** Where an invocation ends; NULL while it is still running. */ -const endingOrdinal = (invocation: string) => ` - (SELECT MIN(o2.ordinal) FROM runtime_events t - JOIN runtime_session_event_ordinals o2 ON o2.event_id = t.event_id - WHERE t.invocation_id = ${invocation} - AND ${TERMINAL_RUNTIME_EVENT_SQL.replaceAll('payload_json', 't.payload_json')})`; /** * A Session migrated from run headers keeps some openings beside the ledger * rather than in it, ordered by the anchor event each one names. @@ -131,15 +136,6 @@ const ledgerOpening = ` WHERE o.session_id = :sessionId AND e.event_kind = 'invocation_opened' AND ${visibleOpening("json_extract(e.payload_json, '$.content')")}`; -/** Either shelf's opening for one invocation, reached from an event it owns. */ -const openingOrdinal = (invocation: string) => ` - COALESCE( - (SELECT o3.ordinal FROM runtime_events op - JOIN runtime_session_event_ordinals o3 ON o3.event_id = op.event_id - WHERE op.invocation_id = ${invocation} AND op.event_kind = 'invocation_opened'), - (SELECT o3.ordinal FROM runtime_legacy_invocation_openings lg - JOIN runtime_session_event_ordinals o3 ON o3.event_id = lg.anchor_event_id - WHERE lg.invocation_id = ${invocation}))`; const openingContent = (invocation: string) => ` COALESCE( (SELECT json_extract(op.payload_json, '$.content') FROM runtime_events op @@ -147,7 +143,49 @@ const openingContent = (invocation: string) => ` (SELECT lg.opening_json FROM runtime_legacy_invocation_openings lg WHERE lg.invocation_id = ${invocation}))`; -type InvocationRow = { invocation_id: string; first: number; last: number }; +/** + * The nearest event in `direction` that a visible invocation owns. + * + * An invocation is reached through its own events rather than through its + * opening or its ending, so a Turn that is still running — and a Turn the walk + * lands in the middle of — is reached the same way any other is. + */ +const seek = (direction: 'older' | 'newer') => ` + SELECT o.ordinal AS ordinal, e.invocation_id AS invocation_id + FROM runtime_session_event_ordinals o + JOIN runtime_events e ON e.event_id = o.event_id + WHERE o.session_id = :sessionId + ${ + direction === 'older' + ? // Folded into one bound because SQLite takes a single inequality per + // column into the index range and leaves the other to filter every + // row it walks — here, every ordinal between the two. + 'AND o.ordinal <= MIN(:position, :throughOrdinal)' + : 'AND o.ordinal >= :position AND o.ordinal <= :throughOrdinal' + } + AND ${visibleOpening(openingContent('e.invocation_id'))} + ORDER BY o.ordinal ${direction === 'older' ? 'DESC' : 'ASC'} + LIMIT 1`; + +/** Where the seeked invocation stops owning consecutive ordinals. */ +const boundary = (direction: 'older' | 'newer') => ` + SELECT o.ordinal AS ordinal + FROM runtime_session_event_ordinals o + JOIN runtime_events e ON e.event_id = o.event_id + WHERE o.session_id = :sessionId + ${ + direction === 'older' + ? 'AND o.ordinal < MIN(:ordinal, :throughOrdinal + 1)' + : 'AND o.ordinal > :ordinal AND o.ordinal <= :throughOrdinal' + } + AND e.invocation_id <> :invocationId + ORDER BY o.ordinal ${direction === 'older' ? 'DESC' : 'ASC'} + LIMIT 1`; + +const RUN_QUERIES = { + older: { seek: seek('older'), boundary: boundary('older') }, + newer: { seek: seek('newer'), boundary: boundary('newer') }, +} as const; /** Selects invocations by Session ordinal. Payloads are decoded, never classified. */ export class RuntimeTranscriptQuery { @@ -160,24 +198,22 @@ export class RuntimeTranscriptQuery { ) {} highWater(sessionId: string): number | null { - // The furthest a transcript reaches is the last ending on it. - const [row] = this.byEnding(sessionId, { - order: 'DESC', - from: 0, - throughOrdinal: Number.MAX_SAFE_INTEGER, - limit: 1, - }); - return row?.last ?? null; + const row = this.db + .prepare( + 'SELECT MAX(ordinal) AS high FROM runtime_session_event_ordinals WHERE session_id = ?', + ) + .get(sessionId) as { high: number | null }; + return row.high; } - invocations( + run( sessionId: string, - request: RuntimeTranscriptInvocationRequest, + request: RuntimeTranscriptRunRequest, project: ( - turn: RuntimeTranscriptInvocationHeader, + run: RuntimeTranscriptRun, events: Iterable<{ readonly ordinal: number; readonly event: RuntimeEvent }>, ) => T, - ): T[] { + ): T | undefined { assertOrdinal(request.throughOrdinal); assertOrdinal(request.position); assertReadLimit(request.maxEvents, 'event count'); @@ -186,33 +222,30 @@ export class RuntimeTranscriptQuery { if (request.direction !== 'older' && request.direction !== 'newer') { throw new Error('Invalid transcript direction'); } - // An invocation is selected by where its own events sit, so a walk that - // starts inside a Turn still finds that Turn and can serve its rows. Both - // ends are the invocation's own two events — its opening and its ending — - // rather than the extremes of everything between them. - // - // Each direction walks the end of the Turn that `position` bounds, which - // is the one the ordinal index can seek to: backward that is the opening, - // forward the ending. Neither assumes Turns do not overlap, and each stops - // at the page, so a page costs the page rather than the Session. - const rows = - request.direction === 'older' - ? this.byOpening(sessionId, request) - : this.byEnding(sessionId, { - order: 'ASC', - from: request.position, - throughOrdinal: request.throughOrdinal, - limit: request.limit, - }).sort((a, b) => a.first - b.first); - return rows.map((row) => - project( - { - invocation: this.invocation(sessionId, row.invocation_id), - firstOrdinal: row.first, - lastOrdinal: row.last, - }, - this.events(row.invocation_id, request), - ), + const queries = RUN_QUERIES[request.direction]; + const bind = { + sessionId, + position: request.position, + throughOrdinal: request.throughOrdinal, + }; + const seeked = this.db.prepare(queries.seek).get(bind) as + | { ordinal: number; invocation_id: string } + | undefined; + if (!seeked) return undefined; + const stop = this.db.prepare(queries.boundary).get({ + sessionId, + throughOrdinal: request.throughOrdinal, + ordinal: seeked.ordinal, + invocationId: seeked.invocation_id, + }) as { ordinal: number } | undefined; + const older = request.direction === 'older'; + return project( + { + invocation: this.invocation(sessionId, seeked.invocation_id), + firstOrdinal: older ? (stop ? stop.ordinal + 1 : 0) : seeked.ordinal, + lastOrdinal: older ? seeked.ordinal : stop ? stop.ordinal - 1 : request.throughOrdinal, + }, + this.events(seeked.invocation_id, request), ); } @@ -222,16 +255,16 @@ export class RuntimeTranscriptQuery { // Evenly spaced Turn starts, chosen before any payload is read. const rows = this.db .prepare(` - WITH settled AS ( + WITH opened AS ( SELECT e.invocation_id AS invocation_id, o.ordinal AS ordinal ${ledgerOpening} - AND ${endingOrdinal('e.invocation_id')} <= :throughOrdinal + AND o.ordinal <= :throughOrdinal UNION ALL SELECT legacy.invocation_id, o.ordinal ${migratedOpening} - AND ${endingOrdinal('legacy.invocation_id')} <= :throughOrdinal + AND o.ordinal <= :throughOrdinal ), candidates AS ( SELECT invocation_id, ordinal, ROW_NUMBER() OVER (ORDER BY ordinal) - 1 AS rank, COUNT(*) OVER () AS total - FROM settled + FROM opened ), samples(n) AS ( SELECT 0 UNION ALL SELECT n + 1 FROM samples WHERE n + 1 < :limit ) @@ -270,88 +303,14 @@ export class RuntimeTranscriptQuery { }); } - /** - * The page of settled visible invocations that opened at or before - * `position`, newest first. - * - * The two shelves are read as separate statements and merged rather than - * unioned, so each keeps its own index walk and stops at the page — and the - * common Session pays nothing for a table its history never wrote to. - */ - private byOpening( - sessionId: string, - request: RuntimeTranscriptInvocationRequest, - ): InvocationRow[] { - const bind = { - sessionId, - position: request.position, - throughOrdinal: request.throughOrdinal, - limit: request.limit, - }; - const ledger = this.db - .prepare(` - SELECT e.invocation_id AS invocation_id, o.ordinal AS first, - ${endingOrdinal('e.invocation_id')} AS last - ${ledgerOpening} - AND o.ordinal <= :position - AND ${endingOrdinal('e.invocation_id')} <= :throughOrdinal - ORDER BY o.ordinal DESC - LIMIT :limit - `) - .all(bind) as InvocationRow[]; - const migrated = this.db - .prepare(` - SELECT legacy.invocation_id AS invocation_id, o.ordinal AS first, - ${endingOrdinal('legacy.invocation_id')} AS last - ${migratedOpening} - AND o.ordinal <= :position - AND ${endingOrdinal('legacy.invocation_id')} <= :throughOrdinal - ORDER BY o.ordinal DESC - LIMIT :limit - `) - .all(bind) as InvocationRow[]; - if (migrated.length === 0) return ledger; - return [...ledger, ...migrated].sort((a, b) => b.first - a.first).slice(0, request.limit); - } - - /** - * The page of settled visible invocations whose ending sits between `from` - * and `throughOrdinal`, in `order` of that ending. - * - * An ending is an event of the invocation like any other, so this walks the - * same ordinal index — one statement, because the ending is on the ledger - * whichever shelf the opening came from. - */ - private byEnding( - sessionId: string, - bounds: { order: 'ASC' | 'DESC'; from: number; throughOrdinal: number; limit: number }, - ): InvocationRow[] { - return this.db - .prepare(` - SELECT ending.invocation_id AS invocation_id, - ${openingOrdinal('ending.invocation_id')} AS first, - o.ordinal AS last - FROM runtime_session_event_ordinals o - JOIN runtime_events ending ON ending.event_id = o.event_id - WHERE o.session_id = :sessionId - AND o.ordinal BETWEEN :from AND :throughOrdinal - AND ${TERMINAL_RUNTIME_EVENT_SQL.replaceAll('payload_json', 'ending.payload_json')} - AND o.ordinal = ${endingOrdinal('ending.invocation_id')} - AND ${visibleOpening(openingContent('ending.invocation_id'))} - ORDER BY o.ordinal ${bounds.order} - LIMIT :limit - `) - .all({ - sessionId, - from: bounds.from, - throughOrdinal: bounds.throughOrdinal, - limit: bounds.limit, - }) as InvocationRow[]; - } - private *events( invocationId: string, - limits: { maxEvents: number; maxBytes: number; maxRecordBytes: number }, + limits: { + throughOrdinal: number; + maxEvents: number; + maxBytes: number; + maxRecordBytes: number; + }, ): Iterable<{ readonly ordinal: number; readonly event: RuntimeEvent }> { // Walked row by row so cumulative limits apply to raw IO without retaining // the Turn. SQLite withholds an oversized payload before it crosses into JS. @@ -361,9 +320,9 @@ export class RuntimeTranscriptQuery { length(CAST(e.payload_json AS BLOB)) AS stored_bytes, CASE WHEN length(CAST(e.payload_json AS BLOB)) <= ? THEN e.payload_json END AS payload_json FROM runtime_events e JOIN runtime_session_event_ordinals o ON o.event_id = e.event_id - WHERE e.invocation_id = ? ORDER BY e.event_seq + WHERE e.invocation_id = ? AND o.ordinal <= ? ORDER BY e.event_seq `) - .iterate(limits.maxRecordBytes, invocationId) as Iterable< + .iterate(limits.maxRecordBytes, invocationId, limits.throughOrdinal) as Iterable< Omit & { ordinal: number; stored_bytes: number; diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index 055e84f285..4bec7d10dd 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -163,9 +163,9 @@ import { assertNoReservedWorkspaceAuthorityAppend } from './runtime-event-author import { RuntimeTranscriptQuery, TERMINAL_RUNTIME_EVENT_SQL, - type RuntimeTranscriptInvocationHeader, - type RuntimeTranscriptInvocationRequest, type RuntimeTranscriptLandmark, + type RuntimeTranscriptRun, + type RuntimeTranscriptRunRequest, } from './runtime-transcript-query.js'; export { SQLITE_RUNTIME_SCHEMA_VERSION } from './sqlite-runtime-schema.js'; @@ -290,6 +290,8 @@ export class SqliteRuntimeStore private readonly databaseLease?: OperationalStateDatabaseLease; private toolLedgerHealth: ToolLedgerHealth | undefined; private closed = false; + private readonly uncommittedEventSessions = new Set(); + private readonly eventCommitListeners = new Set<(sessionId: string) => void>(); constructor( path: string, @@ -555,19 +557,16 @@ export class SqliteRuntimeStore return this.readTransaction(() => this.transcriptQuery().highWater(sessionId)); } - async readTranscriptInvocations( + async readTranscriptRun( sessionId: string, - request: RuntimeTranscriptInvocationRequest, + request: RuntimeTranscriptRunRequest, project: ( - turn: RuntimeTranscriptInvocationHeader, + run: RuntimeTranscriptRun, events: Iterable<{ readonly ordinal: number; readonly event: RuntimeEvent }>, ) => T, - ): Promise { + ): Promise { assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); - assertInvocationSearchLimit(request.limit); - return this.readTransaction(() => - this.transcriptQuery().invocations(sessionId, request, project), - ); + return this.readTransaction(() => this.transcriptQuery().run(sessionId, request, project)); } async readTranscriptLandmarks( @@ -3393,18 +3392,42 @@ export class SqliteRuntimeStore private transaction(operation: () => T): T { if (this.databaseLease) return this.databaseLease.transaction('write', operation); this.db.exec('BEGIN IMMEDIATE'); + let result: T; try { - const result = operation(); + result = operation(); this.db.exec('COMMIT'); - return result; } catch (error) { try { this.db.exec('ROLLBACK'); } catch { // Preserve the protocol failure that caused rollback. } + this.settleEventCommits(false); throw error; } + this.settleEventCommits(true); + return result; + } + + private noteEventCommit(sessionId: string): void { + if (this.uncommittedEventSessions.size === 0 && this.databaseLease) { + this.databaseLease.onTransactionSettled((committed) => this.settleEventCommits(committed)); + } + this.uncommittedEventSessions.add(sessionId); + } + + private settleEventCommits(committed: boolean): void { + const sessionIds = [...this.uncommittedEventSessions]; + this.uncommittedEventSessions.clear(); + if (!committed) return; + for (const sessionId of sessionIds) { + for (const listener of this.eventCommitListeners) listener(sessionId); + } + } + + subscribeRuntimeEventCommits(listener: (sessionId: string) => void): () => void { + this.eventCommitListeners.add(listener); + return () => this.eventCommitListeners.delete(listener); } private readTransaction(operation: () => T): T { @@ -4008,6 +4031,7 @@ export class SqliteRuntimeStore VALUES (?, ?, ?) `) .run(canonicalEvent.sessionId, ordinal, canonicalEvent.id); + this.noteEventCommit(canonicalEvent.sessionId); this.deleteCompletedPartialSnapshot(canonicalEvent); return next; } diff --git a/packages/storage/src/test-only/memory-execution-runtime.ts b/packages/storage/src/test-only/memory-execution-runtime.ts index 4fa63359ab..5b5292ae60 100644 --- a/packages/storage/src/test-only/memory-execution-runtime.ts +++ b/packages/storage/src/test-only/memory-execution-runtime.ts @@ -67,7 +67,7 @@ import { assertNoReservedWorkspaceAuthorityAppend } from '../runtime-event-autho import { immutableSteeringMessageId } from '../runtime-event-invariants.js'; import { RuntimeTranscriptOversizedTurnError, - type RuntimeTranscriptInvocationHeader, + type RuntimeTranscriptRun, } from '../runtime-transcript-query.js'; import { partialRuntimeStream, @@ -433,24 +433,30 @@ function transcript( s: MemoryState, sessionId: string, throughOrdinal = Number.MAX_SAFE_INTEGER, -): Array { +): Array { check(sessionId); const entries = ordinals(s).get(sessionId) ?? []; return runtimeInvocationsFromSessionEvents( sessionId, entries.map((e) => e.event), ) - .filter((i) => isSessionInlineInvocation(i.opening) && i.terminalEvent) + .filter((i) => isSessionInlineInvocation(i.opening)) .map((invocation) => { - const own = entries.filter((e) => e.event.invocationId === invocation.invocationId); + const events = entries.filter( + (e) => e.event.invocationId === invocation.invocationId && e.ordinal <= throughOrdinal, + ); + const ending = events.find((e) => e.event.id === invocation.terminalEvent?.id); return { invocation, - firstOrdinal: own.find((e) => e.event.content?.kind === 'invocation_opened')!.ordinal, - lastOrdinal: own.find((e) => e.event.id === invocation.terminalEvent!.id)!.ordinal, - events: own.filter((e) => e.ordinal <= throughOrdinal), + firstOrdinal: events.find((e) => e.event.content?.kind === 'invocation_opened')?.ordinal, + lastOrdinal: ending?.ordinal ?? events.at(-1)?.ordinal, + events, }; }) - .filter((i) => i.lastOrdinal <= throughOrdinal) + .filter( + (i): i is typeof i & { firstOrdinal: number; lastOrdinal: number } => + i.firstOrdinal !== undefined, + ) .sort((x, y) => x.firstOrdinal - y.firstOrdinal); } function limit(value: number) { @@ -867,58 +873,71 @@ export function createMemoryRuntimeStore(a: MemoryExecutionAuthority): Execution ), readTranscriptHighWater: async (sessionId) => a.read((s) => { - const all = transcript(s, sessionId); - return all.length ? Math.max(...all.map((i) => i.lastOrdinal)) : null; + check(sessionId); + return ordinals(s).get(sessionId)?.at(-1)?.ordinal ?? null; }), - readTranscriptInvocations: async (sessionId, request, project) => { + subscribeRuntimeEventCommits: (listener) => { + a.runtimeEventListeners.add(listener); + return () => { + a.runtimeEventListeners.delete(listener); + }; + }, + readTranscriptRun: async (sessionId, request, project) => { const { maxEvents, maxBytes, maxRecordBytes } = request; // Detach the selected storage facts before handing them to the caller. // The caller owns its projection result; it may contain live methods and // must not pass back through the authority's structured-clone boundary. const selected = a.read((s) => { - const { - direction, - throughOrdinal, - position, - limit: n, - maxEvents, - maxBytes, - maxRecordBytes, - } = request; + const { direction, throughOrdinal, position, maxEvents, maxBytes, maxRecordBytes } = + request; for (const value of [throughOrdinal, position]) if (!Number.isSafeInteger(value) || value < 0) throw new RangeError('Invalid transcript bound'); - limit(n); for (const value of [maxEvents, maxBytes, maxRecordBytes]) if (!Number.isSafeInteger(value) || value < 1) throw new RangeError('Invalid transcript read limit'); if (direction !== 'older' && direction !== 'newer') throw new Error('Invalid transcript direction'); - const all = transcript(s, sessionId, throughOrdinal); - const selected = ( - direction === 'older' - ? all - .filter((i) => i.firstOrdinal <= position) - .sort((x, y) => y.firstOrdinal - x.firstOrdinal) - : all - .filter((i) => i.lastOrdinal >= position) - .sort((x, y) => x.lastOrdinal - y.lastOrdinal) - ).slice(0, n); - return selected.sort((x, y) => x.firstOrdinal - y.firstOrdinal); + const older = direction === 'older'; + const visible = new Map( + transcript(s, sessionId, throughOrdinal).map((i) => [i.invocation.invocationId, i]), + ); + const walked = (ordinals(s).get(sessionId) ?? []).filter( + (e) => e.ordinal <= throughOrdinal, + ); + if (older) walked.reverse(); + const seeked = walked.find( + (e) => + (older ? e.ordinal <= position : e.ordinal >= position) && + visible.has(e.event.invocationId), + ); + if (!seeked) return undefined; + // Where the run stops: an ordinal some other invocation owns. Whether + // that one is visible does not matter — it breaks the stretch either way. + const stop = walked.find( + (e) => + (older ? e.ordinal < seeked.ordinal : e.ordinal > seeked.ordinal) && + e.event.invocationId !== seeked.event.invocationId, + ); + return { + ...visible.get(seeked.event.invocationId)!, + firstOrdinal: older ? (stop ? stop.ordinal + 1 : 0) : seeked.ordinal, + lastOrdinal: older ? seeked.ordinal : stop ? stop.ordinal - 1 : throughOrdinal, + }; }); - return selected.map(({ events, ...header }) => { - function* read(): Iterable { - let count = 0; - let bytes = 0; - for (const entry of events) { - const size = Buffer.byteLength(JSON.stringify(entry.event), 'utf8'); - if (++count > maxEvents || size > maxRecordBytes || (bytes += size) > maxBytes) - throw new RuntimeTranscriptOversizedTurnError('Transcript Turn exceeds budget'); - yield copy(entry); - } + if (!selected) return undefined; + const { events, ...header } = selected; + function* read(): Iterable { + let count = 0; + let bytes = 0; + for (const entry of events) { + const size = Buffer.byteLength(JSON.stringify(entry.event), 'utf8'); + if (++count > maxEvents || size > maxRecordBytes || (bytes += size) > maxBytes) + throw new RuntimeTranscriptOversizedTurnError('Transcript Turn exceeds budget'); + yield copy(entry); } - return project(header, read()); - }); + } + return project(header, read()); }, readTranscriptLandmarks: async (sessionId, throughOrdinal, n) => a.read((s) => { @@ -926,8 +945,9 @@ export function createMemoryRuntimeStore(a: MemoryExecutionAuthority): Execution throw new RangeError('Invalid landmark bounds'); if (n < 1) return []; const all = transcript(s, sessionId, throughOrdinal); + if (all.length === 0) return []; const positions = new Set( - Array.from({ length: Math.min(n, all.length) }, (_, i) => + Array.from({ length: n }, (_, i) => n === 1 ? all.length - 1 : Math.floor((i * (all.length - 1)) / (n - 1)), ), ); diff --git a/packages/storage/src/test-only/memory-execution-state.ts b/packages/storage/src/test-only/memory-execution-state.ts index dac2b98dc5..37f0946d4b 100644 --- a/packages/storage/src/test-only/memory-execution-state.ts +++ b/packages/storage/src/test-only/memory-execution-state.ts @@ -54,6 +54,7 @@ export interface MemoryExecutionFaults { export class MemoryExecutionAuthority { state: MemoryState = new Map(); readonly listeners = new Set<(sessionId: string) => void>(); + readonly runtimeEventListeners = new Set<(sessionId: string) => void>(); constructor(readonly faults: MemoryExecutionFaults = {}) {} read(operation: (state: MemoryState) => T): T { try { @@ -70,7 +71,12 @@ export class MemoryExecutionAuthority { if (result instanceof Promise) throw new TypeError('Memory reference transactions must be synchronous'); this.faults.beforeCommit?.(name); + const before = rows(this.state, 'runtimeOrdinals'); this.state = draft; + for (const [sessionId, entries] of rows(draft, 'runtimeOrdinals')) { + if (entries.length === before.get(sessionId)?.length) continue; + for (const listener of this.runtimeEventListeners) listener(sessionId); + } this.faults.afterCommit?.(name); return copy(result); } catch (error) { diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index ca0a645b51..bbd2c55eb5 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -809,5 +809,5 @@ test('uses a generic process label when no duration is recorded, and localizes k await renderTurn(root, turn); assert.equal(container.querySelector('.maka-processing-summary')?.textContent, 'Execution process'); await act(() => root.render()); - assert.equal(container.querySelector('.maka-processing-summary')?.textContent, '用时 3分 33秒'); + assert.equal(container.querySelector('.maka-processing-summary')?.textContent, '用时 3 分 33 秒'); }); diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index 5211940c8f..250f6adb22 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -51,6 +51,33 @@ describe('the unconfirmed claim an arm carries', () => { }); }); +describe('the start a live Turn carries', () => { + it('stays at its first event while later tools arrive, before the transcript reaches the Turn', () => { + let projection = applyLiveTurnEvent(armLiveTurn('turn-1'), { + type: 'text_delta', + id: 'event-1', + turnId: 'turn-1', + messageId: 'step-1', + ts: 100, + text: 'a', + }); + const first = overlayLiveTurn([], projection, 'en')[0]?.startedAt; + projection = applyLiveTurnEvent(projection, { + type: 'tool_start', + id: 'event-2', + turnId: 'turn-1', + stepId: 'step-2', + toolUseId: 'tool-1', + toolName: 'Read', + args: { path: 'README.md' }, + ts: 5_000, + }); + + assert.equal(first, 100); + assert.equal(overlayLiveTurn([], projection, 'en')[0]?.startedAt, 100); + }); +}); + describe('provider retry copy', () => { it('describes capacity retries without collapsing them into generic unavailability', () => { assert.match(getConversationCopy('zh-CN').messages.providerRetryReason.provider_capacity, /满载/); diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 6e23672340..ba8f794f6f 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -552,7 +552,7 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `选择项目:${label},当前分支 ${branch}` : `选择项目:${label}`, }, messages: { - you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], processDetails: '执行过程', processDuration: (minutes, seconds) => `用时 ${minutes > 0 ? `${minutes}分 ` : ''}${seconds}秒`, providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小时', minute: '分', second: '秒' })}后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重试(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '响应中途断开', network: '网络中断', provider_capacity: '模型服务暂时满载', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, failureDetailsUnavailable: '无可用诊断详情。', safeResumePending: '正在检查…', safeResume: '继续这一轮', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '包含已展开上下文的历史消息暂不支持编辑并重发', + you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], processDetails: '执行过程', processDuration: (minutes, seconds) => `用时 ${minutes > 0 ? `${minutes} 分 ` : ''}${seconds} 秒`, providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小时', minute: '分', second: '秒' })}后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重试(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '响应中途断开', network: '网络中断', provider_capacity: '模型服务暂时满载', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, failureDetailsUnavailable: '无可用诊断详情。', safeResumePending: '正在检查…', safeResume: '继续这一轮', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '包含已展开上下文的历史消息暂不支持编辑并重发', editMessageDisabledDirectoryReferences: '包含文件夹引用的历史消息暂不支持编辑并重发', userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '已中断', abortedByStop: '已中断 · 由停止按钮触发', @@ -710,7 +710,7 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `選擇專案:${label},目前分支 ${branch}` : `選擇專案:${label}`, }, messages: { - you: '你', assistant: 'Maka', processing: '正在處理…', continuing: '繼續中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盤算…', '正在鑽研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓搗…', '正在醞釀…', '正在攻堅…', '正在權衡…', '正在拾掇…'], processDetails: '執行過程', processDuration: (minutes, seconds) => `用時 ${minutes > 0 ? `${minutes}分 ` : ''}${seconds}秒`, providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小時', minute: '分', second: '秒' })}後重試(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重試(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重試(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '回應中途斷開', network: '網路中斷', provider_capacity: '模型服務暫時滿載', provider_unavailable: '模型服務暫時不可用', rate_limit: '觸發模型速率限制', timeout: '請求超時', unknown: '模型請求失敗' }, failureDetailsUnavailable: '無可用診斷詳情。', safeResumePending: '正在檢查…', safeResume: '繼續這一輪', thinking: '深度思考', truncated: '已截斷', copied: '已複製', copying: '複製中', copyFailed: '複製失敗', copy: '複製', editMessage: '編輯並重發', editMessageDisabledRunning: '目前回答仍在進行中,結束後再編輯', editMessageDisabledAttachments: '包含附件的歷史訊息暫不支援編輯並重發', editMessageDisabledQuotes: '包含引用的歷史訊息暫不支援編輯並重發', editMessageDisabledTransformedText: '包含已展開上下文的歷史訊息暫不支援編輯並重發', + you: '你', assistant: 'Maka', processing: '正在處理…', continuing: '繼續中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盤算…', '正在鑽研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓搗…', '正在醞釀…', '正在攻堅…', '正在權衡…', '正在拾掇…'], processDetails: '執行過程', processDuration: (minutes, seconds) => `用時 ${minutes > 0 ? `${minutes} 分 ` : ''}${seconds} 秒`, providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小時', minute: '分', second: '秒' })}後重試(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重試(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重試(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '回應中途斷開', network: '網路中斷', provider_capacity: '模型服務暫時滿載', provider_unavailable: '模型服務暫時不可用', rate_limit: '觸發模型速率限制', timeout: '請求超時', unknown: '模型請求失敗' }, failureDetailsUnavailable: '無可用診斷詳情。', safeResumePending: '正在檢查…', safeResume: '繼續這一輪', thinking: '深度思考', truncated: '已截斷', copied: '已複製', copying: '複製中', copyFailed: '複製失敗', copy: '複製', editMessage: '編輯並重發', editMessageDisabledRunning: '目前回答仍在進行中,結束後再編輯', editMessageDisabledAttachments: '包含附件的歷史訊息暫不支援編輯並重發', editMessageDisabledQuotes: '包含引用的歷史訊息暫不支援編輯並重發', editMessageDisabledTransformedText: '包含已展開上下文的歷史訊息暫不支援編輯並重發', editMessageDisabledDirectoryReferences: '包含資料夾引用的歷史訊息暫不支援編輯並重發', userAriaLabel: '你傳送的訊息', systemAriaLabel: '系統訊息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}訊息${context ? `:${context}` : ''}`, sourceAriaLabel: '本輪迴答的來源', derivativesAriaLabel: '本輪迴答的衍生', scheduledTaskTriggered: '定時任務觸發', scheduledTaskTitle: (id) => `由定時任務觸發 · ${id}`, legacyAutomationTriggered: '舊版自動化(僅歷史)', legacyAutomationTitle: (id) => `由舊版自動化觸發 · ${id} · 僅保留歷史,不會再次執行`, goalContinued: 'Goal 自動繼續', goalTitle: (id) => `由 Goal 繼續執行 · ${id}`, agentGraphTriggered: 'Agent Graph 自動繼續', agentGraphTitle: (graphId) => `由 Agent Graph 排程器觸發 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截斷;顯示的是最近的內容', outputTruncatedTitle: '助手輸出已超過單次回合上限,超出部分未渲染。如需完整內容請重新生成或檢視持久化的任務記錄。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展開引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '(已中斷)', abortedByStop: '(已中斷 · 由停止按鈕觸發)', diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index 5231e91ed6..9f0130dd53 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -100,8 +100,8 @@ export interface LiveTurnProjection { * flight; the row disappears when the Turn settles (no durable turn state). */ rootExecutionKind?: 'context_compact'; - /** Event ts of the first authority word about this Turn; a stable ts for the - * synthesized "compacting" row so reprojection does not churn identity. */ + /** Event ts of the first authority word about this Turn, so a Turn the + * transcript has not reached yet still has a stable start. */ startedAt?: number; /** Steering acknowledged after the current content and awaiting its next provider step. */ pendingSteering?: LiveSteeringProjection[]; @@ -200,6 +200,17 @@ export function applyLiveTurnEvent( current: LiveTurnProjection | undefined, event: SessionEvent, locale: UiLocale, +): LiveTurnProjection | undefined { + const next = projectLiveTurnEvent(current, event, locale); + if (!next || next === current || next.startedAt !== undefined) return next; + const startedAt = current?.turnId === next.turnId ? current.startedAt : undefined; + return { ...next, startedAt: startedAt ?? event.ts }; +} + +function projectLiveTurnEvent( + current: LiveTurnProjection | undefined, + event: SessionEvent, + locale: UiLocale, ): LiveTurnProjection | undefined { if (event.type === 'steering_message') { const prior = current?.turnId === event.turnId diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index 443d6c654a..7659bf74cd 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -531,7 +531,7 @@ export function overlayLiveTurn( tools: [], notes: [], timeline: [], - startedAt: Date.now(), + startedAt: liveTurn.startedAt ?? 0, } satisfies TurnViewModel); // Only a recorded turn_state is evidence the turn ended; a legacy turn's // inferred `completed` is a guess, and such a turn cannot be live anyway. diff --git a/packages/ui/stories/turn-elapsed-clock.stories.tsx b/packages/ui/stories/turn-elapsed-clock.stories.tsx new file mode 100644 index 0000000000..47a12d45dd --- /dev/null +++ b/packages/ui/stories/turn-elapsed-clock.stories.tsx @@ -0,0 +1,172 @@ +/* + * 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 { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import type { StoredMessage } from '@maka/core/session'; +import { ProcessingBlock, TurnView } from '../src/chat-turn.js'; +import { useUiLocale } from '../src/locale-context.js'; +import { applyLiveTurnEvent, armLiveTurn } from '../src/live-turn-projection.js'; +import { materializeTurns, overlayLiveTurn } from '../src/materialize.js'; + +// Fidelity convention (#1433): the desktop transcript reaches this path +// through app-shell live events → overlayLiveTurn → TurnView, which is what +// the harness below reproduces with the same public functions. + +const TURN_ID = 'turn-elapsed-clock'; +const RUNNING_FOR_MS = 213_000; +// The Turn really started earlier than the client's first live event: only a +// gap between the two can tell a stable stand-in start from the recorded one. +const DURABLE_RUNNING_FOR_MS = 333_000; +// Each tool reads a different file: two identical rows would be ambiguous to a +// screen reader. +function toolAt(index: number) { + return { + toolUseId: `tool-${index}`, + toolName: index % 2 === 0 ? 'Read' : 'Grep', + path: `docs/step-${index}.md`, + }; +} + +// What the transcript carries once it reaches this running Turn: the Host's +// own record of when it started. +function durableTurnMessages(startedAt: number): StoredMessage[] { + return [ + { type: 'user', id: 'durable-user', turnId: TURN_ID, ts: startedAt, text: '查一下仓库里的用法' }, + { type: 'turn_state', id: 'durable-state', turnId: TURN_ID, ts: startedAt, status: 'running' }, + ]; +} + +function RunningTurn() { + const locale = useUiLocale(); + // The Turn began before the transcript reached it — the case where the + // renderer has no durable row to take a start from. + const [startedAt] = useState(() => Date.now() - RUNNING_FOR_MS); + const [projection, setProjection] = useState(() => + applyLiveTurnEvent(armLiveTurn(TURN_ID), { + type: 'text_delta', + id: 'event-start', + turnId: TURN_ID, + messageId: 'step-1', + ts: startedAt, + text: '正在查阅仓库…', + }, locale), + ); + const [started, setStarted] = useState(0); + const [durable, setDurable] = useState([]); + const turn = overlayLiveTurn(materializeTurns(durable, locale), projection, locale)[0]; + + return ( +
+ + + {turn && } +
+ ); +} + +const meta = { + title: 'Product/Turn Elapsed Clock', + component: RunningTurn, + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function elapsedSeconds(canvasElement: HTMLElement): number { + const label = canvasElement.querySelector('.maka-turn-elapsed')?.textContent ?? ''; + const [, minutes, seconds] = /(?:(\d+)m\s*)?(\d+)s/.exec(label) ?? []; + if (seconds === undefined) throw new Error(`the elapsed clock reads "${label}"`); + return Number(minutes ?? 0) * 60 + Number(seconds); +} + +// #5365: the clock measures one Turn, so each new tool must leave it running +// from the Turn's first event. It reset to zero while the running Turn was +// missing from the transcript and the renderer stamped `Date.now()` instead. +export const KeepsRunningAcrossTools: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => expect(elapsedSeconds(canvasElement)).toBeGreaterThanOrEqual(213)); + + for (const index of [0, 1]) { + await userEvent.click(canvas.getByRole('button', { name: /next tool event/ })); + await canvas.findAllByText(new RegExp(toolAt(index).path)); + await expect(elapsedSeconds(canvasElement)).toBeGreaterThanOrEqual(213); + } + }, +}; + +// #5365: the live start is only a stand-in until the transcript reaches the +// Turn. Once the Host's own record arrives the clock has to adopt its start — +// correcting upward for the time that ran before the client subscribed — and +// keep it across later events rather than falling back to the stand-in. +export const AdoptsTheRecordedStart: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(() => expect(elapsedSeconds(canvasElement)).toBeGreaterThanOrEqual(213)); + await expect(elapsedSeconds(canvasElement)).toBeLessThan(333); + + await userEvent.click(canvas.getByRole('button', { name: /durable record arrives/ })); + await waitFor(() => expect(elapsedSeconds(canvasElement)).toBeGreaterThanOrEqual(333)); + + for (const index of [0, 1]) { + await userEvent.click(canvas.getByRole('button', { name: /next tool event/ })); + await canvas.findAllByText(new RegExp(toolAt(index).path)); + await expect(elapsedSeconds(canvasElement)).toBeGreaterThanOrEqual(333); + } + }, +}; + +// The same Turn once it settles: the duration is copy, not a clock, and the +// zh number needs a space before its unit. +export const SettledDuration: Story = { + render: () => , + play: async ({ canvasElement }) => { + await expect(canvasElement.querySelector('.maka-processing-summary')).toHaveTextContent( + '用时 3 分 33 秒', + ); + }, +};