diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index d32a711b72..d7af4da7df 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -31,6 +31,7 @@ import type { } from '@maka/core/events'; import { deriveTurnRecords, + isRuntimeSystemNoteKind, STEP_LIMIT_NOTICE_TEXT, type StoredMessage, type SystemNoteMessage, @@ -1344,14 +1345,10 @@ function tokenDelta(before: number | undefined, after: number | undefined): numb } function systemNoteText(message: SystemNoteMessage): string | undefined { + // Retired kinds are still decoded off legacy transcript rows, and none of + // them ever had a line here worth reading. + if (!isRuntimeSystemNoteKind(message.kind)) return undefined; switch (message.kind) { - case 'session_start': - case 'session_resume': - return undefined; - case 'mode_change': - return 'Permission mode changed.'; - case 'model_change': - return 'Model changed.'; case 'context_compacted': return 'Context compacted to keep this task within the model window.'; case 'context_compaction_failed_open': @@ -1408,10 +1405,6 @@ function systemNoteText(message: SystemNoteMessage): string | undefined { } case 'step_limit': return STEP_LIMIT_NOTICE_TEXT; - case 'error': - return 'Session recorded an error.'; - case 'abort': - return 'Session was stopped.'; } } diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 8323d289b4..863679e82e 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -61,7 +61,11 @@ import { type OrchestrationMode, } from './orchestration.js'; import { isToolMode, type ToolMode } from './tool-mode.js'; -import type { PersistedBackendKind } from './session.js'; +import { + isRuntimeSystemNoteKind, + type PersistedBackendKind, + type RuntimeSystemNoteKind, +} from './session.js'; import { decodeTurnOrigin, type TurnOrigin } from './turn-origin.js'; import type { UserQuestionRequest } from './user-question.js'; import { @@ -217,6 +221,22 @@ export interface RuntimeEventFunctionResponseContent { modelProjection?: DurableToolResultProjection; } +/** + * A note the runtime wrote about what happened during an invocation — context + * was compacted, the step cap was reached, the turn was aborted. + * + * It is a transcript row, not a model-facing payload: nothing replays it to a + * provider. It lives here because it is a fact of the invocation, and the + * invocation's events are the only record of those. Notes that happen between + * turns have no invocation, so they stay Session transcript rows. + */ +export interface RuntimeEventSystemNoteContent { + kind: 'system_note'; + note: RuntimeSystemNoteKind; + /** Shape depends on `note`, exactly as it does on the transcript row. */ + data?: unknown; +} + export interface RuntimeEventErrorContent { kind: 'error'; code?: string; @@ -337,6 +357,7 @@ export type RuntimeEventContent = | RuntimeEventFunctionCallContent | RuntimeEventFunctionResponseContent | RuntimeEventErrorContent + | RuntimeEventSystemNoteContent | RuntimeEventInvocationOpenedContent; export const RUNTIME_EVENT_CONTENT_KINDS = [ @@ -345,6 +366,7 @@ export const RUNTIME_EVENT_CONTENT_KINDS = [ 'function_call', 'function_response', 'error', + 'system_note', 'invocation_opened', ] as const; export type RuntimeEventContentKind = (typeof RUNTIME_EVENT_CONTENT_KINDS)[number]; @@ -365,6 +387,12 @@ export interface RuntimeEventTokenUsage extends TokenUsageFields {} */ export interface RuntimeEventPermissionDecision extends PermissionResponse { toolName?: string; + /** + * What the prompt told the user they were approving. Normally read off the + * paired request; carried here when the decision is the only surviving + * evidence that the prompt happened. + */ + hint?: string; } export const TOOL_BOUNDARY_PROTOCOL_V1 = 't1_after_preflight_v1' as const; @@ -696,6 +724,10 @@ const ERROR_CONTENT_SHAPE = defineObjectShape()( ['kind', 'message'], ['code', 'reason', 'details'], ); +const SYSTEM_NOTE_CONTENT_SHAPE = defineObjectShape()( + ['kind', 'note'], + ['data'], +); const INVOCATION_OPENED_CONTENT_SHAPE = defineObjectShape()( ['kind', 'protocol', 'route', 'configuration', 'root', 'source'], ['lineage'], @@ -803,7 +835,7 @@ const PERMISSION_CLOSURE_ACCEPTED_SHAPE = defineObjectShape()(['requestId', 'reason'], []); const RUNTIME_PERMISSION_DECISION_SHAPE = defineObjectShape()( ['requestId', 'decision'], - ['rememberForTurn', 'reviewer', 'rationale', 'riskLevel', 'toolName'], + ['rememberForTurn', 'reviewer', 'rationale', 'riskLevel', 'toolName', 'hint'], ); const UTF8 = new TextEncoder(); const RUNTIME_TOOL_DISPATCH_SHAPE = defineObjectShape()( @@ -1022,6 +1054,12 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { typeof value.message === 'string' && (value.details === undefined || isStringArray(value.details) || isRecord(value.details)) ); + case 'system_note': + return ( + hasExactShape(value, SYSTEM_NOTE_CONTENT_SHAPE) && + typeof value.note === 'string' && + isRuntimeSystemNoteKind(value.note) + ); case 'invocation_opened': return isRuntimeInvocationOpened(value); default: @@ -1266,7 +1304,8 @@ function isRuntimeEventPermissionDecision(value: unknown): value is RuntimeEvent (value.toolName === undefined || (typeof value.toolName === 'string' && value.toolName.length > 0 && - UTF8.encode(value.toolName).byteLength <= INTERACTION_TOOL_NAME_MAX_BYTES)) + UTF8.encode(value.toolName).byteLength <= INTERACTION_TOOL_NAME_MAX_BYTES)) && + isOptionalString(value.hint) ); } @@ -1485,6 +1524,7 @@ export function runtimeEventHasModelVisibleContent(event: RuntimeEvent): boolean case 'function_response': return true; case 'error': + case 'system_note': case 'invocation_opened': return false; } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index dc03aea7be..cd1431c0e1 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -283,7 +283,7 @@ export interface SessionHeader { /** Immutable Connection entity identity. Optional only on legacy Session records. */ llmConnectionId?: string; llmConnectionSlug: string; - /** True after first UserMessage is flushed. Storage self-heals (§5.2). */ + /** True once the Session's first UserMessage is durable. One-way. */ connectionLocked: boolean; /** Sticky session default model id, captured when the session is created. */ model: string; @@ -789,20 +789,12 @@ export function userFacingText(message: Pick()( ['text'], ['signature', 'providerOptions', 'parts'], ); -const SYSTEM_NOTE_KINDS = new Set([ - 'session_start', - 'session_resume', - 'mode_change', - 'model_change', - 'context_compacted', - 'context_compaction_failed_open', - 'context_provider_dropping', - 'context_window_suggestion', - 'context_window_overrun', - 'context_reported_window_exceeded', - 'context_overflow_after_compaction', - 'step_limit', - 'error', - 'abort', +const SYSTEM_NOTE_KINDS = new Set([ + ...RUNTIME_SYSTEM_NOTE_KINDS, + ...RETIRED_SYSTEM_NOTE_KINDS, ]); export function decodeCanonicalMessage(value: unknown): StoredMessage { diff --git a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts index a68e1d6d71..9f9f40e286 100644 --- a/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-admission-integration.test.ts @@ -150,7 +150,6 @@ test('cancels managed approval owners and joiners with the canonical provider id header: sessionHeader(), connection: llmConnection(), modelId: 'model-1', - appendMessage: async () => undefined, readExecutionBoundary: async () => createManagedExecutionBoundary(createWorkspaceWritePermissionProfile(), 0), newId: nextId(), diff --git a/packages/runtime-host/src/__tests__/execution-composition.test.ts b/packages/runtime-host/src/__tests__/execution-composition.test.ts index 3e5de76f15..b11dd56aed 100644 --- a/packages/runtime-host/src/__tests__/execution-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-composition.test.ts @@ -65,6 +65,7 @@ import { stopReplacedWorkHubRoot, } from '../server/execution-composition.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; +import { readLedgerMessages } from './fixtures/ledger-transcript.js'; const require = createRequire(import.meta.url); const FAKE_CONNECTION_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; @@ -462,6 +463,9 @@ test('production recovery preserves legacy Automation history and closes an orph const composition = await createExecutionRuntimeHostComposition(compositionContext(owner)); try { await composition.recover(); + // The legacy transcript itself, as the converter reads it: recovery must + // leave a pre-ledger Automation's origin intact for the import that + // follows on the Session's first read. const history = await stores.sessionStore.readMessages(historical.id); assert.deepEqual(history[0]?.type === 'user' ? history[0].origin : undefined, { kind: 'legacy_automation', @@ -1725,7 +1729,7 @@ async function assertUniqueGraphExecutionFacts( ): Promise { const [runs, messages, runtimeEvents] = await Promise.all([ stores.runtimeEventStore.listSessionInvocations(claim.targetSessionId), - stores.sessionStore.readMessages(claim.targetSessionId), + readLedgerMessages(stores.runtimeEventStore, claim.targetSessionId), stores.runtimeEventStore.readImmutableRuntimeEvents(claim.targetSessionId, claim.targetRunId), ]); assert.deepEqual( 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 88d4da5e37..0d62d9a3b3 100644 --- a/packages/runtime-host/src/__tests__/execution-host-queue.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-queue.test.ts @@ -90,6 +90,7 @@ import { } from '../protocol/index.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; import { FramedTransport } from '../transport/framed-transport.js'; +import { readLedgerMessages } from './fixtures/ledger-transcript.js'; import { CONNECTION_EFFECT_MODEL_IDS, @@ -773,7 +774,7 @@ test('startup recovery canonically closes pending linked child admissions withou assert.equal(terminal.fact.failureClass, 'app_restarted'); } const userMessages: StoredMessage[] = ( - await stores.sessionStore.readMessages(recovered.sessionId) + await readLedgerMessages(stores.runtimeEventStore, recovered.sessionId) ).filter((message) => message.type === 'user' && message.turnId === recovered.turnId); assert.equal(userMessages.length, recovered.kind === 'linked_child_provider_retry' ? 0 : 1); if (recovered.kind !== 'linked_child_provider_retry') { diff --git a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts index 7288617e0d..911534b6c9 100644 --- a/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/execution-host-recovery.test.ts @@ -209,47 +209,42 @@ test('startup recovery replays an admitted regenerate with its source lineage', }); }); -test('startup recovery materializes legacy terminal Root sources exactly once', async () => { +// A Root folded from several queued Messages ran as one prompt, so the ledger +// carries that one prompt — under an id derived from its Run, which is what +// makes a second recovery pass write nothing new. +function legacyRootPrompt(legacy: { + runId: string; + turnId: string; + sources: readonly { content: { text: string }; admittedAt: number }[]; +}) { + return { + id: `${legacy.runId}-admitted-prompt`, + turnId: legacy.turnId, + ts: legacy.sources[0]!.admittedAt, + text: legacy.sources.map((source) => source.content.text).join('\n\n'), + }; +} + +test('startup recovery retires a legacy terminal Root without reopening its sealed Run', async () => { await withExecutionRoot(async (fixture) => { const legacy = await fixture.seedLegacyRootWithoutSourceTranscripts(); - assert.deepEqual( - (await fixture.readSessionUserMessages()).filter((message) => - legacy.sources.some((source) => source.messageId === message.id), - ), - [], - ); + assert.deepEqual(await fixture.readSessionUserMessages(), []); const firstHost = await fixture.startHost(); await fixture.stopHost(firstHost); - assert.deepEqual( - (await fixture.readSessionUserMessages()) - .filter((message) => legacy.sources.some((source) => source.messageId === message.id)) - .map(({ id, turnId, ts, text }) => ({ id, turnId, ts, text })), - legacy.sources.map((source) => ({ - id: source.messageId, - turnId: legacy.turnId, - ts: source.admittedAt, - text: source.content.text, - })), - ); - const secondHost = await fixture.startHost(); await fixture.stopHost(secondHost); - assert.deepEqual( - (await fixture.readSessionUserMessages()) - .filter((message) => legacy.sources.some((source) => source.messageId === message.id)) - .map(({ id, turnId, ts, text }) => ({ id, turnId, ts, text })), - legacy.sources.map((source) => ({ - id: source.messageId, - turnId: legacy.turnId, - ts: source.admittedAt, - text: source.content.text, - })), - ); + + // A sealed Run is immutable, so its ledger stays exactly as the crash left + // it; recovery's job here is only to retire the admission it outlived. + assert.deepEqual(await fixture.readSessionUserMessages(), []); + const ledger = await fixture.readTurn(legacy.turnId); + assert.equal(ledger.runs.length, 1); + assert.equal(ledger.terminalEvents.length, 1); }); }); -test('startup recovery replays a legacy Root without a Run before materializing its sources', async () => { +test('startup recovery replays a legacy Root without a Run before recording its prompt', async () => { await withExecutionRoot(async (fixture) => { const legacy = await fixture.seedLegacyRootWithoutSourceTranscripts('missing'); @@ -259,15 +254,8 @@ test('startup recovery replays a legacy Root without a Run before materializing await fixture.stopHost(secondHost); assert.deepEqual( - (await fixture.readSessionUserMessages()) - .filter((message) => legacy.sources.some((source) => source.messageId === message.id)) - .map(({ id, turnId, ts, text }) => ({ id, turnId, ts, text })), - legacy.sources.map((source) => ({ - id: source.messageId, - turnId: legacy.turnId, - ts: source.admittedAt, - text: source.content.text, - })), + (await fixture.readSessionUserMessages()).map(({ turnId, text }) => ({ turnId, text })), + [{ turnId: legacy.turnId, text: legacyRootPrompt(legacy).text }], ); const ledger = await fixture.readTurn(legacy.turnId); assert.equal(ledger.runs.length, 1); @@ -275,7 +263,7 @@ test('startup recovery replays a legacy Root without a Run before materializing }); }); -test('startup recovery closes a legacy non-terminal Run before materializing its sources', async () => { +test('startup recovery closes a legacy non-terminal Run before recording its prompt', async () => { await withExecutionRoot(async (fixture) => { const legacy = await fixture.seedLegacyRootWithoutSourceTranscripts('created'); @@ -285,15 +273,13 @@ test('startup recovery closes a legacy non-terminal Run before materializing its await fixture.stopHost(secondHost); assert.deepEqual( - (await fixture.readSessionUserMessages()) - .filter((message) => legacy.sources.some((source) => source.messageId === message.id)) - .map(({ id, turnId, ts, text }) => ({ id, turnId, ts, text })), - legacy.sources.map((source) => ({ - id: source.messageId, - turnId: legacy.turnId, - ts: source.admittedAt, - text: source.content.text, + (await fixture.readSessionUserMessages()).map(({ id, turnId, ts, text }) => ({ + id, + turnId, + ts, + text, })), + [legacyRootPrompt(legacy)], ); const ledger = await fixture.readTurn(legacy.turnId); assert.equal(ledger.runs.length, 1); @@ -301,6 +287,37 @@ test('startup recovery closes a legacy non-terminal Run before materializing its }); }); +test('startup recovery leaves a folded Root prompt the Run already recorded alone', async () => { + await withExecutionRoot(async (fixture) => { + // A folded Root has no single Message identity, so the prompt sits under an + // id recovery cannot rederive. Reading that as "no prompt yet" would record + // the one prompt the model already ran a second time. + const recordedPromptEventId = randomUUID(); + const legacy = await fixture.seedLegacyRootWithoutSourceTranscripts( + 'created', + recordedPromptEventId, + ); + + const host = await fixture.startHost(); + await fixture.stopHost(host); + + assert.deepEqual( + (await fixture.readSessionUserMessages()).map(({ id, turnId, text }) => ({ + id, + turnId, + text, + })), + [ + { + id: recordedPromptEventId, + turnId: legacy.turnId, + text: legacyRootPrompt(legacy).text, + }, + ], + ); + }); +}); + test('startup recovery rejects an unproven legacy Root without creating its missing Run', async () => { await withExecutionRoot(async (fixture) => { const legacy = await fixture.seedLegacyRootWithoutSourceTranscripts('missing'); diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index bfae661190..398934d65c 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -116,6 +116,7 @@ import { } from '../server/oauth-execution-authority.js'; import type { HostSkillCatalogCoordinator } from '../server/skill-catalog-coordinator.js'; import { AgentGraphProviderScenario } from './fixtures/agent-graph-provider-scenario.js'; +import { readLedgerMessages } from './fixtures/ledger-transcript.js'; const MODEL_ID = 'hosted-real-model'; const API_KEY = 'hosted-provider-key'; @@ -1979,7 +1980,7 @@ test('production Host executes a canonical ai-sdk Session against a real provide ]); assert.match(JSON.stringify(compactRequests[0]?.body), /context summarization assistant/); - const messages = await execution.sessionStore.readMessagesSnapshot(session.id); + const messages = await readLedgerMessages(execution.runtimeEventStore, session.id); const assistant = messages.find( (message) => message.type === 'assistant' && message.turnId === turnIds[0], ); @@ -2445,7 +2446,7 @@ test('production Host executes a durable runnable child with an exact tool ceili assert.equal(childRuns.length, 1); assert.equal(childRuns[0] && runtimeInvocationOutcome(childRuns[0]), 'completed'); assert.equal(childRuns[0]?.opening.lineage?.parentRunId, undefined); - const childMessages = await execution.sessionStore.readMessagesSnapshot(child.id); + const childMessages = await readLedgerMessages(execution.runtimeEventStore, child.id); assert.equal( childMessages.find((message) => message.type === 'assistant')?.text, CHILD_AGENT_RESULT_TEXT, @@ -2661,7 +2662,7 @@ test('production Host publishes and retires an implementation child patch', asyn assert.equal(childRuns.length, 1); assert.equal(childRuns[0] && runtimeInvocationOutcome(childRuns[0]), 'completed'); assert.equal(childRuns[0]?.opening.lineage?.parentRunId, undefined); - const childMessages = await execution.sessionStore.readMessagesSnapshot(child.id); + const childMessages = await readLedgerMessages(execution.runtimeEventStore, child.id); assert.equal( childMessages.find((message) => message.type === 'assistant')?.text, CHILD_AGENT_RESULT_TEXT, 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 520c689a22..0d2c921aea 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -55,6 +55,7 @@ import type { StoredMessage } from '@maka/core/session'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { BackendRegistry, SessionManager } from '@maka/runtime/session-manager'; +import { readLedgerMessages } from './ledger-transcript.js'; import { buildRecoveredTerminalRuntimeEvent, classifyTerminalRuntimeLedger, @@ -302,16 +303,7 @@ export class ExecutionFixture { try { stores = await openInteractiveExecutionStoresForWrite(owner.lease); const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => - new FakeBackend({ - sessionId: ctx.sessionId, - header: ctx.header, - store: ctx.store, - appendMessage: ctx.appendMessage, - }), - ); + backends.register('ai-sdk', (ctx) => new FakeBackend({ sessionId: ctx.sessionId })); const workspace = await resolveWorkspaceIdentity({ path: this.root }); let markReached!: () => void; const reached = new Promise((resolve) => { @@ -759,8 +751,15 @@ export class ExecutionFixture { } } + /** + * @param recordedPromptEventId The id the Run already recorded its prompt + * under, for the crash that happened after `begin()` wrote it. An older build + * derived that id differently, so it is a parameter rather than the id + * recovery would derive today. + */ async seedLegacyRootWithoutSourceTranscripts( runState: 'missing' | 'created' | 'terminal' = 'terminal', + recordedPromptEventId?: string, ): Promise<{ turnId: string; runId: string; @@ -845,6 +844,17 @@ export class ExecutionFixture { }, }); } + if (runState !== 'missing' && recordedPromptEventId) { + await stores.runtimeEventStore.appendRuntimeEvent(this.sessionId, runId, { + ...run, + id: recordedPromptEventId, + ts: admittedAt, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', ...normalizedInput }, + }); + } if (runState === 'terminal') { const terminalAt = admittedAt + 1; const terminal = buildRecoveredTerminalRuntimeEvent({ @@ -973,7 +983,7 @@ export class ExecutionFixture { let stores: Awaited> | undefined; try { stores = await openInteractiveExecutionStoresForWrite(owner.lease); - const messages = await stores.sessionStore.readMessages(this.sessionId); + const messages = await readLedgerMessages(stores.runtimeEventStore, this.sessionId); const source = messages.find( (message): message is Extract => message.type === 'user' && message.turnId === sourceTurnId, @@ -1058,12 +1068,18 @@ export class ExecutionFixture { } assert.ok(result.admission.userMessageId); if (createUserMessage) { - await stores.sessionStore.appendMessage(this.sessionId, { - type: 'user', + assert.ok(createRun, 'a seeded UserMessage needs the invocation that carries it'); + await stores.runtimeEventStore.appendRuntimeEvent(this.sessionId, result.admission.runId, { id: result.admission.userMessageId, + sessionId: this.sessionId, + invocationId: result.admission.runId, + runId: result.admission.runId, turnId, ts: admittedAt, - ...content, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', ...content }, }); } return { @@ -1149,11 +1165,11 @@ export class ExecutionFixture { const runs = invocations.filter((candidate) => candidate.turnId === turnId); const run = invocations.find((candidate) => candidate.runId === admission.runId); assert.ok(run); - const messages = await stores.sessionStore.readMessages(this.sessionId); const runtimeEvents = await stores.runtimeEventStore.readImmutableRuntimeEvents( this.sessionId, admission.runId, ); + const messages = await readLedgerMessages(stores.runtimeEventStore, this.sessionId); return { runs, userMessages: messages.filter( @@ -1201,7 +1217,7 @@ export class ExecutionFixture { let stores: Awaited> | undefined; try { stores = await openInteractiveExecutionStoresForRead(reader.lease); - return (await stores.sessionStore.readMessages(this.sessionId)).filter( + return (await readLedgerMessages(stores.runtimeEventStore, this.sessionId)).filter( (message): message is Extract => message.type === 'user', ); } finally { diff --git a/packages/runtime-host/src/__tests__/fixtures/ledger-transcript.ts b/packages/runtime-host/src/__tests__/fixtures/ledger-transcript.ts new file mode 100644 index 0000000000..b5d87a0e3d --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/ledger-transcript.ts @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; +import type { StoredMessage } from '@maka/core/session'; +import type { ExecutionRuntimeEventReader } from '@maka/storage/execution-stores'; +import { RuntimeReadModel } from '@maka/runtime/runtime-read-model'; + +/** + * A Session's transcript as the ledger tells it, for tests that used to read + * `session_messages` directly. This is the read model itself, without a + * SessionManager to host it — so ordering, inline-invocation scope and running + * turns read exactly as the product presents them. + */ +export async function readLedgerMessages( + runtimeEventStore: Readonly, + sessionId: string, +): Promise { + // The read model only reads; the reader fragment carries every method it uses. + const store = runtimeEventStore as unknown as RuntimeEventStore; + return (await new RuntimeReadModel({ runtimeEventStore: store }).getSessionView(sessionId)) + .messages; +} diff --git a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts index 8d2dbecb96..916989da53 100644 --- a/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts +++ b/packages/runtime-host/src/__tests__/fixtures/session-transcript-reader.ts @@ -18,6 +18,8 @@ */ import type { StoredMessage } from '@maka/core/session'; +import type { SessionTurnContribution, SessionTurnLandmark } from '@maka/storage/execution-stores'; +import { foldTurnContribution } from '@maka/storage/session-message-projection'; import type { SessionTranscriptReader } from '../../server/session-transcript-reader.js'; export function transcriptReader( @@ -142,6 +144,48 @@ export function transcriptReader( (message, sequence) => sequence <= request.throughSequence! && request.messageIds.includes(message.id), ), + readDurableTurnContributions: async ( + _sessionId, + throughSequence, + position, + maxContributions, + ) => { + const watermark = throughSequence ?? (durable.length === 0 ? null : durable.length - 1); + if (watermark === null) + return { throughSequence: null, contributions: [], nextPosition: null }; + const folded = new Map(); + for (const [sequence, message] of durable.entries()) { + const turnId = message.turnId; + if (turnId === undefined || sequence < position || sequence > watermark) continue; + if (!folded.has(turnId) && folded.size >= maxContributions) { + return { + throughSequence: watermark, + contributions: [...folded.values()], + nextPosition: sequence, + }; + } + folded.set(turnId, foldTurnContribution(folded.get(turnId), turnId, sequence, message)); + } + return { + throughSequence: watermark, + contributions: [...folded.values()], + nextPosition: null, + }; + }, + readDurableTurnLandmarks: async (_sessionId, maxLandmarks) => { + const watermark = durable.length === 0 ? null : durable.length - 1; + if (watermark === null) return { throughSequence: null, landmarks: [] }; + const seen = new Set(); + const landmarks: SessionTurnLandmark[] = []; + for (const [sequence, message] of durable.entries()) { + if (landmarks.length >= maxLandmarks) break; + const turnId = message.turnId; + if (message.type !== 'user' || turnId === undefined || seen.has(turnId)) continue; + seen.add(turnId); + landmarks.push({ turnId, sequence, label: message.displayText ?? message.text }); + } + return { throughSequence: watermark, landmarks }; + }, readActiveOverlay: async () => overlay, }; } diff --git a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts index b69e4e0538..16c619d717 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -63,6 +63,7 @@ test('one Host Goal is shared across clients with CAS control and crash-clear re const coordinator = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => stores.sessionStore.readMessagesSnapshot(sessionId), executions: { reconcile: async () => assert.fail('No Goal execution recovery is expected'), subscribe: () => () => undefined, @@ -201,6 +202,7 @@ test('one Host Goal is shared across clients with CAS control and crash-clear re const recovered = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => stores.sessionStore.readMessagesSnapshot(sessionId), executions: { reconcile: async () => assert.fail('Recovered Goal has no current execution'), subscribe: () => () => undefined, @@ -275,6 +277,7 @@ test('session retirement forgets a terminal Goal without recreating deleted auth const coordinator = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => stores.sessionStore.readMessagesSnapshot(sessionId), executions: { reconcile: async () => assert.fail('A terminal Goal has no execution to recover'), subscribe: () => () => undefined, @@ -399,6 +402,7 @@ test('restart settles the durable current Goal execution through Hosted Executio const coordinator = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => stores.sessionStore.readMessagesSnapshot(sessionId), executions: { reconcile: (requested) => executionProjection.read(requested), subscribe: () => () => undefined, @@ -485,6 +489,7 @@ test('restart replaces a stale current execution with the current durable Goal i const coordinator = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => stores.sessionStore.readMessagesSnapshot(sessionId), executions: { reconcile: async () => assert.fail('A stale execution must not be reconciled'), subscribe: () => () => undefined, @@ -573,6 +578,7 @@ test('goal.arm creates one Goal per Session and refuses a second while it is unf const coordinator = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => stores.sessionStore.readMessagesSnapshot(sessionId), executions: { reconcile: async () => assert.fail('Arming alone has no execution to recover'), subscribe: () => () => undefined, @@ -687,6 +693,7 @@ test('a Goal armed but never carried by a Turn does not start itself after a res const armingHost = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => stores.sessionStore.readMessagesSnapshot(sessionId), executions: { reconcile: async () => assert.fail('Arming alone has no execution to recover'), subscribe: () => () => undefined, @@ -726,6 +733,7 @@ test('a Goal armed but never carried by a Turn does not start itself after a res const restarted = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => stores.sessionStore.readMessagesSnapshot(sessionId), executions: { reconcile: async () => assert.fail('An armed Goal has no execution to recover'), subscribe: () => () => undefined, @@ -789,6 +797,7 @@ test('resuming an armed Goal drives it, and a restart puts that drive back', asy const host = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => stores.sessionStore.readMessagesSnapshot(sessionId), executions: { reconcile: async () => assert.fail('Arming alone has no execution to recover'), subscribe: () => () => undefined, @@ -875,6 +884,7 @@ test('resuming an armed Goal drives it, and a restart puts that drive back', asy const restarted = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => stores.sessionStore.readMessagesSnapshot(sessionId), executions: { reconcile: async () => assert.fail('A busy admission left no execution to recover'), subscribe: () => () => undefined, @@ -932,6 +942,7 @@ test('an arm admitted before the drain creates no Goal after it', async () => { const host = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => stores.sessionStore.readMessagesSnapshot(sessionId), executions: { reconcile: async () => assert.fail('A refused arm has no execution'), subscribe: () => () => undefined, diff --git a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts index c6e89e8376..b17c1003c2 100644 --- a/packages/runtime-host/src/__tests__/goal-root-authority.test.ts +++ b/packages/runtime-host/src/__tests__/goal-root-authority.test.ts @@ -60,6 +60,7 @@ import { RootAdmissionOwner } from '../server/root-admission-owner.js'; import { RootTurnCoordinator } from '../server/root-turn-coordinator.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; import { SessionContinuityCoordinator } from '../server/session-continuity-coordinator.js'; +import { readLedgerMessages } from './fixtures/ledger-transcript.js'; test('Goal continuation uses the canonical root admission and durable origin', { timeout: 10_000, @@ -105,9 +106,9 @@ test('Goal continuation uses the canonical root admission and durable origin', { if (!durableAdmission) return; const run = await readInvocation(fixture, durableAdmission.runId); assert.deepEqual(run?.opening.root, { kind: 'goal', goalId: created.id }); - const user = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( - (message) => message.type === 'user' && message.turnId === admission.turnId, - ); + const user = ( + await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId) + ).find((message) => message.type === 'user' && message.turnId === admission.turnId); assert.deepEqual(user?.type === 'user' ? user.origin : undefined, { kind: 'goal', goalId: created.id, @@ -172,7 +173,7 @@ test('queued Goal control revokes a prepared root before durable admission', asy false, ); assert.equal( - (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).some( + (await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId)).some( (message) => message.type === 'user' && message.turnId === admission.turnId, ), false, @@ -430,9 +431,9 @@ test('restart closes an admitted Goal without a Run instead of replaying it', as assert.deepEqual(run?.opening.root, { kind: 'goal', goalId: 'goal-restart' }); assert.equal(run && runtimeInvocationOutcome(run), 'failed'); assert.equal(run && runtimeInvocationFailureClass(run), 'app_restarted'); - const user = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( - (message) => message.type === 'user' && message.turnId === turnId, - ); + const user = ( + await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId) + ).find((message) => message.type === 'user' && message.turnId === turnId); assert.deepEqual(user?.type === 'user' ? user.origin : undefined, { kind: 'goal', goalId: 'goal-restart', @@ -447,11 +448,12 @@ test('restart rejects an admitted Goal whose existing UserMessage lost its origi const fixture = await createFixture({ recoverAdmissions: false }); try { const turnId = randomUUID(); + const runId = randomUUID(); const userMessageId = randomUUID(); await fixture.stores.agentRunStore.admitRootTurn({ sessionId: fixture.sessionId, turnId, - proposedRunId: randomUUID(), + proposedRunId: runId, proposedUserMessageId: userMessageId, execution: { kind: 'goal', goalId: 'goal-corrupt-origin' }, previousRootTurnId: null, @@ -459,12 +461,23 @@ test('restart rejects an admitted Goal whose existing UserMessage lost its origi sourceMessages: [], admittedAt: 1, }); - await fixture.stores.sessionStore.appendMessage(fixture.sessionId, { - type: 'user', + const seeded = await seedInvocation(fixture.stores.runtimeEventStore, { + sessionId: fixture.sessionId, + turnId, + runId, + opening: { root: { kind: 'goal', goalId: 'goal-corrupt-origin' } }, + }); + await fixture.stores.runtimeEventStore.appendRuntimeEvent(fixture.sessionId, runId, { id: userMessageId, + sessionId: fixture.sessionId, + invocationId: seeded.invocationId, + runId, turnId, ts: 1, - text: 'Preserve durable Goal provenance', + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'Preserve durable Goal provenance' }, }); await assert.rejects( @@ -668,6 +681,7 @@ async function createFixture(options: { recoverAdmissions?: boolean } = {}): Pro goal = new HostGoalCoordinator({ store: goalStore, stores, + readSessionMessages: (sessionId) => manager.getMessages(sessionId), executions: rootCoordinator, sessionAdmission: admission, evaluator: { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 46eca23e2c..de08c631da 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -443,6 +443,10 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 117); }); + test('publishes a new compatibility epoch for event-addressed transcript cursors', () => { + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 118); + }); + test('selects the highest mutually supported protocol and rejects a gap', () => { assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0); assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3); 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 7897dde2ea..26283ba699 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -106,6 +106,7 @@ import type { SessionContinuityFrameSink } from '../server/session-continuity-se import { HostTurnControlCoordinator } from '../server/turn-control-coordinator.js'; import { RuntimePolicyActivationGate } from '../server/runtime-policy-activation-gate.js'; import { PROCESS_TIMEOUT_MS } from './fixtures/execution-host-suite.js'; +import { readLedgerMessages } from './fixtures/ledger-transcript.js'; import { waitFor } from '@maka/core/test-only/async-primitives'; const HOLD_EXTERNAL_PROMPT = 'hold external root before follow-up'; @@ -404,9 +405,9 @@ test('uses the submitted Turn identity for the canonical external user message', assertStartedTurn(started); await fixture.coordinator.whenIdle(fixture.sessionId); - const user = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( - (message) => message.type === 'user' && message.turnId === turnId, - ); + const user = ( + await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId) + ).find((message) => message.type === 'user' && message.turnId === turnId); assert.equal(user?.id, turnId); } finally { await fixture.coordinator.close(); @@ -474,7 +475,7 @@ test('startup recovery replays one admitted safe-boundary continuation without a 'completed', ); assert.equal( - (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).some( + (await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId)).some( (message) => message.type === 'user' && message.turnId === pending.targetTurnId, ), false, @@ -515,14 +516,6 @@ test('startup recovery closes a ScheduledTask Run after its pending fire was set admittedAt, }); assert.equal(admission.kind, 'admitted'); - await fixture.stores.sessionStore.appendMessage(fixture.sessionId, { - type: 'user', - id: userMessageId, - turnId, - ts: admittedAt, - text: 'Continue the scheduled work.', - origin: { kind: 'scheduled_task', scheduledTaskId: 'task-settled-fire' }, - }); await seedInvocation(fixture.stores.runtimeEventStore, { sessionId: fixture.sessionId, invocationId: runId, @@ -548,6 +541,22 @@ test('startup recovery closes a ScheduledTask Run after its pending fire was set root: { kind: 'scheduled_task', scheduledTaskId: 'task-settled-fire' }, }, }); + await fixture.stores.runtimeEventStore.appendRuntimeEvent(fixture.sessionId, runId, { + id: userMessageId, + sessionId: fixture.sessionId, + invocationId: runId, + runId, + turnId, + ts: admittedAt, + partial: false, + role: 'user', + author: 'host', + content: { + kind: 'text', + text: 'Continue the scheduled work.', + origin: { kind: 'scheduled_task', scheduledTaskId: 'task-settled-fire' }, + }, + }); recovery = fixture.createRecoveryCoordinator(); await recovery.prepareRecovery(); @@ -692,7 +701,7 @@ test('a failed exact Capability retry does not poison the parked continuation bi 1, ); assert.equal( - (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).filter( + (await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId)).filter( (message) => message.type === 'user' && message.turnId === pending.targetTurnId, ).length, 0, @@ -1297,7 +1306,10 @@ test('idle Skill admission persists a canonical draft without history before roo displayText: '/skill:writer Draft this.', inlineReferences: [], }); - assert.deepEqual(await fixture.stores.sessionStore.readMessages(fixture.sessionId), []); + assert.deepEqual( + await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId), + [], + ); } finally { await fixture.dispose(); } @@ -2108,9 +2120,9 @@ test('Agent Graph supervisor wake waits for root idle and binds one durable exec }); assert.equal(graphRun.opening.configuration.orchestrationMode, 'graph'); assert.equal(graphRun.opening.configuration.orchestrationSource, 'turn_override'); - const userMessage = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( - (message) => message.id === graphAdmission?.userMessageId, - ); + const userMessage = ( + await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId) + ).find((message) => message.id === graphAdmission?.userMessageId); assert.ok(userMessage?.type === 'user'); if (userMessage?.type === 'user') { assert.deepEqual(userMessage.origin, { @@ -2295,7 +2307,7 @@ test('manual context compact uses durable root query, stop, and exact retry auth assert.deepEqual(admission?.execution, { kind: 'context_compact' }); assert.equal(admission?.userMessageId, null); assert.equal( - (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).some( + (await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId)).some( (message) => message.type === 'user' && message.turnId === turnId, ), false, @@ -2516,7 +2528,10 @@ test('Agent Graph supervisor wake revalidates freshness before durable root admi await fixture.stores.runtimeEventStore.listSessionInvocations(fixture.sessionId), [], ); - assert.deepEqual(await fixture.stores.sessionStore.readMessages(fixture.sessionId), []); + assert.deepEqual( + await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId), + [], + ); assert.equal(fixture.drainRequested(), false); } finally { await fixture.coordinator.close(); @@ -2574,9 +2589,9 @@ test('Agent Graph supervisor recovery closes a durable admission that has no Run }); assert.equal(run.opening.configuration.orchestrationMode, 'graph'); assert.equal(run.opening.configuration.orchestrationSource, 'turn_override'); - const message = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( - (candidate) => candidate.id === userMessageId, - ); + const message = ( + await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId) + ).find((candidate) => candidate.id === userMessageId); assert.ok(message?.type === 'user'); if (message?.type === 'user') { assert.deepEqual(message.origin, { @@ -3690,7 +3705,7 @@ test('mixed-Client queued follow-ups use separate Session successors without con [[], ['followup-from-provider-b'], ['followup-from-provider-a']], ); assert.deepEqual( - (await fixture.stores.sessionStore.readMessages(fixture.sessionId)) + (await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId)) .filter((message) => message.type === 'user' && message.id.startsWith('followup-from-')) .map((message) => message.id), ['followup-from-provider-b', 'followup-from-provider-a'], @@ -5467,9 +5482,9 @@ test('directory references enforce Host identity without reading the filesystem' ); assert.equal(accepted.ok, true, JSON.stringify(accepted)); await fixture.coordinator.whenIdle(fixture.sessionId); - const user = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( - (message) => message.type === 'user' && message.id === 'local-directory', - ); + const user = ( + await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId) + ).find((message) => message.type === 'user' && message.id === 'local-directory'); assert.equal(user?.type, 'user'); if (user?.type !== 'user') throw new Error('Expected directory user message'); assert.equal(user.text, 'inspect local directory'); @@ -5530,7 +5545,7 @@ test('turn start and regeneration preserve one Host-bound directory reference', assert.deepEqual(input.directoryReferences, [reference]); } const regeneratedUser = ( - await fixture.stores.sessionStore.readMessages(fixture.sessionId) + await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId) ).find((message) => message.type === 'user' && message.turnId === 'directory-regenerated'); assert.equal(regeneratedUser?.type, 'user'); if (regeneratedUser?.type !== 'user') throw new Error('Expected regenerated user message'); @@ -5607,16 +5622,19 @@ test('queued directory references survive text editing and next-Turn delivery', release.resolve(); await fixture.coordinator.whenIdle(fixture.sessionId); await waitUntil(async () => - (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).some( + (await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId)).some( (message) => message.type === 'user' && message.text === 'edited inspection', ), ); - const user = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( - (message) => message.type === 'user' && message.text === 'edited inspection', - ); + const user = ( + await readLedgerMessages(fixture.stores.runtimeEventStore, fixture.sessionId) + ).find((message) => message.type === 'user' && message.text === 'edited inspection'); assert.equal(user?.type, 'user'); if (user?.type !== 'user') throw new Error('Expected queued directory user message'); assert.deepEqual(user.directoryReferences, [reference]); + // The ledger carries the delivered message before its Turn ends; close only + // once that Turn has, so shutdown does not race its terminal fact. + await fixture.coordinator.whenIdle(fixture.sessionId); } finally { release.resolve(); await fixture.coordinator.close(); diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 578a4f9c30..dcc21cf9a1 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -61,6 +61,7 @@ import { import { SessionAdmissionGate } from '../server/session-admission-gate.js'; type CatalogStores = HostSessionCatalogCoordinatorOptions['stores']; +type CatalogTurnIndex = HostSessionCatalogCoordinatorOptions['turnIndex']; type RuntimePolicy = HostSessionCatalogCoordinatorOptions['runtimePolicy']; type ConfigurationAuthority = HostSessionCatalogCoordinatorOptions['manager']; type SessionContinuity = HostSessionCatalogCoordinatorOptions['continuity']; @@ -104,8 +105,8 @@ test('reduces turn pages to their encoded wire budget without skipping contribut })); const requestedLimits: number[] = []; const fixture = createFixture({ - stores: { - readTurnContributionsSnapshot: async (_sessionId, _watermark, position, limit) => { + turnIndex: { + readDurableTurnContributions: async (_sessionId, _watermark, position, limit) => { requestedLimits.push(limit); const end = Math.min(position + limit, contributions.length); return { @@ -148,6 +149,108 @@ test('reduces turn pages to their encoded wire budget without skipping contribut assert.ok(requestedLimits.some((limit) => limit < 128)); }); +test('read marker clears unread only at the ledger transcript tail', async () => { + const fixture = createFixture({ + header: { hasUnread: true }, + turnIndex: { + readDurableRecords: async () => ({ + throughSequence: 1, + records: [ + { + sequence: 1, + message: { + type: 'assistant', + id: 'message-2', + turnId: 'turn-1', + ts: 20, + text: 'answer', + modelId: 'fake-model', + }, + }, + { + sequence: 0, + message: { type: 'user', id: 'message-1', turnId: 'turn-1', ts: 10, text: 'ask' }, + }, + ], + nextPosition: null, + }), + }, + }); + const setReadMarker = async (readThroughMessageId: string) => { + const outcome = await fixture.coordinator.handlers['session.read_marker.set']( + { sessionId: fixture.sessionId, readThroughMessageId }, + context, + ); + assert.equal(outcome.ok, true); + if (!outcome.ok || !('hasUnread' in outcome.result)) assert.fail('Read marker failed'); + return outcome.result; + }; + + const behind = await setReadMarker('message-1'); + assert.equal(behind.hasUnread, true); + assert.equal(behind.lastReadMessageId, undefined); + + const caughtUp = await setReadMarker('message-2'); + assert.equal(caughtUp.hasUnread, false); + assert.equal(caughtUp.lastReadMessageId, 'message-2'); +}); + +test('read marker pages past a hidden tail to reach the newest visible message', async () => { + // A Turn that ends on tool traffic can put more hidden records at the tail + // than one page holds. Stopping at the page boundary would read the Session + // as never caught up and leave it unread for good. + const hiddenTail = { + throughSequence: 2, + records: [ + { + sequence: 2, + message: { + type: 'turn_state' as const, + id: 'turn-state-1', + turnId: 'turn-1', + ts: 30, + status: 'completed' as const, + partialOutputRetained: false, + }, + }, + ], + nextPosition: 1, + }; + const visiblePage = { + throughSequence: 2, + records: [ + { + sequence: 1, + message: { + type: 'assistant' as const, + id: 'message-2', + turnId: 'turn-1', + ts: 20, + text: 'answer', + modelId: 'fake-model', + }, + }, + ], + nextPosition: null, + }; + const fixture = createFixture({ + header: { hasUnread: true }, + turnIndex: { + readDurableRecords: async (_sessionId, request) => + request.position === undefined ? hiddenTail : visiblePage, + }, + }); + + const outcome = await fixture.coordinator.handlers['session.read_marker.set']( + { sessionId: fixture.sessionId, readThroughMessageId: 'message-2' }, + context, + ); + assert.equal(outcome.ok, true); + if (!outcome.ok || !('hasUnread' in outcome.result)) assert.fail('Read marker failed'); + assert.equal(outcome.result.hasUnread, false); + assert.equal(outcome.result.lastReadMessageId, 'message-2'); +}); + test('metadata replacement preserves execution-semantic labels and ignores injected ones', async () => { const fixture = createFixture({ labels: ['old-user-label', DEEP_RESEARCH_SESSION_LABEL], @@ -1596,6 +1699,7 @@ function createFixture( readonly labels?: readonly string[]; readonly cwd?: string; readonly stores?: Partial; + readonly turnIndex?: Partial; readonly manager?: Partial; readonly continuity?: Partial; readonly connection?: FixtureConnection; @@ -1628,17 +1732,10 @@ function createFixture( records: [catalogRecord(header, revision)], hasMore: false, }), - markSessionReadThroughMessage: async () => headerSnapshot(header, revision), probeStableSessionCreate: async () => ({ kind: 'absent' }), readCatalogRecord: async () => catalogRecord(header, revision), readExecutionBoundary: async () => createGenesisExecutionBoundary('ask'), readHeaderRecordSnapshot: async () => headerSnapshot(header, revision), - readTurnContributionsSnapshot: async () => ({ - throughSequence: null, - contributions: [], - nextPosition: null, - }), - readTurnLandmarksSnapshot: async () => ({ throughSequence: null, landmarks: [] }), updateHeaderVersioned: async (_sessionId, patch, expectedRevision) => { if (expectedRevision !== revision) { throw new SessionMetadataVersionConflictError(sessionId, expectedRevision, revision); @@ -1649,6 +1746,16 @@ function createFixture( }, ...options.stores, }; + const turnIndex: CatalogTurnIndex = { + readDurableRecords: async () => ({ throughSequence: null, records: [], nextPosition: null }), + readDurableTurnContributions: async () => ({ + throughSequence: null, + contributions: [], + nextPosition: null, + }), + readDurableTurnLandmarks: async () => ({ throughSequence: null, landmarks: [] }), + ...options.turnIndex, + }; const runtimePolicy = options.runtimePolicy ?? runtimePolicyFixture(options.connection ?? {}); const manager: ConfigurationAuthority = { runningTurnIds: () => [], @@ -1677,6 +1784,7 @@ function createFixture( }; const coordinator = new HostSessionCatalogCoordinator({ stores, + turnIndex, runtimePolicy, manager, admission: new SessionAdmissionGate(), 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 560bf3a3f5..dc689b52d9 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 @@ -31,6 +31,7 @@ import { DatabaseSync } from 'node:sqlite'; import { DEEP_RESEARCH_SESSION_LABEL, DEEP_RESEARCH_SESSION_NAME } from '@maka/core/deep-research'; import { openInteractiveArtifactStoreForWrite } from '@maka/storage/artifact-stores'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; +import { seedInvocation } from '@maka/runtime/test-only/invocation-fixture'; import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores'; import { resolveRootControlNamespace, @@ -824,25 +825,45 @@ async function seedAuthority( model: 'fake-model', permissionMode: 'ask', }); - await execution.sessionStore.appendMessages(unread.id, [ - { type: 'user', id: 'message-1', turnId: 'turn-1', ts: 1, text: 'one' }, + await seedInvocation(execution.runtimeEventStore, { + sessionId: unread.id, + runId: 'run-1', + turnId: 'turn-1', + openedAt: 1, + }); + for (const event of [ + { + id: 'message-1', + ts: 1, + role: 'user' as const, + author: 'user' as const, + content: { kind: 'text' as const, text: 'one' }, + }, { - type: 'assistant', id: 'message-2', - turnId: 'turn-1', ts: 2, - text: 'two', - modelId: 'fake-model', + role: 'model' as const, + author: 'agent' as const, + content: { kind: 'text' as const, text: 'two' }, }, { - type: 'tool_call', - id: 'tool-1', - turnId: 'turn-1', + id: 'run-1-terminal', ts: 3, - toolName: 'Read', - args: {}, + role: 'system' as const, + author: 'system' as const, + status: 'completed' as const, + actions: { endInvocation: true }, }, - ]); + ]) { + await execution.runtimeEventStore.appendRuntimeEvent(unread.id, 'run-1', { + sessionId: unread.id, + invocationId: 'run-1', + runId: 'run-1', + turnId: 'turn-1', + partial: false, + ...event, + }); + } await execution.sessionStore.updateHeader(unread.id, { hasUnread: true, lastMessageAt: 2, diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index 8e039340ca..4a490c001a 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -52,6 +52,7 @@ import { import { openInteractiveSessionTodoStoreForWrite } from '@maka/storage/session-todo-authority'; import { removePosixEndpointDirectories } from './fixtures/endpoint-hygiene.js'; import { requireStartedTurn } from './fixtures/execution-host-suite.js'; +import { readLedgerMessages } from './fixtures/ledger-transcript.js'; import { connectRuntimeHost, RuntimeHostOperationError, @@ -858,13 +859,6 @@ async function seedSource( model: 'fake-model', permissionMode: 'ask', }); - await execution.sessionStore.appendMessage(continuationSource.id, { - type: 'user', - id: 'continuation-parent-user', - turnId: 'continuation-parent-turn', - ts: 1, - text: 'retain the child continuation closure', - }); const continuationParent = agentRunHeader( root, continuationSource.id, @@ -969,45 +963,6 @@ async function seedSource( source: 'tool_result', now: 2, }); - await execution.sessionStore.appendMessages(source.id, [ - { - type: 'user', - id: 'user-1', - turnId: 'turn-1', - ts: 1, - text: 'first', - attachments: [ - { - kind: 'code', - name: 'source.txt', - mimeType: 'text/plain', - bytes: 14, - ref: { - kind: 'session_file', - sessionId: source.id, - relativePath: artifact.id, - }, - }, - ], - }, - { - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 2, - text: 'first response', - modelId: 'fake-model', - }, - { type: 'user', id: 'user-2', turnId: 'turn-2', ts: 3, text: 'second' }, - { - type: 'assistant', - id: 'assistant-2', - turnId: 'turn-2', - ts: 4, - text: 'second response', - modelId: 'fake-model', - }, - ]); await execution.sessionStore.updateHeader(source.id, { isFlagged: true, titleIsManual: true, @@ -1293,39 +1248,6 @@ async function seedSource( completedAt: 2, durationMs: 1, }; - await execution.sessionStore.appendMessages(linkedChildSource.id, [ - { - type: 'user', - id: 'linked-user', - turnId: 'linked-turn', - ts: 1, - text: 'delegate this', - }, - { - type: 'tool_result', - id: 'linked-result', - turnId: 'linked-turn', - ts: 2, - toolUseId: 'linked-call', - isError: false, - content: graphResult, - }, - { - type: 'user', - id: 'linked-after-user', - turnId: 'linked-after-turn', - ts: 3, - text: 'revise this later turn', - }, - { - type: 'assistant', - id: 'linked-after-assistant', - turnId: 'linked-after-turn', - ts: 4, - text: 'later response', - modelId: 'fake-model', - }, - ]); for (const run of [ agentRunHeader( root, @@ -1420,13 +1342,39 @@ async function seedSource( stop: [], finish: { resultIds: ['graph-item'], reason: 'complete' }, }); - await execution.sessionStore.appendMessage(metadataLinkedSource.id, { - type: 'user', - id: 'metadata-linked-user', - turnId: 'metadata-linked-turn', - ts: 1, - text: 'delegate without a committed result', - }); + await seedInvocation( + execution.runtimeEventStore, + agentRunHeader( + root, + metadataLinkedSource.id, + 'metadata-linked-run', + 'metadata-linked-invocation', + 'metadata-linked-turn', + ), + ); + for (const event of [ + runtimeEvent( + metadataLinkedSource.id, + 'metadata-linked-run', + 'metadata-linked-invocation', + 'metadata-linked-turn', + { + id: 'metadata-linked-user', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'delegate without a committed result' }, + }, + ), + runtimeEvent( + metadataLinkedSource.id, + 'metadata-linked-run', + 'metadata-linked-invocation', + 'metadata-linked-turn', + { id: 'metadata-linked-terminal', ts: 2, status: 'completed' }, + ), + ]) { + await execution.runtimeEventStore.appendRuntimeEvent(event.sessionId, event.runId, event); + } const ordinaryLinkedChild = await execution.sessionStore.createSubagent({ cwd: root, name: 'Metadata-linked Child Session', @@ -1483,15 +1431,6 @@ async function seedSource( source: 'tool_result_archive', now: 1, }); - await execution.sessionStore.appendMessages(archivedOwnedSource.id, [ - { - type: 'user', - id: 'archived-owned-user', - turnId: 'archived-owned-turn', - ts: 1, - text: 'reuse the archived result', - }, - ]); const archivedOwnedRuns = [ agentRunHeader( root, @@ -1692,7 +1631,7 @@ async function verifyDurableBranch( // readable copy of the user-uploaded attachment (regression guard for the // turn-scoped-only artifact selection that dropped user uploads). const assertCopiedUpload = async (sessionId: string): Promise => { - const sessionMessages = await execution.sessionStore.readMessagesSnapshot(sessionId); + const sessionMessages = await readLedgerMessages(execution.runtimeEventStore, sessionId); const uploadMessage = sessionMessages.find( (message) => message.type === 'user' && message.attachments?.[0], ); @@ -1707,12 +1646,12 @@ async function verifyDurableBranch( text: 'retained bytes', }); }; - const messages = await execution.sessionStore.readMessagesSnapshot(branchSessionId); + const messages = await readLedgerMessages(execution.runtimeEventStore, branchSessionId); // The copied invocation opens on the branch's own spine, so its transcript // projects the copied turn as ended, exactly as the source reads. assert.deepEqual( messages.map((message) => message.type), - ['user', 'assistant', 'tool_call', 'tool_result', 'system_note', 'turn_state'], + ['user', 'assistant', 'tool_call', 'tool_result', 'turn_state'], ); const user = messages.find((message) => message.type === 'user'); assert.ok(user?.attachments?.[0]); @@ -1827,7 +1766,8 @@ async function verifyDurableBranch( ); assert.equal(sideConversationHeader.conversationCopy?.intent, 'side_conversation'); assert.ok(sideConversationHeader.labels.includes('mode:side_conversation')); - const sideConversationMessages = await execution.sessionStore.readMessagesSnapshot( + const sideConversationMessages = await readLedgerMessages( + execution.runtimeEventStore, graphSideConversationTargetId, ); const sideConversationResult = sideConversationMessages.find( @@ -1845,7 +1785,8 @@ async function verifyDurableBranch( assert.equal(sideConversationResult.content.items[0]?.runId, undefined); const sideConversationArtifactId = sideConversationResult.content.items[0]?.artifactIds[0]; assert.ok(sideConversationArtifactId); - const activeSourceSideConversationMessages = await execution.sessionStore.readMessagesSnapshot( + const activeSourceSideConversationMessages = await readLedgerMessages( + execution.runtimeEventStore, activeSourceSideConversationTargetId, ); assert.ok(activeSourceSideConversationMessages.some((message) => message.turnId === 'turn-2')); @@ -1929,8 +1870,10 @@ async function verifyDurableBranch( { offset: 0, limit: 10 }, ); assert.equal(archivedSideConversationArtifacts.total, 0); - const graphRevisionMessages = - await execution.sessionStore.readMessagesSnapshot(graphRevisionTargetId); + const graphRevisionMessages = await readLedgerMessages( + execution.runtimeEventStore, + graphRevisionTargetId, + ); const graphResult = graphRevisionMessages.find( (message) => message.type === 'tool_result' && message.content.kind === 'agent_swarm', ); 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 1da0eab3ce..512b04a4d5 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-reader.test.ts @@ -25,6 +25,10 @@ import test from 'node:test'; import { seedInvocation, testInvocationOpening } from '@maka/runtime/test-only/invocation-fixture'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { StoredMessage } from '@maka/core/session'; +import { projectRuntimeEventsToStoredMessages } from '@maka/runtime/runtime-event-read-model'; +import { foldTurnContribution } from '@maka/storage/session-message-projection'; +import type { SessionTurnContribution } from '@maka/storage/execution-stores'; import { type ExecutionStoresWriter, openInteractiveExecutionStoresForWrite, @@ -50,12 +54,43 @@ test('keeps durable history separate from the canonical active overlay', async ( model: 'fake-model', permissionMode: 'ask', }); - await stores.sessionStore.appendMessage(session.id, { - type: 'system_note', - id: 'history-1', - ts: 1, - kind: 'session_start', + // 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', + turnId: 'turn-0', + openedAt: 0, }); + await stores.runtimeEventStore.appendRuntimeEvent( + session.id, + 'run-0', + runtimeEvent(session.id, { + id: 'user-event-0', + invocationId: 'run-0', + runId: 'run-0', + turnId: 'turn-0', + ts: 0.1, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'settled' }, + refs: { storedMessageId: 'user-0' }, + }), + ); + await stores.runtimeEventStore.appendRuntimeEvent( + session.id, + 'run-0', + runtimeEvent(session.id, { + id: 'terminal-0', + invocationId: 'run-0', + runId: 'run-0', + turnId: 'turn-0', + ts: 0.2, + role: 'system', + author: 'system', + status: 'completed', + }), + ); await seedInvocation(stores.runtimeEventStore, { sessionId: session.id, runId: 'run-1', @@ -219,11 +254,297 @@ test('keeps durable history separate from the canonical active overlay', async ( maxBytes: 1024, maxMessages: 10, }); - assert.equal(durable.throughSequence, 0); + assert.equal(durable.throughSequence, await read.readDurableHighWater(session.id)); + assert.ok(durable.throughSequence !== null); + assert.deepEqual( + durable.fragments.map((fragment) => { + const message = JSON.parse(fragment.data.toString('utf8')) as StoredMessage; + return { type: message.type, id: message.id }; + }), + [ + { type: 'turn_state', id: 'terminal-0' }, + { type: 'user', id: 'user-0' }, + ], + ); + } finally { + await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +test('pages the ledger without materializing off-page Turns or messages', async (t) => { + const base = await mkdtemp(join(tmpdir(), 'maka-transcript-seek-')); + const capability = await resolveStorageRoot({ path: join(base, 'root'), kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + try { + const stores = await openInteractiveExecutionStoresForWrite(owner.lease); + const session = await stores.sessionStore.create({ + cwd: capability.canonicalPath, + llmConnectionId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const expected: StoredMessage[] = []; + for (let turn = 0; turn < 5; turn++) { + const runId = `run-${turn}`; + const turnId = `turn-${turn}`; + await seedInvocation(stores.runtimeEventStore, { + sessionId: session.id, + runId, + turnId, + openedAt: turn, + }); + let count = 0; + const append = (overrides: Partial) => + stores.runtimeEventStore.appendRuntimeEvent( + session.id, + runId, + runtimeEvent(session.id, { + id: `${runId}-event-${count++}`, + invocationId: runId, + runId, + turnId, + ...overrides, + }), + ); + await append({ + role: 'user', + author: 'user', + content: { kind: 'text', text: `prompt ${turn}` }, + }); + if (turn === 4) { + // More than 5 MiB in a single Turn, outside a tiny head/tail page. + for (let index = 0; index < 180; index++) { + await append({ + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'x'.repeat(32 * 1024) }, + }); + } + await append({ + role: 'model', + author: 'agent', + content: { kind: 'function_call', id: 'tool-1', name: 'Read', args: {} }, + refs: { toolCallId: 'tool-1', stepId: 'assistant-final' }, + }); + await append({ + actions: { + permissionRequest: { + kind: 'tool_permission', + requestId: 'request-1', + toolUseId: 'tool-1', + toolName: 'Read', + category: 'read', + reason: 'custom', + args: {}, + rememberForTurnAllowed: true, + hint: 'original permission hint', + }, + }, + }); + await append({ + actions: { + permissionDecision: { + requestId: 'request-1', + decision: 'allow', + rememberForTurn: true, + }, + }, + refs: { toolCallId: 'tool-1' }, + }); + await append({ + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'tool-1', + name: 'Read', + result: { kind: 'text', text: 'result' }, + isError: true, + }, + }); + await append({ + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: 'before text' }, + refs: { providerEventId: 'assistant-final' }, + }); + await append({ + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'final answer 中文' }, + refs: { storedMessageId: 'assistant-final' }, + }); + await append({ + role: 'model', + author: 'agent', + content: { kind: 'thinking', text: ' after text' }, + refs: { providerEventId: 'assistant-final', storedMessageId: 'usage-final' }, + actions: { tokenUsage: { input: 100, output: 25 } }, + }); + await append({ content: { kind: 'system_note', note: 'step_limit' } }); + } else { + await append({ + role: 'model', + author: 'agent', + content: { kind: 'text', text: '\u3000\u00a0' }, + }); + } + await append({ + status: 'failed', + actions: { endInvocation: true, stateDelta: { failureClass: 'tool_step_cap_reached' } }, + }); + const invocation = await stores.runtimeEventStore.readRunInvocation(session.id, runId); + assert.ok(invocation); + const projection = projectRuntimeEventsToStoredMessages( + await stores.runtimeEventStore.readRuntimeEvents(session.id, runId), + { invocations: [invocation] }, + ); + assert.deepEqual(projection.diagnostics, []); + expected.push(...projection.messages); + } + const read = createSessionTranscriptReader({ + stores, + canonicalPermissionOutcomes: { readPermissionOutcome: async () => undefined }, + }); + // Measure actual JSON decoded, not only the eventual response size. Neither + // a small page, the Turn index, nor a lookup miss may decode the 5 MiB Turn. + let decodedBytes = 0; + const parse = JSON.parse; + const measured = t.mock.method(JSON, 'parse', (...args: Parameters) => { + decodedBytes += Buffer.byteLength(args[0]); + return parse(...args); + }); + const through = await read.readDurableHighWater(session.id); + const tail = await read.readDurablePage(session.id, { + direction: 'older', + maxBytes: 1024, + maxMessages: 1, + }); + assert.equal(JSON.parse(tail.fragments[0]!.data.toString()).type, 'system_note'); + const head = await read.readDurablePage(session.id, { + direction: 'newer', + maxBytes: 1024, + maxMessages: 1, + }); + assert.equal(JSON.parse(head.fragments[0]!.data.toString()).text, 'prompt 0'); assert.deepEqual( - durable.fragments.map((fragment) => JSON.parse(fragment.data.toString('utf8'))), - [{ type: 'system_note', id: 'history-1', ts: 1, kind: 'session_start' }], + await read.readDurableMessagesById(session.id, { + throughSequence: through, + messageIds: ['missing-stream'], + maxBytes: 1024, + maxMessages: 1, + }), + [], ); + const landmarks = await read.readDurableTurnLandmarks(session.id, 3); + assert.deepEqual( + landmarks.landmarks.map((item) => item.label), + ['prompt 0', 'prompt 2', 'prompt 4'], + ); + const contributions: SessionTurnContribution[] = []; + let contributionPosition = 0; + for (;;) { + const page = await read.readDurableTurnContributions( + session.id, + through, + contributionPosition, + 2, + ); + contributions.push(...page.contributions); + if (page.nextPosition === null) break; + contributionPosition = page.nextPosition; + } + assert.ok(decodedBytes < 512 * 1024, `decoded ${decodedBytes} bytes for bounded reads`); + measured.mock.restore(); + + const records: Array<{ sequence: number; message: StoredMessage }> = []; + let position = 0; + for (;;) { + const page = await read.readDurableRecords(session.id, { + direction: 'newer', + throughSequence: through, + position, + maxMessages: 2, + maxStoredBytes: 128 * 1024, + }); + records.push(...page.records); + if (page.nextPosition === null) break; + position = page.nextPosition; + } + assert.deepEqual( + records.map((record) => record.message), + expected, + ); + const folded = new Map(); + for (const record of records) { + if (!('turnId' in record.message) || !record.message.turnId) continue; + const turnId = record.message.turnId; + folded.set( + turnId, + foldTurnContribution(folded.get(turnId), turnId, record.sequence, record.message), + ); + } + 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; + const chunks: Buffer[] = []; + for (;;) { + const page = await read.readDurablePage(session.id, { + direction, + throughSequence: through, + position: assistant.sequence, + ...(byteOffset === undefined ? {} : { byteOffset }), + maxBytes: 37, + maxMessages: 1, + }); + chunks.push(page.fragments[0]!.data); + if (page.next?.position !== assistant.sequence) break; + assert.notEqual(page.next.byteOffset, null); + byteOffset = page.next.byteOffset!; + } + if (direction === 'older') chunks.reverse(); + assert.deepEqual(JSON.parse(Buffer.concat(chunks).toString()), assistant.message); + } + // A later sealed Turn must not alter a previously issued snapshot. + await seedInvocation(stores.runtimeEventStore, { + sessionId: session.id, + runId: 'later', + turnId: 'later', + openedAt: 99, + }); + await stores.runtimeEventStore.appendRuntimeEvent( + session.id, + 'later', + runtimeEvent(session.id, { + id: 'later-terminal', + invocationId: 'later', + runId: 'later', + turnId: 'later', + status: 'completed', + }), + ); + const frozen = await read.readDurablePage(session.id, { + direction: 'older', + throughSequence: through, + maxBytes: 1024, + maxMessages: 1, + }); + assert.deepEqual(frozen.fragments, tail.fragments); } finally { await owner.close(); await rm(base, { recursive: true, force: true }); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 62845e8fb8..e8981fc972 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 = 119 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 120 as const; +// 120: Durable transcript cursors seek Session event ordinals instead of run indexes. // 119: Session Guest principals expose optional display names and an owner-only // rename command. Older peers reject named principal projections. // 118: External-session import publishes distinct `model_unavailable` and diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index b3a1c107e4..38ff686c36 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -172,7 +172,10 @@ import { HostSessionRetirementCoordinator } from './session-retirement-coordinat import { HostSessionRevisionCoordinator } from './session-revision-coordinator.js'; import { HostSessionEffectCoordinator } from './session-effect-coordinator.js'; import { SessionContinuityCoordinator } from './session-continuity-coordinator.js'; -import { createSessionTranscriptReader } from './session-transcript-reader.js'; +import { + createSessionTranscriptReader, + type SessionTranscriptReader, +} from './session-transcript-reader.js'; import { HostSkillCatalogCoordinator } from './skill-catalog-coordinator.js'; import { SkillCatalogRepository } from './skill-catalog-repository.js'; import { HostSessionTodoCoordinator } from './session-todo-coordinator.js'; @@ -269,6 +272,7 @@ export async function createExecutionRuntimeHostComposition( let sessionEffects: HostSessionEffectCoordinator | undefined; let memoryExtraction: HostMemoryExtractionCoordinator | undefined; let unsubscribeTranscriptChanges: (() => void) | undefined; + let transcriptReader: SessionTranscriptReader | undefined; let unsubscribeUsageChanges: (() => void) | undefined; let workspaceExecution: RuntimeHostWorkspaceExecutionComposition | undefined; let goalExecutions: HostGoalExecutionCoordinator | undefined; @@ -608,12 +612,18 @@ export async function createExecutionRuntimeHostComposition( const canonicalPermissionOutcomes = new HostCanonicalPermissionOutcomeReader({ store: stores.interactionStore, }); + transcriptReader = createSessionTranscriptReader({ + stores, + canonicalPermissionOutcomes, + ensureTranscriptLedger: (sessionId) => + requireSessionManager(manager).ensureTranscriptLedgerForRead(sessionId), + }); continuity = new SessionContinuityCoordinator( context.hostEpoch, (sessionId) => canonicalProjectionReader.read(sessionId), sessionAdmission, context.requestDrain, - createSessionTranscriptReader({ stores, canonicalPermissionOutcomes }), + transcriptReader, (sessionId) => hostChanges.publishSessionCatalog(sessionId), context.sessionAccessAuthority, ); @@ -957,6 +967,10 @@ export async function createExecutionRuntimeHostComposition( if (!preview.ok) throw new Error(preview.message); return preview.value; }; + const recapReadModel = new RuntimeReadModel({ + runtimeEventStore: stores.runtimeEventStore, + canonicalPermissionOutcomes, + }); const sessionEffectCoordinator = new HostSessionEffectCoordinator({ model: createHostSessionEffectModel({ runtimePolicy: runtimePolicyStores, @@ -964,11 +978,14 @@ export async function createExecutionRuntimeHostComposition( usage: openedUsageStores, requestDrain: context.requestDrain, }), - readModel: new RuntimeReadModel({ - runtimeEventStore: stores.runtimeEventStore, - projectionCache: stores.sessionStore, - canonicalPermissionOutcomes, - }), + readModel: { + getSessionView: async (sessionId) => { + // A Session whose transcript predates the ledger projects an empty + // view, and a recap of nothing reads as a successful recap. + await requireSessionManager(manager).ensureTranscriptLedgerForRead(sessionId); + return recapReadModel.getSessionView(sessionId); + }, + }, artifacts: openedArtifactStore, sessions: stores.sessionStore, readSessionHeader: (sessionId) => stores.sessionStore.readHeaderSnapshot(sessionId), @@ -1310,6 +1327,7 @@ export async function createExecutionRuntimeHostComposition( goal = new HostGoalCoordinator({ store: openedGoalStore, stores, + readSessionMessages: (sessionId) => requireSessionManager(manager).getMessages(sessionId), executions: coordinator, sessionAdmission, evaluator: createHostGoalEvaluator({ @@ -1342,6 +1360,7 @@ export async function createExecutionRuntimeHostComposition( }); const sessionCatalog = new HostSessionCatalogCoordinator({ stores: stores.sessionStore, + turnIndex: requireTranscriptReader(transcriptReader), runtimePolicy: runtimePolicyStores, manager, admission: sessionAdmission, @@ -2221,6 +2240,13 @@ function requireSessionManager(manager: SessionManager | undefined): SessionMana return manager; } +function requireTranscriptReader( + reader: SessionTranscriptReader | undefined, +): SessionTranscriptReader { + if (!reader) throw new Error('Runtime Host transcript reader is not composed'); + return reader; +} + function requireGraphCoordinator( coordinator: AgentGraphCoordinator | undefined, ): AgentGraphCoordinator { diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index f06cb71872..4eafd729ed 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -343,9 +343,9 @@ async function buildHostAiSdkBackend( permissionMode: input.context.header.permissionMode, }), }, - appendMessage: - input.context.appendMessage ?? - ((message) => input.context.store.appendMessage(input.context.sessionId, message)), + ...(input.context.recordSystemNote + ? { recordSystemNote: input.context.recordSystemNote } + : {}), readExecutionBoundary: () => input.context.store.readExecutionBoundary(input.context.sessionId), ...(input.context.store.createSandboxBoundaryRequest diff --git a/packages/runtime-host/src/server/goal-coordinator.ts b/packages/runtime-host/src/server/goal-coordinator.ts index 7104a83076..f8252a77c3 100644 --- a/packages/runtime-host/src/server/goal-coordinator.ts +++ b/packages/runtime-host/src/server/goal-coordinator.ts @@ -75,6 +75,8 @@ type GoalStores = Pick, 'sessionStore' | 'a export interface HostGoalCoordinatorOptions { readonly store: InteractiveGoalAuthorityWriter; readonly stores: GoalStores; + /** The Session transcript as its ledger projects it; the Goal reads its tail. */ + readonly readSessionMessages: (sessionId: string) => Promise; readonly sessionAdmission: SessionAdmissionGate; readonly evaluator: GoalEvaluatorResource; readonly executions: Pick; @@ -156,7 +158,7 @@ export class HostGoalCoordinator { goalManager: this.manager, evaluator: options.evaluator, getRecentContext: async (sessionId) => { - const messages = await this.#stores.sessionStore.readMessagesSnapshot(sessionId); + const messages = await options.readSessionMessages(sessionId); tokenCache.set(sessionId, tokenCount(messages)); return recentContext(messages); }, diff --git a/packages/runtime-host/src/server/hosted-execution-recovery.ts b/packages/runtime-host/src/server/hosted-execution-recovery.ts index 82133c7e9d..4fec474291 100644 --- a/packages/runtime-host/src/server/hosted-execution-recovery.ts +++ b/packages/runtime-host/src/server/hosted-execution-recovery.ts @@ -21,14 +21,20 @@ import { isDeepStrictEqual } from 'node:util'; import { runtimeInvocationOutcome, type RootExecutionDescriptor, + type RuntimeInvocationRecord, } from '@maka/core/runtime-invocation'; import { messageContentsEqual, normalizeMessageContent, type MessageContent, } from '@maka/core/events'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { StoredMessage } from '@maka/core/session'; -import { RuntimeMessageAuthorityInvariantError } from '@maka/runtime/message-authority'; +import { projectRuntimeEventUserMessage } from '@maka/runtime/runtime-event-read-model'; +import { + admittedPromptEventId, + RuntimeMessageAuthorityInvariantError, +} from '@maka/runtime/message-authority'; import { type SessionManager } from '@maka/runtime/session-manager'; import type { ExecutionStoresWriter, RootTurnAdmission } from '@maka/storage/execution-stores'; import type { RootAdmissionOwner } from './root-admission-owner.js'; @@ -56,32 +62,40 @@ export async function prepareHostedExecutionRecovery( input: PrepareHostedExecutionRecoveryInput, ): Promise { // listHeaders() avoids listForRecovery()'s discarded per-Session message - // pre-read; the per-Session readMessagesForRecovery below remains the single - // decode that validates durable messages before replay. + // pre-read. The messages an admission is checked against are the Session's + // own RuntimeEvents: the ledger is where a Turn's user message is committed, + // so it is also the only place a missing one can be detected. const sessions = await input.stores.sessionStore.listHeaders(); const prepared: PreparedRecoverySession[] = []; for (const session of sessions) { const admissions = await input.rootAdmissions.recoverSession(session.id); - const messages = await input.stores.sessionStore.readMessagesForRecovery(session.id); const runs = await input.stores.runtimeEventStore.listSessionInvocations(session.id); const runsById = new Map(runs.map((run) => [run.runId, run])); for (const run of runs) { await input.stores.agentRunStore.readEventsForRecovery(session.id, run.runId); await input.stores.runtimeEventStore.readRuntimeEvents(session.id, run.runId); } - const messageIndex = indexRecoveryMessages(messages); + const messageIndex = indexRecoveryMessages( + recoveryUserMessagesFromLedger( + await input.stores.runtimeEventStore.readSessionRuntimeEvents(session.id), + ), + ); const replayAdmissions: RootTurnAdmission[] = []; const rootReplayAdmissions: RootTurnAdmission[] = []; - const missingMessages: RecoveryUserMessage[] = []; - const pendingRecoveryClosures: RootTurnAdmission[] = []; + const pendingRecoveryClosures: PendingRecoveryClosure[] = []; for (const admission of admissions) { const run = runsById.get(admission.runId); - const rootUserMessages = ( - messageIndex.userMessagesByTurnId.get(admission.turnId) ?? [] - ).filter((message) => message.id === admission.userMessageId); - const messageIdOwners = admission.userMessageId - ? (messageIndex.messagesById.get(admission.userMessageId) ?? []) - : []; + const admittedMessageId = admittedPromptEventId(admission.runId, admission.userMessageId); + // Whether the prompt is on the ledger is a question about the Turn, not + // about the id it landed under: a Run written by an older build derived + // that id differently, and matching on the id would read its prompt as + // missing and record a second one. + const rootUserMessages = messageIndex.userMessagesByTurnId.get(admission.turnId) ?? []; + const messageIdOwners = messageIndex.messagesById.get(admittedMessageId) ?? []; + if (messageIdOwners.length > 1) { + throw new Error(`Admitted Turn ${admission.turnId} has a duplicated UserMessage identity`); + } + const messageIdOwner = messageIdOwners[0]; const executionContract = recoveryExecutionContract(admission.execution); if ( admission.execution.kind === 'scheduled_task' && @@ -114,12 +128,9 @@ export async function prepareHostedExecutionRecovery( if (admission.sourceMessages.length > 0) { await verifyQueueSourceMessages(admission, messageIndex, input.stores.agentRunStore); } - if (rootUserMessages.length > 0) { - throw new Error(`Admitted Turn ${admission.turnId} must not record a UserMessage`); - } if (!run) { if (executionContract.pendingWithoutRun === 'host_recovery_closure') { - pendingRecoveryClosures.push(admission); + pendingRecoveryClosures.push({ admission }); } else { replayAdmissions.push(admission); if (executionContract.pendingWithoutRun === 'root_replay') { @@ -137,49 +148,33 @@ export async function prepareHostedExecutionRecovery( admission.execution, ); } + if ( + executionContract.requiresUserMessage && + !verifyUserMessage(admission, rootUserMessages, messageIdOwner) + ) { + await recordAdmittedUserMessage(input.stores, admission, run); + } continue; } - if (messageIdOwners.length > 1) { - throw new Error(`Admitted Turn ${admission.turnId} has a duplicated UserMessage identity`); - } - const messageIdOwner = messageIdOwners[0]; if (!run && executionContract.pendingWithoutRun === 'host_recovery_closure') { - verifyOrRecoverUserMessage( - admission, - rootUserMessages, - messageIdOwner, - missingMessages, - messageIndex, - ); - pendingRecoveryClosures.push(admission); - continue; - } - if (!run && executionContract.pendingWithoutRun === 'domain_replay') { - verifyOrRecoverUserMessage( + // The closure below opens this Turn's invocation, so it is also what + // writes the message the crashed admission never got to record. + const recorded = verifyUserMessage(admission, rootUserMessages, messageIdOwner); + pendingRecoveryClosures.push({ admission, - rootUserMessages, - messageIdOwner, - missingMessages, - messageIndex, - ); - replayAdmissions.push(admission); + ...(recorded ? {} : { writesUserMessage: true }), + }); continue; } if (!run) { - if (executionContract.pendingWithoutRun === 'root_replay') { - verifyOrRecoverUserMessage( - admission, - rootUserMessages, - messageIdOwner, - missingMessages, - messageIndex, - false, - ); - } else if (rootUserMessages.length > 0 || messageIdOwner) { - throw new Error(`Admitted Turn ${admission.turnId} has a UserMessage but no Run`); - } + // Every remaining path replays the admission, and a replay opens the + // Turn with the admission's own message id — writing the message here + // would only race the Run that owns it. + verifyUserMessage(admission, rootUserMessages, messageIdOwner); replayAdmissions.push(admission); - rootReplayAdmissions.push(admission); + if (executionContract.pendingWithoutRun !== 'domain_replay') { + rootReplayAdmissions.push(admission); + } continue; } await input.projection.assertRunIdentityAndContinuation( @@ -187,13 +182,9 @@ export async function prepareHostedExecutionRecovery( admission.turnId, admission.execution, ); - verifyOrRecoverUserMessage( - admission, - rootUserMessages, - messageIdOwner, - missingMessages, - messageIndex, - ); + if (!verifyUserMessage(admission, rootUserMessages, messageIdOwner)) { + await recordAdmittedUserMessage(input.stores, admission, run); + } } if (replayAdmissions.length > 1) { throw new Error(`Session ${session.id} has multiple admitted Turns without Runs`); @@ -205,25 +196,31 @@ export async function prepareHostedExecutionRecovery( sessionId: session.id, admissions, ...(rootReplayAdmissions[0] ? { rootReplayAdmission: rootReplayAdmissions[0] } : {}), - missingMessages, pendingRecoveryClosures, }); } for (const plan of prepared) { - for (const message of plan.missingMessages) { - await input.stores.sessionStore.appendMessage(plan.sessionId, message); - } - for (const admission of plan.pendingRecoveryClosures) { + for (const { admission, writesUserMessage } of plan.pendingRecoveryClosures) { if (!usesHostRecoveryClosure(admission.execution)) { throw new Error('Execution domain cannot use Host recovery closure'); } + const origin = hostedExecutionMessageOrigin(admission.execution); await input.runtime.closePendingHostedAdmission({ sessionId: admission.sessionId, turnId: admission.turnId, runId: admission.runId, admittedAt: admission.admittedAt, execution: admission.execution, + ...(writesUserMessage && admission.userMessageId + ? { + userMessage: { + id: admission.userMessageId, + content: requireHostedExecutionMessageContent(admission), + ...(origin ? { origin } : {}), + }, + } + : {}), }); } } @@ -234,6 +231,48 @@ export async function prepareHostedExecutionRecovery( })); } +/** + * The message a crashed Turn was admitted with, written into the Run that had + * already opened for it. + * + * A Run records its own user message right after its opening fact, so a Run + * that exists without one crashed between those two writes. Nothing else will + * write it now: the terminal fact recovery is about to append would seal the + * Turn without ever saying what the user asked for. + * + * The admission's normalized input is what goes in, not its queue sources: a + * Root folded from several Messages ran as one prompt, and that is the prompt + * the Turn was executed with. + */ +async function recordAdmittedUserMessage( + stores: ExecutionStoresWriter<'interactive'>, + admission: RootTurnAdmission, + run: RuntimeInvocationRecord, +): Promise { + if (run.terminalEvent) return; + const content = requireHostedExecutionMessageContent(admission); + const origin = hostedExecutionMessageOrigin(admission.execution); + const event: RuntimeEvent = { + id: admittedPromptEventId(admission.runId, admission.userMessageId), + sessionId: admission.sessionId, + invocationId: run.invocationId, + runId: run.runId, + turnId: admission.turnId, + ts: admission.admittedAt, + partial: false, + role: 'user', + author: origin ? 'host' : 'user', + content: { kind: 'text', ...content, ...(origin ? { origin } : {}) }, + }; + await stores.runtimeEventStore.appendRuntimeEvent(admission.sessionId, run.runId, event); + // The Turn never reached the commit that carries these, and no later path + // recomputes them: the connection lock is one-way and the preview is a write. + const message = projectRuntimeEventUserMessage(event, event.id); + if (message) { + await stores.sessionStore.commitMessageCatalogProjection(admission.sessionId, message); + } +} + export function requireHostedExecutionMessageContent(admission: RootTurnAdmission): MessageContent { if (admission.normalizedInput === null) { throw new RuntimeMessageAuthorityInvariantError( @@ -270,8 +309,13 @@ export function hostedExecutionMessageOrigin(execution: RootExecutionDescriptor) } interface PreparedRecoverySession extends HostedExecutionRecoveryPlan { - readonly missingMessages: readonly RecoveryUserMessage[]; - readonly pendingRecoveryClosures: readonly RootTurnAdmission[]; + readonly pendingRecoveryClosures: readonly PendingRecoveryClosure[]; +} + +interface PendingRecoveryClosure { + readonly admission: RootTurnAdmission; + /** The ledger has no message for this admission; the closure records it. */ + readonly writesUserMessage?: true; } type RecoveryUserMessage = Extract; @@ -287,22 +331,22 @@ interface RecoveryExecutionContract { readonly pendingWithoutRun: 'root_replay' | 'domain_replay' | 'host_recovery_closure'; } -function verifyOrRecoverUserMessage( +/** + * Whether the ledger already carries this admission's message, throwing when + * what it carries contradicts the admission. + */ +function verifyUserMessage( admission: RootTurnAdmission, rootUserMessages: readonly RecoveryUserMessage[], messageIdOwner: StoredMessage | undefined, - missingMessages: RecoveryUserMessage[], - index: RecoveryMessageIndex, - materializeMissing = true, -): void { +): boolean { if (rootUserMessages.length > 1) { throw new Error(`Admitted Turn ${admission.turnId} has multiple UserMessages`); } const userMessage = rootUserMessages[0]; if (userMessage) { if ( - messageIdOwner !== userMessage || - userMessage.id !== admission.userMessageId || + (messageIdOwner !== undefined && messageIdOwner !== userMessage) || !recoveryUserMessageOriginMatches(userMessage, admission.execution) || !messageContentsEqual( normalizeMessageContent(userMessage), @@ -311,15 +355,35 @@ function verifyOrRecoverUserMessage( ) { throw new Error(`Admitted Turn ${admission.turnId} does not match its UserMessage`); } - return; + return true; } if (messageIdOwner) { throw new Error(`Admitted Turn ${admission.turnId} reuses another message identity`); } - if (!materializeMissing) return; - const recoveredMessage = recoveryUserMessage(admission); - missingMessages.push(recoveredMessage); - indexRecoveryMessage(index, recoveredMessage); + return false; +} + +/** + * The prompts a Session's ledger holds, as the transcript presents them. + * + * Recovery reads the raw events rather than the read model: a Session it is + * about to repair may be exactly the one whose projection is still incomplete. + * Steering is excluded — it is typed as a user message but is something said + * into a Turn that was already admitted, so it is never the Turn's own prompt. + */ +function recoveryUserMessagesFromLedger( + events: readonly RuntimeEvent[], +): readonly RecoveryUserMessage[] { + const messages: RecoveryUserMessage[] = []; + for (const event of events) { + if (event.role !== 'user' || event.content?.kind !== 'text' || event.partial) continue; + const projected: RecoveryUserMessage | undefined = projectRuntimeEventUserMessage( + event, + event.id, + ); + if (projected && projected.steeringEventId === undefined) messages.push(projected); + } + return messages; } async function verifyQueueSourceMessages( @@ -364,21 +428,6 @@ async function verifyQueueSourceMessages( } } -function recoveryUserMessage(admission: RootTurnAdmission): RecoveryUserMessage { - if (!admission.userMessageId || !admission.normalizedInput) { - throw new Error(`Admitted Turn ${admission.turnId} does not own a UserMessage`); - } - const origin = hostedExecutionMessageOrigin(admission.execution); - return { - type: 'user', - id: admission.userMessageId, - turnId: admission.turnId, - ts: admission.admittedAt, - ...normalizeMessageContent(admission.normalizedInput), - ...(origin ? { origin } : {}), - }; -} - function recoveryUserMessageOriginMatches( message: RecoveryUserMessage, execution: RootExecutionDescriptor, diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index 0c08a2f029..138b6e2d4e 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -39,18 +39,19 @@ import { isWorkHubCoordinationSessionTarget, type SessionHeader, type SessionHeaderPatch, + type StoredMessage, } from '@maka/core/session'; import { isSessionNotFoundError, SessionMetadataConflictError, SessionMetadataVersionConflictError, - SessionReadMarkerMessageNotFoundError, type SessionCatalogPageCursor, type SessionCatalogRecord, type SessionHeaderSnapshot, type ExecutionStoresWriter, } from '@maka/storage/execution-stores'; import type { CreateStableSessionRequest } from '@maka/storage/session-store'; +import { isVisibleSessionMessage } from '@maka/storage/session-message-projection'; import type { RuntimePolicyStoresWriter } from '@maka/storage/runtime-policy-stores'; import { SessionConfigurationRevisionConflictError, @@ -94,22 +95,30 @@ import type { SessionCatalogOperationHandlerMap } from './operation-dispatcher.j import type { RuntimeHostAccessAuthority } from './access-authority.js'; import { type SessionAdmissionLease, SessionAdmissionGate } from './session-admission-gate.js'; import type { SessionContinuityCoordinator } from './session-continuity-coordinator.js'; +import type { SessionTranscriptReader } from './session-transcript-reader.js'; import { type HostWorkspaceResolver, WorkspaceResolutionError } from './workspace-resolver.js'; type SessionCatalogStores = Pick< ExecutionStoresWriter<'interactive'>['sessionStore'], | 'createStableSession' | 'listCatalogPage' - | 'markSessionReadThroughMessage' | 'probeStableSessionCreate' | 'readCatalogRecord' | 'readExecutionBoundary' | 'readHeaderRecordSnapshot' - | 'readTurnContributionsSnapshot' - | 'readTurnLandmarksSnapshot' | 'updateHeaderVersioned' >; +/** The Turn index a Session catalog page is built from, read off the ledger. */ +type SessionTurnIndexReader = Pick< + SessionTranscriptReader, + 'readDurableRecords' | 'readDurableTurnContributions' | 'readDurableTurnLandmarks' +>; + +/** One page of the backwards scan a read marker walks to find the newest visible message. */ +const SESSION_READ_MARKER_TAIL_MAX_MESSAGES = 64; +const SESSION_READ_MARKER_TAIL_MAX_BYTES = 256 * 1024; + type SessionRuntimePolicyStores = { readonly connectionCatalog: Pick; readonly runtimePolicy: Pick; @@ -166,6 +175,7 @@ export class NoUsableImportModelError extends SessionOperationFailure { export interface HostSessionCatalogCoordinatorOptions { readonly stores: SessionCatalogStores; + readonly turnIndex: SessionTurnIndexReader; readonly runtimePolicy: SessionRuntimePolicyStores; readonly manager: SessionConfigurationAuthority; readonly admission: SessionAdmissionGate; @@ -276,6 +286,7 @@ export class HostSessionCatalogCoordinator { }; readonly #stores: SessionCatalogStores; + readonly #turnIndex: SessionTurnIndexReader; readonly #runtimePolicy: SessionRuntimePolicyStores; readonly #manager: SessionConfigurationAuthority; readonly #admission: SessionAdmissionGate; @@ -288,6 +299,7 @@ export class HostSessionCatalogCoordinator { constructor(options: HostSessionCatalogCoordinatorOptions) { this.#stores = options.stores; + this.#turnIndex = options.turnIndex; this.#runtimePolicy = options.runtimePolicy; this.#manager = options.manager; this.#admission = options.admission; @@ -487,7 +499,7 @@ export class HostSessionCatalogCoordinator { let maxContributions = input.maxContributions; let throughSequence = input.throughSequence; while (true) { - const page = await this.#stores.readTurnContributionsSnapshot( + const page = await this.#turnIndex.readDurableTurnContributions( input.sessionId, throughSequence, input.position, @@ -527,7 +539,7 @@ export class HostSessionCatalogCoordinator { input: SessionTurnLandmarksQueryInput, ): Promise> { try { - const snapshot = await this.#stores.readTurnLandmarksSnapshot( + const snapshot = await this.#turnIndex.readDurableTurnLandmarks( input.sessionId, input.maxLandmarks, ); @@ -819,10 +831,7 @@ export class HostSessionCatalogCoordinator { 'WorkHub Coordination Session read state requires WorkHub authority', ); } - await this.#stores.markSessionReadThroughMessage( - input.sessionId, - input.readThroughMessageId, - ); + await this.#clearUnreadAtTranscriptTail(current, input.readThroughMessageId); await this.#continuity.refreshCanonical(input.sessionId, lease); return { ok: true, @@ -832,9 +841,6 @@ export class HostSessionCatalogCoordinator { }; } catch (error) { if (isNotFound(error)) return readMarkerFailure('not_found', 'Session does not exist'); - if (error instanceof SessionReadMarkerMessageNotFoundError) { - return readMarkerFailure('invalid_request', error.message); - } if (error instanceof SessionMetadataVersionConflictError) { return readMarkerFailure( 'operation_conflict', @@ -850,6 +856,52 @@ export class HostSessionCatalogCoordinator { }); } + /** + * A Session is read once the client has caught up with the ledger's newest + * visible message. `hasUnread` is the only thing the marker decides and every + * Turn raises it again, so a client still behind the tail changes nothing. + */ + async #clearUnreadAtTranscriptTail( + record: SessionHeaderSnapshot, + readThroughMessageId: string, + ): Promise { + const latest = await this.#newestVisibleMessage(record.header.id); + if (latest?.id !== readThroughMessageId) return; + if (record.header.lastReadMessageId === readThroughMessageId && !record.header.hasUnread) { + return; + } + await this.#stores.updateHeaderVersioned( + record.header.id, + { lastReadMessageId: readThroughMessageId, hasUnread: false }, + record.revision, + ); + } + + /** + * The ledger's newest message a client can actually see. A Turn that ends on + * tool traffic can put more hidden records at the tail than one page holds, + * so the scan pages past them instead of reading the Session as never caught + * up and leaving it unread for good. + */ + async #newestVisibleMessage(sessionId: string): Promise { + let throughSequence: number | null | undefined; + let position: number | undefined; + while (true) { + const page = await this.#turnIndex.readDurableRecords(sessionId, { + direction: 'older', + maxMessages: SESSION_READ_MARKER_TAIL_MAX_MESSAGES, + maxStoredBytes: SESSION_READ_MARKER_TAIL_MAX_BYTES, + ...(throughSequence === undefined ? {} : { throughSequence }), + ...(position === undefined ? {} : { position }), + }); + const visible = page.records.find(({ message }) => isVisibleSessionMessage(message)); + if (visible) return visible.message; + if (page.nextPosition === null) return undefined; + throughSequence = page.throughSequence; + position = page.nextPosition; + } + } + async #committedUpdate( sessionId: string, lease: SessionAdmissionLease, diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index 3972b9baf8..4b517a25c2 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -608,13 +608,8 @@ export class HostSessionRevisionCoordinator { copyCurrent: kind === 'branch' && slice.beforeTs === undefined && input.sourceTurnId !== undefined, }); - if (copiedMessages.length > 0) { - await this.#stores.sessionStore.appendMessages(input.targetSessionId, [...copiedMessages]); - } - await this.#stores.sessionStore.appendMessage( - input.targetSessionId, - conversationCopyStartNote(kind, input, createInput), - ); + // `cloneConversationRuntimeLedger` already wrote the copy's own spine, and + // the copy reads back off that: nothing here writes a second transcript. await this.#stores.sessionStore.updateHeader(input.targetSessionId, { conversationCopy: { ...createInput.conversationCopy!, @@ -890,25 +885,15 @@ export class HostSessionRevisionCoordinator { ); } + /** + * A revision copy that admitted a turn of its own. The admission ledger is + * the whole answer: a copy clones the source's history but never its + * admissions, so every row it holds was admitted on this session. + */ async #hasAdmittedRevisionTurn(sessionId: string): Promise { - if ( + return ( (await this.#stores.agentRunStore.listRootTurnAdmissionsForRecovery(sessionId)).length > 0 - ) { - return true; - } - const messages = await this.#stores.sessionStore.readMessagesForRecovery(sessionId); - let boundary = -1; - for (let index = 0; index < messages.length; index += 1) { - const message = messages[index]!; - if ( - message.type === 'system_note' && - message.kind === 'session_start' && - isRevisionStartData(message.data) - ) { - boundary = index; - } - } - return boundary >= 0 && messages.slice(boundary + 1).some((message) => message.type === 'user'); + ); } async #hasCommittedConversationCopyDependent(sessionId: string): Promise { @@ -948,42 +933,6 @@ function conversationCopyFingerprint( return `sha256:${createHash('sha256').update(JSON.stringify(identity)).digest('hex')}`; } -function conversationCopyStartNote( - kind: ConversationCopySemanticKind, - input: SessionConversationCopyInput, - createInput: ConversationCopyCreateInput, -): StoredMessage { - const base = { - type: 'system_note' as const, - id: randomUUID(), - ts: Date.now(), - kind: 'session_start' as const, - }; - if (kind !== 'revision') { - // Empty copies record provenance without a branch turn. - return { - ...base, - data: { - parentSessionId: input.sourceSessionId, - ...(input.sourceTurnId === undefined ? {} : { branchOfTurnId: input.sourceTurnId }), - }, - }; - } - if (input.sourceTurnId === undefined) { - throw new Error('Session revision copy requires a turn boundary'); - } - return { - ...base, - data: { - revisionRootSessionId: createInput.revisionRootSessionId, - revisionParentSessionId: input.sourceSessionId, - revisionOfTurnId: input.sourceTurnId, - revisionIndex: createInput.revisionIndex, - revisionState: 'preparing', - }, - }; -} - function conversationCopySemanticKind( kind: ConversationCopyKind, input: SessionConversationCopyInput, @@ -995,15 +944,6 @@ function persistedConversationCopyKind(kind: ConversationCopySemanticKind): Conv return kind === 'revision' ? 'revision' : 'branch'; } -function isRevisionStartData(value: unknown): boolean { - return ( - !!value && - typeof value === 'object' && - !Array.isArray(value) && - 'revisionRootSessionId' in value - ); -} - function collectArchivedToolResultPlaceholders( events: readonly RuntimeEvent[], messages: readonly StoredMessage[], diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts index cc51d9bd4a..87ea53765b 100644 --- a/packages/runtime-host/src/server/session-transcript-reader.ts +++ b/packages/runtime-host/src/server/session-transcript-reader.ts @@ -18,11 +18,14 @@ */ import type { RuntimeEvent } from '@maka/core/runtime-event'; +import { readRunInvocation } from '@maka/core/runtime-event-store'; import type { StoredMessage } from '@maka/core/session'; import { + activePresentationRuntimeEvents, affectsRuntimeEventStoredMessageProjection, isHardRuntimeEventReadModelDiagnostic, projectRuntimeEventsToStoredMessages, + projectRuntimeEventUserMessage, } from '@maka/runtime/runtime-event-read-model'; import { type CanonicalPermissionOutcomeReader, @@ -34,11 +37,20 @@ import type { SessionTranscriptPageRequest, SessionTranscriptRecordScanPage, SessionTranscriptRecordScanRequest, + SessionTranscriptStorageFragment, SessionTranscriptStoragePage, + SessionTurnContribution, + SessionTurnContributionPage, + SessionTurnLandmark, + SessionTurnLandmarkSnapshot, + RuntimeTranscriptSource, } 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 ACTIVE_TRANSCRIPT_SOURCE_MAX_EVENTS = ACTIVE_TRANSCRIPT_OVERLAY_MAX_MESSAGES * 2; @@ -47,29 +59,67 @@ const ACTIVE_TRANSCRIPT_SCAN_BATCH_MAX_BYTES = 256 * 1024; export function createSessionTranscriptReader(input: { stores: ExecutionStoresWriter<'interactive'>; canonicalPermissionOutcomes: CanonicalPermissionOutcomeReader; + /** + * Converts a Session whose transcript predates the ledger, before this reader + * looks for invocations that only the conversion can create. Omitted only by + * tests that seed the ledger themselves. + */ + ensureTranscriptLedger?: (sessionId: string) => Promise; }): SessionTranscriptReader { + const durable = createDurableLedgerTranscriptReader(input); + const prepared = async (sessionId: string): Promise => { + await input.ensureTranscriptLedger?.(sessionId); + }; return { - readDurableHighWater: (sessionId) => - input.stores.sessionStore.readTranscriptHighWaterSnapshot(sessionId), - readDurablePage: (sessionId, request) => - input.stores.sessionStore.readTranscriptPageSnapshot(sessionId, request), - readDurableRecords: (sessionId, request) => - input.stores.sessionStore.readTranscriptRecordsSnapshot(sessionId, request), - readDurableMessagesById: (sessionId, request) => - input.stores.sessionStore.readTranscriptMessagesSnapshot(sessionId, request), + readDurableHighWater: async (sessionId) => { + await prepared(sessionId); + return durable.readHighWater(sessionId); + }, + readDurablePage: async (sessionId, request) => { + await prepared(sessionId); + return durable.readPage(sessionId, request); + }, + readDurableRecords: async (sessionId, request) => { + await prepared(sessionId); + return durable.readRecords(sessionId, request); + }, + readDurableMessagesById: async (sessionId, request) => { + await prepared(sessionId); + return durable.readMessagesById(sessionId, request); + }, + readDurableTurnContributions: async ( + sessionId, + throughSequence, + position, + maxContributions, + ) => { + await prepared(sessionId); + return durable.readTurnContributions(sessionId, throughSequence, position, maxContributions); + }, + readDurableTurnLandmarks: async (sessionId, maxLandmarks) => { + await prepared(sessionId); + return durable.readTurnLandmarks(sessionId, maxLandmarks); + }, readActiveOverlay: async (sessionId, rootTurn) => { if (!rootTurn || isTerminalTurn(rootTurn)) return []; - const invocations = await input.stores.runtimeEventStore.listSessionInvocations(sessionId); + const invocation = await readRunInvocation( + input.stores.runtimeEventStore, + sessionId, + rootTurn.runId, + ); const events = await readActiveProjectionEvents(input.stores, sessionId, rootTurn.runId); const canonicalPermissionOutcomes = await readCanonicalPermissionOutcomes( events, input.canonicalPermissionOutcomes, ); - const projected = projectRuntimeEventsToStoredMessages(activePresentationEvents(events), { - invocations: invocations.filter((invocation) => invocation.runId === rootTurn.runId), - canonicalPermissionOutcomes, - }); + const projected = projectRuntimeEventsToStoredMessages( + activePresentationRuntimeEvents(events), + { + invocations: invocation ? [invocation] : [], + canonicalPermissionOutcomes, + }, + ); if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) { throw new Error('Active RuntimeEvent transcript projection is incomplete'); } @@ -93,12 +143,316 @@ export interface SessionTranscriptReader { sessionId: string, request: SessionTranscriptMessageLookupRequest, ): Promise; + readDurableTurnContributions( + sessionId: string, + throughSequence: number | null, + position: number, + maxContributions: number, + ): Promise; + readDurableTurnLandmarks( + sessionId: string, + maxLandmarks: number, + ): Promise; readActiveOverlay( sessionId: string, rootTurn: TurnSnapshot | null, ): Promise; } +/** + * Pages seek immutable Session event ordinals before decoding payloads. Each + * event is projected with just its indexed message context, so neither a long + * Session nor a long Turn has to be loaded to serve a page. The low sequence + * bits distinguish the few rows one event can emit. + */ +function createDurableLedgerTranscriptReader(input: { + stores: ExecutionStoresWriter<'interactive'>; + canonicalPermissionOutcomes: CanonicalPermissionOutcomeReader; +}) { + const store = input.stores.runtimeEventStore; + const highWater = async (sessionId: string): Promise => { + const ordinal = await store.readTranscriptSourceHighWater(sessionId); + return ordinal === null ? null : ordinal * EVENT_SEQUENCE_STRIDE + EVENT_SEQUENCE_STRIDE - 1; + }; + const projectSource = async ( + source: RuntimeTranscriptSource, + ): Promise => { + const event = source.event; + const projected = projectRuntimeEventsToStoredMessages(source.events, { + invocations: [source.invocation], + canonicalPermissionOutcomes: await readCanonicalPermissionOutcomes( + source.events, + input.canonicalPermissionOutcomes, + ), + context: { + messageId: event.refs?.storedMessageId ?? event.refs?.providerEventId ?? event.id, + ...(source.contentOrder ? { contentOrder: source.contentOrder } : {}), + ...(source.permissionRequest ? { permissionRequest: source.permissionRequest } : {}), + ...(source.toolName ? { toolName: source.toolName } : {}), + ...(event.refs?.toolCallId ? { toolUseId: event.refs.toolCallId } : {}), + hasRetainedOutput: source.hasRetainedOutput, + }, + }); + if (projected.diagnostics.some(isHardRuntimeEventReadModelDiagnostic)) { + throw new Error('Durable RuntimeEvent transcript projection is incomplete'); + } + if (projected.messages.length > EVENT_SEQUENCE_STRIDE) { + throw new Error('RuntimeEvent exceeds its transcript sequence stride'); + } + return projected.messages; + }; + + const scan = async function* ( + sessionId: string, + request: { + direction: 'older' | 'newer'; + throughSequence?: number | null; + position?: number; + }, + ): AsyncGenerator<{ sequence: number; message: StoredMessage }> { + const throughSequence = + request.throughSequence === undefined ? await highWater(sessionId) : request.throughSequence; + if (throughSequence === null) return; + const position = request.position ?? (request.direction === 'older' ? throughSequence : 0); + let ordinal = ordinalOf(position); + while (ordinal >= 0 && ordinal <= ordinalOf(throughSequence)) { + const source = await store.readTranscriptSource(sessionId, { + direction: request.direction, + throughOrdinal: ordinalOf(throughSequence), + position: ordinal, + }); + if (!source) return; + const messages = await projectSource(source); + const records = messages + .map((message, index) => ({ + sequence: source.ordinal * EVENT_SEQUENCE_STRIDE + index, + message, + })) + .filter( + ({ sequence }) => + sequence <= throughSequence && + (request.direction === 'older' ? sequence <= position : sequence >= position), + ); + if (request.direction === 'older') records.reverse(); + yield* records; + ordinal = source.ordinal + (request.direction === 'older' ? -1 : 1); + } + }; + + return { + readHighWater: highWater, + + async readPage( + sessionId: string, + request: SessionTranscriptPageRequest, + ): Promise { + const throughSequence = + request.throughSequence === undefined + ? await this.readHighWater(sessionId) + : request.throughSequence; + if (throughSequence === null) { + return { throughSequence: null, fragments: [], rawBytes: 0, next: null }; + } + const fragments: SessionTranscriptStorageFragment[] = []; + let rawBytes = 0; + let next: SessionTranscriptStoragePage['next'] = null; + let truncated = false; + for await (const record of scan(sessionId, { ...request, throughSequence })) { + if (fragments.length >= request.maxMessages || rawBytes >= request.maxBytes) { + truncated = true; + next = { position: record.sequence, byteOffset: null }; + break; + } + const data = Buffer.from(JSON.stringify(record.message), 'utf8'); + // A message larger than the remaining budget is served in byte slices, + // from the edge the traversal is moving away from, so the next page + // resumes inside the same record instead of skipping it. + const continued = record.sequence === request.position && request.byteOffset !== undefined; + const edge = continued + ? request.byteOffset! + : request.direction === 'older' + ? data.byteLength + : 0; + const available = request.maxBytes - rawBytes; + const byteOffset = request.direction === 'older' ? Math.max(0, edge - available) : edge; + const end = + request.direction === 'older' ? edge : Math.min(data.byteLength, edge + available); + fragments.push({ + sequence: record.sequence, + byteOffset, + totalBytes: data.byteLength, + payloadDigest: null, + data: data.subarray(byteOffset, end), + }); + rawBytes += end - byteOffset; + const complete = request.direction === 'older' ? byteOffset === 0 : end === data.byteLength; + if (!complete) { + truncated = true; + next = { + position: record.sequence, + byteOffset: request.direction === 'older' ? byteOffset : end, + }; + break; + } + } + if (!truncated) next = null; + return { throughSequence, fragments, rawBytes, next }; + }, + + async readRecords( + sessionId: string, + request: SessionTranscriptRecordScanRequest, + ): Promise { + const throughSequence = + request.throughSequence === undefined + ? await this.readHighWater(sessionId) + : request.throughSequence; + if (throughSequence === null) { + return { throughSequence: null, records: [], nextPosition: null }; + } + const records: Array<{ sequence: number; message: StoredMessage }> = []; + let storedBytes = 0; + let nextPosition: number | null = null; + for await (const record of scan(sessionId, { ...request, throughSequence })) { + if (records.length >= request.maxMessages || storedBytes >= request.maxStoredBytes) { + nextPosition = record.sequence; + break; + } + records.push(record); + storedBytes += Buffer.byteLength(JSON.stringify(record.message), 'utf8'); + } + return { throughSequence, records, nextPosition }; + }, + + /** Fold indexed Turn facts, loading only the prompt and terminal payloads. */ + async readTurnContributions( + sessionId: string, + throughSequence: number | null, + position: number, + maxContributions: number, + ): Promise { + const watermark = throughSequence ?? (await highWater(sessionId)); + if (watermark === null) { + return { throughSequence: null, contributions: [], nextPosition: null }; + } + const turns = await store.readTranscriptTurns( + sessionId, + ordinalOf(watermark), + ordinalOf(position), + maxContributions + 1, + ); + const contributions: SessionTurnContribution[] = []; + for (const turn of turns.slice(0, maxContributions)) { + let contribution: SessionTurnContribution = { + turnId: turn.invocation.turnId, + firstSequence: Math.max(position, turn.firstOrdinal * EVENT_SEQUENCE_STRIDE), + latestState: null, + userPromptPreview: null, + hasAssistantMessage: turn.hasAssistantMessage, + hasAssistantOutput: turn.hasAssistantOutput, + hasToolResult: turn.hasToolResult, + hasFailedToolResult: turn.hasFailedToolResult, + hasAbortNote: turn.hasAbortNote, + }; + if (turn.user) { + const user = projectRuntimeEventUserMessage(turn.user.event, turn.user.event.id); + if (user) + contribution = foldTurnContribution( + contribution, + turn.invocation.turnId, + turn.user.ordinal * EVENT_SEQUENCE_STRIDE, + user, + ); + } + const source = await store.readTranscriptSource(sessionId, { + direction: 'newer', + throughOrdinal: ordinalOf(watermark), + position: turn.terminalOrdinal, + }); + if (source?.ordinal === turn.terminalOrdinal) { + const messages = await projectSource(source); + for (const [index, message] of messages.entries()) { + const sequence = source.ordinal * EVENT_SEQUENCE_STRIDE + index; + if (sequence < position || sequence > watermark) continue; + contribution = foldTurnContribution( + contribution, + turn.invocation.turnId, + sequence, + message, + ); + } + } + contributions.push(contribution); + } + const next = turns[maxContributions]; + return { + throughSequence: watermark, + contributions, + nextPosition: next ? next.firstOrdinal * EVENT_SEQUENCE_STRIDE : null, + }; + }, + + /** Evenly spaced Turn starts, selected in SQL before loading their prompts. */ + async readTurnLandmarks( + sessionId: string, + maxLandmarks: number, + ): Promise { + const throughSequence = await highWater(sessionId); + if (throughSequence === null) return { throughSequence: null, landmarks: [] }; + const turns = await store.readTranscriptLandmarks( + sessionId, + ordinalOf(throughSequence), + maxLandmarks, + ); + const landmarks: SessionTurnLandmark[] = []; + for (const turn of turns) { + if (!turn.user) continue; + const message = projectRuntimeEventUserMessage(turn.user.event, turn.user.event.id); + const label = (message?.displayText ?? message?.text ?? '').trim(); + if (!label) continue; + landmarks.push({ + turnId: turn.invocation.turnId, + sequence: turn.user.ordinal * EVENT_SEQUENCE_STRIDE, + label, + }); + } + return { throughSequence, landmarks }; + }, + + async readMessagesById( + sessionId: string, + request: SessionTranscriptMessageLookupRequest, + ): Promise { + if (request.throughSequence === null || request.messageIds.length === 0) return []; + const found: Array<{ sequence: number; message: StoredMessage }> = []; + let bytes = 0; + for (const messageId of new Set(request.messageIds)) { + const source = await store.readTranscriptSource(sessionId, { + direction: 'older', + throughOrdinal: ordinalOf(request.throughSequence), + position: ordinalOf(request.throughSequence), + messageId, + }); + if (!source) continue; + for (const [index, message] of (await projectSource(source)).entries()) { + const sequence = source.ordinal * EVENT_SEQUENCE_STRIDE + index; + if (message.id !== messageId || sequence > request.throughSequence) continue; + bytes += Buffer.byteLength(JSON.stringify(message), 'utf8'); + if (found.length >= request.maxMessages || bytes > request.maxBytes) { + return found.sort((a, b) => a.sequence - b.sequence).map((record) => record.message); + } + found.push({ sequence, message }); + } + } + return found.sort((a, b) => a.sequence - b.sequence).map((record) => record.message); + }, + }; +} + +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'); @@ -144,37 +498,6 @@ async function readCanonicalPermissionOutcomes( return outcomes; } -function activePresentationEvents(events: readonly RuntimeEvent[]): RuntimeEvent[] { - const textMessages = new Set(); - const lastThinkingByMessage = new Map(); - - for (const event of events) { - const content = event.content; - if (event.role !== 'model' || (content?.kind !== 'text' && content?.kind !== 'thinking')) { - continue; - } - const messageKey = activeMessageKey(event); - if (content.kind === 'text') textMessages.add(messageKey); - else lastThinkingByMessage.set(messageKey, event); - } - - const syntheticAfter = new Map(); - for (const [messageKey, thinking] of lastThinkingByMessage) { - if (textMessages.has(messageKey)) continue; - const existing = syntheticAfter.get(thinking) ?? []; - existing.push(emptyAssistantText(thinking)); - syntheticAfter.set(thinking, existing); - } - - const presented: RuntimeEvent[] = []; - for (const event of events) { - presented.push(presentationEvent(event)); - const synthetic = syntheticAfter.get(event); - if (synthetic) presented.push(...synthetic); - } - return presented; -} - async function readActiveProjectionEvents( stores: ExecutionStoresWriter<'interactive'>, sessionId: string, @@ -215,29 +538,6 @@ async function readActiveProjectionEvents( return events; } -function activeMessageKey(event: RuntimeEvent): string { - const messageId = event.refs?.providerEventId ?? event.refs?.storedMessageId ?? event.id; - return `${event.runId}\0${messageId}`; -} - -function presentationEvent(event: RuntimeEvent): RuntimeEvent { - const content = event.content; - return event.partial && - event.role === 'model' && - (content?.kind === 'text' || content?.kind === 'thinking') - ? { ...event, partial: false } - : event; -} - -function emptyAssistantText(thinking: RuntimeEvent): RuntimeEvent { - return { - ...thinking, - id: `${thinking.id}:active-transcript-empty-text`, - partial: false, - content: { kind: 'text', text: '' }, - }; -} - function isTerminalTurn(turn: TurnSnapshot): boolean { return turn.status === 'completed' || turn.status === 'failed' || turn.status === 'cancelled'; } diff --git a/packages/runtime/src/__tests__/admission-limiter.test.ts b/packages/runtime/src/__tests__/admission-limiter.test.ts index 25380bba18..25d2372ec3 100644 --- a/packages/runtime/src/__tests__/admission-limiter.test.ts +++ b/packages/runtime/src/__tests__/admission-limiter.test.ts @@ -318,7 +318,6 @@ function buildRuntime( header: testHeader(), connection: testConnection(), modelId: 'mock-model', - appendMessage: async () => {}, newId: nextId(), now: () => 1, getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts b/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts index 0c1d48965b..7d88a24454 100644 --- a/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts +++ b/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts @@ -29,7 +29,6 @@ import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { createSessionStore } from '@maka/storage/session-store'; import { AgentRun } from '../agent-run.js'; -import { RuntimeLedgerRepair } from '../runtime-ledger-repair.js'; import { buildStatusPatch } from '../session-projection-helpers.js'; import { waitFor as pollFor } from '@maka/core/test-only/async-primitives'; import { seedInvocation } from './invocation-fixture.js'; @@ -55,7 +54,6 @@ test('rejects an invalid tool mode before a durable AgentRun can be created', as userInput: { turnId: 'turn-invalid-mode', text: 'invalid', toolMode: 'typo' as never }, runStore, runtimeEventStore, - store, newId: () => 'unused', now: () => 1, hooks: { @@ -65,7 +63,6 @@ test('rejects an invalid tool mode before a durable AgentRun can be created', as unregisterRun: () => {}, updateHeader: async () => session, updateStatus: async () => {}, - appendTurnState: async () => {}, }, }), /invalid tool mode/i, @@ -94,7 +91,6 @@ test('does not re-append atomically committed tool facts through the generic eve header: session, userInput: { turnId, text: 'run a durable tool' }, runId, - store, runtimeEventStore, toolBoundaryProtocol: 't1_after_preflight_v1', newId: () => 'unused-id', @@ -106,7 +102,6 @@ test('does not re-append atomically committed tool facts through the generic eve unregisterRun: () => {}, updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }, }); const sessionEvent: SessionEvent = { @@ -168,7 +163,6 @@ test('acks a steering event whose canonical append preceded proof publication fa header: session, userInput: { turnId, text: 'start' }, runId, - store, runStore, runtimeEventStore, newId: () => 'unused-id', @@ -180,7 +174,6 @@ test('acks a steering event whose canonical append preceded proof publication fa unregisterRun: () => {}, updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }, }); const sessionEvent: SessionEvent = { @@ -224,177 +217,6 @@ test('acks a steering event whose canonical append preceded proof publication fa } }); -test('materializes a durable steering event into the transcript exactly once', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-agent-run-steering-transcript-')); - try { - const store = createSessionStore(root); - const session = await store.create({ - cwd: '/tmp/cwd', - llmConnectionSlug: 'fake', - model: 'fake-model', - permissionMode: 'ask', - }); - const runtimeEventStore = createWorkspaceRuntimeStore(root); - const turnId = 'turn-steering-transcript'; - const sessionEvent: SessionEvent = { - type: 'steering_message', - id: 'runtime-steering-transcript', - turnId, - ts: 2, - messageId: 'message-steering-transcript', - content: { text: 'persist this interjection' }, - }; - const run = new AgentRun({ - sessionId: session.id, - header: session, - userInput: { turnId, text: 'start' }, - runId: 'run-steering-transcript', - store, - runtimeEventStore, - newId: () => 'unused-id', - now: () => 10, - hooks: { - reserveRun: async () => { - throw new Error('reserveRun should not be called'); - }, - unregisterRun: () => {}, - updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), - updateStatus: async () => {}, - appendTurnState: async () => {}, - }, - }); - const runtimeEvent: RuntimeEvent = { - id: sessionEvent.id, - invocationId: run.invocationId, - runId: 'run-steering-transcript', - sessionId: session.id, - turnId, - ts: sessionEvent.ts, - partial: false, - role: 'user', - author: 'user', - content: { - kind: 'text', - text: sessionEvent.content.text, - displayText: '/skill:writer persist this interjection', - inlineReferences: [{ kind: 'skill', value: '/skill:writer', label: 'Writer', start: 0 }], - steering: true, - }, - refs: { providerEventId: sessionEvent.messageId }, - }; - - await run.acceptMappedEvent(sessionEvent, runtimeEvent); - await run.acceptMappedEvent(sessionEvent, runtimeEvent); - - assert.deepEqual(await store.readMessages(session.id), [ - { - type: 'user', - id: sessionEvent.messageId, - turnId, - ts: sessionEvent.ts, - text: sessionEvent.content.text, - displayText: '/skill:writer persist this interjection', - inlineReferences: [{ kind: 'skill', value: '/skill:writer', label: 'Writer', start: 0 }], - steeringEventId: sessionEvent.id, - }, - ]); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('recovers a steering transcript message from the committed RuntimeEvent ledger', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-agent-run-steering-crash-cut-')); - try { - const store = createSessionStore(root); - const session = await store.create({ - cwd: '/tmp/cwd', - llmConnectionSlug: 'fake', - model: 'fake-model', - permissionMode: 'ask', - }); - const runId = 'run-steering-crash-cut'; - const turnId = 'turn-steering-crash-cut'; - const runStore = createSqliteAgentRunStore(root); - const runtimeEventStore = createWorkspaceRuntimeStore(root); - const steeringContent = { - kind: 'text' as const, - text: 'canonical steering envelope', - displayText: '/skill:writer recover this interjection', - attachments: [ - { - kind: 'pdf' as const, - name: 'evidence.pdf', - mimeType: 'application/pdf', - bytes: 2048, - ref: { - kind: 'session_file' as const, - sessionId: session.id, - relativePath: 'attachments/evidence.pdf', - }, - }, - ], - quotes: [{ text: 'quoted evidence', label: 'Assistant', sourceTurnId: 'turn-source' }], - inlineReferences: [ - { kind: 'skill' as const, value: '/skill:writer', label: 'Writer', start: 0 }, - ], - steering: true as const, - }; - await seedInvocation(runtimeEventStore, { - sessionId: session.id, - invocationId: 'invocation-steering-crash-cut', - runId, - turnId, - openedAt: 1, - }); - const runtimeEvent: RuntimeEvent = { - id: 'runtime-steering-crash-cut', - invocationId: 'invocation-steering-crash-cut', - runId, - sessionId: session.id, - turnId, - ts: 2, - partial: false, - role: 'user', - author: 'user', - content: steeringContent, - refs: { providerEventId: 'message-steering-crash-cut' }, - }; - await runtimeEventStore.appendRuntimeEvent(session.id, runId, runtimeEvent); - assert.deepEqual(await store.readMessages(session.id), []); - - const recoveredStore = createSessionStore(root); - const recoveredRunStore = createSqliteAgentRunStore(root); - const recoveredRuntimeEventStore = createWorkspaceRuntimeStore(root); - const repair = new RuntimeLedgerRepair({ - runtimeEventStore: recoveredRuntimeEventStore, - readMessages: (sessionId) => recoveredStore.readMessages(sessionId), - appendMessage: (sessionId, message) => recoveredStore.appendMessage(sessionId, message), - newId: () => 'unused-id', - now: () => 10, - }); - - assert.equal(await repair.repairSteeringMessagesOnce(session.id), 1); - assert.equal(await repair.repairSteeringMessagesOnce(session.id), 0); - assert.deepEqual(await recoveredStore.readMessages(session.id), [ - { - type: 'user', - id: 'message-steering-crash-cut', - turnId, - ts: 2, - text: 'canonical steering envelope', - displayText: '/skill:writer recover this interjection', - attachments: steeringContent.attachments, - quotes: steeringContent.quotes, - inlineReferences: steeringContent.inlineReferences, - steeringEventId: runtimeEvent.id, - }, - ]); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - test('awaits the durable settlement fact before accepting an interaction resume', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-agent-run-status-barrier-')); try { @@ -437,7 +259,6 @@ test('awaits the durable settlement fact before accepting an interaction resume' userInput: { turnId, text: 'resume after answer' }, runId, durability: 'required', - store, runStore, runtimeEventStore: delayedRuntimeEventStore, newId: () => 'status-event', @@ -452,7 +273,6 @@ test('awaits the durable settlement fact before accepting an interaction resume' sessionUpdateStarted = true; await store.updateHeader(sessionId, buildStatusPatch(status, ts, blockedReason)); }, - appendTurnState: async () => {}, }, }); let accepted = false; diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 055875101e..41331c840d 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -87,6 +87,7 @@ import { buildLlmHistorySummarizer } from '../history-compact-summarizer.js'; import { createToolResultArchiveCapability } from '../tool-result-archive-capability.js'; import { createTestAiSdkBackend, + projectedTranscriptOf, readExternalExecutionBoundary, testToolResultArchive, } from './execution-boundary-test-helpers.js'; @@ -108,7 +109,6 @@ describe('AiSdkBackend ApplyPatch routing', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: providerType === 'openai' ? { ...connection(), slug: 'openai', providerType } @@ -138,7 +138,6 @@ describe('AiSdkBackend ApplyPatch routing', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { ...connection(), slug: 'deepseek', @@ -170,7 +169,6 @@ describe('AiSdkBackend ApplyPatch routing', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { ...connection(), slug: 'openai', providerType: 'openai' }, apiKey: 'sk-test', modelId: 'gpt-5.4', @@ -242,7 +240,6 @@ describe('AiSdkBackend ApplyPatch routing', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: targetConnection, apiKey: 'sk-test', modelId, @@ -343,7 +340,6 @@ describe('AiSdkBackend ApplyPatch routing', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -415,7 +411,6 @@ describe('AiSdkBackend ApplyPatch routing', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { ...connection(), slug: 'openai', providerType: 'openai' }, apiKey: 'sk-test', modelId: 'gpt-5.4', @@ -499,7 +494,6 @@ describe('AiSdkBackend ApplyPatch routing', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { ...connection(), slug: 'openai', providerType: 'openai' }, apiKey: 'sk-test', modelId: 'gpt-5.4', @@ -632,7 +626,6 @@ describe('AiSdkBackend Memory Extraction triggers', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -713,7 +706,6 @@ describe('AiSdkBackend Memory Extraction triggers', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { ...connection(), providerType: 'openai' }, apiKey: 'sk-test', modelId: 'gpt-5.4', @@ -788,7 +780,6 @@ describe('AiSdkBackend Memory Extraction triggers', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -879,7 +870,6 @@ describe('AiSdkBackend Memory Extraction triggers', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -962,7 +952,6 @@ describe('AiSdkBackend Memory Extraction triggers', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -1079,7 +1068,6 @@ describe('AiSdkBackend sandbox boundary convergence', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: { ...header(), cwd, workspaceRoot: cwd }, - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -1229,7 +1217,6 @@ describe('AiSdkBackend sandbox boundary convergence', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -1352,7 +1339,6 @@ describe('AiSdkBackend sandbox boundary convergence', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -1559,7 +1545,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'kimi-coding-plan', providerType: 'kimi-coding-plan', @@ -1590,7 +1575,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'kimi-coding-plan', providerType: 'kimi-coding-plan', @@ -1627,7 +1611,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'mistral', providerType: 'mistral', @@ -1657,7 +1640,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -1720,7 +1702,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -1765,7 +1746,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -1818,7 +1798,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -1878,7 +1857,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { ...connection(), providerType: 'openai' }, apiKey: 'sk-test', modelId: 'mock-model-id', @@ -1928,7 +1906,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2021,7 +1998,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2072,7 +2048,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2139,7 +2114,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2192,7 +2166,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2260,7 +2233,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2324,7 +2296,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2392,7 +2363,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2445,7 +2415,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2496,7 +2465,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2540,7 +2508,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2590,7 +2557,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2679,7 +2645,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2717,7 +2682,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2810,7 +2774,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2912,7 +2875,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -2979,7 +2941,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -3085,7 +3046,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -3168,7 +3128,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -3377,7 +3336,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'deepseek', providerType: 'deepseek', @@ -3461,7 +3419,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'deepseek', providerType: 'deepseek', @@ -3575,7 +3532,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -3701,7 +3657,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -3839,7 +3794,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4000,7 +3954,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4283,7 +4236,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4333,7 +4285,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4426,7 +4377,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: { ...header(), llmConnectionId: 'test-connection-id', model: 'mock-model-id' }, - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4499,7 +4449,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4569,7 +4518,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4649,7 +4597,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4735,7 +4682,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4775,7 +4721,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4828,7 +4773,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4885,7 +4829,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -4986,7 +4929,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -5071,7 +5013,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -5183,7 +5124,6 @@ describe('AiSdkBackend model history', () => { const backendInput: AiSdkBackendInput = { sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -5248,7 +5188,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -5286,7 +5225,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -5351,7 +5289,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -5392,7 +5329,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -5538,7 +5474,6 @@ describe('AiSdkBackend model history', () => { backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -5572,8 +5507,9 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async (message) => { - if (message.type !== 'token_usage') return; + // The usage checkpoint is the persistence this turn awaits at its step + // boundary, so holding it here is the window the stop has to win. + recordUsageCheckpoint: async () => { usagePersistenceStarted = true; await gate.promise; }, @@ -5774,7 +5710,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: codexConnection, apiKey: 'codex-token', modelId: 'mock-model-id', @@ -5842,7 +5777,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: codexConnection, apiKey: 'codex-token', modelId: 'mock-model-id', @@ -5934,7 +5868,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: codexConnection, apiKey: 'codex-token', modelId: 'mock-model-id', @@ -6027,7 +5960,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: codexConnection, apiKey: 'codex-token', modelId: 'mock-model-id', @@ -6062,7 +5994,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -6134,7 +6065,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -6194,7 +6124,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: openAiConnection, apiKey: 'sk-test', modelId: 'mock-model-id', @@ -6265,7 +6194,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: { ...header(), llmConnectionId: 'connection-a', model: 'claude-b' }, - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'claude-b', @@ -6349,7 +6277,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: { ...header(), llmConnectionId: 'connection-a', model: 'claude-a' }, - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'claude-a', @@ -6402,7 +6329,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: { ...header(), llmConnectionId: 'connection-a', model: 'claude-a' }, - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'claude-a', @@ -6463,7 +6389,6 @@ describe('AiSdkBackend model history', () => { llmConnectionSlug: 'github-copilot', model: 'gpt-5.4', }, - appendMessage: async () => {}, connection: copilotConnection, apiKey: 'sk-test', modelId: 'gpt-5.4', @@ -6557,7 +6482,6 @@ describe('AiSdkBackend model history', () => { llmConnectionSlug: 'openai-main', model: 'gpt-5.4', }, - appendMessage: async () => {}, connection: openAiConnection, apiKey: 'sk-test', modelId: 'gpt-5.4', @@ -6610,7 +6534,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { ...connection(), providerType: 'openai' }, apiKey: 'sk-test', modelId: 'mock-model-id', @@ -6680,7 +6603,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { ...connection(), slug: 'kimi-main', @@ -6761,7 +6683,6 @@ describe('AiSdkBackend model history', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { ...connection(), providerType: 'openai' }, apiKey: 'sk-test', modelId: 'mock-model-id', @@ -6823,7 +6744,6 @@ describe('AiSdkBackend error surfaces', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-live-secret-token-value', modelId: 'claude-sonnet-4-5-20250929', @@ -7220,7 +7140,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -7276,7 +7195,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -7337,7 +7255,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -7389,7 +7306,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -7437,7 +7353,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -7474,7 +7389,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -7511,7 +7425,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -7583,7 +7496,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: { ...header(), collaborationMode: 'agent' }, - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -7649,7 +7561,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -7746,7 +7657,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -7858,7 +7768,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -8148,7 +8057,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -8194,7 +8102,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'unpriced-model', @@ -8275,7 +8182,6 @@ describe('AiSdkBackend usage telemetry', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -8766,7 +8672,6 @@ describe('AiSdkBackend tool availability diagnostics', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -8842,7 +8747,6 @@ describe('AiSdkBackend tool availability diagnostics', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -8933,7 +8837,6 @@ describe('AiSdkBackend context budget and prompt attribution', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -9099,7 +9002,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -9171,7 +9073,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { ...connection(), models: [{ id: 'mock-model-id', contextWindow: 200_000 }], @@ -9348,7 +9249,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -9409,7 +9309,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -9672,7 +9571,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -9816,7 +9714,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -9890,7 +9787,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { ...connection(), slug: 'deepseek', @@ -9928,59 +9824,6 @@ describe('AiSdkBackend RunTrace', () => { assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'end_turn'); }); - test('does not report a consumed idle timeout for a later assistant append failure', async () => { - const timers = manualWatchdogTimer(); - let calls = 0; - const model = new MockLanguageModelV4({ - doStream: async (options) => { - calls += 1; - return { - stream: hangingProviderStream( - [ - { type: 'stream-start', warnings: [] }, - { type: 'reasoning-start', id: 'reasoning-1' }, - { - type: 'reasoning-delta', - id: 'reasoning-1', - delta: 'partial thought', - }, - ], - options.abortSignal, - ), - }; - }, - }); - const backend = createTestAiSdkBackend({ - sessionId: 'session-1', - header: header(), - appendMessage: async () => { - throw new Error('assistant append failed'); - }, - connection: connection(), - apiKey: 'sk-test', - modelId: 'mock-model-id', - modelFactory: () => model, - tools: [], - newId: idGenerator(), - now: monotonicClock(), - streamWatchdogTimer: timers.clock, - providerRetrySleep: async () => {}, - }); - - const events: SessionEvent[] = []; - for await (const event of backend.send({ turnId: 'turn-1', text: 'hi', context: [] })) { - events.push(event); - if (event.type === 'thinking_delta' && event.text === 'partial thought') timers.fire(); - } - - assert.equal(calls, 1); - const error = events.find((event) => event.type === 'error'); - assert.equal(error?.type, 'error'); - assert.notEqual(error?.type === 'error' ? error.reason : undefined, 'timeout'); - assert.equal(error?.type === 'error' ? error.message : undefined, 'Operation failed'); - assert.equal(events.find((event) => event.type === 'complete')?.stopReason, 'error'); - }); - test('links a recovered tool call to the retry assistant step', async () => { const timers = manualWatchdogTimer(); const durable = durableTurnHarness('turn-1', 'read notes'); @@ -10056,7 +9899,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10120,7 +9962,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10200,7 +10041,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10272,7 +10112,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10330,7 +10169,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10488,7 +10326,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10548,7 +10385,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10603,7 +10439,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10640,7 +10475,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10724,7 +10558,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10769,7 +10602,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10852,7 +10684,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10929,7 +10760,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -10959,7 +10789,6 @@ describe('AiSdkBackend RunTrace', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'claude-sonnet-4-5-20250929', @@ -10997,7 +10826,6 @@ describe('AiSdkBackend tool execution', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header('bypass'), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'claude-sonnet-4-5-20250929', @@ -11094,7 +10922,6 @@ describe('AiSdkBackend tool execution', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header('ask'), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'claude-sonnet-4-5-20250929', @@ -11158,7 +10985,6 @@ describe('AiSdkBackend tool execution', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header('explore'), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'claude-sonnet-4-5-20250929', @@ -11221,7 +11047,6 @@ describe('AiSdkBackend tool execution', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header('explore'), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'claude-sonnet-4-5-20250929', @@ -11320,7 +11145,6 @@ describe('AiSdkBackend tool execution', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header('bypass'), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'claude-sonnet-4-5-20250929', @@ -11642,7 +11466,6 @@ describe('AiSdkBackend concurrent turns', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -11703,7 +11526,6 @@ describe('AiSdkBackend concurrent turns', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -11746,7 +11568,6 @@ describe('AiSdkBackend concurrent turns', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -11883,7 +11704,6 @@ describe('AiSdkBackend thinking persistence', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -12070,7 +11890,6 @@ describe('AiSdkBackend thinking persistence', () => { const firstBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: openCodeClaudeConnection, apiKey: 'sk-test', modelId: 'claude-opus-4-8', @@ -12117,7 +11936,6 @@ describe('AiSdkBackend thinking persistence', () => { const secondBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: openCodeClaudeConnection, apiKey: 'sk-test', modelId: 'claude-opus-4-8', @@ -12218,7 +12036,6 @@ describe('AiSdkBackend thinking persistence', () => { const secondBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -12338,7 +12155,6 @@ describe('AiSdkBackend thinking persistence', () => { const secondBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'openai', providerType: 'openai', @@ -12490,7 +12306,6 @@ describe('AiSdkBackend thinking persistence', () => { const secondBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'volcengine-agent-plan', providerType: 'volcengine-agent-plan', @@ -12601,7 +12416,6 @@ describe('AiSdkBackend thinking persistence', () => { const secondBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'deepseek', providerType: 'deepseek', @@ -12753,7 +12567,6 @@ describe('AiSdkBackend thinking persistence', () => { const firstBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: tokenPlanConnection, apiKey: 'alibaba-token', modelId: 'qwen3.8-max', @@ -12821,7 +12634,6 @@ describe('AiSdkBackend thinking persistence', () => { const secondBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: tokenPlanConnection, apiKey: 'alibaba-token', modelId: 'qwen3.8-max', @@ -12956,7 +12768,6 @@ describe('AiSdkBackend thinking persistence', () => { const recoveryBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'alibaba-token-plan-cn', providerType: 'alibaba-token-plan-cn', @@ -13167,7 +12978,6 @@ describe('AiSdkBackend thinking persistence', () => { const recoveryBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection, apiKey: 'alibaba-token', modelId: 'qwen3.8-max', @@ -13312,7 +13122,6 @@ describe('AiSdkBackend thinking persistence', () => { const recoveryBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection, apiKey: 'alibaba-token', modelId: 'qwen3.8-max', @@ -13352,7 +13161,6 @@ describe('AiSdkBackend thinking persistence', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'alibaba-token-plan-cn', providerType: 'alibaba-token-plan-cn', @@ -13443,7 +13251,6 @@ describe('AiSdkBackend thinking persistence', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'alibaba-token-plan-cn', providerType: 'alibaba-token-plan-cn', @@ -13519,7 +13326,6 @@ describe('AiSdkBackend thinking persistence', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: { ...header(), thinkingLevel: 'max' }, - appendMessage: async () => {}, connection: { slug: 'deepseek', providerType: 'deepseek', @@ -13701,7 +13507,6 @@ describe('AiSdkBackend thinking persistence', () => { const secondBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: planConnection, apiKey: 'ark-plan-token', modelId: 'ark-code-latest', @@ -13809,7 +13614,6 @@ describe('AiSdkBackend thinking persistence', () => { const secondBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -13920,7 +13724,6 @@ describe('AiSdkBackend thinking persistence', () => { const secondBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -14040,7 +13843,6 @@ describe('AiSdkBackend thinking persistence', () => { const secondBackend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -14350,7 +14152,6 @@ describe('AiSdkBackend thinking persistence', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: { slug: 'openai-main', providerType: 'openai', @@ -14391,7 +14192,6 @@ describe('AiSdkBackend steering durability and identity', () => { createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -14968,7 +14768,6 @@ describe('AiSdkBackend steering durability and identity', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -15065,7 +14864,6 @@ describe('AiSdkBackend steering durability and identity', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -15542,7 +15340,6 @@ describe('AiSdkBackend steering durability and identity', () => { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -15667,7 +15464,6 @@ function imageReplayBackend( return createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -15776,7 +15572,6 @@ async function runPlanToolBoundary(input: { const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -15960,7 +15755,6 @@ async function replayPrompt( const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', @@ -16353,9 +16147,18 @@ function runtimeExecute( eventSink: { push(event: SessionEvent): void }, ) { const runtime = turnScope(backend, turnId).toolRuntime; + // This drives the tool runtime beneath `send()`, so the stream that becomes + // the ledger is teed here instead. + const project = projectedTranscriptOf(backend); const durableEventSink: DurableSessionEventSink = { - push: (event) => eventSink.push(event), - pushAndWaitUntilConsumed: async (event) => eventSink.push(event), + push: (event) => { + eventSink.push(event); + void project?.(event, turnId); + }, + pushAndWaitUntilConsumed: async (event) => { + eventSink.push(event); + await project?.(event, turnId); + }, }; return async ( input: unknown, diff --git a/packages/runtime/src/__tests__/ask-user-question.test.ts b/packages/runtime/src/__tests__/ask-user-question.test.ts index 9eee1664cb..acc11f1976 100644 --- a/packages/runtime/src/__tests__/ask-user-question.test.ts +++ b/packages/runtime/src/__tests__/ask-user-question.test.ts @@ -137,7 +137,6 @@ describe('AskUserQuestion runtime round trip', () => { header: header(), connection: { providerType: 'openai', slug: 'c' } as never, modelId: 'm', - appendMessage: async () => {}, newId: () => `id-${++id}`, now: () => 1, getPermissionPauseTarget: () => null, @@ -191,7 +190,6 @@ describe('AskUserQuestion runtime round trip', () => { header: header(), connection: { providerType: 'openai', slug: 'c' } as never, modelId: 'm', - appendMessage: async () => {}, newId: () => `id-${++id}`, now: () => 1, getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/__tests__/code-mode-backend.test.ts b/packages/runtime/src/__tests__/code-mode-backend.test.ts index 61b37f8c83..226fbf66d3 100644 --- a/packages/runtime/src/__tests__/code-mode-backend.test.ts +++ b/packages/runtime/src/__tests__/code-mode-backend.test.ts @@ -974,7 +974,6 @@ function backend( return createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', diff --git a/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts b/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts index ee0904306e..d87630b7ad 100644 --- a/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts +++ b/packages/runtime/src/__tests__/computer-use-privacy-boundary.test.ts @@ -354,7 +354,6 @@ test('the model reads its own call back in the names the tool accepts', async () header: header(), connection: connection(), modelId: 'mock-model', - appendMessage: async () => {}, newId: nextId(), now: () => 1, getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts index fc1e335d1c..73a0cc617d 100644 --- a/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts +++ b/packages/runtime/src/__tests__/computer-use-provider-protocol.test.ts @@ -92,7 +92,6 @@ describe('Anthropic-compatible Computer Use product loops', () => { ...header('anthropic', 'claude-sonnet-4-5-20250929'), llmConnectionId: 'connection-anthropic', }, - appendMessage: async () => {}, connection: providerConnection, apiKey: 'test-key', providerStateIdentity: PROVIDER_STATE_IDENTITY, @@ -265,7 +264,6 @@ describe('Anthropic-compatible Computer Use product loops', () => { testProjectionArtifacts: true, sessionId, header: header(provider.providerType, provider.modelId), - appendMessage: async () => {}, connection: providerConnection, apiKey: 'test-key', modelId: provider.modelId, @@ -383,7 +381,6 @@ describe('Anthropic-compatible Computer Use product loops', () => { testProjectionArtifacts: true, sessionId, header: header(provider.providerType, provider.modelId), - appendMessage: async () => {}, connection: connection( provider.providerType, `${server.url}${provider.baseSuffix}`, @@ -465,7 +462,6 @@ describe('OpenAI-compatible product loops', () => { ...header('github-copilot', 'gpt-5.4'), llmConnectionId: 'connection-copilot', }, - appendMessage: async () => {}, connection: providerConnection, apiKey: 'test-key', providerStateIdentity: PROVIDER_STATE_IDENTITY, @@ -607,7 +603,6 @@ describe('OpenAI-compatible product loops', () => { testProjectionArtifacts: true, sessionId, header: header(provider.providerType, provider.modelId), - appendMessage: async () => {}, connection: providerConnection, apiKey: 'test-key', modelId: provider.modelId, @@ -725,7 +720,6 @@ describe('OpenAI-compatible product loops', () => { testProjectionArtifacts: true, sessionId, header: header('kimi-coding-plan', 'k3'), - appendMessage: async () => {}, connection: providerConnection, apiKey: 'test-key', providerStateIdentity: PROVIDER_STATE_IDENTITY, @@ -933,7 +927,6 @@ describe('OpenAI-compatible product loops', () => { testProjectionArtifacts: true, sessionId, header: header('kimi-coding-plan', 'k3'), - appendMessage: async () => {}, connection: providerConnection, apiKey: 'test-key', modelId: 'k3', diff --git a/packages/runtime/src/__tests__/deferred-tools-backend.test.ts b/packages/runtime/src/__tests__/deferred-tools-backend.test.ts index 086f717a27..9902b7d512 100644 --- a/packages/runtime/src/__tests__/deferred-tools-backend.test.ts +++ b/packages/runtime/src/__tests__/deferred-tools-backend.test.ts @@ -85,7 +85,6 @@ function backend(input: { return createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', diff --git a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts index b715b10c73..890e2167a0 100644 --- a/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts +++ b/packages/runtime/src/__tests__/execution-boundary-test-helpers.ts @@ -18,8 +18,22 @@ */ import { createExternalExecutionBoundary } from '@maka/core/sandbox-boundary'; +import type { SessionEvent } from '@maka/core/events'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { StoredMessage } from '@maka/core/session'; +import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; +import { + buildInvocationOpenedEvent, + runtimeInvocationsFromSessionEvents, +} from '@maka/core/runtime-invocation'; import { AiSdkBackend, type AiSdkBackendInput } from '../ai-sdk-backend.js'; +import { + createSessionEventMapMemory, + isLiveBackendSessionEvent, + mapSessionEventToRuntimeEvent, +} from '../session-event-runtime-mapper.js'; +import { projectRuntimeEventsToStoredMessages } from '../runtime-event-read-model.js'; import { createToolResultArchiveCapability, type ToolResultArchiveCapability, @@ -34,10 +48,49 @@ export const readExternalExecutionBoundary: AiSdkBackendInput['readExecutionBoun type TestAiSdkBackendInput = Omit & Partial> & { testProjectionArtifacts?: boolean; + /** + * The transcript this backend's turn produces, row by row as it appears. + * + * The backend writes no transcript: it emits SessionEvents, an AgentRun + * maps them onto the ledger, and the read model projects the ledger back. + * This runs that same path over the stream so a fixture can read the + * transcript rows a turn yields without standing a whole Session up. + */ + appendMessage?: (message: StoredMessage) => Promise; }; +type ProjectedTranscriptSink = (event: SessionEvent, turnId: string) => Promise; + +const projectedTranscripts = new WeakMap(); + +/** + * The transcript sink of a backend built with `appendMessage`, for a fixture + * that drives the backend's tool runtime directly instead of through `send()`. + */ +export function projectedTranscriptOf(backend: AiSdkBackend): ProjectedTranscriptSink | undefined { + return projectedTranscripts.get(backend); +} + +/** Tee one live backend stream into projected transcript rows. */ +function teeProjectedTranscript( + backend: AiSdkBackend, + sessionId: string, + appendMessage: (message: StoredMessage) => Promise, +): AiSdkBackend { + const send = backend.send.bind(backend); + const project = projectedTranscriptSink(sessionId, appendMessage); + projectedTranscripts.set(backend, project); + backend.send = async function* (sendInput) { + for await (const event of send(sendInput) as AsyncIterable) { + yield event; + await project(event, sendInput.turnId); + } + } as AiSdkBackend['send']; + return backend; +} + export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBackend { - const { testProjectionArtifacts, ...backendInput } = input; + const { testProjectionArtifacts, appendMessage, ...backendInput } = input; const artifacts = new Map(); let nextArtifactId = 0; // A whole transition ledger by default, for the same reason the archive @@ -45,7 +98,7 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke // when it can be made durable, so a fixture without this seam would silently // disable pruning rather than exercise it (#4283). const transitions: ModelProjectionTransition[] = []; - return new AiSdkBackend({ + const backend = new AiSdkBackend({ readExecutionBoundary: readExternalExecutionBoundary, loadModelProjectionTransitions: async () => ({ transitions: [...transitions], @@ -85,6 +138,7 @@ export function createTestAiSdkBackend(input: TestAiSdkBackendInput): AiSdkBacke } : {}), }); + return appendMessage ? teeProjectedTranscript(backend, input.sessionId, appendMessage) : backend; } /** @@ -105,13 +159,96 @@ export function testToolResultArchive( } type TestToolRuntimeInput = Omit & - Partial>; + Partial> & { + /** The transcript rows this runtime's calls produce; see the backend helper. */ + appendMessage?: (message: StoredMessage) => Promise; + }; /** Defaults to the turn id nearly every ToolRuntime test already uses. */ export function createTestToolRuntime(input: TestToolRuntimeInput): ToolRuntime { - return new ToolRuntime({ + const { appendMessage, ...runtimeInput } = input; + const runtime = new ToolRuntime({ readExecutionBoundary: readExternalExecutionBoundary, turnId: 'turn-1', - ...input, + ...runtimeInput, }); + if (!appendMessage) return runtime; + const settleToolCall = runtime.settleToolCall.bind(runtime); + const project = projectedTranscriptSink(input.sessionId, appendMessage); + runtime.settleToolCall = (call) => + settleToolCall({ + ...call, + eventSink: { + push: (event) => { + call.eventSink.push(event); + void project(event, call.turnId); + }, + pushAndWaitUntilConsumed: async (event) => { + await call.eventSink.pushAndWaitUntilConsumed(event); + await project(event, call.turnId); + }, + }, + }); + return runtime; +} + +/** + * A stateful sink turning one live stream into projected transcript rows. + * + * Every row is derived by the production mapper and the production read model, + * so what a fixture observes is what a reader of the ledger would see — not a + * second copy written beside it. + */ +function projectedTranscriptSink( + sessionId: string, + appendMessage: (message: StoredMessage) => Promise, +): (event: SessionEvent, turnId: string) => Promise { + const memory = createSessionEventMapMemory(); + const events: RuntimeEvent[] = []; + let projected = 0; + return async (event, turnId) => { + if (!isLiveBackendSessionEvent(event)) return; + const run = { sessionId, invocationId: turnId, runId: turnId, turnId }; + // Nothing projects without the invocation it belongs to. A fixture drives + // the backend directly, so the opening fact an AgentRun would have + // committed is stated here once, on the run's first event. + if (events.length === 0) { + events.push( + buildInvocationOpenedEvent({ + id: `${turnId}-opened`, + run, + openedAt: event.ts, + opening: { + kind: 'invocation_opened', + protocol: 'invocation_opened_v1', + route: { + provenance: 'unknown', + backendKind: 'ai-sdk', + llmConnectionSlug: 'test-connection', + modelId: 'test-model', + }, + configuration: { + cwd: '/', + permissionMode: 'bypass', + collaborationMode: 'agent', + orchestrationMode: 'default', + orchestrationSource: 'session', + toolMode: DEFAULT_TOOL_MODE, + }, + root: { kind: 'user' }, + source: { kind: 'fresh' }, + }, + }), + ); + } + events.push(mapSessionEventToRuntimeEvent(event, run, memory)); + // Re-project the whole run: a row can only be completed by a later event + // (a step's thinking pairs with the text row that follows it), so the + // prefix is re-derived and only genuinely new rows are emitted. + const messages = projectRuntimeEventsToStoredMessages(events, { + invocations: runtimeInvocationsFromSessionEvents(sessionId, events), + }).messages; + for (const message of messages.slice(projected)) await appendMessage(message); + projected = messages.length; + }; } diff --git a/packages/runtime/src/__tests__/fake-backend.test.ts b/packages/runtime/src/__tests__/fake-backend.test.ts index c61f3447e5..afe9fac4ad 100644 --- a/packages/runtime/src/__tests__/fake-backend.test.ts +++ b/packages/runtime/src/__tests__/fake-backend.test.ts @@ -52,12 +52,7 @@ test('Fake question publication waits for exact hosted admission', async () => { }, { sessionId: 'session-1', turnId: 'turn-1', runId: 'run-1' }, ); - const backend = new FakeBackend({ - sessionId: 'session-1', - header: { model: 'fake-model' } as SessionHeader, - store: {} as SessionStore, - appendMessage: async () => {}, - }); + const backend = new FakeBackend({ sessionId: 'session-1' }); const iterator = backend .send({ turnId: 'turn-1', @@ -101,12 +96,7 @@ test('Fake question publication waits for exact hosted admission', async () => { }); test('pullSteering drains queued messages at step boundaries as steering events', async () => { - const backend = new FakeBackend({ - sessionId: 'session-1', - header: { model: 'fake-model' } as SessionHeader, - store: {} as SessionStore, - appendMessage: async () => {}, - }); + const backend = new FakeBackend({ sessionId: 'session-1' }); // Queue two steering messages, delivered one per step boundary, then dry up. const pending = [ { id: 'lease-1', messageId: 'message-1', content: { text: 'do X' } }, @@ -138,12 +128,7 @@ test('a batch of leases settles per lease: delivered ones ack, undelivered ones // while suspended at B's yield: A crossed its yield (delivered — the // consumer pulled past it), B did not. Batch settlement would nack both, // redelivering the already-delivered A. - const backend = new FakeBackend({ - sessionId: 'session-1', - header: { model: 'fake-model' } as SessionHeader, - store: {} as SessionStore, - appendMessage: async () => {}, - }); + const backend = new FakeBackend({ sessionId: 'session-1' }); let pulled = false; const acked: string[] = []; const nacked: string[] = []; @@ -183,12 +168,7 @@ test('a lease is acked only after its event is consumed, and nacked when the con // durable ledger, so its delivery boundary is the consumer receiving the // echoed event; acking at pull time marked messages delivered that a // detaching consumer never saw, silently dropping them. - const backend = new FakeBackend({ - sessionId: 'session-1', - header: { model: 'fake-model' } as SessionHeader, - store: {} as SessionStore, - appendMessage: async () => {}, - }); + const backend = new FakeBackend({ sessionId: 'session-1' }); const pending = [{ id: 'lease-1', messageId: 'message-1', content: { text: 'do X' } }]; const acked: string[] = []; const nacked: string[] = []; diff --git a/packages/runtime/src/__tests__/interaction-authority.test.ts b/packages/runtime/src/__tests__/interaction-authority.test.ts index ebca8c44c3..78ce640e16 100644 --- a/packages/runtime/src/__tests__/interaction-authority.test.ts +++ b/packages/runtime/src/__tests__/interaction-authority.test.ts @@ -621,7 +621,6 @@ function toolRuntime( header: header(), connection: { providerType: 'openai', slug: 'c' } as never, modelId: 'm', - appendMessage: async () => {}, newId: () => `runtime-${++id}`, now: () => 1, getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/__tests__/latest-context-commit.test.ts b/packages/runtime/src/__tests__/latest-context-commit.test.ts index af4149cda5..766af9193b 100644 --- a/packages/runtime/src/__tests__/latest-context-commit.test.ts +++ b/packages/runtime/src/__tests__/latest-context-commit.test.ts @@ -70,7 +70,6 @@ test('a real send seals its observation into SQLite and reconstructs it after re createTestAiSdkBackend({ sessionId: ctx.sessionId, header: ctx.header, - appendMessage: async () => {}, connection: { slug: 'mock-main', providerType: 'anthropic', @@ -208,7 +207,6 @@ test('a turn aborted before dispatch does not create a canonical sent attempt', backend = createTestAiSdkBackend({ sessionId: ctx.sessionId, header: ctx.header, - appendMessage: async () => {}, connection: { slug: 'mock-main', providerType: 'anthropic', diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts index 8e0eff790d..a1f9df154b 100644 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts @@ -471,6 +471,17 @@ function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { appendMessage: async (message) => { messages.push(message); }, + // A note is a runtime event now. The fixture records it in the shape the + // read model projects back, so these assertions still read the row a + // transcript would show. + recordSystemNote: async (kind, turnId, data) => { + messages.push({ + type: 'system_note', + kind, + turnId, + ...(data !== undefined ? { data } : {}), + }); + }, connection: { ...connection(), ...(options.providerNative diff --git a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts index 36a3b8e2e9..f8de7b5087 100644 --- a/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts +++ b/packages/runtime/src/__tests__/overflow-reactive-recovery.test.ts @@ -587,6 +587,17 @@ function buildReactiveFixture(options: ReactiveFixtureOptions): ReactiveFixture if (!options.slowAppendMessage) return; for (let i = 0; i < 5; i += 1) await flushMacrotask(); }, + // A note is a runtime event now. The fixture records it in the shape the + // read model projects back, so these assertions still read the row a + // transcript would show. + recordSystemNote: async (kind, turnId, data) => { + messages.push({ + type: 'system_note', + kind, + turnId, + ...(data !== undefined ? { data } : {}), + }); + }, connection: { ...connection(), ...(options.providerNative diff --git a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts index 5207802f61..1adbc14da7 100644 --- a/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts +++ b/packages/runtime/src/__tests__/pre-dispatch-refusal-ledger.test.ts @@ -142,7 +142,6 @@ function runtimeInput(h: LedgerHarness) { runId: RUN_ID, invocationId: INVOCATION_ID, runtimeCommitSink: h.sink, - appendMessage: async () => {}, newId: nextId(), now: () => 1, getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts index a44272cda5..462c8b9e5a 100644 --- a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts @@ -160,16 +160,7 @@ async function runCrashChild(): Promise { const runStore = createSqliteAgentRunStore(workspaceRoot); const runtimeEventStore = createCrashRuntimeStore(workspaceRoot); const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => - new FakeBackend({ - sessionId: ctx.sessionId, - header: ctx.header, - store: ctx.store, - appendMessage: ctx.appendMessage, - }), - ); + backends.register('ai-sdk', (ctx) => new FakeBackend({ sessionId: ctx.sessionId })); let id = 0; let resolveSelectedFailpoint!: () => void; const selectedFailpointReached = new Promise((resolve) => { @@ -244,16 +235,7 @@ function createManager(workspaceRoot: string): { const runStore = createSqliteAgentRunStore(workspaceRoot); const runtimeEventStore = createCrashRuntimeStore(workspaceRoot); const backends = new BackendRegistry(); - backends.register( - 'ai-sdk', - (ctx) => - new FakeBackend({ - sessionId: ctx.sessionId, - header: ctx.header, - store: ctx.store, - appendMessage: ctx.appendMessage, - }), - ); + backends.register('ai-sdk', (ctx) => new FakeBackend({ sessionId: ctx.sessionId })); let id = 100; return { agentRunStore: runStore, 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 c47760680f..af88e86651 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -22,6 +22,7 @@ import { describe, test } from 'node:test'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { CreateSessionInput, SessionListFilter } from '@maka/core/runtime-inputs'; import type { RuntimeEvent, RuntimeEventActions } from '@maka/core/runtime-event'; +import { runtimeEventHasModelVisibleContent } from '@maka/core/runtime-event'; import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; import { deriveTurnRecords } from '@maka/core/session'; import { @@ -1995,6 +1996,192 @@ const ACTION_COVERAGE_SAMPLES: ActionCoverageSamples = { runtimeProtocol: { action: { toolBoundary: 't1_after_preflight_v1' } }, }; +describe('system note projection', () => { + test('projects a turn-scoped note back into its transcript row', () => { + const out = projectRuntimeEventsToStoredMessages( + [ + ev({ + id: 'evt-note', + content: { + kind: 'system_note', + note: 'context_compacted', + data: { removedMessages: 12 }, + }, + modelVisibility: 'hidden', + refs: { storedMessageId: 'legacy-note' }, + }), + ], + { invocations: [invocation] }, + ); + + assert.deepStrictEqual(out.diagnostics, []); + assert.deepStrictEqual(out.messages, [ + { + type: 'system_note', + id: 'legacy-note', + turnId, + ts, + kind: 'context_compacted', + data: { removedMessages: 12 }, + }, + ]); + }); + + test('converts a legacy turn-scoped note and reads back the same row', () => { + const note: StoredMessage = { + type: 'system_note', + id: 'legacy-step-limit', + turnId, + ts, + kind: 'step_limit', + data: { steps: 40 }, + }; + + const backfilled = backfillRuntimeEventsFromStoredMessages({ + run: { sessionId, invocationId, runId, turnId }, + outcome: { status: 'completed', ts }, + messages: [note], + modelHistory: 'full', + now: () => ts, + }); + + assert.deepStrictEqual(backfilled.diagnostics, []); + const projected = projectRuntimeEventsToStoredMessages(backfilled.events, { + invocations: [invocation], + }); + assert.deepStrictEqual( + projected.messages.filter((message) => message.type === 'system_note'), + [note], + ); + }); + + test('leaves a session-level note out of the run ledger', () => { + const backfilled = backfillRuntimeEventsFromStoredMessages({ + run: { sessionId, invocationId, runId, turnId }, + messages: [ + { + type: 'system_note', + id: 'legacy-mode-change', + turnId, + ts, + kind: 'mode_change', + data: { from: 'ask', to: 'bypass' }, + }, + ], + modelHistory: 'full', + now: () => ts, + }); + + assert.deepStrictEqual( + backfilled.events.filter((event) => event.content?.kind === 'system_note'), + [], + ); + assert.partialDeepStrictEqual(backfilled.diagnostics, [{ code: 'skipped_high_risk_message' }]); + }); +}); + +describe('legacy transcript conversion keeps every row', () => { + const convert = (messages: readonly StoredMessage[]) => + backfillRuntimeEventsFromStoredMessages({ + run: { sessionId, invocationId, runId, turnId }, + outcome: { status: 'completed', ts }, + messages, + modelHistory: 'full', + now: () => ts, + }); + + test('keeps a tool result whose call is not in the turn, out of model replay', () => { + const orphan: StoredMessage = { + type: 'tool_result', + id: 'legacy-orphan-result', + turnId, + ts, + toolUseId: 'tool-gone', + isError: false, + content: { kind: 'text', text: 'done' }, + }; + + const converted = convert([orphan]); + const response = converted.events.find((event) => event.content?.kind === 'function_response'); + assert.strictEqual(response?.modelVisibility, 'hidden'); + assert.strictEqual(runtimeEventHasModelVisibleContent(response as RuntimeEvent), false); + + const projected = projectRuntimeEventsToStoredMessages(converted.events, { + invocations: [invocation], + }); + assert.partialDeepStrictEqual( + projected.messages.filter((message) => message.type === 'tool_result'), + [{ id: 'legacy-orphan-result', toolUseId: 'tool-gone' }], + ); + }); + + test('keeps a provider-native call whose opaque output was not retained', () => { + const converted = convert([ + { + type: 'tool_call', + id: 'tool-native', + turnId, + ts, + toolName: 'WebSearch', + args: { query: 'maka' }, + providerExecuted: true, + }, + ]); + + const call = converted.events.find((event) => event.content?.kind === 'function_call'); + assert.strictEqual(call?.modelVisibility, 'hidden'); + assert.partialDeepStrictEqual(converted.diagnostics, [ + { code: 'skipped_provider_native_replay_gap' }, + ]); + + const projected = projectRuntimeEventsToStoredMessages(converted.events, { + invocations: [invocation], + }); + assert.partialDeepStrictEqual( + projected.messages.filter((message) => message.type === 'tool_call'), + [{ id: 'tool-native', toolName: 'WebSearch' }], + ); + }); + + test('converts a permission decision on its own evidence', () => { + const decision: StoredMessage = { + type: 'permission_decision', + id: 'request-1', + turnId, + ts, + toolUseId: 'tool-1', + toolName: 'Bash', + decision: 'allow', + hint: 'rm -rf build', + }; + + const converted = convert([decision]); + assert.deepStrictEqual(converted.diagnostics, []); + + const projected = projectRuntimeEventsToStoredMessages(converted.events, { + invocations: [invocation], + }); + assert.deepStrictEqual( + projected.messages.filter((message) => message.type === 'permission_decision'), + [decision], + ); + }); + + test('ends a turn whose transcript never said how it ended', () => { + const converted = backfillRuntimeEventsFromStoredMessages({ + run: { sessionId, invocationId, runId, turnId }, + messages: [{ type: 'user', id: 'legacy-user', turnId, ts, text: 'hello' }], + modelHistory: 'full', + now: () => ts, + }); + + const terminal = converted.events.filter((event) => event.actions?.endInvocation); + assert.partialDeepStrictEqual(terminal, [{ status: 'failed' }]); + assert.strictEqual(terminal[0]?.actions?.stateDelta?.failureClass, 'missing_terminal_event'); + assert.partialDeepStrictEqual(converted.diagnostics, [{ code: 'synthesized_terminal_event' }]); + }); +}); + describe('RuntimeEventActions projection coverage', () => { for (const [field, sample] of Object.entries(ACTION_COVERAGE_SAMPLES)) { test(`actions.${field} projects without an unclaimed-event diagnostic`, () => { @@ -2237,6 +2424,23 @@ class ReadOnlyStore implements SessionStore { return [...this.messages]; } + async readMessagesAfter( + _sessionId: string, + request: { afterSequence?: number; maxMessages: number }, + ): Promise<{ + records: readonly { sequence: number; message: StoredMessage }[]; + highWaterSequence: number | null; + }> { + this.readMessagesCalls += 1; + return { + records: this.messages + .map((message, sequence) => ({ sequence, message })) + .filter(({ sequence }) => sequence > (request.afterSequence ?? -1)) + .slice(0, request.maxMessages), + highWaterSequence: this.messages.length > 0 ? this.messages.length - 1 : null, + }; + } + async listTurns(_sessionId: string): Promise { return deriveTurnRecords(this.messages); } diff --git a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts index 2216a52160..34faf6750e 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts @@ -250,21 +250,6 @@ describe('RuntimeKernel Interaction close cleanup', () => { ); assert.equal(containsFailure(retryFailure, stopFailure), true); assert.equal(fixture.backend.stopCalls.length, 1); - const messages = await fixture.store.readMessages(SESSION_ID); - assert.equal( - messages.filter( - (message) => - message.type === 'turn_state' && - message.turnId === 'turn-blocked-send' && - message.status === 'aborted', - ).length, - 1, - ); - assert.equal( - messages.filter((message) => message.type === 'system_note' && message.kind === 'abort') - .length, - 1, - ); const blockedActivation = fixture.kernel .startTurn(SESSION_ID, { turnId: 'turn-before-runner-settled', text: 'must not send' }) @@ -366,21 +351,6 @@ describe('RuntimeKernel Interaction close cleanup', () => { await drainIterator(first); assert.equal(built[0]?.disposeCalls, 1); assert.deepEqual(built[0]?.stopCalls, [{ reason: 'user_stop', mode: 'after_step' }]); - const firstMessages = await store.readMessages(SESSION_ID); - assert.equal( - firstMessages.filter( - (message) => - message.type === 'turn_state' && - message.turnId === 'turn-generation-1' && - message.status === 'aborted', - ).length, - 1, - ); - assert.equal( - firstMessages.filter((message) => message.type === 'system_note' && message.kind === 'abort') - .length, - 1, - ); const second = kernel .startTurn(SESSION_ID, { turnId: 'turn-generation-2', text: 'second' }) @@ -654,13 +624,16 @@ function memoryStore(): SessionStore { list: async () => [], readHeader: async () => header, readMessages: async () => [...messages], - listTurns: async () => [], - appendMessage: async (_sessionId, message) => { - messages.push(message); - }, - appendMessages: async (_sessionId, next) => { - messages.push(...next); - }, + readMessagesAfter: async ( + _sessionId: string, + request: { afterSequence?: number; maxMessages: number }, + ) => ({ + records: messages + .map((message, sequence) => ({ sequence, message })) + .filter(({ sequence }) => sequence > (request.afterSequence ?? -1)) + .slice(0, request.maxMessages), + highWaterSequence: messages.length > 0 ? messages.length - 1 : null, + }), updateHeader: async (_sessionId, patch) => { header = { ...header, ...patch }; return header; diff --git a/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts b/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts index 3e42ad0777..d16260e85e 100644 --- a/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts +++ b/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts @@ -18,10 +18,12 @@ */ import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; +import { DatabaseSync } from 'node:sqlite'; import type { StoredMessage } from '@maka/core/session'; import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createExternalSessionAdapterRegistry } from '@maka/storage/external-sessions'; @@ -36,6 +38,7 @@ import { import { runtimeInvocationFailureClass } from '../runtime-event-read-model.js'; import { backfillRuntimeEventsFromStoredMessages } from '../runtime-event-backfill.js'; import { RuntimeLedgerRepair } from '../runtime-ledger-repair.js'; +import { BackendRegistry, SessionManager } from '../session-manager.js'; test('repairs imported transcript turns into provider-neutral canonical history', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-transcript-ledger-repair-')); @@ -103,10 +106,7 @@ test('repairs imported transcript turns into provider-neutral canonical history' assert.equal(session.transcriptLedgerVersion, 0); const repair = new RuntimeLedgerRepair({ runtimeEventStore: runtimeEvents, - readMessages: (sessionId) => sessions.readMessages(sessionId), - appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message), - newId, - now: () => 100, + readMessagesAfter: (sessionId, request) => sessions.readMessagesAfter(sessionId, request), }); await repair.materializeTranscriptLedger(session); @@ -285,10 +285,7 @@ test('an imported snapshot cutoff survives materialization as aborted', async () ); const repair = new RuntimeLedgerRepair({ runtimeEventStore: runtimeEvents, - readMessages: (sessionId) => sessions.readMessages(sessionId), - appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message), - newId, - now: () => 100, + readMessagesAfter: (sessionId, request) => sessions.readMessagesAfter(sessionId, request), }); await repair.materializeTranscriptLedger(session); @@ -342,10 +339,7 @@ test('does not import Host-handed-off transcript messages as synthetic runs', as ); const repair = new RuntimeLedgerRepair({ runtimeEventStore: runtimeEvents, - readMessages: (sessionId) => sessions.readMessages(sessionId), - appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message), - newId: () => `host-repair-${++sequence}`, - now: () => 100, + readMessagesAfter: (sessionId, request) => sessions.readMessagesAfter(sessionId, request), }); await repair.materializeTranscriptLedger(session); @@ -394,10 +388,7 @@ test('an imported turn with no terminal state is repaired to failed', async () = ); const repair = new RuntimeLedgerRepair({ runtimeEventStore: runtimeEvents, - readMessages: (sessionId) => sessions.readMessages(sessionId), - appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message), - newId, - now: () => 100, + readMessagesAfter: (sessionId, request) => sessions.readMessagesAfter(sessionId, request), }); await repair.materializeTranscriptLedger(session); @@ -412,6 +403,189 @@ test('an imported turn with no terminal state is repaired to failed', async () = } }); +test("converts Maka's own legacy transcript whole, and resumes an interrupted conversion", async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-native-transcript-')); + const sessions = createSessionStore(root); + const runtimeEvents = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + + try { + const ts = Date.now(); + const session = await sessions.create({ + cwd: '/repo', + llmConnectionSlug: 'anthropic', + model: 'claude-opus-5', + permissionMode: 'ask', + }); + await sessions.appendMessages(session.id, [ + { type: 'user', id: 'n-user', turnId: 'turn-1', ts, text: 'run the tests' }, + { + type: 'tool_call', + id: 'n-tool', + turnId: 'turn-1', + ts: ts + 1, + toolName: 'Bash', + args: { command: 'npm test' }, + }, + { + type: 'tool_result', + id: 'n-result', + turnId: 'turn-1', + ts: ts + 2, + toolUseId: 'n-tool', + isError: false, + content: { kind: 'text', text: 'ok' }, + }, + { + type: 'system_note', + id: 'n-note', + turnId: 'turn-1', + ts: ts + 3, + kind: 'step_limit', + }, + { + type: 'assistant', + id: 'n-assistant', + turnId: 'turn-1', + ts: ts + 4, + text: 'All green.', + modelId: 'claude-opus-5', + }, + { + type: 'turn_state', + id: 'n-state', + turnId: 'turn-1', + ts: ts + 5, + status: 'completed', + partialOutputRetained: true, + }, + ]); + + const repair = new RuntimeLedgerRepair({ + runtimeEventStore: runtimeEvents, + readMessagesAfter: (sessionId, request) => sessions.readMessagesAfter(sessionId, request), + }); + + const append = runtimeEvents.appendRuntimeEvent.bind(runtimeEvents); + runtimeEvents.appendRuntimeEvent = async (sessionId, runId, event) => { + await append(sessionId, runId, event); + if (event.role === 'user') throw new Error('interrupted conversion'); + }; + await assert.rejects( + repair.materializeTranscriptLedger(await sessions.readHeader(session.id)), + /interrupted conversion/, + ); + runtimeEvents.appendRuntimeEvent = append; + const [interrupted] = await runtimeEvents.listSessionInvocations(session.id); + assert.ok(interrupted); + const prefix = await runtimeEvents.readRuntimeEvents(session.id, interrupted.runId); + assert.equal(interrupted.terminalEvent, undefined); + const resumed = new RuntimeLedgerRepair({ + runtimeEventStore: runtimeEvents, + readMessagesAfter: (sessionId, request) => sessions.readMessagesAfter(sessionId, request), + }); + await resumed.materializeTranscriptLedger(await sessions.readHeader(session.id)); + + const [run] = await runtimeEvents.listSessionInvocations(session.id); + assert.ok(run); + assert.equal(runtimeInvocationOutcome(run), 'completed'); + const events = await runtimeEvents.readRuntimeEvents(session.id, run.runId); + assert.deepEqual(events.slice(0, prefix.length), prefix); + assert.deepEqual( + events.flatMap((event) => (event.content ? [event.content.kind] : [])), + ['invocation_opened', 'text', 'function_call', 'function_response', 'system_note', 'text'], + ); + } finally { + await runtimeEvents.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('startup recovery leaves an interrupted legacy conversion for the importer to finish', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-transcript-restart-')); + const sessions = createSessionStore(root); + const runs = createSqliteAgentRunStore(root); + const runtimeEvents = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + try { + const session = await sessions.create({ + cwd: '/repo', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + }); + const db = new DatabaseSync(join(root, 'runtime.sqlite')); + try { + db.prepare( + "UPDATE session_metadata SET payload_json = json_remove(payload_json, '$.transcriptLedgerVersion') WHERE session_id = ?", + ).run(session.id); + } finally { + db.close(); + } + await sessions.appendMessages(session.id, [ + { + type: 'user', + id: 'legacy-user', + turnId: 'legacy-turn', + ts: 10, + text: 'Keep this conversation', + }, + { + type: 'assistant', + id: 'legacy-answer', + turnId: 'legacy-turn', + ts: 20, + text: 'The complete original answer', + modelId: 'fake-model', + }, + { + type: 'turn_state', + id: 'legacy-end', + turnId: 'legacy-turn', + ts: 30, + status: 'completed', + partialOutputRetained: true, + }, + ]); + const append = runtimeEvents.appendRuntimeEvent.bind(runtimeEvents); + runtimeEvents.appendRuntimeEvent = async (sessionId, runId, event) => { + await append(sessionId, runId, event); + if (event.role === 'user') throw new Error('interrupted conversion'); + }; + const repair = new RuntimeLedgerRepair({ + runtimeEventStore: runtimeEvents, + readMessagesAfter: (id, request) => sessions.readMessagesAfter(id, request), + }); + await assert.rejects( + repair.materializeTranscriptLedger(await sessions.readHeader(session.id)), + /interrupted conversion/, + ); + runtimeEvents.appendRuntimeEvent = append; + let id = 0; + const manager = new SessionManager({ + store: sessions, + runStore: runs, + runtimeEventStore: runtimeEvents, + backends: new BackendRegistry(), + now: () => 100, + newId: () => `recovery-${++id}`, + }); + await manager.recoverInterruptedSessionsStrict({ sessionStore: sessions, agentRunStore: runs }); + const [pending] = await runtimeEvents.listSessionInvocations(session.id); + assert.ok(pending); + assert.equal(pending.terminalEvent, undefined); + const messages = await manager.getMessages(session.id); + assert.deepEqual( + messages.map((message) => message.id), + ['legacy-user', 'legacy-answer', 'legacy-end'], + ); + assert.equal((await sessions.readHeader(session.id)).transcriptLedgerVersion, 1); + } finally { + runtimeEvents.close(); + await runs.close?.(); + await sessions.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + test('a resolved Claude transcript replays as the conversation the user kept', async () => { // The whole path, end to end: raw records → lineage resolution → conversion // → Ledger materialization → the replay a continuation would be given. @@ -560,10 +734,7 @@ test('a resolved Claude transcript replays as the conversation the user kept', a ); const repair = new RuntimeLedgerRepair({ runtimeEventStore: runtimeEvents, - readMessages: (sessionId) => sessions.readMessages(sessionId), - appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message), - newId, - now: () => 100, + readMessagesAfter: (sessionId, request) => sessions.readMessagesAfter(sessionId, request), }); await repair.materializeTranscriptLedger(session); @@ -625,3 +796,208 @@ test('a resolved Claude transcript replays as the conversation the user kept', a await rm(root, { recursive: true, force: true }); } }); + +/** One legacy turn, as a released build would have left it for the converter. */ +async function seedLegacyTurn(sessions: ReturnType) { + const ts = Date.now(); + const session = await sessions.create({ + cwd: '/repo', + llmConnectionSlug: 'anthropic', + model: 'claude-opus-5', + permissionMode: 'ask', + }); + await sessions.appendMessages(session.id, [ + { type: 'user', id: 'r-user', turnId: 'turn-1', ts, text: 'run the tests' }, + { + type: 'assistant', + id: 'r-assistant', + turnId: 'turn-1', + ts: ts + 1, + text: 'All green.', + modelId: 'claude-opus-5', + }, + { + type: 'turn_state', + id: 'r-state', + turnId: 'turn-1', + ts: ts + 2, + status: 'completed', + partialOutputRetained: true, + }, + ]); + return session; +} + +test('resumes a conversion a released build opened under a random event id', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-released-prefix-')); + const sessions = createSessionStore(root); + const runtimeEvents = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + + try { + const session = await seedLegacyTurn(sessions); + const deps = { + runtimeEventStore: runtimeEvents, + readMessagesAfter: ( + sessionId: string, + request: { maxMessages: number; maxStoredBytes: number }, + ) => sessions.readMessagesAfter(sessionId, request), + }; + + // A released build derived the run id the same way but every event id with + // `newId()`, so its interrupted conversion left an opening this build + // cannot name. `runtime_events_one_opening_per_invocation` refuses a second + // one, so the retry has to read what the run already holds. + const append = runtimeEvents.appendRuntimeEvent.bind(runtimeEvents); + runtimeEvents.appendRuntimeEvent = async (sessionId, runId, event) => { + await append(sessionId, runId, { ...event, id: randomUUID() }); + throw new Error('interrupted conversion'); + }; + await assert.rejects( + new RuntimeLedgerRepair(deps).materializeTranscriptLedger( + await sessions.readHeader(session.id), + ), + /interrupted conversion/, + ); + runtimeEvents.appendRuntimeEvent = append; + + await new RuntimeLedgerRepair(deps).materializeTranscriptLedger( + await sessions.readHeader(session.id), + ); + + const [run] = await runtimeEvents.listSessionInvocations(session.id); + assert.ok(run); + assert.equal(runtimeInvocationOutcome(run), 'completed'); + const events = await runtimeEvents.readRuntimeEvents(session.id, run.runId); + assert.deepEqual( + events.flatMap((event) => (event.content ? [event.content.kind] : [])), + ['invocation_opened', 'text', 'text'], + ); + } finally { + runtimeEvents.close(); + await sessions.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('seals a released conversion that had already converted messages', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-released-partial-')); + const sessions = createSessionStore(root); + const runtimeEvents = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + + try { + const session = await seedLegacyTurn(sessions); + const deps = { + runtimeEventStore: runtimeEvents, + readMessagesAfter: ( + sessionId: string, + request: { maxMessages: number; maxStoredBytes: number }, + ) => sessions.readMessagesAfter(sessionId, request), + }; + + const append = runtimeEvents.appendRuntimeEvent.bind(runtimeEvents); + let written = 0; + runtimeEvents.appendRuntimeEvent = async (sessionId, runId, event) => { + await append(sessionId, runId, { ...event, id: randomUUID() }); + written += 1; + if (written === 2) throw new Error('interrupted conversion'); + }; + await assert.rejects( + new RuntimeLedgerRepair(deps).materializeTranscriptLedger( + await sessions.readHeader(session.id), + ), + /interrupted conversion/, + ); + runtimeEvents.appendRuntimeEvent = append; + + await new RuntimeLedgerRepair(deps).materializeTranscriptLedger( + await sessions.readHeader(session.id), + ); + + const [run] = await runtimeEvents.listSessionInvocations(session.id); + assert.ok(run); + // The prefix cannot be finished and must not be doubled: one user text, not two. + assert.equal(runtimeInvocationOutcome(run), 'failed'); + assert.equal(runtimeInvocationFailureClass(run), 'missing_terminal_event'); + const events = await runtimeEvents.readRuntimeEvents(session.id, run.runId); + assert.equal(events.filter((event) => event.content?.kind === 'text').length, 1); + } finally { + runtimeEvents.close(); + await sessions.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + +test('converts a legacy transcript larger than one page without reading it whole', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-paged-conversion-')); + const sessions = createSessionStore(root); + const runtimeEvents = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + + try { + const ts = Date.now(); + const session = await sessions.create({ + cwd: '/repo', + llmConnectionSlug: 'anthropic', + model: 'claude-opus-5', + permissionMode: 'ask', + }); + const turnCount = 200; + for (let turn = 0; turn < turnCount; turn += 1) { + await sessions.appendMessages(session.id, [ + { + type: 'user', + id: `p-user-${turn}`, + turnId: `turn-${turn}`, + ts: ts + turn * 3, + text: `ask ${turn}`, + }, + { + type: 'assistant', + id: `p-assistant-${turn}`, + turnId: `turn-${turn}`, + ts: ts + turn * 3 + 1, + text: `answer ${turn}`, + modelId: 'claude-opus-5', + }, + { + type: 'turn_state', + id: `p-state-${turn}`, + turnId: `turn-${turn}`, + ts: ts + turn * 3 + 2, + status: 'completed', + partialOutputRetained: true, + }, + ]); + } + + // The whole transcript is 600 rows. A conversion that still read it whole + // would ask for all of them at once, and the Session cannot serve its first + // transcript page until this finishes. + let largestRead = 0; + await new RuntimeLedgerRepair({ + runtimeEventStore: runtimeEvents, + readMessagesAfter: async (sessionId, request) => { + const page = await sessions.readMessagesAfter(sessionId, request); + largestRead = Math.max(largestRead, page.records.length); + return page; + }, + }).materializeTranscriptLedger(await sessions.readHeader(session.id)); + + assert.ok(largestRead < turnCount * 3, `read ${largestRead} rows in one page`); + const invocations = await runtimeEvents.listSessionInvocations(session.id); + assert.equal(invocations.length, turnCount); + assert.ok(invocations.every((run) => runtimeInvocationOutcome(run) === 'completed')); + // Every imported opening still sorts ahead of anything the Session does + // natively, and turns keep the order the transcript had. + const openedAt = invocations.map((run) => run.openedAt); + assert.ok(openedAt.every((value) => value < session.createdAt)); + assert.deepEqual( + openedAt, + [...openedAt].sort((left, right) => left - right), + ); + assert.equal(new Set(openedAt).size, turnCount); + } finally { + runtimeEvents.close(); + await sessions.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts b/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts index 1ad13b4d54..17f1539f1c 100644 --- a/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts +++ b/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts @@ -27,6 +27,7 @@ import type { AgentRunEvent, EmittedAgentRunEvent } from '@maka/core/agent-run'; import { buildInvocationOpenedEvent } from '@maka/core/runtime-invocation'; import { runtimeInvocationFailureClass } from '../runtime-event-read-model.js'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { type DurableAgentRunStore, @@ -68,9 +69,10 @@ describe('sandbox boundary restart recovery on durable stores', () => { await manager(stores).recoverInterruptedSessions(); }); - await withStores(root, async ({ sessions, runtimeEvents }) => { + await withStores(root, async (stores) => { + const { sessions, runtimeEvents } = stores; assert.deepEqual(await sessions.listPendingSandboxBoundaryRequests(session.id), []); - const [turn] = await sessions.listTurns(session.id); + const [turn] = await manager(stores).listTurns(session.id); assert.equal(turn?.status, 'failed'); assert.equal(turn?.errorClass, 'sandbox_boundary_closed_by_restart'); const [invocation] = await runtimeEvents.listSessionInvocations(session.id); @@ -115,15 +117,15 @@ describe('sandbox boundary restart recovery on durable stores', () => { await manager(stores).recoverInterruptedSessions(); }); - const failedStatesAfterFirst = await withStores(root, async ({ sessions, runtimeEvents }) => { - const [turn] = await sessions.listTurns(session.id); + const failedStatesAfterFirst = await withStores(root, async (stores) => { + const [turn] = await manager(stores).listTurns(session.id); assert.equal(turn?.errorClass, 'sandbox_boundary_closed_by_restart'); - const [invocation] = await runtimeEvents.listSessionInvocations(session.id); + const [invocation] = await stores.runtimeEvents.listSessionInvocations(session.id); assert.equal( invocation && runtimeInvocationFailureClass(invocation), 'sandbox_boundary_closed_by_restart', ); - return countFailedTurnStates(await sessions.readMessages(session.id)); + return countFailedTurnStates(await manager(stores).getMessages(session.id)); }); // A later restart re-reads the same durable closure and must change @@ -132,11 +134,12 @@ describe('sandbox boundary restart recovery on durable stores', () => { await manager(stores).recoverInterruptedSessions(); }); - await withStores(root, async ({ sessions, runtimeEvents }) => { - const [turn] = await sessions.listTurns(session.id); + await withStores(root, async (stores) => { + const { sessions, runtimeEvents } = stores; + const [turn] = await manager(stores).listTurns(session.id); assert.equal(turn?.errorClass, 'sandbox_boundary_closed_by_restart'); assert.equal( - countFailedTurnStates(await sessions.readMessages(session.id)), + countFailedTurnStates(await manager(stores).getMessages(session.id)), failedStatesAfterFirst, ); const [invocation] = await runtimeEvents.listSessionInvocations(session.id); @@ -208,28 +211,37 @@ function manager(stores: DurableStores): SessionManager { }); } +/** + * A turn whose invocation opened and never ended: the opening fact and the + * user's own event are on the ledger, and no terminal fact follows them. + */ async function seedInterruptedTurn( sessions: SessionAuthorityStore, runs: DurableAgentRunStore, runtimeEvents: DurableRuntimeEventStore, sessionId: string, ): Promise { - await sessions.appendMessages(sessionId, [ - { type: 'user', id: 'turn-1-user', turnId: 'turn-1', ts: 9, text: 'build it' }, - { - type: 'turn_state', - id: 'turn-1-state', - turnId: 'turn-1', - ts: 10, - status: 'running', - partialOutputRetained: false, - }, - ]); await sessions.updateHeader(sessionId, { status: 'waiting_for_user' }); await runtimeEvents.appendRuntimeEvent(sessionId, 'run-1', openingEvent(sessionId)); + await runtimeEvents.appendRuntimeEvent(sessionId, 'run-1', userEvent(sessionId)); await runs.appendEvent(sessionId, 'run-1', runEvent(sessionId)); } +function userEvent(sessionId: string): RuntimeEvent { + return { + id: 'run-1-user', + sessionId, + invocationId: 'run-1', + runId: 'run-1', + turnId: 'turn-1', + ts: 11, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'build it' }, + }; +} + function countFailedTurnStates(messages: readonly StoredMessage[]): number { return messages.filter((message) => message.type === 'turn_state' && message.status === 'failed') .length; diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 3c0f693608..64e533a3b7 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -74,7 +74,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runtimeEventStore, newId: nextId(), now: nextNow(10_000), @@ -142,7 +141,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runtimeEventStore, newId: nextId(), now: nextNow(10_100), @@ -486,7 +484,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -546,7 +543,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -616,7 +612,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -688,7 +683,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -730,7 +724,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId, @@ -767,7 +760,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-2', text: 'again' }, - store, runStore, runtimeEventStore: runStore, newId, @@ -830,7 +822,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -1102,7 +1093,6 @@ describe('SessionManager terminal ledger invariants', () => { schemaVersion: 1, }, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, newId: nextId(), now: nextNow(25_200), @@ -1113,7 +1103,6 @@ describe('SessionManager terminal ledger invariants', () => { unregisterRun: () => {}, updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }, }), /RuntimeEventStore/, @@ -1128,7 +1117,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -1140,7 +1128,6 @@ describe('SessionManager terminal ledger invariants', () => { unregisterRun: () => {}, updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }, }); const terminalEvent = runtimeEvent({ @@ -1180,7 +1167,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -1192,7 +1178,6 @@ describe('SessionManager terminal ledger invariants', () => { unregisterRun: () => {}, updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }, }); @@ -1224,7 +1209,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -1247,7 +1231,6 @@ describe('SessionManager terminal ledger invariants', () => { }, updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }, }); @@ -1268,6 +1251,64 @@ describe('SessionManager terminal ledger invariants', () => { await new RuntimeReadModel({ runtimeEventStore: runStore }).getSessionView(session.id); }); + test('a run that failed while recording its prompt records it before sealing', async () => { + const store = new TinySessionStore(); + const runStore = new TinyAgentRunStore({ durability: 'canonical' }); + // The invocation is already open when this append is refused, so the run + // exists with nothing saying what it was asked to do. Its terminal event + // seals it against every later append, crash recovery's included. + runStore.rejectRuntimeEventIdsOnce.add('run-1-admitted-prompt'); + const session = await store.create(makeInput()); + const run = new AgentRun({ + sessionId: session.id, + header: session, + runId: 'run-1', + userInput: { turnId: 'turn-1', text: 'hello' }, + runStore, + runtimeEventStore: runStore, + newId: nextId(), + now: nextNow(41_750), + hooks: inertAgentRunHooks(store), + }); + + await assert.rejects(run.begin()); + await run.finalize(); + + const events = await runStore.readRuntimeEvents(session.id, 'run-1'); + const prompt = events.find((event) => event.role === 'user'); + assert.strictEqual(prompt?.id, 'run-1-admitted-prompt'); + assert.deepEqual(prompt.content, { kind: 'text', text: 'hello' }); + assert.strictEqual(events.filter(isTerminalRuntimeEvent).length, 1); + }); + + test('a run that failed while opening still records its prompt once finalize reopens it', async () => { + const store = new TinySessionStore(); + const runStore = new TinyAgentRunStore({ durability: 'canonical' }); + // The opening append is what `begin()` fails on here, so the prompt is owed + // from before it — `finalize` reopens the invocation, and a run it can open + // is a run that has to say what it was asked to do. + runStore.rejectRuntimeEventIdsOnce.add('id-1'); + const session = await store.create(makeInput()); + const run = new AgentRun({ + sessionId: session.id, + header: session, + runId: 'run-1', + userInput: { turnId: 'turn-1', text: 'hello' }, + runStore, + runtimeEventStore: runStore, + newId: nextId(), + now: nextNow(41_900), + hooks: inertAgentRunHooks(store), + }); + + await assert.rejects(run.begin()); + await run.finalize(); + + const events = await runStore.readRuntimeEvents(session.id, 'run-1'); + assert.strictEqual(events.find((event) => event.role === 'user')?.id, 'run-1-admitted-prompt'); + assert.strictEqual(events.filter(isTerminalRuntimeEvent).length, 1); + }); + test('a stop settlement racing finalize commits exactly one terminal run event', async () => { const store = new TinySessionStore(); const settleReachedAppend = deferred(); @@ -1286,7 +1327,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -1309,7 +1349,6 @@ describe('SessionManager terminal ledger invariants', () => { }, updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }, }); @@ -1342,7 +1381,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -1365,7 +1403,6 @@ describe('SessionManager terminal ledger invariants', () => { }, updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }, }); await run.begin(); @@ -1405,7 +1442,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -1428,7 +1464,6 @@ describe('SessionManager terminal ledger invariants', () => { }, updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }, }); await run.begin(); @@ -1483,7 +1518,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -1506,7 +1540,6 @@ describe('SessionManager terminal ledger invariants', () => { }, updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }, }); await run.begin(); @@ -1562,7 +1595,6 @@ describe('SessionManager terminal ledger invariants', () => { sessionId: session.id, header: session, userInput: { turnId: 'turn-1', text: 'hello' }, - store, runStore, runtimeEventStore: runStore, newId: nextId(), @@ -1585,7 +1617,6 @@ describe('SessionManager terminal ledger invariants', () => { }, updateHeader: (sessionId, patch) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }, }); await run.begin(); @@ -2215,6 +2246,23 @@ class TinySessionStore implements SessionStore { return clone(this.messages.get(sessionId) ?? []); } + async readMessagesAfter( + sessionId: string, + request: { afterSequence?: number; maxMessages: number }, + ): Promise<{ + records: readonly { sequence: number; message: StoredMessage }[]; + highWaterSequence: number | null; + }> { + const all = clone(this.messages.get(sessionId) ?? []); + return { + records: all + .map((message, sequence) => ({ sequence, message })) + .filter(({ sequence }) => sequence > (request.afterSequence ?? -1)) + .slice(0, request.maxMessages), + highWaterSequence: all.length > 0 ? all.length - 1 : null, + }; + } + async listTurns(sessionId: string): Promise { return deriveTurnRecords(await this.readMessages(sessionId)); } @@ -2262,6 +2310,8 @@ class TinyAgentRunStore implements AgentRunStore, RuntimeEventStore { private runtimeEventEntries: RuntimeEvent[] = []; /** One-shot append rejections, for latching the store availability. */ failNextRuntimeEventAppends = 0; + /** Event ids the ledger refuses once, the way a transient transition check would. */ + readonly rejectRuntimeEventIdsOnce = new Set(); /** While true every runtime-event read rejects, a store that is down. */ failRuntimeEventReads = false; /** One-shot run-event append rejections, for latching the Run store. */ @@ -2322,6 +2372,9 @@ class TinyAgentRunStore implements AgentRunStore, RuntimeEventStore { if (this.options.rejectRuntimeEventIds?.includes(event.id)) { throw new ToolLedgerRejectionError('orphan_response', event.id); } + if (this.rejectRuntimeEventIdsOnce.delete(event.id)) { + throw new ToolLedgerRejectionError('orphan_response', event.id); + } if (isTerminalRuntimeEvent(event)) await this.options.beforeTerminalRuntimeEventAppend?.(); const eventKey = key(sessionId, runId); this.runtimeEvents.set(eventKey, [...(this.runtimeEvents.get(eventKey) ?? []), clone(event)]); @@ -2580,7 +2633,6 @@ function inertAgentRunHooks(store: TinySessionStore) { updateHeader: (sessionId: string, patch: Partial) => store.updateHeader(sessionId, patch), updateStatus: async () => {}, - appendTurnState: async () => {}, }; } diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index b059a7e8af..beb9cfa921 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -1311,7 +1311,7 @@ describe('SessionManager claimed graph intent execution', () => { }); assert.deepStrictEqual((executions[0] as { content?: unknown }).content, { text: prompt }); assert.partialDeepStrictEqual( - (await store.readMessages(child.id)).find( + (await manager.getMessages(child.id)).find( (message) => message.type === 'user' && message.turnId === claim.targetTurnId, ), { id: 'id-1', text: prompt }, @@ -1461,7 +1461,7 @@ describe('SessionManager claimed graph intent execution', () => { assert.strictEqual(result.status, 'completed'); assert.strictEqual(newIdCallsAtExecution, 0); assert.partialDeepStrictEqual( - (await store.readMessages(child.id)).find( + (await manager.getMessages(child.id)).find( (message) => message.type === 'user' && message.turnId === claim.targetTurnId, ), { @@ -1747,7 +1747,7 @@ describe('SessionManager claimed graph intent execution', () => { assert.strictEqual(run.opening.lineage?.parentRunId, undefined); assert.strictEqual(run.turnId, 'graph-turn'); assert.partialDeepStrictEqual( - (await store.readMessages(child.id)).find( + (await manager.getMessages(child.id)).find( (message) => message.type === 'user' && message.turnId === 'graph-turn', ), { text: 'summarize the routed records' }, @@ -2029,7 +2029,7 @@ describe('SessionManager claimed graph intent execution', () => { [firstClaim.targetTurnId], ); assert.deepStrictEqual( - (await store.readMessages(child.id)).filter( + (await manager.getMessages(child.id)).filter( (message) => 'turnId' in message && (message.turnId === secondClaim.targetTurnId || @@ -2394,8 +2394,8 @@ describe('SessionManager child-session runtime primitive', () => { false, ); - const parentMessages = await store.readMessages(parent.id); - const childMessages = await store.readMessages(result.childSessionId); + const parentMessages = await manager.getMessages(parent.id); + const childMessages = await manager.getMessages(result.childSessionId); assert.strictEqual( parentMessages.some( (message) => message.type === 'user' && message.text === 'inspect the storage boundary', @@ -2932,7 +2932,9 @@ describe('SessionManager child-session runtime primitive', () => { runtimeEventStore: runStore, backends, childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), + // Its own id space: a restarted host mints fresh ids, it does not replay + // the sequence the dead process was on. + newId: nextId('restarted'), now: nextNow(196), }); await drain( @@ -3384,7 +3386,7 @@ describe('SessionManager child-session runtime primitive', () => { assert.strictEqual(runtimeInvocationOutcome(recoveredRun), 'failed'); assert.strictEqual(runtimeInvocationFailureClass(recoveredRun), 'app_restarted'); assert.strictEqual( - (await store.readMessages(child.id)).some( + (await manager.getMessages(child.id)).some( (message) => message.type === 'turn_state' && message.turnId === 'child-turn' && @@ -3453,7 +3455,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => if (complete?.type !== 'complete') throw new Error('expected complete'); assert.strictEqual(complete.contextCompactionOutcome?.kind, 'compacted'); - const messages = await store.readMessages(session.id); + const messages = await manager.getMessages(session.id); assert.strictEqual( messages.some((message) => message.type === 'user' && message.text.includes('compact')), false, @@ -3520,7 +3522,6 @@ describe('SessionManager manual compaction and quiescent session changes', () => createTestAiSdkBackend({ sessionId: ctx.sessionId, header: ctx.header, - appendMessage: async () => {}, connection: { slug: 'mock-main', providerType: 'anthropic', @@ -3618,7 +3619,7 @@ describe('SessionManager manual compaction and quiescent session changes', () => await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); await drain(manager.compactSession(session.id, { turnId: 'turn-compact' })); - const warnings = (await store.readMessages(session.id)).filter( + const warnings = (await manager.getMessages(session.id)).filter( (message) => message.type === 'system_note' && message.turnId === 'turn-compact' && @@ -4161,13 +4162,11 @@ describe('SessionManager manual compaction and quiescent session changes', () => const turn = drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'start' })); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(activatedModels, []); - assert.equal((await store.readHeader(session.id)).transcriptLedgerVersion, undefined); releaseUpdate.release(); await transition; await turn; assert.deepEqual(activatedModels, ['new-model']); - assert.equal((await store.readHeader(session.id)).transcriptLedgerVersion, 1); }); test('backend refresh propagates delayed disposal failure after an active turn settles', async () => { @@ -4591,13 +4590,6 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(summary.permissionMode, 'ask'); assert.deepStrictEqual(summary.labels, ['kept']); assert.deepStrictEqual((await store.readHeader(session.id)).labels, ['kept']); - - const messages = await store.readMessages(session.id); - const modeNote = messages.find( - (message) => message.type === 'system_note' && message.kind === 'mode_change', - ); - if (modeNote?.type !== 'system_note') throw new Error('mode_change note was not written'); - assert.deepStrictEqual(modeNote.data, { from: 'explore', to: 'ask' }); }); test('starts a new turn without workspace identity when safety inspection fails', async () => { @@ -5218,7 +5210,7 @@ describe('SessionManager permission mode updates', () => { { kind: 'workspace_file', value: '@accepted.ts', label: 'accepted.ts', start: 0 }, ], }); - const storedUserMessage = (await store.readMessages(session.id)).find( + const storedUserMessage = (await manager.getMessages(session.id)).find( (message) => message.type === 'user' && message.turnId === 'turn-snapshot', ); assert.deepStrictEqual( @@ -5264,7 +5256,6 @@ describe('SessionManager permission mode updates', () => { createTestAiSdkBackend({ sessionId: ctx.sessionId, header: ctx.header, - appendMessage: ctx.appendMessage ?? (async () => {}), connection: { slug: 'mock-main', providerType: 'anthropic', @@ -5548,10 +5539,6 @@ describe('SessionManager permission mode updates', () => { continuationEvents.some((event) => event.role === 'user'), false, ); - assert.strictEqual( - (await store.readMessages(session.id)).some((message) => message.type === 'user'), - false, - ); assert.deepStrictEqual( (await runStore.readRuntimeEvents(session.id, sourceRunId)).slice(1), sourceEvents, @@ -5615,7 +5602,6 @@ describe('SessionManager permission mode updates', () => { createTestAiSdkBackend({ sessionId: ctx.sessionId, header: ctx.header, - appendMessage: ctx.appendMessage ?? (async () => {}), connection: { slug: ctx.header.llmConnectionSlug, providerType: 'anthropic', @@ -5938,7 +5924,7 @@ describe('SessionManager permission mode updates', () => { await refresh; assert.strictEqual(store.disposeCount, 1); - const cachedMessages = await store.readMessages(session.id); + const cachedMessages = await manager.getMessages(session.id); assert.partialDeepStrictEqual( cachedMessages .filter( @@ -7296,6 +7282,7 @@ describe('SessionManager permission mode updates', () => { now: nextNow(7_025), }); const session = await manager.createSession(makeInput()); + await store.updateHeader(session.id, { transcriptLedgerVersion: 0 }); await store.appendMessages(session.id, [ { type: 'user', id: 'imported-user-1', turnId: 'turn-1', ts: 101, text: 'First question' }, { @@ -7333,10 +7320,19 @@ describe('SessionManager permission mode updates', () => { }, ]); + // The first conversion dies partway through. A staged import stays staged + // until one whole conversion lands, so the retry is another import — a live + // Turn is refused meanwhile, and the second pass re-derives the same event + // ids and appends only what the interrupted one never wrote. await expectRejects( manager.prepareImportedSessionHistory(session.id), /runtime event append failed/, ); + await expectRejects( + drain(manager.sendMessage(session.id, { turnId: 'turn-early', text: 'Too early' })), + /history is still being prepared/, + ); + await manager.prepareImportedSessionHistory(session.id); await seedRuntimeRun( runStore, makeRunHeader({ @@ -7589,17 +7585,6 @@ describe('SessionManager permission mode updates', () => { hint: 'write approval', }, ); - - const cachedView = await new RuntimeReadModel({ - runtimeEventStore: runStore, - projectionCache: { - readMessages: async () => - messages.filter((message) => message.type !== 'permission_decision'), - }, - canonicalPermissionOutcomes, - }).getSessionView(header.sessionId); - - assert.deepStrictEqual(cachedView.diagnostics, []); }); test('SessionManager joins a canonical hosted permission without a ledger request', async () => { @@ -8221,7 +8206,7 @@ describe('SessionManager permission mode updates', () => { ); }); - test('getMessages includes in-flight projection cache rows for an active RuntimeEvent run', async () => { + test('getMessages reads an active run from its own ledger', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); const manager = makeManagerForReadCutover(store, runStore); @@ -8236,7 +8221,51 @@ describe('SessionManager permission mode updates', () => { assistantText: 'completed answer', legacyIdPrefix: 'legacy', }); - const activeMessages: StoredMessage[] = [ + const activeHeader = makeRunHeader({ + sessionId: session.id, + runId: 'run-2', + turnId: 'turn-2', + status: 'running', + createdAt: 200, + updatedAt: 203, + }); + await seedInvocationFromHeader(runStore, activeHeader); + await runStore.appendRuntimeEvent( + session.id, + 'run-2', + runtimeEvent({ + id: 'active-user-event', + sessionId: session.id, + runId: 'run-2', + turnId: 'turn-2', + ts: 201, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'active question' }, + refs: { storedMessageId: 'active-user' }, + }), + ); + // Still arriving: the row a reader sees now, with more of it to come. + await runStore.appendRuntimeEvent( + session.id, + 'run-2', + runtimeEvent({ + id: 'active-assistant-event', + sessionId: session.id, + runId: 'run-2', + turnId: 'turn-2', + ts: 202, + partial: true, + role: 'model', + author: 'agent', + content: { kind: 'text', text: 'partial active answer' }, + refs: { storedMessageId: 'active-assistant' }, + }), + ); + + const messages = await manager.getMessages(session.id); + assert.deepStrictEqual(messages, [ + ...completed.projectedMessages, { type: 'user', id: 'active-user', turnId: 'turn-2', ts: 201, text: 'active question' }, { type: 'assistant', @@ -8246,30 +8275,7 @@ describe('SessionManager permission mode updates', () => { text: 'partial active answer', modelId: 'fake-model', }, - { - type: 'turn_state', - id: 'active-state', - turnId: 'turn-2', - ts: 203, - status: 'running', - partialOutputRetained: true, - }, - ]; - await store.appendMessages(session.id, activeMessages); - await seedInvocationFromHeader( - runStore, - makeRunHeader({ - sessionId: session.id, - runId: 'run-2', - turnId: 'turn-2', - status: 'running', - createdAt: 200, - updatedAt: 203, - }), - ); - - const messages = await manager.getMessages(session.id); - assert.deepStrictEqual(messages, [...completed.projectedMessages, ...activeMessages]); + ]); assert.deepStrictEqual(await manager.listTurns(session.id), [ { turnId: 'turn-1', @@ -8284,19 +8290,6 @@ describe('SessionManager permission mode updates', () => { partialOutputRetained: true, }, ]); - - const view = await new RuntimeReadModel({ - runtimeEventStore: runStore, - projectionCache: store, - }).getSessionView(session.id); - assert.strictEqual( - view.diagnostics.some( - (diagnostic) => - diagnostic.code === 'incomplete_event' && - diagnostic.message.includes('in-flight projection cache'), - ), - true, - ); }); test('getMessages overlays a canonical permission acceptance from a running ledger', async () => { @@ -8480,7 +8473,6 @@ describe('SessionManager permission mode updates', () => { const view = await new RuntimeReadModel({ runtimeEventStore: runStore, - projectionCache: store, }).getSessionView(session.id); const readRequestId = (value: unknown): string[] => @@ -8677,12 +8669,12 @@ describe('SessionManager permission mode updates', () => { userText: 'runtime regenerate text', assistantText: 'runtime answer', legacyIdPrefix: 'legacy', + legacyUserText: 'stale transcript text', }); - store.failNextReadMessagesFor.set(session.id, 1); await drain(manager.regenerateTurn(session.id, { sourceTurnId: 'source', turnId: 'regen-1' })); - const messages = await store.readMessages(session.id); + const messages = await manager.getMessages(session.id); const regenUser = messages.find( (message) => message.type === 'user' && message.turnId === 'regen-1', ); @@ -8812,13 +8804,13 @@ describe('SessionManager permission mode updates', () => { }), ], ); - store.failNextReadMessagesFor.set(session.id, 1); - + // The transcript store holds no source rows at all, so what regenerate + // finds can only have come from the ledger. await drain( manager.regenerateTurn(session.id, { sourceTurnId: 'source', turnId: 'regen-aborted' }), ); - const regenUser = (await store.readMessages(session.id)).find( + const regenUser = (await manager.getMessages(session.id)).find( (message) => message.type === 'user' && message.turnId === 'regen-aborted', ); assert.strictEqual( @@ -9449,12 +9441,7 @@ describe('SessionManager permission mode updates', () => { while (!(await turn.next()).done) {} assert.strictEqual(backend?.stopCalls, 1); - const messages = await store.readMessages(session.id); - assert.strictEqual( - messages.filter((message) => message.type === 'system_note' && message.kind === 'abort') - .length, - 1, - ); + const messages = await manager.getMessages(session.id); assert.strictEqual( messages.filter( (message) => @@ -9629,9 +9616,19 @@ describe('SessionManager permission mode updates', () => { assert.strictEqual(backend?.sendInputs?.length, 2); }); - test('stopSession retries only unfinished projections', async () => { + test('stopSession retries an unsettled abort without a second backend stop', async () => { const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); + // The stop's own terminal fact fails once. Nothing else records the abort, + // so the retry has to settle the ledger — and must not reach the backend a + // second time to do it. + let failAbortAppend = false; + const runStore = new MemoryAgentRunStore({ + beforeRuntimeEventAppend: (_sessionId, _runId, event) => { + if (!failAbortAppend || event.status !== 'aborted') return; + failAbortAppend = false; + throw new Error('append runtime event failed'); + }, + }); const backends = new BackendRegistry(); const sendGate = makeGate(); let backend: CountingStopBackend | undefined; @@ -9652,17 +9649,16 @@ describe('SessionManager permission mode updates', () => { .sendMessage(session.id, { turnId: 'turn-1', text: 'hello' }) [Symbol.asyncIterator](); await turn.next(); - store.failAfterNextAppendMessage = (message) => - message.type === 'system_note' && message.kind === 'abort'; + failAbortAppend = true; await expectRejects( manager.stopSession(session.id, { source: 'stop_button' }), - /append message failed/, + /append runtime event failed/, ); await manager.stopSession(session.id, { source: 'stop_button' }); assert.strictEqual(backend?.stopCalls, 1); - const messages = await store.readMessages(session.id); + const messages = await manager.getMessages(session.id); assert.strictEqual( messages.filter( (message) => @@ -9672,11 +9668,6 @@ describe('SessionManager permission mode updates', () => { ).length, 1, ); - assert.strictEqual( - messages.filter((message) => message.type === 'system_note' && message.kind === 'abort') - .length, - 1, - ); sendGate.release(); while (!(await turn.next()).done) {} }); @@ -9717,7 +9708,7 @@ describe('SessionManager permission mode updates', () => { await manager.stopSession(session.id, { source: 'stop_button' }); assert.strictEqual(backend?.stopCalls, 1); - const messages = await store.readMessages(session.id); + const messages = await manager.getMessages(session.id); assert.strictEqual( messages.filter( (message) => @@ -9727,11 +9718,6 @@ describe('SessionManager permission mode updates', () => { ).length, 1, ); - assert.strictEqual( - messages.filter((message) => message.type === 'system_note' && message.kind === 'abort') - .length, - 1, - ); }); test('agent projections list catalog definitions separately from child runs and read output artifacts by child turn', async () => { @@ -10214,12 +10200,12 @@ describe('SessionManager permission mode updates', () => { const header = await store.readHeader(session.id); assert.strictEqual(header.status, 'blocked'); assert.strictEqual(header.blockedReason, 'unknown'); - const messages = await store.readMessages(session.id); + const messages = await manager.getMessages(session.id); assert.strictEqual( messages.some((message) => message.type === 'user' && message.turnId === 'turn-1'), true, ); - const turn = (await store.listTurns(session.id)).find( + const turn = (await manager.listTurns(session.id)).find( (candidate) => candidate.turnId === 'turn-1', ); assert.strictEqual(turn?.status, 'failed'); @@ -10531,7 +10517,7 @@ describe('SessionManager permission mode updates', () => { await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - const [turn] = await store.listTurns(session.id); + const [turn] = await manager.listTurns(session.id); assert.strictEqual(turn?.status, 'failed'); assert.strictEqual(turn?.errorClass, 'runtime_error'); const [run] = await runStore.listSessionInvocations(session.id); @@ -10559,7 +10545,7 @@ describe('SessionManager permission mode updates', () => { await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); assert.strictEqual((await store.readHeader(session.id)).status, 'active'); - const [turn] = await store.listTurns(session.id); + const [turn] = await manager.listTurns(session.id); assert.strictEqual(turn?.status, 'failed'); assert.strictEqual(turn?.errorClass, 'tool_step_cap_reached'); const [run] = await runStore.listSessionInvocations(session.id); @@ -10577,6 +10563,7 @@ describe('SessionManager permission mode updates', () => { test('does not let a late complete event overwrite a prior turn error', async () => { const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); backends.register( 'ai-sdk', @@ -10586,29 +10573,44 @@ describe('SessionManager permission mode updates', () => { { type: 'complete', stopReason: 'end_turn' }, ]), ); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(10_500) }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(10_500), + }); const session = await manager.createSession(makeInput()); await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - const states = (await store.readMessages(session.id)).filter( + const states = (await manager.getMessages(session.id)).filter( (message) => message.type === 'turn_state' && message.turnId === 'turn-1', ); assert.deepStrictEqual( states.map((state) => (state.type === 'turn_state' ? state.status : '')), - ['running', 'failed'], + ['failed'], ); - const [turn] = await store.listTurns(session.id); + const [turn] = await manager.listTurns(session.id); assert.strictEqual(turn?.status, 'failed'); assert.strictEqual(turn?.errorClass, 'tool_failed'); }); test('stopSession records renderer abort source for diagnostics', async () => { const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); const gate = makeGate(); backends.register('ai-sdk', (ctx) => new TestBackend(ctx, gate)); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(12_500) }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(12_500), + }); const session = await manager.createSession(makeInput()); const iterator = manager @@ -10617,15 +10619,9 @@ describe('SessionManager permission mode updates', () => { await iterator.next(); await manager.stopSession(session.id, { source: 'stop_button' }); - const [turn] = await store.listTurns(session.id); + const [turn] = await manager.listTurns(session.id); assert.strictEqual(turn?.status, 'aborted'); assert.strictEqual(turn?.abortSource, 'renderer.stop_button'); - const abortNote = (await store.readMessages(session.id)).find( - (message) => message.type === 'system_note' && message.kind === 'abort', - ); - assert.strictEqual(abortNote?.type, 'system_note'); - if (abortNote?.type !== 'system_note') throw new Error('abort note missing'); - assert.deepStrictEqual(abortNote.data, { source: 'renderer.stop_button' }); }); test('stopSession persists abortSource on a terminal RuntimeEvent emitted during backend stop', async () => { @@ -10713,7 +10709,7 @@ describe('SessionManager permission mode updates', () => { ); const [run] = await runStore.listSessionInvocations(session.id); const runtimeEvents = await runStore.readRuntimeEvents(session.id, run!.runId); - const turnStates = (await store.readMessages(session.id)).filter( + const turnStates = (await manager.getMessages(session.id)).filter( (message) => message.type === 'turn_state' && message.turnId === 'turn-1' && @@ -10766,7 +10762,7 @@ describe('SessionManager permission mode updates', () => { ); const [run] = await runStore.listSessionInvocations(session.id); const runtimeEvents = await runStore.readRuntimeEvents(session.id, run!.runId); - const turnStates = (await store.readMessages(session.id)).filter( + const turnStates = (await manager.getMessages(session.id)).filter( (message) => message.type === 'turn_state' && message.turnId === 'turn-1' && @@ -10821,7 +10817,7 @@ describe('SessionManager permission mode updates', () => { await iterator.next(); assert.strictEqual((await store.readHeader(session.id)).status, 'aborted'); - const [turn] = await store.listTurns(session.id); + const [turn] = await manager.listTurns(session.id); assert.strictEqual(turn?.status, 'aborted'); assert.strictEqual(turn?.abortSource, 'renderer.stop_button'); const [run] = await runStore.listSessionInvocations(session.id); @@ -11157,11 +11153,19 @@ describe('SessionManager permission mode updates', () => { assert.deepStrictEqual(checkpointCoverage, [10]); }); - test('startup recovery marks persisted running turns as failed instead of leaving them stuck', async () => { + test('startup recovery unsticks a legacy transcript Session and its import settles the turns', async () => { const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); const backends = new BackendRegistry(); backends.register('ai-sdk', (ctx) => new TestBackend(ctx)); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(12_800) }); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(12_800), + }); const running = await manager.createSession(makeInput({ status: 'running' })); const waiting = await manager.createSession(makeInput({ status: 'waiting_for_user' })); const activeStuck = await manager.createSession(makeInput({ status: 'active' })); @@ -11253,43 +11257,42 @@ describe('SessionManager permission mode updates', () => { }, ]); + // These Sessions predate the ledger: their transcript is all they have. + for (const seeded of [running, waiting, activeStuck, failedThenCompleted, activeDone]) { + store.markPreLedgerSession(seeded.id); + } + + // Recovery owns only what it can still decide without a ledger: a header + // left mid-turn by a crash. Nothing here re-reads the transcript to guess a + // turn's outcome — the import below is the one path that converts it. const recovered = await manager.recoverInterruptedSessions(); - assert.deepStrictEqual(recovered, [ - running.id, - waiting.id, - activeStuck.id, - failedThenCompleted.id, - ]); + assert.deepStrictEqual(recovered, [running.id, waiting.id]); assert.strictEqual((await store.readHeader(running.id)).status, 'active'); assert.strictEqual((await store.readHeader(waiting.id)).status, 'active'); assert.strictEqual((await store.readHeader(activeStuck.id)).status, 'active'); assert.strictEqual((await store.readHeader(failedThenCompleted.id)).status, 'active'); assert.strictEqual((await store.readHeader(activeDone.id)).status, 'active'); - const runningTurn = (await store.listTurns(running.id)).find( - (turn) => turn.turnId === 'running-turn', - ); - const waitingTurn = (await store.listTurns(waiting.id)).find( - (turn) => turn.turnId === 'waiting-turn', - ); - const activeStuckTurn = (await store.listTurns(activeStuck.id)).find( - (turn) => turn.turnId === 'active-stuck-turn', - ); - const failedThenCompletedTurn = (await store.listTurns(failedThenCompleted.id)).find( - (turn) => turn.turnId === 'failed-completed-turn', - ); - const activeTurn = (await store.listTurns(activeDone.id)).find( - (turn) => turn.turnId === 'active-turn', + + const turnOf = async (sessionId: string, turnId: string) => + (await manager.listTurns(sessionId)).find((turn) => turn.turnId === turnId); + // A turn the transcript never recorded an ending for converts to the + // failure it actually was, rather than to an inferred restart class. + for (const [sessionId, turnId] of [ + [running.id, 'running-turn'], + [waiting.id, 'waiting-turn'], + [activeStuck.id, 'active-stuck-turn'], + ] as const) { + const turn = await turnOf(sessionId, turnId); + assert.strictEqual(turn?.status, 'failed'); + assert.strictEqual(turn?.errorClass, 'missing_terminal_event'); + } + // A recorded ending is imported as recorded, last state wins. + assert.strictEqual( + (await turnOf(failedThenCompleted.id, 'failed-completed-turn'))?.status, + 'completed', ); - assert.strictEqual(runningTurn?.status, 'failed'); - assert.strictEqual(runningTurn?.errorClass, 'app_restarted'); - assert.strictEqual(waitingTurn?.status, 'failed'); - assert.strictEqual(waitingTurn?.errorClass, 'app_restarted'); - assert.strictEqual(activeStuckTurn?.status, 'failed'); - assert.strictEqual(activeStuckTurn?.errorClass, 'app_restarted'); - assert.strictEqual(failedThenCompletedTurn?.status, 'failed'); - assert.strictEqual(failedThenCompletedTurn?.errorClass, 'tool_failed'); - assert.strictEqual(activeTurn?.status, 'completed'); + assert.strictEqual((await turnOf(activeDone.id, 'active-turn'))?.status, 'completed'); }); test('startup recovery derives the interrupted outcome sink from the runtime store', async () => { @@ -11472,7 +11475,7 @@ describe('SessionManager permission mode updates', () => { await manager.recoverInterruptedSessions(); assert.strictEqual((await store.readHeader(session.id)).status, 'active'); - const [turn] = await store.listTurns(session.id); + const [turn] = await manager.listTurns(session.id); assert.strictEqual(turn?.status, 'failed'); // This turn owned the pending request, so its failure names the closure // rather than the bare restart. @@ -11546,7 +11549,7 @@ describe('SessionManager permission mode updates', () => { await manager.recoverInterruptedSessions(); - const [turn] = await store.listTurns(session.id); + const [turn] = await manager.listTurns(session.id); assert.strictEqual(turn?.errorClass, 'app_restarted'); }); }); @@ -11612,7 +11615,6 @@ async function steeringDeliverySession( createTestAiSdkBackend({ sessionId: ctx.sessionId, header: ctx.header, - appendMessage: async () => {}, connection: { slug: 'mock-main', providerType: 'anthropic', @@ -12697,6 +12699,15 @@ class MemorySessionStore implements SessionStore { nextReadHeaderGate: { started: Gate; release: Gate } | undefined; nextGraphOperatorProvisionGate: { started: Gate; release: Gate } | undefined; + /** A Session written before the header carried a transcript ledger version. */ + markPreLedgerSession(sessionId: string): void { + const header = this.headers.get(sessionId); + if (!header) throw new Error(`Unknown session ${sessionId}`); + const { transcriptLedgerVersion: _version, ...legacy } = header; + void _version; + this.headers.set(sessionId, legacy); + } + async createSubagent( input: CreateSessionInput, initialBoundary?: ExecutionBoundary, @@ -12796,6 +12807,7 @@ class MemorySessionStore implements SessionStore { permissionMode: input.permissionMode, collaborationMode: input.collaborationMode ?? 'agent', orchestrationMode: input.orchestrationMode ?? 'default', + transcriptLedgerVersion: 1, schemaVersion: 1, }; this.headers.set(header.id, header); @@ -12921,6 +12933,23 @@ class MemorySessionStore implements SessionStore { return [...(this.messages.get(sessionId) ?? [])]; } + async readMessagesAfter( + sessionId: string, + request: { afterSequence?: number; maxMessages: number }, + ): Promise<{ + records: readonly { sequence: number; message: StoredMessage }[]; + highWaterSequence: number | null; + }> { + const all = await this.readMessages(sessionId); + return { + records: all + .map((message, sequence) => ({ sequence, message })) + .filter(({ sequence }) => sequence > (request.afterSequence ?? -1)) + .slice(0, request.maxMessages), + highWaterSequence: all.length > 0 ? all.length - 1 : null, + }; + } + async listTurns(sessionId: string): Promise { if (this.failListTurnsFor.has(sessionId)) throw new Error(`Cannot list turns for ${sessionId}`); return deriveTurnRecords(await this.readMessages(sessionId)); @@ -13198,7 +13227,11 @@ class MemoryAgentRunStore this.options.failRuntimeEventAppendAfter = undefined; throw new Error('runtime event append failed'); } - assertDoubleRunNotSealed(this.runtimeEvents.get(key(sessionId, runId)) ?? [], event); + const existing = this.runtimeEvents.get(key(sessionId, runId)) ?? []; + // Same identity, same event: the store writes an id once, so a retry of an + // interrupted append lands on what is already there instead of a copy. + if (event.partial !== true && existing.some((candidate) => candidate.id === event.id)) return; + assertDoubleRunNotSealed(existing, event); this.seedRuntimeEvent(sessionId, runId, event); } @@ -14224,6 +14257,8 @@ async function seedRuntimeReadTurn(input: { userText: string; assistantText: string; legacyIdPrefix: string; + /** Says something else in the transcript store, so a reader proves its source. */ + legacyUserText?: string; }): Promise<{ legacyMessages: StoredMessage[]; projectedMessages: StoredMessage[] }> { const header = makeRunHeader({ sessionId: input.sessionId, @@ -14275,7 +14310,7 @@ async function seedRuntimeReadTurn(input: { id: `${input.legacyIdPrefix}-user`, turnId: input.turnId, ts: 101, - text: input.userText, + text: input.legacyUserText ?? input.userText, }, { type: 'assistant', diff --git a/packages/runtime/src/__tests__/session-projection-helpers.test.ts b/packages/runtime/src/__tests__/session-projection-helpers.test.ts index cda0243994..def11a42fe 100644 --- a/packages/runtime/src/__tests__/session-projection-helpers.test.ts +++ b/packages/runtime/src/__tests__/session-projection-helpers.test.ts @@ -19,15 +19,12 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { StoredMessage } from '@maka/core/session'; import { buildStatusPatch, - buildTurnStateMessage, isTerminalRunStatus, normalizeStopSessionSource, statusFromEvent, turnStatusFromEvent, - turnHasRetainedOutput, workHubDirectStopAbortSource, } from '../session-projection-helpers.js'; @@ -64,80 +61,6 @@ describe('session projection helpers', () => { }); }); - test('buildTurnStateMessage preserves lineage and terminal status fields', () => { - assert.deepStrictEqual( - buildTurnStateMessage({ - id: 'state-1', - turnId: 'turn-1', - ts: 100, - status: 'aborted', - lineage: { - parentTurnId: 'parent', - retriedFromTurnId: 'retry-source', - regeneratedFromTurnId: 'regen-source', - branchOfTurnId: 'branch-source', - parentSessionId: 'parent-session', - }, - abortSource: 'renderer.stop_button', - partialOutputRetained: true, - }), - { - type: 'turn_state', - id: 'state-1', - turnId: 'turn-1', - ts: 100, - status: 'aborted', - parentTurnId: 'parent', - retriedFromTurnId: 'retry-source', - regeneratedFromTurnId: 'regen-source', - branchOfTurnId: 'branch-source', - parentSessionId: 'parent-session', - abortedAt: 100, - abortSource: 'renderer.stop_button', - partialOutputRetained: true, - }, - ); - - assert.partialDeepStrictEqual( - buildTurnStateMessage({ - id: 'state-2', - turnId: 'turn-2', - ts: 101, - status: 'failed', - partialOutputRetained: false, - }), - { - type: 'turn_state', - id: 'state-2', - turnId: 'turn-2', - ts: 101, - status: 'failed', - errorClass: 'unknown', - partialOutputRetained: false, - }, - ); - }); - - test('turnHasRetainedOutput only treats visible assistant text and tool results as retained output', () => { - const messages: StoredMessage[] = [ - { type: 'assistant', id: 'blank', turnId: 'turn-1', ts: 1, text: ' ', modelId: 'model' }, - { type: 'assistant', id: 'other', turnId: 'turn-2', ts: 2, text: 'kept', modelId: 'model' }, - { - type: 'tool_result', - id: 'tool', - turnId: 'turn-3', - ts: 3, - toolUseId: 'call-1', - isError: false, - content: { kind: 'text', text: 'ok' }, - }, - ]; - - assert.strictEqual(turnHasRetainedOutput(messages, 'turn-1'), false); - assert.strictEqual(turnHasRetainedOutput(messages, 'turn-2'), true); - assert.strictEqual(turnHasRetainedOutput(messages, 'turn-3'), true); - }); - test('projects terminal run statuses and session terminal events', () => { assert.strictEqual(isTerminalRunStatus('completed'), true); assert.strictEqual(isTerminalRunStatus('failed'), true); diff --git a/packages/runtime/src/__tests__/shell-run-tool-result.test.ts b/packages/runtime/src/__tests__/shell-run-tool-result.test.ts index fb43b07e9a..8f01f16def 100644 --- a/packages/runtime/src/__tests__/shell-run-tool-result.test.ts +++ b/packages/runtime/src/__tests__/shell-run-tool-result.test.ts @@ -154,7 +154,7 @@ describe('shell run sandbox denial projection', () => { content, }); - const messages = await store.readMessagesForRecovery(session.id); + const messages = await store.readMessages(session.id); const result = messages.find((message) => message.id === 'tool-result-1'); assert.deepEqual(result?.type === 'tool_result' ? result.content : undefined, content); } finally { diff --git a/packages/runtime/src/__tests__/subagent-tools.test.ts b/packages/runtime/src/__tests__/subagent-tools.test.ts index 3eb0cdaa18..0a26942d44 100644 --- a/packages/runtime/src/__tests__/subagent-tools.test.ts +++ b/packages/runtime/src/__tests__/subagent-tools.test.ts @@ -1026,7 +1026,6 @@ function makeChildToolRuntime(cwd: string): ToolRuntime { header: childHeader(cwd), connection: testConnection(), modelId: 'mock-model', - appendMessage: async () => {}, newId: nextId(), now: () => 1, getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/__tests__/tool-args-violation.test.ts b/packages/runtime/src/__tests__/tool-args-violation.test.ts index efbbf44773..3b140e34a7 100644 --- a/packages/runtime/src/__tests__/tool-args-violation.test.ts +++ b/packages/runtime/src/__tests__/tool-args-violation.test.ts @@ -238,7 +238,6 @@ test('ToolRuntime validates without rewriting arguments at permission and implem header: header(), connection: connection(), modelId: 'mock-model', - appendMessage: async () => {}, newId: nextId(), now: () => 1, getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/__tests__/tool-artifacts.test.ts b/packages/runtime/src/__tests__/tool-artifacts.test.ts index 3f5056819b..4a8828f8c3 100644 --- a/packages/runtime/src/__tests__/tool-artifacts.test.ts +++ b/packages/runtime/src/__tests__/tool-artifacts.test.ts @@ -173,7 +173,6 @@ function makeToolRuntime(overrides: Partial = {}): { header: testHeader(), connection: testConnection(), modelId: 'mock-model', - appendMessage: async () => {}, newId: nextId(), now: () => 1, getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/__tests__/tool-result-archive-capability-backend.test.ts b/packages/runtime/src/__tests__/tool-result-archive-capability-backend.test.ts index 659c854215..3dd86309b0 100644 --- a/packages/runtime/src/__tests__/tool-result-archive-capability-backend.test.ts +++ b/packages/runtime/src/__tests__/tool-result-archive-capability-backend.test.ts @@ -236,7 +236,6 @@ function backendWith( return createTestAiSdkBackend({ sessionId: 'session-1', header: header(), - appendMessage: async () => {}, connection: connection(), apiKey: 'sk-test', modelId: 'mock-model-id', diff --git a/packages/runtime/src/__tests__/tool-runtime-argument-ownership.test.ts b/packages/runtime/src/__tests__/tool-runtime-argument-ownership.test.ts index b421bf365b..bf3ff78783 100644 --- a/packages/runtime/src/__tests__/tool-runtime-argument-ownership.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-argument-ownership.test.ts @@ -49,10 +49,6 @@ describe('ToolRuntime argument ownership', () => { header: testHeader(), connection: testConnection(), modelId: 'test-model', - appendMessage: async (message) => { - if (message.type !== 'tool_call') return; - observeAndMutate(observed, 'storage', message.args); - }, newId: nextId(), now: () => 1, getPermissionPauseTarget: () => null, @@ -90,7 +86,7 @@ describe('ToolRuntime argument ownership', () => { }); mutateArgs(providerArgs, 'provider'); - const owners = ['storage', 'event', 'implementation', 'artifact']; + const owners = ['event', 'implementation', 'artifact']; for (const owner of owners) { assert.deepEqual(observed.get(owner), initialArgs); } diff --git a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts index 2c68e24c32..aef99fbb26 100644 --- a/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-durable-boundary.test.ts @@ -35,7 +35,6 @@ import { ToolRuntime, type MakaTool, type RuntimeManagedMutationAdmission, - type ToolRuntimeInput, } from '../tool-runtime.js'; describe('ToolRuntime durable boundary', () => { @@ -1726,7 +1725,7 @@ function makeHarness( sink: RuntimeCommitSink, order?: string[], runId: string | null = 'run-1', - overrides: Partial = {}, + overrides: Partial[0]> = {}, ) { const messages: StoredMessage[] = []; const events: SessionEvent[] = []; diff --git a/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts b/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts index 8959f36e04..0bb97be2fe 100644 --- a/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts @@ -96,7 +96,6 @@ function runtime(events: SessionEvent[]) { header: header(), connection: { providerType: 'openai', slug: 'c' } as never, modelId: 'm', - appendMessage: async () => {}, newId: () => `id-${++id}`, now: () => 1, getPermissionPauseTarget: () => null, @@ -212,7 +211,6 @@ describe('ToolRuntime form Interaction', () => { header: header(), connection: { providerType: 'openai', slug: 'c' } as never, modelId: 'm', - appendMessage: async () => {}, newId: (() => { let id = 0; return () => `id-${++id}`; @@ -270,7 +268,6 @@ describe('ToolRuntime form Interaction', () => { header: header(), connection: { providerType: 'openai', slug: 'c' } as never, modelId: 'm', - appendMessage: async () => {}, newId: (() => { let id = 0; return () => `id-${++id}`; diff --git a/packages/runtime/src/__tests__/tool-runtime-progress.test.ts b/packages/runtime/src/__tests__/tool-runtime-progress.test.ts index 142e7f561d..0e77d22ecd 100644 --- a/packages/runtime/src/__tests__/tool-runtime-progress.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-progress.test.ts @@ -34,7 +34,6 @@ test('ToolRuntime emits only valid progress through the shared codec', async () header: testHeader(), connection: testConnection(), modelId: 'test-model', - appendMessage: async () => {}, newId: nextId(), now: () => 1, getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts index e4a71162ac..4b546b7c21 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -55,7 +55,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, newId: nextId(), now: () => 1, getPermissionPauseTarget: () => null, @@ -73,7 +72,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -117,7 +115,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => managed, createSandboxBoundaryRequest: async (input) => { created = { @@ -239,7 +236,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => managed, newId: nextId(), now: () => 1, @@ -321,7 +317,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -379,7 +374,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(root), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -458,7 +452,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(root), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -518,7 +511,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => managed, createSandboxBoundaryRequest: async (input) => { created = { @@ -594,7 +586,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => managed, createSandboxBoundaryRequest: async () => { markCreateStarted(); @@ -659,7 +650,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -722,7 +712,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -796,7 +785,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -877,7 +865,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -933,7 +920,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), @@ -1002,7 +988,6 @@ describe('ToolRuntime session sandbox boundary', () => { header: header(), connection: { providerType: 'openai', slug: 'test' } as never, modelId: 'test', - appendMessage: async () => {}, readExecutionBoundary: async () => ({ kind: 'managed', profile: createWorkspaceWritePermissionProfile(), diff --git a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts index 2540f554b2..c6d9c4be40 100644 --- a/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-settlement.test.ts @@ -654,7 +654,6 @@ function makeRuntime( header: header(), connection: connection(), modelId: 'model-1', - appendMessage: async () => {}, newId: nextId(), now: () => 1, getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index 6146bae39b..1998048d03 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -81,7 +81,6 @@ describe('ToolRuntime with real SQLite boundary', () => { header: header(), connection: connection(), modelId: 'model-1', - appendMessage: async () => {}, newId: nextId(), now: nextNow(), getPermissionPauseTarget: () => null, @@ -162,7 +161,6 @@ describe('ToolRuntime with real SQLite boundary', () => { header: header(), connection: connection(), modelId: 'model-1', - appendMessage: async () => {}, readExecutionBoundary: async () => createGenesisExecutionBoundary('ask'), newId: nextId(), now: nextNow(), @@ -238,7 +236,6 @@ describe('ToolRuntime with real SQLite boundary', () => { header: header(), connection: connection(), modelId: 'model-1', - appendMessage: async () => {}, newId: nextId(), now: nextNow(), getPermissionPauseTarget: () => null, @@ -337,7 +334,6 @@ describe('ToolRuntime with real SQLite boundary', () => { header: header(), connection: connection(), modelId: 'model-1', - appendMessage: async () => {}, newId: nextId(), now: nextNow(), getPermissionPauseTarget: () => null, @@ -426,7 +422,6 @@ describe('ToolRuntime with real SQLite boundary', () => { header: header(), connection: connection(), modelId: 'model-1', - appendMessage: async () => {}, newId: nextId(), now: nextNow(), getPermissionPauseTarget: () => null, @@ -634,7 +629,6 @@ describe('ToolRuntime with real SQLite boundary', () => { header: header(), connection: connection(), modelId: 'model-1', - appendMessage: async () => {}, newId: nextId(), now: nextNow(), getPermissionPauseTarget: () => null, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index ff919edf1d..f9542b2f6a 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -59,17 +59,16 @@ import type { SessionHeader, SessionHeaderPatch, SessionStatus, - StoredMessage, - SystemNoteMessage, - TurnRecord, + RuntimeSystemNoteKind, UserMessage, + AssistantMessage, } from '@maka/core/session'; import type { UserMessageInput } from '@maka/core/runtime-inputs'; import { resolveEffectiveOrchestration, type EffectiveOrchestration, } from '@maka/core/orchestration'; -import { messageContentsEqual, type SessionEvent } from '@maka/core/events'; +import type { SessionEvent } from '@maka/core/events'; import type { AgentBackend, BackendSendInput } from '@maka/core/backend-types'; import type { RunTraceEvent } from './run-trace.js'; import type { StopSessionInput } from './session-manager.js'; @@ -85,6 +84,7 @@ import { statusFromEvent, turnStatusFromEvent, } from './session-projection-helpers.js'; +import { admittedPromptEventId } from './message-authority.js'; import { commitOrCreateTerminalRunFact } from './terminal-run-commit.js'; import type { RuntimeContinuation } from './runtime-resume.js'; import { @@ -92,8 +92,8 @@ import { type RuntimeContinuationStartAdmissionProof, } from './runtime-continuation-admission.js'; import { DEFAULT_TOOL_MODE, isToolMode, type ToolMode } from '@maka/core/tool-mode'; -import { materializeRuntimeEventTranscriptProjection } from './runtime-ledger-repair.js'; import { cloneAndFreezeRuntimeSnapshot } from './runtime-snapshot.js'; +import { projectRuntimeEventUserMessage } from './runtime-event-read-model.js'; export interface AgentRunActiveSession { sessionId: string; @@ -117,12 +117,15 @@ export interface AgentRunHooks { blockedReason?: SessionBlockedReason, ts?: number, ): Promise; - appendTurnState( + /** + * The catalog facts a durable message carries — its time, the Session list's + * preview line, and the connection lock a Session takes on its first user + * message. The transcript write used to commit these on its way to disk; the + * ledger is not that store, so the run commits them here instead. + */ + commitMessageProjection?( sessionId: string, - turnId: string, - status: TurnRecord['status'], - lineage?: AgentRunLineage, - options?: { ts?: number; errorClass?: string; abortSource?: string }, + message: UserMessage | AssistantMessage, ): Promise; } @@ -150,7 +153,6 @@ export interface AgentRunInput { runId?: string; userMessageId?: string | null; durability?: AgentRunDurability; - store: AgentRunSessionStore; runStore?: AgentRunStore; runtimeEventStore?: RuntimeEventStore; newId: () => string; @@ -173,11 +175,6 @@ export interface AgentRunInput { toolBoundaryProtocol?: ToolBoundaryProtocol; } -export interface AgentRunSessionStore { - appendMessage(sessionId: string, message: StoredMessage): Promise; - readMessages(sessionId: string): Promise; -} - export type RuntimeContinuationFailpoint = | 'after_continuation_claim_committed' | 'after_continuation_start_committed' @@ -237,6 +234,7 @@ export class AgentRun { private runStoreAvailable = true; private runtimeEventStoreAvailable = true; private runtimeEventStoreFailure: unknown; + private lastAssistantPreview: AssistantMessage | undefined; private runtimePartialStreamKey: string | undefined; private runtimePartialBuffer: RuntimeEvent[] = []; private runtimePartialBufferBytes = 0; @@ -256,6 +254,8 @@ export class AgentRun { private providerStateIdentity: `sha256:${string}` | undefined; private invocationOpening: RuntimeEventInvocationOpenedContent | undefined; private invocationOpeningCommitted = false; + /** Set once `begin()` owes this run's prompt, cleared once the ledger has it. */ + private initialRuntimeEventPending = false; private terminalClaim: | { owner: 'event' | 'stop'; @@ -617,8 +617,10 @@ export class AgentRun { requireTerminalWrite: options.requireTerminalWrite ?? Boolean(this.input.runtimeEventStore), }); await this.recordSessionEvent(sessionEvent, options); + await this.commitMessageProjection(this.lastAssistantPreview); return; } + this.rememberAssistantPreview(runtimeEvent); if (this.requiresDurablePersistence() && isInteractionResumeAck(sessionEvent)) { // A hosted continuation may resume execution only after its identity-only // settlement fact is durable. Session status advances next, and the queue @@ -645,58 +647,58 @@ export class AgentRun { const steering = runtimeEvent.content?.kind === 'text' && runtimeEvent.content.steering === true; await this.recordRuntimeEvents([runtimeEvent], steering ? { requireDurableWrite: true } : {}); - - await materializeRuntimeEventTranscriptProjection( - this.input.store, - this.sessionId, - runtimeEvent, - ); + if (steering) { + await this.commitMessageProjection( + projectRuntimeEventUserMessage(runtimeEvent, runtimeEvent.id), + ); + } } } + /** + * A user message is fail-CLOSED: it also takes the Session's connection lock, + * and no other path re-derives that latch now that the transcript is not a + * second authority. An assistant preview is fail-open — losing it costs a + * stale sidebar entry, never the turn. + */ + private async commitMessageProjection( + message: UserMessage | AssistantMessage | undefined, + ): Promise { + const commit = this.input.hooks.commitMessageProjection; + if (!commit || !message) return; + const committed = commit.call(this.input.hooks, this.sessionId, message); + if (message.type === 'user') return committed; + await committed.catch(() => {}); + } + + /** + * The assistant text the Session list shows once the Turn ends. + * + * Kept as the run goes so the catalog costs one write per Turn rather than + * one per streamed step, and read only after the terminal fact is durable — + * a Turn that never spoke leaves the previous preview standing. + */ + private rememberAssistantPreview(event: RuntimeEvent): void { + if (event.role !== 'model' || event.content?.kind !== 'text') return; + if (!event.content.text?.trim()) return; + this.lastAssistantPreview = { + type: 'assistant', + id: event.id, + turnId: event.turnId, + ts: event.ts, + text: event.content.text, + modelId: this.header.model, + }; + } + async begin(): Promise { + // Owed from here, not from after the opening: `openInvocation` can leave the + // invocation open and still throw, and `finalize` reopens what it can. + this.initialRuntimeEventPending = true; await this.openInvocation(); - let initialRuntimeEventId: string; - - const userMessageTs = this.input.now(); - if (this.input.userMessageId === null) { - initialRuntimeEventId = this.input.newId(); - } else { - const userMessageId = this.input.userMessageId ?? this.input.newId(); - initialRuntimeEventId = userMessageId; - const userMsg = cloneAndFreezeRuntimeSnapshot({ - type: 'user', - id: userMessageId, - turnId: this.turnId, - ts: userMessageTs, - text: this.input.userInput.text, - ...(this.input.userInput.displayText !== undefined - ? { displayText: this.input.userInput.displayText } - : {}), - ...(this.input.userInput.attachments - ? { attachments: this.input.userInput.attachments } - : {}), - ...(this.input.userInput.directoryReferences - ? { directoryReferences: this.input.userInput.directoryReferences } - : {}), - ...(this.input.userInput.quotes ? { quotes: this.input.userInput.quotes } : {}), - ...(this.input.userInput.inlineReferences - ? { inlineReferences: this.input.userInput.inlineReferences } - : {}), - ...(this.input.userInput.origin ? { origin: this.input.userInput.origin } : {}), - }); - await appendUserMessageOnce(this.input.store, this.sessionId, userMsg); - } - await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage); - this.lastTs = userMessageTs; - - const initialRuntimeEvent = cloneAndFreezeRuntimeSnapshot( - this.buildInitialRuntimeEvent(initialRuntimeEventId, this.lastTs), - ); - await this.recordRuntimeEvents([initialRuntimeEvent], { - requireDurableWrite: this.requiresDurablePersistence(), - }); + this.lastTs = this.input.now(); + const initialRuntimeEvent = await this.recordInitialRuntimeEvent(this.lastTs); this.active = await this.input.hooks.reserveRun(this.sessionId, this.header, this); @@ -732,16 +734,28 @@ export class AgentRun { }; } + /** Say what this run was asked to do. */ + private async recordInitialRuntimeEvent(ts: number): Promise { + const event = cloneAndFreezeRuntimeSnapshot( + this.buildInitialRuntimeEvent( + admittedPromptEventId(this.runId, this.input.userMessageId), + ts, + ), + ); + await this.recordRuntimeEvents([event], { + requireDurableWrite: this.requiresDurablePersistence(), + }); + this.initialRuntimeEventPending = false; + await this.commitMessageProjection(projectRuntimeEventUserMessage(event, event.id)); + return event; + } + async beginOperation(): Promise { await this.openInvocation(); const startedAt = this.input.now(); this.lastTs = startedAt; - await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage, { - ts: startedAt, - }); - this.active = await this.input.hooks.reserveRun(this.sessionId, this.header, this); await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, startedAt); @@ -781,10 +795,6 @@ export class AgentRun { } await this.input.continuationFailpoint?.('after_continuation_start_committed'); - await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage, { - ts: startedAt, - }); - this.active = await this.input.hooks.reserveRun(this.sessionId, this.header, this); await this.input.hooks.updateStatus(this.sessionId, 'running', undefined, startedAt); @@ -843,10 +853,29 @@ export class AgentRun { }; } - async recordStoredSessionEvent(ev: SessionEvent): Promise { - if (ev.type === 'token_usage') { - await this.input.store.appendMessage(this.sessionId, { ...ev } satisfies StoredMessage); - } + /** + * Record something the runtime needs to tell the reader about this turn. + * + * It is a fact of the invocation, so it goes where the invocation's facts go. + * Never model-visible: the note describes what happened to the conversation, + * it is not part of it. + */ + async recordSystemNote(kind: RuntimeSystemNoteKind, data?: unknown): Promise { + await this.recordRuntimeEvents([ + { + id: this.input.newId(), + invocationId: this.invocationId, + runId: this.runId, + sessionId: this.sessionId, + turnId: this.turnId, + ts: this.input.now(), + partial: false, + role: 'system', + author: 'system', + modelVisibility: 'hidden', + content: { kind: 'system_note', note: kind, ...(data !== undefined ? { data } : {}) }, + }, + ]); } async recordSessionEvent( @@ -897,42 +926,12 @@ export class AgentRun { }; await updateSessionStatus(); } - if (turnStatus && !this.stopped) { - const appendTurnState = this.input.hooks.appendTurnState( - this.sessionId, - this.turnId, - turnStatus.status, - this.lineage, - { - ts: ev.ts, - errorClass: turnStatus.errorClass, - ...(turnStatus.status === 'aborted' && this.abortSource - ? { abortSource: this.abortSource } - : {}), - }, - ); - if (terminalSessionEvent || ev.type === 'error') { - await appendTurnState.catch((error) => - this.enqueueTraceWriteFailure(error, 'terminal session projection'), - ); - } else { - await appendTurnState; - } - } if (ev.type === 'error') { if (this.stopped) { this.finalStatus = { status: 'aborted' }; } else { this.turnFailed = true; this.finalStatus = transition ?? { status: 'blocked', blockedReason: 'unknown' }; - - await this.input.hooks - .appendTurnState(this.sessionId, this.turnId, 'failed', this.lineage, { - ts: ev.ts, - errorClass: ev.reason ?? ev.code ?? 'unknown', - }) - .catch((error) => this.enqueueTraceWriteFailure(error, 'terminal session projection')); - this.markRunFailed(ev.reason ?? ev.code ?? 'unknown', ev.message); } } @@ -1041,13 +1040,6 @@ export class AgentRun { return; } this.finalStatus = { status: 'blocked', blockedReason: 'unknown' }; - - await this.input.hooks - .appendTurnState(this.sessionId, this.turnId, 'failed', this.lineage, { - errorClass: error instanceof Error ? error.name : 'unknown', - }) - .catch(() => {}); - this.markRunFailed(error instanceof Error ? error.name : 'unknown', errorMessage(error)); } @@ -1060,6 +1052,13 @@ export class AgentRun { // exception at both ends: its opening rides the continuation-start event, // and a continuation that never committed one has no invocation to end. if (!this.input.commitContinuationStart) await this.openInvocation().catch(() => {}); + // A run also cannot end without saying what it was asked to do. `begin()` + // can fail between opening the invocation and recording its prompt, and + // the terminal event below seals the run against every later append — + // including the one crash recovery would use to repair the same shape. + if (this.initialRuntimeEventPending) { + await this.recordInitialRuntimeEvent(this.lastTs || this.input.now()).catch(() => {}); + } await this.flushRuntimePartialBuffer(true); const lastTs = this.lastTs || this.input.now(); if (this.stopped) this.finalStatus = { status: 'aborted' }; @@ -1084,17 +1083,6 @@ export class AgentRun { } catch { // The user-visible turn already completed; preserve existing behavior. } - if (this.sawCompletion) { - await this.input.store - .appendMessage(this.sessionId, { - type: 'system_note', - id: this.input.newId(), - turnId: this.turnId, - ts: lastTs, - kind: 'session_resume', - } satisfies SystemNoteMessage) - .catch(() => {}); - } await this.finishRun(this.finalStatus, lastTs); } @@ -1715,27 +1703,6 @@ function errorMessage(error: unknown): string { return redactTraceString(error instanceof Error ? error.message : String(error)); } -async function appendUserMessageOnce( - store: AgentRunSessionStore, - sessionId: string, - message: UserMessage, -): Promise { - const existing = (await store.readMessages(sessionId)).find( - (candidate) => candidate.id === message.id, - ); - if (!existing) { - await store.appendMessage(sessionId, message); - return; - } - if ( - existing.type !== 'user' || - existing.turnId !== message.turnId || - !messageContentsEqual(existing, message) - ) { - throw new Error(`Durable UserMessage identity ${message.id} has conflicting content`); - } -} - function isInteractionResumeAck(event: SessionEvent): boolean { return ( event.type === 'sandbox_boundary_decision_ack' || diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 2a065fffb2..6cb58ad38d 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -25,7 +25,12 @@ */ import type { SessionEvent } from '@maka/core/events'; -import type { BackendKind, SessionHeader, StoredMessage } from '@maka/core/session'; +import type { + BackendKind, + RuntimeSystemNoteKind, + SessionHeader, + StoredMessage, +} from '@maka/core/session'; import type { AgentBackend, BackendCompactHistoryInput, @@ -99,7 +104,6 @@ export type { } from '@maka/core/backend-types'; export { INVALID_TOOL_NAME, repairMakaToolCall } from './ai-sdk-tool-repair.js'; -export type AppendMessageFn = (m: StoredMessage) => Promise; export type ToolTelemetryRecorder = (record: ToolInvocationRecord) => void; export type { HistoryCompactCheckpointLoader, @@ -114,8 +118,6 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { header: SessionHeader; /** Host-frozen provider endpoint and credential ownership for this backend generation. */ providerStateIdentity?: `sha256:${string}`; - /** Append-message function bound to this session (e.g. SessionStore wrapper). */ - appendMessage: AppendMessageFn; /** Reads the authoritative session boundary immediately before every local tool invocation. */ readExecutionBoundary: ToolRuntimeInput['readExecutionBoundary']; createSandboxBoundaryRequest?: ToolRuntimeInput['createSandboxBoundaryRequest']; @@ -173,6 +175,11 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { readChildAgentOutput?: ToolRuntimeInput['readChildAgentOutput']; /** Optional diagnostic trace hook for explaining a runtime turn without changing renderer events. */ recordRunTrace?: RunTraceRecorder; + /** + * Writes one runtime note — something that happened inside this invocation — + * to the invocation's RuntimeEvent ledger, which is where its record lives. + */ + recordSystemNote?: (kind: RuntimeSystemNoteKind, turnId: string, data?: unknown) => Promise; /** * Commits one settled provider request: the canonical attempt and, when it * is the completed main call, the derived latest-context row it authorises. @@ -444,7 +451,6 @@ export class AiSdkBackend implements AgentBackend { header: input.header, connection: input.connection, modelId: input.modelId, - appendMessage: input.appendMessage, readExecutionBoundary: input.readExecutionBoundary, createSandboxBoundaryRequest: input.createSandboxBoundaryRequest, settleSandboxBoundaryRequest: input.settleSandboxBoundaryRequest, diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index c501a66ac7..5519724053 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -43,9 +43,8 @@ import type { AssistantMessage, AssistantStepContentKind, AssistantThinkingPart, + RuntimeSystemNoteKind, SessionHeader, - SystemNoteMessage, - TokenUsageMessage, } from '@maka/core/session'; import type { BackendSendInput } from '@maka/core/backend-types'; import type { RuntimeEvent } from '@maka/core/runtime-event'; @@ -103,7 +102,6 @@ import { type RepairableAiSdkToolCall, } from './model-adapter.js'; import { persistedOpenAiResponsesStepMessages } from './openai-responses-continuation.js'; -import { nonCanonicalContentOrder } from './runtime-event-read-model.js'; import { composeRequestProjection, type DispatchRequestShape, @@ -852,6 +850,20 @@ export class AiSdkTurn { }; } + /** + * A note about what happened inside this invocation, written to the + * invocation's own ledger. Fail-open: the note explains a turn, it is not + * what the turn did, so losing it must never end a send that is otherwise + * fine. + */ + private async recordSystemNote( + kind: RuntimeSystemNoteKind, + turnId: string, + data?: unknown, + ): Promise { + await this.deps.backend.recordSystemNote?.(kind, turnId, data).catch(() => {}); + } + // -------------------------------------------------------------------------- // manual history compaction // -------------------------------------------------------------------------- @@ -905,36 +917,6 @@ export class AiSdkTurn { return; } const stepId = currentStepMessageId; - const thinkingText = stepThinkingParts.map((part) => part.text).join(''); - const contentOrder = nonCanonicalContentOrder(stepContentOrder); - const msg: AssistantMessage = { - type: 'assistant', - id: stepId, - turnId, - ts: this.deps.now(), - text: stepText, - ...(stepTextProviderOptions !== undefined - ? { providerOptions: stepTextProviderOptions } - : {}), - ...(contentOrder ? { contentOrder } : {}), - modelId: this.deps.backend.modelId, - ...(hasThinking - ? { - thinking: { - text: thinkingText, - ...(stepThinkingParts.length === 1 && stepThinkingParts[0]!.signature !== undefined - ? { signature: stepThinkingParts[0]!.signature } - : {}), - ...(stepThinkingParts.length === 1 && - stepThinkingParts[0]!.providerOptions !== undefined - ? { providerOptions: stepThinkingParts[0]!.providerOptions } - : {}), - ...(stepThinkingParts.length > 1 ? { parts: stepThinkingParts } : {}), - }, - } - : {}), - }; - await this.deps.backend.appendMessage(msg); if (hasThinking) { for (const part of stepThinkingParts) { queue.push({ @@ -1669,15 +1651,10 @@ export class AiSdkTurn { : stepUsage.inputTokens <= priorInput) ) { this.deps.session.contextProviderDroppingReported = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.deps.newId(), - turnId, - ts: this.deps.now(), - kind: 'context_provider_dropping', - data: { inputTokens: stepUsage.inputTokens, priorInputTokens: priorInput }, - }; - await this.deps.backend.appendMessage(note).catch(() => {}); + await this.recordSystemNote('context_provider_dropping', turnId, { + inputTokens: stepUsage.inputTokens, + priorInputTokens: priorInput, + }); } // Fail closed: reset on every step boundary so a missing final // step's usage does not leave a stale value from an earlier step. @@ -1698,18 +1675,10 @@ export class AiSdkTurn { stepUsage.inputTokens + stepUsage.outputTokens > midTurnState.capacity ) { contextWindowOverrunNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.deps.newId(), - turnId, - ts: this.deps.now(), - kind: 'context_window_overrun', - data: { - usedTokens: stepUsage.inputTokens + stepUsage.outputTokens, - declaredContextWindow: midTurnState.capacity, - }, - }; - await this.deps.backend.appendMessage(note).catch(() => {}); + await this.recordSystemNote('context_window_overrun', turnId, { + usedTokens: stepUsage.inputTokens + stepUsage.outputTokens, + declaredContextWindow: midTurnState.capacity, + }); } // Nothing declared, and the provider accepted a request past // the window this model reports. Every other signal in this @@ -1751,15 +1720,10 @@ export class AiSdkTurn { (previousTotal === undefined || previousTotal <= reported); if (reported !== undefined && crossedNow) { contextReportedWindowNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.deps.newId(), - turnId, - ts: this.deps.now(), - kind: 'context_reported_window_exceeded', - data: { usedTokens: used, reportedContextWindow: reported }, - }; - await this.deps.backend.appendMessage(note).catch(() => {}); + await this.recordSystemNote('context_reported_window_exceeded', turnId, { + usedTokens: used, + reportedContextWindow: reported, + }); } } lastStepInputTokens = stepUsage?.inputTokens; @@ -2078,20 +2042,12 @@ export class AiSdkTurn { (midTurnState.capacity === undefined || acceptedTotal < midTurnState.capacity) ) { contextWindowSuggestionNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.deps.newId(), - turnId, - ts: this.deps.now(), - kind: 'context_window_suggestion', - data: { - suggestedContextWindow: acceptedTotal, - ...(midTurnState.capacity !== undefined - ? { declaredContextWindow: midTurnState.capacity } - : {}), - }, - }; - await this.deps.backend.appendMessage(note).catch(() => {}); + await this.recordSystemNote('context_window_suggestion', turnId, { + suggestedContextWindow: acceptedTotal, + ...(midTurnState.capacity !== undefined + ? { declaredContextWindow: midTurnState.capacity } + : {}), + }); } // A folded projection was selected in this send and the provider // still rejects the request. That is worth saying, because the @@ -2107,14 +2063,7 @@ export class AiSdkTurn { midTurnState?.compactionAppliedThisSend === true ) { contextOverflowAfterCompactionNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.deps.newId(), - turnId, - ts: this.deps.now(), - kind: 'context_overflow_after_compaction', - }; - await this.deps.backend.appendMessage(note).catch(() => {}); + await this.recordSystemNote('context_overflow_after_compaction', turnId); } const idleWatchdogRecovery = settledWatchdogTimeout?.phase === 'idle' && @@ -2481,41 +2430,19 @@ export class AiSdkTurn { } : {}), }; - const tu: TokenUsageMessage = { - type: 'token_usage', - id: this.deps.newId(), - turnId, - ts: this.deps.now(), - ...usageFields, - }; - await this.deps.backend.appendMessage(tu).catch(() => {}); if ( !contextCompactionFailedOpenNoteWritten && shouldAppendContextCompactionFailedOpenNote(contextBudgetForUsage) ) { contextCompactionFailedOpenNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.deps.newId(), - turnId, - ts: this.deps.now(), - kind: 'context_compaction_failed_open', - }; - await this.deps.backend.appendMessage(note).catch(() => {}); + await this.recordSystemNote('context_compaction_failed_open', turnId); } if ( !contextCompactedNoteWritten && shouldAppendContextCompactedNote(contextBudgetForUsage) ) { contextCompactedNoteWritten = true; - const note: SystemNoteMessage = { - type: 'system_note', - id: this.deps.newId(), - turnId, - ts: this.deps.now(), - kind: 'context_compacted', - }; - await this.deps.backend.appendMessage(note).catch(() => {}); + await this.recordSystemNote('context_compacted', turnId); } queue.push({ type: 'token_usage', diff --git a/packages/runtime/src/message-authority.ts b/packages/runtime/src/message-authority.ts index ad9df40ad3..52e97b033d 100644 --- a/packages/runtime/src/message-authority.ts +++ b/packages/runtime/src/message-authority.ts @@ -85,6 +85,19 @@ export class RuntimeMessageAuthorityInvariantError extends Error { readonly name = 'RuntimeMessageAuthorityInvariantError'; } +/** + * The id a Root Turn's admitted prompt is durable under. A Root folded from + * several queued Messages carries no single Message identity, so the id comes + * from the Run instead — one rule, so the run that writes the prompt and the + * recovery that rewrites it derive the same id and the store dedupes. + */ +export function admittedPromptEventId( + runId: string, + userMessageId: string | null | undefined, +): string { + return userMessageId ?? `${runId}-admitted-prompt`; +} + export class RuntimeHostedRootConflictError extends Error { readonly name = 'RuntimeHostedRootConflictError'; readonly code = 'session_busy'; diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index 2b5ab6137c..4c33befd1f 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -19,6 +19,7 @@ import type { RuntimeInvocationOutcome } from '@maka/core/runtime-invocation'; import type { RunIdentity } from './terminal-run-commit.js'; +import { isRuntimeSystemNoteKind } from '@maka/core/session'; import type { PermissionDecisionMessage, StoredMessage, @@ -36,8 +37,7 @@ export type RuntimeEventBackfillDiagnosticCode = | 'skipped_high_risk_message' | 'skipped_provider_native_replay_gap' | 'skipped_unmatched_tool_result' - | 'skipped_unmatched_permission_decision' - | 'skipped_unsafe_terminal_state'; + | 'synthesized_terminal_event'; export interface RuntimeEventBackfillDiagnostic { code: RuntimeEventBackfillDiagnosticCode; @@ -193,14 +193,18 @@ export function backfillRuntimeEventsFromStoredMessages( case 'tool_call': { if (conversationTextOnly) break; - if (message.providerExecuted === true && !replayableProviderToolUseIds.has(message.id)) { + // A provider-native call whose opaque output was not retained can never + // be replayed to a provider again, so it is converted hidden: the + // transcript keeps the card, and no model request is built from it. + const unreplayable = + message.providerExecuted === true && !replayableProviderToolUseIds.has(message.id); + if (unreplayable) { diagnostics.push({ code: 'skipped_provider_native_replay_gap', message: 'provider-native tool history requires the opaque provider output for lossless recovery', detail: { messageId: message.id, toolUseId: message.id }, }); - break; } const stateDelta = toolCallStateDelta(message); events.push({ @@ -209,6 +213,7 @@ export function backfillRuntimeEventsFromStoredMessages( role: 'model', author: 'agent', ...storedToolActivityIdentity(message), + ...(unreplayable ? { modelVisibility: 'hidden' as const } : {}), content: { kind: 'function_call', id: message.id, @@ -242,15 +247,18 @@ export function backfillRuntimeEventsFromStoredMessages( case 'tool_result': { if (conversationTextOnly) break; - if (message.providerExecuted === true && message.providerOutput === undefined) { - break; - } + // Same rule as the call it answers: a result the provider cannot be + // shown again is kept as a transcript row and hidden from replay. A + // result whose call is not in this turn is hidden for the same reason — + // a lone result is not a request a provider would accept. const call = safePriorToolCall(toolCalls, message); + const unreplayable = + !call || (message.providerExecuted === true && message.providerOutput === undefined); if (!call) { diagnostics.push({ code: 'skipped_unmatched_tool_result', message: - 'tool_result requires an earlier same-turn tool_call to recover RuntimeEvent function_response', + 'tool_result has no earlier same-turn tool_call, so its RuntimeEvent stays out of model replay', detail: { messageId: message.id, toolUseId: message.toolUseId, @@ -258,18 +266,18 @@ export function backfillRuntimeEventsFromStoredMessages( turnId: input.run.turnId, }, }); - break; } events.push({ ...base, id: newId(), role: 'tool', author: 'tool', - ...storedToolActivityIdentity(call), + ...storedToolActivityIdentity(call ?? message), + ...(unreplayable ? { modelVisibility: 'hidden' as const } : {}), content: { kind: 'function_response', id: message.toolUseId, - name: call.toolName, + name: call?.toolName ?? '', result: message.content, isError: message.isError, ...(message.providerExecuted !== undefined @@ -285,10 +293,10 @@ export function backfillRuntimeEventsFromStoredMessages( refs: { storedMessageId: message.id, toolCallId: message.toolUseId, - ...(call.parentToolCallId !== undefined + ...(call?.parentToolCallId !== undefined ? { parentToolCallId: call.parentToolCallId } : {}), - ...(call.parentOperationId !== undefined + ...(call?.parentOperationId !== undefined ? { parentOperationId: call.parentOperationId } : {}), }, @@ -296,23 +304,11 @@ export function backfillRuntimeEventsFromStoredMessages( break; } - case 'permission_decision': { + // The decision names the tool it answered for, so it converts on its own + // evidence; a matching call in the same turn is confirmation, not a + // requirement. + case 'permission_decision': if (conversationTextOnly) break; - const call = safePriorToolCall(toolCalls, message); - if (!call) { - diagnostics.push({ - code: 'skipped_unmatched_permission_decision', - message: - 'permission_decision requires an earlier same-turn tool_call to recover RuntimeEvent permissionDecision', - detail: { - messageId: message.id, - toolUseId: message.toolUseId, - runId: input.run.runId, - turnId: input.run.turnId, - }, - }); - break; - } events.push({ ...base, id: newId(), @@ -323,15 +319,19 @@ export function backfillRuntimeEventsFromStoredMessages( permissionDecision: { requestId: message.id, decision: message.decision, + toolName: message.toolName, ...(message.rememberForTurn !== undefined ? { rememberForTurn: message.rememberForTurn } : {}), + ...(message.reviewer !== undefined ? { reviewer: message.reviewer } : {}), + ...(message.rationale !== undefined ? { rationale: message.rationale } : {}), + ...(message.riskLevel !== undefined ? { riskLevel: message.riskLevel } : {}), + ...(message.hint !== undefined ? { hint: message.hint } : {}), }, }, - refs: { storedMessageId: message.id, toolCallId: call.id }, + refs: { storedMessageId: message.id, toolCallId: message.toolUseId }, }); break; - } case 'token_usage': if (conversationTextOnly) break; @@ -353,21 +353,45 @@ export function backfillRuntimeEventsFromStoredMessages( }); break; + // Both are already accounted for elsewhere: the turn's ending becomes the + // terminal RuntimeEvent below, and a coordination record is the WorkHub's + // own durable proof, which no run ledger owns a copy of. case 'turn_state': + case 'workhub_coordination': break; + // A note that names a turn is that invocation's own fact, so it converts. + // A session-level kind that somehow carries a turnId is not: it says + // something about the Session, and the Session transcript keeps it. case 'system_note': if (conversationTextOnly) break; - diagnostics.push({ - code: 'skipped_high_risk_message', - message: - 'system_note is not recovered into a run ledger because session-level notes may not belong to this run', - detail: { - messageId: message.id, - kind: message.kind, - runId: input.run.runId, - turnId: input.run.turnId, + if (!isRuntimeSystemNoteKind(message.kind)) { + diagnostics.push({ + code: 'skipped_high_risk_message', + message: + 'session-level system_note is not recovered into a run ledger because it does not belong to this run', + detail: { + messageId: message.id, + kind: message.kind, + runId: input.run.runId, + turnId: input.run.turnId, + }, + }); + break; + } + events.push({ + ...base, + id: newId(), + role: 'system', + author: 'system', + modelVisibility: 'hidden', + content: { + kind: 'system_note', + note: message.kind, + ...(message.data !== undefined ? { data: structuredClone(message.data) } : {}), }, + actions: { stateDelta: recoveryState(now, message) }, + refs: { storedMessageId: message.id }, }); break; } @@ -381,11 +405,8 @@ export function backfillRuntimeEventsFromStoredMessages( newId, now, }); - if (terminal.event) { - events.push(terminal.event); - } else if (terminal.diagnostic) { - diagnostics.push(terminal.diagnostic); - } + if (terminal.event) events.push(terminal.event); + if (terminal.diagnostic) diagnostics.push(terminal.diagnostic); return { events, diagnostics }; } @@ -448,25 +469,30 @@ function terminalRuntimeEvent(input: { now: () => number; }): { event?: RuntimeEvent; diagnostic?: RuntimeEventBackfillDiagnostic } { const turnState = latestTurnState(input.turnMessages); - const status = terminalStatus(input.outcome, turnState); - if (!status) { - return { - diagnostic: { - code: 'skipped_unsafe_terminal_state', + const readStatus = terminalStatus(input.outcome, turnState); + // An invocation with no ending is not a legal ledger state, and leaving one + // open would strand the turn in recovery forever. Incomplete legacy evidence + // does not get to claim the turn completed, so it ends as the failure it + // actually was, marked so a reader can tell it apart from a recorded one. + const status = readStatus ?? 'failed'; + const diagnostic: RuntimeEventBackfillDiagnostic | undefined = readStatus + ? undefined + : { + code: 'synthesized_terminal_event', message: - 'terminal RuntimeEvent was not recovered because legacy terminal evidence is incomplete', + 'terminal RuntimeEvent was synthesized because legacy terminal evidence is incomplete', detail: { runId: input.run.runId, turnId: input.run.turnId, declaredStatus: input.outcome?.status, turnStatus: turnState?.status, }, - }, - }; - } + }; const ts = turnState?.ts ?? input.outcome?.ts ?? input.now(); const failureClass = - status === 'failed' ? (turnState?.errorClass ?? input.outcome?.failureClass) : undefined; + status === 'failed' + ? (turnState?.errorClass ?? input.outcome?.failureClass ?? 'missing_terminal_event') + : undefined; const abortSource = status === 'aborted' ? (turnState?.abortSource ?? @@ -495,6 +521,7 @@ function terminalRuntimeEvent(input: { }, ...(turnState ? { refs: { storedMessageId: turnState.id } } : {}), }, + ...(diagnostic ? { diagnostic } : {}), }; } diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index d3cbba2a6e..819d19fceb 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -152,6 +152,15 @@ export interface ProjectRuntimeEventsToStoredMessagesOptions { | readonly RuntimeInvocationRecord[] | Readonly>; canonicalPermissionOutcomes?: ReadonlyMap; + /** Facts read by indexed lookup when projecting one durable message. */ + context?: { + messageId: string; + contentOrder?: readonly AssistantStepContentKind[]; + permissionRequest?: RuntimeEvent; + toolName?: string; + toolUseId?: string; + hasRetainedOutput: boolean; + }; } export interface ArchivedToolResultReadModelStatus { @@ -204,6 +213,7 @@ interface ProjectionState { */ thinkingByMessageId: Map; contentOrderByMessageId: Map; + hasRetainedOutput?: boolean; } interface PendingThinking { @@ -228,6 +238,29 @@ export function projectRuntimeEventsToStoredMessages( }; const messages: StoredMessage[] = []; + const context = options.context; + if (context) { + state.hasRetainedOutput = context.hasRetainedOutput; + if (context.contentOrder) + state.contentOrderByMessageId.set(context.messageId, [...context.contentOrder]); + if (context.toolName && context.toolUseId) + state.toolNameByUseId.set(context.toolUseId, context.toolName); + const requestEvent = context.permissionRequest; + const request = requestEvent?.actions?.permissionRequest; + if (request && requestEvent) { + state.permissionRequestById.set(request.requestId, { + requestId: request.requestId, + toolUseId: request.toolUseId, + toolName: request.toolName, + sessionId: requestEvent.sessionId, + runId: requestEvent.runId, + turnId: requestEvent.turnId, + ...(request.hint !== undefined ? { hint: request.hint } : {}), + }); + state.toolNameByUseId.set(request.toolUseId, request.toolName); + } + } + for (const event of events) { recordStepContentOrder(event, state); if (isPartialRuntimeEvent(event)) { @@ -251,6 +284,9 @@ export function projectRuntimeEventsToStoredMessages( case 'thinking': projected = projectThinking(event, state, messages) || projected; break; + case 'system_note': + projected = projectSystemNote(event, state, messages) || projected; + break; case 'invocation_opened': // The opening fact records route, configuration and lineage once per // invocation. Every reader joins it by invocationId; it has no chat row. @@ -437,6 +473,68 @@ export function projectRuntimeEventsToStoredMessages( return { messages, diagnostics: state.diagnostics }; } +/** + * A running invocation's events as the transcript should show them right now. + * + * Two things separate a live run from a finished one. Its last text or thinking + * event is still arriving, so it is presented as settled rather than withheld; + * and a step that has only thought so far has no assistant row to hang that + * thinking on, so an empty one is opened for it. Neither changes the ledger: + * both are how the same events read before the run ends. + */ +export function activePresentationRuntimeEvents(events: readonly RuntimeEvent[]): RuntimeEvent[] { + const textMessages = new Set(); + const lastThinkingByMessage = new Map(); + + for (const event of events) { + const content = event.content; + if (event.role !== 'model' || (content?.kind !== 'text' && content?.kind !== 'thinking')) { + continue; + } + const messageKey = activeMessageKey(event); + if (content.kind === 'text') textMessages.add(messageKey); + else lastThinkingByMessage.set(messageKey, event); + } + + const syntheticAfter = new Map(); + for (const [messageKey, thinking] of lastThinkingByMessage) { + if (textMessages.has(messageKey)) continue; + const existing = syntheticAfter.get(thinking) ?? []; + existing.push(emptyAssistantText(thinking)); + syntheticAfter.set(thinking, existing); + } + + const presented: RuntimeEvent[] = []; + for (const event of events) { + presented.push(settledPresentationEvent(event)); + presented.push(...(syntheticAfter.get(event) ?? [])); + } + return presented; +} + +function activeMessageKey(event: RuntimeEvent): string { + const messageId = event.refs?.providerEventId ?? event.refs?.storedMessageId ?? event.id; + return `${event.runId}\0${messageId}`; +} + +function settledPresentationEvent(event: RuntimeEvent): RuntimeEvent { + const content = event.content; + return event.partial && + event.role === 'model' && + (content?.kind === 'text' || content?.kind === 'thinking') + ? { ...event, partial: false } + : event; +} + +function emptyAssistantText(thinking: RuntimeEvent): RuntimeEvent { + return { + ...thinking, + id: `${thinking.id}:active-transcript-empty-text`, + partial: false, + content: { kind: 'text', text: '' }, + }; +} + export function projectRuntimeEventsToStoredMessagesWithArchiveStatuses( events: readonly RuntimeEvent[], options: ProjectRuntimeEventsToStoredMessagesOptions & { @@ -1016,6 +1114,9 @@ function projectPermissionDecision( ); return false; } + // The prompt's own wording when the request survived, and the decision's copy + // of it when the decision is all that is left. + const hint = request?.hint ?? decision.hint; messages.push({ type: 'permission_decision', id: decision.requestId, @@ -1030,7 +1131,7 @@ function projectPermissionDecision( ...(decision.reviewer !== undefined ? { reviewer: decision.reviewer } : {}), ...(decision.rationale !== undefined ? { rationale: decision.rationale } : {}), ...(decision.riskLevel !== undefined ? { riskLevel: decision.riskLevel } : {}), - ...(request?.hint !== undefined ? { hint: request.hint } : {}), + ...(hint !== undefined ? { hint } : {}), }); return true; } @@ -1172,12 +1273,14 @@ function projectTerminalTurnState( } const abortSource = status === 'aborted' ? abortSourceFromRuntime(event) : undefined; const failureClass = status === 'failed' ? failureClassFromRuntimeEvent(event) : undefined; - const partialOutputRetained = messages.some( - (message) => - message.turnId === event.turnId && - ((message.type === 'assistant' && message.text.trim().length > 0) || - message.type === 'tool_result'), - ); + const partialOutputRetained = + state.hasRetainedOutput ?? + messages.some( + (message) => + message.turnId === event.turnId && + ((message.type === 'assistant' && message.text.trim().length > 0) || + message.type === 'tool_result'), + ); messages.push({ type: 'turn_state', id: stableMessageId(event, state, 'turn_state'), @@ -1211,6 +1314,29 @@ function projectTerminalTurnState( return true; } +/** + * The note row of an invocation that wrote one. + * + * There is nothing to reconcile: the event carries the kind and the payload the + * row is made of, so the row is the event said back in the transcript's shape. + */ +function projectSystemNote( + event: RuntimeEvent, + state: ProjectionState, + messages: StoredMessage[], +): boolean { + if (event.content?.kind !== 'system_note') return false; + messages.push({ + type: 'system_note', + id: stableMessageId(event, state, 'system_note'), + turnId: event.turnId, + ts: event.ts, + kind: event.content.note, + ...(event.content.data !== undefined ? { data: structuredClone(event.content.data) } : {}), + }); + return true; +} + function attachPendingThinking( event: RuntimeEvent, state: ProjectionState, diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index d18cce8d65..672b4ff1cb 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -45,10 +45,6 @@ import type { SessionHeader, SessionHeaderPatch, SessionStatus, - StoredMessage, - SystemNoteMessage, - TurnRecord, - TurnStateMessage, } from '@maka/core/session'; import { isDeepStrictEqual } from 'node:util'; import type { UserMessageInput } from '@maka/core/runtime-inputs'; @@ -65,6 +61,7 @@ import { type AgentRunActiveSession, type AgentRunBeginResult, type AgentRunDurability, + type AgentRunHooks, type AgentRunLineage, type RuntimeContinuationFailpoint, } from './agent-run.js'; @@ -94,12 +91,7 @@ import type { } from './session-manager.js'; import type { TurnShellPlan } from './shell-detect.js'; import type { ShellRunProcessManager } from './shell-run-manager.js'; -import { - buildStatusPatch, - buildTurnStateMessage, - normalizeStopSessionSource, - turnHasRetainedOutput as messagesHaveRetainedOutput, -} from './session-projection-helpers.js'; +import { buildStatusPatch, normalizeStopSessionSource } from './session-projection-helpers.js'; import { buildToolsForAgentDefinition } from './agent-catalog.js'; import { loadLatestHistoryCompactCheckpointFromRunLedger } from './history-compact-ledger.js'; import { loadModelProjectionTransitionsFromRunLedger } from './model-projection-transition-ledger.js'; @@ -322,18 +314,6 @@ interface StopOperation { abortSource: string | undefined; ts: number; statusProjected: boolean; - turnProjections: Map< - string, - { - id: string; - turnId: string; - lineage: AgentRunLineage; - message?: TurnStateMessage; - projected: boolean; - } - >; - abortNote: SystemNoteMessage; - abortNoteProjected: boolean; targets: Map; queue: Promise; } @@ -660,7 +640,6 @@ export class RuntimeKernel implements RuntimeKernelLike { runId: options.runId, userMessageId: options.userMessageId, durability: options.durability, - store: this.deps.store, runStore: this.deps.runStore, runtimeEventStore: this.deps.runtimeEventStore, ...(this.deps.toolBoundaryProtocol @@ -684,8 +663,7 @@ export class RuntimeKernel implements RuntimeKernelLike { updateHeader: (targetSessionId, patch) => this.updateHeader(targetSessionId, patch), updateStatus: (targetSessionId, status, blockedReason, ts) => this.updateStatus(targetSessionId, status, blockedReason, ts), - appendTurnState: (targetSessionId, turnId, status, lineage, options) => - this.appendTurnState(targetSessionId, turnId, status, lineage, options), + ...this.messageProjectionHook(), }, }); if (options.admitTurn && (await options.admitTurn()) === 'cancelled') { @@ -836,7 +814,6 @@ export class RuntimeKernel implements RuntimeKernelLike { runLineage: { parentRunId: continuation.sourceRunId }, runId: continuation.runId, invocationId: continuation.invocationId, - store: this.deps.store, runStore: this.deps.runStore, runtimeEventStore: this.deps.runtimeEventStore, ...(continuationToolBoundaryProtocol @@ -919,8 +896,7 @@ export class RuntimeKernel implements RuntimeKernelLike { updateHeader: (targetSessionId, patch) => this.updateHeader(targetSessionId, patch), updateStatus: (targetSessionId, status, blockedReason, ts) => this.updateStatus(targetSessionId, status, blockedReason, ts), - appendTurnState: (targetSessionId, turnId, status, lineage, options) => - this.appendTurnState(targetSessionId, turnId, status, lineage, options), + ...this.messageProjectionHook(), }, }); @@ -996,7 +972,6 @@ export class RuntimeKernel implements RuntimeKernelLike { userInput: { turnId, text: '' }, rootExecutionKind: 'context_compact', ...(input.hostedRoot ? { runId: input.hostedRoot.runId } : {}), - store: this.deps.store, runStore: this.deps.runStore, runtimeEventStore: this.deps.runtimeEventStore, ...(this.deps.toolBoundaryProtocol @@ -1020,8 +995,7 @@ export class RuntimeKernel implements RuntimeKernelLike { updateHeader: (targetSessionId, patch) => this.updateHeader(targetSessionId, patch), updateStatus: (targetSessionId, status, blockedReason, ts) => this.updateStatus(targetSessionId, status, blockedReason, ts), - appendTurnState: (targetSessionId, nextTurnId, status, lineage, options) => - this.appendTurnState(targetSessionId, nextTurnId, status, lineage, options), + ...this.messageProjectionHook(), }, }); @@ -1085,24 +1059,18 @@ export class RuntimeKernel implements RuntimeKernelLike { runId: run.runId, turnId: run.turnId, }); + // Ahead of the usage row, because the ledger seals on its terminal fact: + // a note queued behind one this compaction may already own would be + // refused, and the reader would never learn the summary was skipped. + if (result.outcome.kind === 'failed') { + await run.recordSystemNote('context_compaction_failed_open').catch(() => {}); + } await run.acceptMappedEvent( tokenUsageEvent, mapSessionEventToRuntimeEvent(tokenUsageEvent, eventContext), { requireTerminalWrite: true }, ); if (run.isStopped()) return; - await run.recordStoredSessionEvent(tokenUsageEvent); - if (run.isStopped()) return; - if (result.outcome.kind === 'failed') { - const note: SystemNoteMessage = { - type: 'system_note', - id: this.deps.newId(), - turnId: run.turnId, - ts: this.deps.now(), - kind: 'context_compaction_failed_open', - }; - await this.deps.store.appendMessage(sessionId, note).catch(() => {}); - } yield tokenUsageEvent; if (run.isStopped()) return; await run.acceptMappedEvent( @@ -1811,15 +1779,6 @@ export class RuntimeKernel implements RuntimeKernelLike { delivery: { kind: 'pending' }, } satisfies StopTarget); const needsRun = !target.runs.has(run.runId); - const projection = - needsRun && run.isSessionInline() && !operation.turnProjections.has(run.runId) - ? { - id: this.deps.newId(), - turnId: run.turnId, - lineage: run.lineage, - projected: false, - } - : undefined; if (!existingOperation) this.stopOperations.set(sessionId, operation); if (!existingTarget) { @@ -1834,7 +1793,6 @@ export class RuntimeKernel implements RuntimeKernelLike { sessionInline: run.isSessionInline(), stopCompleted: false, }); - if (projection) operation.turnProjections.set(run.runId, projection); } return operation; } @@ -1846,15 +1804,6 @@ export class RuntimeKernel implements RuntimeKernelLike { abortSource, ts, statusProjected: false, - turnProjections: new Map(), - abortNote: { - type: 'system_note', - id: this.deps.newId(), - ts, - kind: 'abort', - ...(abortSource ? { data: { source: abortSource } } : {}), - }, - abortNoteProjected: false, targets: new Map(), queue: Promise.resolve(), }; @@ -1934,28 +1883,11 @@ export class RuntimeKernel implements RuntimeKernelLike { await this.updateStatus(sessionId, 'aborted', undefined, operation.ts); operation.statusProjected = true; } - for (const projection of operation.turnProjections.values()) { - if (projection.projected) continue; - projection.message ??= buildTurnStateMessage({ - id: projection.id, - turnId: projection.turnId, - ts: operation.ts, - status: 'aborted', - lineage: projection.lineage, - ...(operation.abortSource ? { abortSource: operation.abortSource } : {}), - partialOutputRetained: await this.turnHasRetainedOutput(sessionId, projection.turnId), - }); - await this.appendStopProjection(sessionId, projection.message); - projection.projected = true; - } - if (!operation.abortNoteProjected) { - await this.appendStopProjection(sessionId, operation.abortNote); - operation.abortNoteProjected = true; - } - // The Session projection above now reads as aborted. The ledger has to say - // the same thing before this stop reports success: a Run left non-terminal - // here stays that way, because the stream that would have finalized it is - // exactly the one the stop could not wake. + // The ledger has to say this turn was aborted before the stop reports + // success: a Run left non-terminal here stays that way, because the stream + // that would have finalized it is exactly the one the stop could not wake. + // Nothing else records the abort — the transcript reads it back off this + // terminal fact. // // Without a Host interaction authority, Runtime owns terminal settlement. // A Hosted Run's terminal fact belongs to the Host, which also parks @@ -1977,8 +1909,6 @@ export class RuntimeKernel implements RuntimeKernelLike { } const completed = operation.statusProjected && - operation.abortNoteProjected && - [...operation.turnProjections.values()].every((projection) => projection.projected) && [...operation.targets.values()].every( (target) => target.delivery.kind !== 'pending' && @@ -1998,19 +1928,6 @@ export class RuntimeKernel implements RuntimeKernelLike { failures.throwIfAny(`Stop cleanup failed for session ${sessionId}`); } - private async appendStopProjection(sessionId: string, message: StoredMessage): Promise { - const existing = (await this.deps.store.readMessages(sessionId)).find( - (candidate) => candidate.id === message.id, - ); - if (existing) { - if (!isDeepStrictEqual(existing, message)) { - throw new Error(`stop projection ${message.id} conflicts with an existing message`); - } - return; - } - await this.deps.store.appendMessage(sessionId, message); - } - async respondToSandboxBoundary( sessionId: string, response: SandboxBoundaryResponse, @@ -2298,6 +2215,7 @@ export class RuntimeKernel implements RuntimeKernelLike { }): Pick< BackendFactoryContext, | 'recordRunTrace' + | 'recordSystemNote' | 'recordModelCallAttempt' | 'recordRunComposition' | 'loadHistoryCompactCheckpoint' @@ -2316,6 +2234,8 @@ export class RuntimeKernel implements RuntimeKernelLike { recordRunTrace: (event) => { runFor(event.turnId)?.recordRunTrace(event); }, + recordSystemNote: (kind, turnId, data) => + runFor(turnId)?.recordSystemNote(kind, data) ?? Promise.resolve(), ...(this.deps.runStore ? { // Resolved by runId rather than turnId: the canonical record names @@ -2757,32 +2677,16 @@ export class RuntimeKernel implements RuntimeKernelLike { return next; } - private async appendTurnState( - sessionId: string, - turnId: string, - status: TurnRecord['status'], - lineage: AgentRunLineage = {}, - options: { id?: string; ts?: number; errorClass?: string; abortSource?: string } = {}, - ): Promise { - const ts = options.ts ?? this.deps.now(); - await this.deps.store.appendMessage( - sessionId, - buildTurnStateMessage({ - id: options.id ?? this.deps.newId(), - turnId, - ts, - status, - lineage, - ...(options.abortSource ? { abortSource: options.abortSource } : {}), - ...(options.errorClass !== undefined ? { errorClass: options.errorClass } : {}), - partialOutputRetained: await this.turnHasRetainedOutput(sessionId, turnId), - }), - ); - } - - private async turnHasRetainedOutput(sessionId: string, turnId: string): Promise { - const messages = await this.deps.store.readMessages(sessionId).catch(() => []); - return messagesHaveRetainedOutput(messages, turnId); + /** Present only when the store keeps a Session catalog to project into. */ + private messageProjectionHook(): Pick { + const commit = this.deps.store.commitMessageCatalogProjection; + if (!commit) return {}; + return { + commitMessageProjection: async (sessionId, message) => { + await commit.call(this.deps.store, sessionId, message); + this.updateCachedHeader(sessionId, await this.deps.store.readHeader(sessionId)); + }, + }; } } diff --git a/packages/runtime/src/runtime-ledger-repair.ts b/packages/runtime/src/runtime-ledger-repair.ts index 8ab8949c99..fe2ea2eeb6 100644 --- a/packages/runtime/src/runtime-ledger-repair.ts +++ b/packages/runtime/src/runtime-ledger-repair.ts @@ -20,7 +20,6 @@ import { createHash } from 'node:crypto'; import { deriveTurnRecords } from '@maka/core/session'; import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode'; -import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEvent, RuntimeEventInvocationOpenedContent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; import { @@ -35,36 +34,27 @@ import type { SessionHeader } from '@maka/core/session'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; import { backfillRuntimeEventsFromStoredMessages } from './runtime-event-backfill.js'; import type { RuntimeEventBackfillOutcome } from './runtime-event-backfill.js'; -import { projectRuntimeEventUserMessage } from './runtime-event-read-model.js'; export interface RuntimeLedgerRepairDeps { runtimeEventStore: RuntimeEventStore; - readMessages(sessionId: string): Promise; - appendMessage(sessionId: string, message: StoredMessage): Promise; - newId: () => string; - now: () => number; -} - -interface RuntimeEventTranscriptProjectionDeps { - readMessages(sessionId: string): Promise; - appendMessage(sessionId: string, message: StoredMessage): Promise; + /** + * One forward page of the legacy transcript this converter reads; nothing + * writes back to it. It is read a page at a time because a Session cannot + * serve its first transcript page until this finishes, and a Session's + * history is not a bound. + */ + readMessagesAfter( + sessionId: string, + request: { afterSequence?: number; maxMessages: number; maxStoredBytes: number }, + ): Promise<{ + records: readonly { sequence: number; message: StoredMessage }[]; + highWaterSequence: number | null; + }>; } -export async function materializeRuntimeEventTranscriptProjection( - deps: RuntimeEventTranscriptProjectionDeps, - sessionId: string, - event: RuntimeEvent, - knownMessageIds?: Set, -): Promise { - const message = steeringMessageFromRuntimeEvent(event); - if (!message) return false; - const messageIds = - knownMessageIds ?? new Set((await deps.readMessages(sessionId)).map((item) => item.id)); - if (messageIds.has(message.id)) return false; - await deps.appendMessage(sessionId, message); - messageIds.add(message.id); - return true; -} +/** How much of a legacy transcript one conversion page holds. */ +const TRANSCRIPT_CONVERSION_PAGE_MAX_MESSAGES = 256; +const TRANSCRIPT_CONVERSION_PAGE_MAX_BYTES = 4 * 1024 * 1024; export class RuntimeLedgerRepair { private readonly queues = new Map>(); @@ -72,51 +62,95 @@ export class RuntimeLedgerRepair { constructor(private readonly deps: RuntimeLedgerRepairDeps) {} /** - * Give an imported transcript a runtime spine: one invocation per turn, opened - * by its own opening fact and closed by its own terminal event. + * Give a transcript a runtime spine: one invocation per turn, opened by its + * own opening fact and closed by its own terminal event. * - * The transcript is the only evidence there is, so a turn it cannot close is - * refused rather than imported half-formed. Re-running is a no-op: a turn - * whose invocation already exists is left exactly as it is. + * Every event id is derived from the run it belongs to and its position in + * that run, so importing the same transcript twice writes the same events and + * the store dedupes them. That is what makes an interrupted import resumable: + * a turn is skipped once its invocation has ended, and re-derived until then. */ async materializeTranscriptLedger(header: SessionHeader): Promise { const sessionId = header.id; - return this.withRepairQueue(sessionId, 'transcript-runs', async () => { - const messages = await this.deps.readMessages(sessionId); - const ledgerMessages = messages.filter( - (message) => message.type !== 'user' || message.steeringEventId === undefined, + return this.withRepairQueue(sessionId, async () => { + // A turn the ledger already owns is not converted again. Its own run is + // the authority even when it never ended — a crashed turn is settled by + // recovery on that run, and a second, transcript-derived invocation for + // the same turn would make the Session read as two. The one exception is + // this converter's own run: an interrupted import re-derives it, and the + // deterministic ids let the store dedupe what already landed. + const inlineInvocations = await this.listInlineInvocations(sessionId); + const ownedTurnIds = new Set( + inlineInvocations + .filter( + (invocation) => + invocation.terminalEvent || + invocation.runId !== transcriptRunId(sessionId, invocation.turnId), + ) + .map((invocation) => invocation.turnId), ); - const openedTurnIds = new Set( - (await this.listInlineInvocations(sessionId)).map((invocation) => invocation.turnId), - ); - const messagesByTurn = groupMessagesByTurn(ledgerMessages); - const turns = deriveTurnRecords(ledgerMessages).filter((turn) => - (messagesByTurn.get(turn.turnId) ?? []).some((message) => message.type === 'user'), - ); - if (turns.length === 0) return; - - const firstOpenedAt = Math.max(0, header.createdAt - turns.length); + const startedRunIds = new Set(inlineInvocations.map((invocation) => invocation.runId)); - for (const [index, turn] of turns.entries()) { - if (openedTurnIds.has(turn.turnId)) continue; - const turnMessages = messagesByTurn.get(turn.turnId) ?? []; + for await (const scanned of this.readTurnsInPages(sessionId)) { + const turnMessages = scanned.messages; + // A turn whose only user row was steering is not a turn of its own: the + // steering was said into a Turn some durable Root already owns, so + // converting it would stand a second, synthetic run beside that one. + if (!turnMessages.some((message) => message.type === 'user')) continue; + const [turn] = deriveTurnRecords(turnMessages); + if (!turn) continue; + if (ownedTurnIds.has(turn.turnId)) continue; const runId = transcriptRunId(sessionId, turn.turnId); - const openedAt = firstOpenedAt + index; + // Ordered by where the turn starts in the transcript rather than by its + // index among all turns: a paged conversion never holds that count, and + // both keep every imported opening ahead of the Session's own runs. + const openedAt = Math.max( + 0, + header.createdAt - 1 - (scanned.highWater - scanned.firstSequence), + ); const run = { sessionId, runId, turnId: turn.turnId, invocationId: runId }; + // A build before the ids were derived converted under random ones, so + // an interrupted run of its can hold events this build cannot rederive. + const started = startedRunIds.has(runId) + ? await this.deps.runtimeEventStore.readRuntimeEvents(sessionId, runId) + : []; + const undeducible = started.filter((event) => !isDerivedTranscriptEventId(runId, event.id)); + // Its opening is the one such event that can be adopted: the run needs + // exactly one, `runtime_events_one_opening_per_invocation` refuses a + // second, and which id it landed under changes nothing a reader sees. + const adoptedOpening = + undeducible.length === 1 && undeducible[0]?.content?.kind === 'invocation_opened'; + if (undeducible.length > 0 && !adoptedOpening) { + // Its converted messages cannot be adopted the same way: rederiving + // them would stand a second, deterministic copy of each beside the + // one already there, and a Session that disagrees with itself is the + // failure this ledger exists to remove. The conversion can neither be + // finished nor withdrawn, so it is sealed as the unfinished thing it + // is — the legacy rows stay, and no one reads this turn as converted. + await this.deps.runtimeEventStore.appendRuntimeEvent( + sessionId, + runId, + abandonedTranscriptTerminalEvent({ run, openedAt }), + ); + continue; + } const events = [ - transcriptOpeningEvent({ header, run, openedAt, newId: this.deps.newId }), + ...(adoptedOpening ? [] : [transcriptOpeningEvent({ header, run, openedAt })]), ...backfillRuntimeEventsFromStoredMessages({ run, outcome: transcriptOutcome(turn, turnMessages, openedAt), messages: turnMessages, - modelHistory: 'conversation_text', - newId: this.deps.newId, - now: this.deps.now, + // Another runtime's tool calls belong to its protocol, not to the + // provider this Session will talk to next, so a foreign transcript + // converts as the conversation it is. Maka's own history converts + // whole: its tool calls are the ones it would replay. + modelHistory: header.externalOrigin ? 'conversation_text' : 'full', + newId: transcriptEventIds(runId), + // The payload must be as repeatable as its id: SQLite dedupes + // complete events, including the backfill provenance timestamps. + now: () => openedAt, }).events, ]; - if (!events.some(isTerminalRuntimeEvent)) { - throw new Error(`Imported transcript Run ${runId} has no terminal RuntimeEvent`); - } for (const event of events) { await this.deps.runtimeEventStore.appendRuntimeEvent(sessionId, runId, event); } @@ -124,24 +158,49 @@ export class RuntimeLedgerRepair { }); } - async repairSteeringMessagesOnce(sessionId: string): Promise { - return this.withRepairQueue(sessionId, 'steering-transcript', async () => { - const messages = await this.deps.readMessages(sessionId); - const messageIds = new Set(messages.map((message) => message.id)); - const inlineRunIds = new Set( - (await this.listInlineInvocations(sessionId)).map((invocation) => invocation.runId), + /** + * The Session's legacy rows, one turn at a time, read a page at a time. + * + * A turn is only complete once a row of another turn follows it, so the rows + * of the page's last turn are carried into the next page rather than + * converted early. Peak memory is therefore one page plus one turn — the same + * bound the transcript reader keeps, and not the Session's whole history. + */ + private async *readTurnsInPages( + sessionId: string, + ): AsyncGenerator<{ messages: StoredMessage[]; firstSequence: number; highWater: number }> { + let carried: { messages: StoredMessage[]; firstSequence: number } | undefined; + let afterSequence: number | undefined; + while (true) { + const page = await this.deps.readMessagesAfter(sessionId, { + ...(afterSequence === undefined ? {} : { afterSequence }), + maxMessages: TRANSCRIPT_CONVERSION_PAGE_MAX_MESSAGES, + maxStoredBytes: TRANSCRIPT_CONVERSION_PAGE_MAX_BYTES, + }); + const highWater = page.highWaterSequence; + if (highWater === null) return; + const scanned = page.records.filter( + ({ message }) => message.type !== 'user' || message.steeringEventId === undefined, ); - let repaired = 0; - for (const event of await this.deps.runtimeEventStore.readSessionRuntimeEvents(sessionId)) { - if (!inlineRunIds.has(event.runId)) continue; - if ( - await materializeRuntimeEventTranscriptProjection(this.deps, sessionId, event, messageIds) - ) { - repaired += 1; - } + const grouped = new Map(); + if (carried) grouped.set(turnIdOf(carried.messages[0]) ?? '', carried); + for (const { sequence, message } of scanned) { + const turnId = turnIdOf(message); + if (!turnId) continue; + const bucket = grouped.get(turnId); + if (bucket) bucket.messages.push(message); + else grouped.set(turnId, { messages: [message], firstSequence: sequence }); } - return repaired; - }); + const turns = [...grouped.values()]; + const lastSequence = page.records.at(-1)?.sequence; + // The last turn of a page may continue into the next one, so it is held + // back rather than converted from a prefix of its own rows. A page with + // nothing left to read ends the scan, and what was held back is whole. + carried = lastSequence === undefined ? undefined : turns.pop(); + for (const turn of turns) yield { ...turn, highWater }; + if (lastSequence === undefined) return; + afterSequence = lastSequence; + } } private async listInlineInvocations(sessionId: string): Promise { @@ -150,12 +209,7 @@ export class RuntimeLedgerRepair { ); } - private async withRepairQueue( - sessionId: string, - runId: string, - operation: () => Promise, - ): Promise { - const key = `${sessionId}:${runId}`; + private async withRepairQueue(key: string, operation: () => Promise): Promise { const previous = this.queues.get(key) ?? Promise.resolve(); const current = previous.then(operation, operation); const cleanup = current.then( @@ -173,11 +227,59 @@ export class RuntimeLedgerRepair { } } +/** Synthetic conversion runs belong to the importer, never execution recovery. */ +export function isTranscriptLedgerInvocation( + invocation: Pick, +): boolean { + return invocation.runId === transcriptRunId(invocation.sessionId, invocation.turnId); +} + function transcriptRunId(sessionId: string, turnId: string): string { const digest = createHash('sha256').update(sessionId).update('\0').update(turnId).digest('hex'); return `transcript-${digest.slice(0, 48)}`; } +/** + * Ids for one run's converted events, numbered in the order the converter + * emits them. The run id is already derived from the Session and turn, so the + * same transcript always produces the same ids and a re-run appends nothing. + */ +/** Whether this build's converter is the one that could have written that id. */ +function isDerivedTranscriptEventId(runId: string, eventId: string): boolean { + return eventId === `${runId}-opened` || new RegExp(`^${runId}-e\\d+$`).test(eventId); +} + +/** + * The terminal fact of a conversion that a released build left part-written. + * Its id sits outside the derived sequence so it cannot collide with an event + * that prefix already holds. + */ +function abandonedTranscriptTerminalEvent(input: { + run: { sessionId: string; runId: string; turnId: string; invocationId: string }; + openedAt: number; +}): RuntimeEvent { + return backfillRuntimeEventsFromStoredMessages({ + run: input.run, + outcome: { + status: 'failed', + ts: input.openedAt, + failureClass: 'missing_terminal_event', + }, + messages: [], + modelHistory: 'conversation_text', + newId: () => `${input.run.runId}-abandoned`, + now: () => input.openedAt, + }).events[0] as RuntimeEvent; +} + +function transcriptEventIds(runId: string): () => string { + let seq = 0; + return () => { + seq += 1; + return `${runId}-e${seq}`; + }; +} + /** * The opening fact of an imported turn. * @@ -189,7 +291,6 @@ function transcriptOpeningEvent(input: { header: SessionHeader; run: { sessionId: string; runId: string; turnId: string; invocationId: string }; openedAt: number; - newId: () => string; }): RuntimeEvent { const opening: RuntimeEventInvocationOpenedContent = { kind: 'invocation_opened', @@ -212,7 +313,7 @@ function transcriptOpeningEvent(input: { source: { kind: 'fresh' }, }; return buildInvocationOpenedEvent({ - id: input.newId(), + id: `${input.run.runId}-opened`, run: input.run, openedAt: input.openedAt, opening, @@ -252,28 +353,7 @@ function transcriptOutcomeStatus(status: TurnRecord['status']): RuntimeInvocatio return 'cancelled'; } -function groupMessagesByTurn(messages: readonly StoredMessage[]): Map { - const grouped = new Map(); - for (const message of messages) { - const turnId = 'turnId' in message ? message.turnId : undefined; - if (!turnId) continue; - const bucket = grouped.get(turnId) ?? []; - bucket.push(message); - grouped.set(turnId, bucket); - } - return grouped; -} - -function steeringMessageFromRuntimeEvent(event: RuntimeEvent): StoredMessage | undefined { - const messageId = event.refs?.providerEventId; - if ( - event.role !== 'user' || - event.content?.kind !== 'text' || - event.content.steering !== true || - typeof messageId !== 'string' || - messageId.length === 0 - ) { - return undefined; - } - return projectRuntimeEventUserMessage(event, messageId); +function turnIdOf(message: StoredMessage | undefined): string | undefined { + if (!message) return undefined; + return 'turnId' in message ? message.turnId : undefined; } diff --git a/packages/runtime/src/runtime-read-model.ts b/packages/runtime/src/runtime-read-model.ts index e103ae0dd5..b402c9e2f6 100644 --- a/packages/runtime/src/runtime-read-model.ts +++ b/packages/runtime/src/runtime-read-model.ts @@ -28,8 +28,8 @@ import type { CanonicalPermissionOutcomeRecord, } from './interaction-authority.js'; import { + activePresentationRuntimeEvents, classifyRuntimeEventTerminalFact, - compareRuntimeReadModelMessages, isHardRuntimeEventReadModelDiagnostic, projectRuntimeEventsToStoredMessages, type RuntimeEventReadModelDiagnostic, @@ -42,13 +42,8 @@ import { const CANONICAL_PERMISSION_READ_CONCURRENCY = 8; -export interface RuntimeReadModelProjectionCache { - readMessages(sessionId: string): Promise; -} - export interface RuntimeReadModelDeps { runtimeEventStore: RuntimeEventStore; - projectionCache?: RuntimeReadModelProjectionCache; canonicalPermissionOutcomes?: CanonicalPermissionOutcomeReader; } @@ -132,29 +127,12 @@ export class RuntimeReadModel { } // No terminal event yet: the invocation is still open, or the process died - // holding it. Either way the ledger is the whole truth about it, so the - // in-flight projection cache supplies the rows a live turn has not - // committed instead of a status field claiming otherwise. + // 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. if (!invocation.terminalEvent) { - diagnostics.push( - readModelDiagnostic( - 'incomplete_event', - 'active run is using the in-flight projection cache', - { runId: invocation.runId, turnId: invocation.turnId }, - ), - ); inFlightTurnIds.add(invocation.turnId); - if (!this.deps.projectionCache) { - throw new RuntimeReadModelError('RuntimeEvent ledger is incomplete for an active run', [ - readModelDiagnostic( - 'incomplete_event', - 'active run has no stable RuntimeEvent read projection', - { runId: invocation.runId, turnId: invocation.turnId }, - ), - ]); - } - const overlayEvents = runEvents.flatMap(activeInteractionOverlayEvent); - appendOrderedEvents(ordered, overlayEvents, runIndex); + appendOrderedEvents(ordered, activePresentationRuntimeEvents(runEvents), runIndex); continue; } @@ -222,46 +200,12 @@ export class RuntimeReadModel { throw new RuntimeReadModelError('RuntimeEvent read projection is incomplete', diagnostics); } - const sessionId = input.invocations[0]?.sessionId; - let cachedMessages: StoredMessage[] | undefined; - if (sessionId && this.deps.projectionCache) { - try { - cachedMessages = await this.deps.projectionCache.readMessages(sessionId); - } catch (error) { - const diagnostic = readModelDiagnostic( - 'unsupported_event', - 'SessionProjectionCache.readMessages failed', - { - error: errorMessage(error), - }, - ); - diagnostics.push(diagnostic); - if (input.inFlightTurnIds && input.inFlightTurnIds.size > 0) { - throw new RuntimeReadModelError( - 'RuntimeEvent active projection cache read failed', - diagnostics, - ); - } - } - } - - const messages = - input.inFlightTurnIds && input.inFlightTurnIds.size > 0 - ? mergeInFlightProjectionCache( - projected.messages, - cachedMessages ?? [], - input.inFlightTurnIds, - ) - : projected.messages; - - diagnostics.push( - ...this.compareProjectionCache(messages, cachedMessages, canonicalPermissionRead.outcomes), - ); + const messages = projected.messages; return { source: 'runtime_events', messages, - turns: deriveTurnRecords(messages), + turns: runningTurnRecords(deriveTurnRecords(messages), input.inFlightTurnIds), events: input.events, invocations: input.invocations, diagnostics, @@ -315,82 +259,36 @@ export class RuntimeReadModel { ); return { outcomes, diagnostics }; } - - private compareProjectionCache( - messages: readonly StoredMessage[], - cached: readonly StoredMessage[] | undefined, - canonicalPermissionOutcomes: ReadonlyMap, - ): RuntimeEventReadModelDiagnostic[] { - if (!cached) return []; - const canonicalRequestIds = new Set(canonicalPermissionOutcomes.keys()); - const excludesCanonicalPermission = (message: StoredMessage): boolean => - message.type === 'permission_decision' && canonicalRequestIds.has(message.id); - return compareRuntimeReadModelMessages( - messages.filter((message) => !excludesCanonicalPermission(message)), - cached.filter((message) => !excludesCanonicalPermission(message)), - ).diagnostics; - } } /** - * The interaction facts an active run must keep even while its messages come - * from the in-flight projection cache. Permission prompts were always carried - * here; sandbox boundary requests and decisions belong for the same reason - * (#1612): they are the only durable record that a prompt was raised and how - * it settled, so dropping them makes a pending request invisible to anything - * reading the view instead of the live backend. + * A turn whose invocation has not ended is running. + * + * The transcript has no row that says so, and it should not: "still running" is + * the absence of the terminal event, read off the invocation itself. Rows are + * what the turn produced, and a turn that has produced an answer but not ended + * would otherwise read as finished. */ -function activeInteractionOverlayEvent(event: RuntimeEvent): RuntimeEvent[] { - const permissionRequest = event.actions?.permissionRequest; - const permissionAnswerAccepted = event.actions?.permissionAnswerAccepted; - const permissionClosureAccepted = event.actions?.permissionClosureAccepted; - const sandboxBoundaryRequest = event.actions?.stateDelta?.sandboxBoundaryRequest; - const sandboxBoundaryDecision = event.actions?.stateDelta?.sandboxBoundaryDecision; - if ( - !permissionRequest && - !permissionAnswerAccepted && - !permissionClosureAccepted && - sandboxBoundaryRequest === undefined && - sandboxBoundaryDecision === undefined - ) { - return []; - } - const overlay = { ...event }; - delete overlay.content; - delete overlay.status; - const stateDelta = { - ...(sandboxBoundaryRequest !== undefined ? { sandboxBoundaryRequest } : {}), - ...(sandboxBoundaryDecision !== undefined ? { sandboxBoundaryDecision } : {}), - }; - overlay.actions = { - ...(permissionRequest ? { permissionRequest } : {}), - ...(permissionAnswerAccepted ? { permissionAnswerAccepted } : {}), - ...(permissionClosureAccepted ? { permissionClosureAccepted } : {}), - ...(Object.keys(stateDelta).length > 0 ? { stateDelta } : {}), - }; - return [overlay]; -} - -function mergeInFlightProjectionCache( - runtimeMessages: readonly StoredMessage[], - cachedMessages: readonly StoredMessage[], - inFlightTurnIds: ReadonlySet, -): StoredMessage[] { - const merged = runtimeMessages.map((message, index) => ({ message, index })); - const seenIds = new Set(runtimeMessages.map((message) => message.id)); - for (const cached of cachedMessages) { - const turnId = messageTurnId(cached); - if (!turnId || !inFlightTurnIds.has(turnId) || seenIds.has(cached.id)) continue; - seenIds.add(cached.id); - merged.push({ message: cached, index: merged.length }); +function runningTurnRecords( + turns: readonly TurnRecord[], + inFlightTurnIds: ReadonlySet | undefined, +): TurnRecord[] { + if (!inFlightTurnIds || inFlightTurnIds.size === 0) return [...turns]; + const running = new Set(inFlightTurnIds); + const marked = turns.map((turn) => { + if (!running.delete(turn.turnId)) return turn; + return { ...turn, status: 'running' as const, statusSource: 'recorded' as const }; + }); + // An invocation that has opened but produced nothing yet still has a turn. + for (const turnId of running) { + marked.push({ + turnId, + status: 'running', + statusSource: 'recorded', + partialOutputRetained: false, + }); } - return merged - .sort((a, b) => a.message.ts - b.message.ts || a.index - b.index) - .map((entry) => entry.message); -} - -function messageTurnId(message: StoredMessage): string | undefined { - return 'turnId' in message && typeof message.turnId === 'string' ? message.turnId : undefined; + return marked; } function readModelDiagnostic( diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 38ac4474e2..d0a49d111e 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -52,11 +52,12 @@ import type { SessionStatus, SessionSummary, StoredMessage, + RuntimeSystemNoteKind, SubagentSessionParent, TurnRecord, UserMessage, + AssistantMessage, PermissionDecisionMessage, - SystemNoteMessage, PersistedBackendKind, } from '@maka/core/session'; import type { @@ -93,7 +94,6 @@ import { SUBAGENT_SESSION_RUNTIME_SCHEMA_VERSION, SUBAGENT_SESSION_SPAWN_SCHEMA_VERSION, childSessionsForParent, - deriveTurnRecords, subagentSessionRuntimeSummary, } from '@maka/core/session'; import { decodeAgentGraphIntentClaim } from '@maka/core/agent-graph-control'; @@ -150,11 +150,10 @@ import { import { RuntimeReadModel, RuntimeReadModelError, - type RuntimeReadModelProjectionCache, type RuntimeReadModelSessionView, } from './runtime-read-model.js'; import { inspectAgentRunReadModel, type AgentRunInspectModel } from './agent-run-inspect.js'; -import { RuntimeLedgerRepair } from './runtime-ledger-repair.js'; +import { isTranscriptLedgerInvocation, RuntimeLedgerRepair } from './runtime-ledger-repair.js'; import { buildRecoveredTerminalRuntimeEvent, classifyTerminalRuntimeLedger, @@ -173,7 +172,7 @@ import type { ShellRunProcessManager } from './shell-run-manager.js'; import type { HistoryCompactCheckpoint } from './history-compact-checkpoint.js'; import type { ModelProjectionTransition } from '@maka/core/model-projection-transition'; import type { LoadedModelProjectionTransitions } from './model-projection-transition-ledger.js'; -import type { AgentRunLineage, RuntimeContinuationFailpoint } from './agent-run.js'; +import type { RuntimeContinuationFailpoint } from './agent-run.js'; import type { RuntimeCommitResult, RuntimeCommitSink } from './runtime-commit-sink.js'; import { attributeSandboxBoundaryRestartClosure, @@ -206,12 +205,7 @@ import type { HistoryCompactCleanupRequest } from './history-compact-checkpoint- import { fingerprintAgentGraphRunnableIntent } from './stream-graph-admission.js'; import type { AgentGraphRunnableIntent } from './stream-graph-readiness.js'; import { projectAgentGraphRecords } from './stream-graph-projection.js'; -import { - buildStatusPatch, - buildTurnStateMessage, - turnHasRetainedOutput as messagesHaveRetainedOutput, - type RunLifecycleStatus, -} from './session-projection-helpers.js'; +import { buildStatusPatch, type RunLifecycleStatus } from './session-projection-helpers.js'; import { assertAgentDefinitionRunnable, buildToolsForAgentDefinition, @@ -619,11 +613,21 @@ export interface SessionStore { ): Promise; list(filter?: SessionListFilter): Promise; readHeader(sessionId: string): Promise; + /** The legacy transcript, read only to convert it onto the ledger. */ readMessages(sessionId: string): Promise; - readMessagesSnapshot?(sessionId: string): Promise; - listTurns(sessionId: string): Promise; - appendMessage(sessionId: string, m: StoredMessage): Promise; - appendMessages(sessionId: string, ms: StoredMessage[]): Promise; + /** One forward page of the legacy rows the transcript converter lifts. */ + readMessagesAfter( + sessionId: string, + request: { afterSequence?: number; maxMessages: number; maxStoredBytes: number }, + ): Promise<{ + records: readonly { sequence: number; message: StoredMessage }[]; + highWaterSequence: number | null; + }>; + /** Commit the Session-list facts a durable message carries. */ + commitMessageCatalogProjection?( + sessionId: string, + message: UserMessage | AssistantMessage, + ): Promise; updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise; updateHeaderVersioned?( sessionId: string, @@ -642,7 +646,6 @@ export interface SessionStore { export interface StrictRecoverySessionStore extends SessionStore { listForRecovery(): Promise; - readMessagesForRecovery(sessionId: string): Promise; } export interface StrictRecoveryAgentRunStore extends AgentRunStore { @@ -665,7 +668,6 @@ export interface BackendFactoryContext { store: SessionStore; /** Process-local cancellation for the execution that owns this activation. */ abortSignal?: AbortSignal; - appendMessage?: (message: StoredMessage) => Promise; /** * Child-agent instruction channel. Linked child sessions populate this; an * ordinary main-session activation leaves it undefined. A @@ -698,6 +700,11 @@ export interface BackendFactoryContext { * provider call, including metering and its prepared-request observation. */ recordModelCallAttempt?: (commit: ModelCallCommit) => Promise; + /** + * Writes one runtime note — something that happened inside the running + * invocation — to that invocation's RuntimeEvent ledger. + */ + recordSystemNote?: (kind: RuntimeSystemNoteKind, turnId: string, data?: unknown) => Promise; /** Immutable Run policy snapshot; provider dispatch waits for this durable commit. */ recordRunComposition?: (runId: string, snapshot: RunCompositionSnapshot) => Promise; loadHistoryCompactCheckpoint?: () => Promise; @@ -903,10 +910,7 @@ export class SessionManager { if (deps.runStore && deps.runtimeEventStore) { this.runtimeLedgerRepair = new RuntimeLedgerRepair({ runtimeEventStore: deps.runtimeEventStore, - readMessages: (sessionId) => deps.store.readMessages(sessionId), - appendMessage: (sessionId, message) => deps.store.appendMessage(sessionId, message), - newId: deps.newId, - now: deps.now, + readMessagesAfter: (sessionId, request) => deps.store.readMessagesAfter(sessionId, request), }); } this.runtimeKernel = deps.runtimeKernel ?? new RuntimeKernel({ ...deps }); @@ -1415,13 +1419,6 @@ export class SessionManager { ); const recovered = new Set(); for (const session of interrupted) { - if (this.runtimeLedgerRepair) { - await recoverOr( - policy, - () => this.runtimeLedgerRepair!.repairSteeringMessagesOnce(session.id), - 0, - ); - } if (this.runtimeKernel.hasActiveRuns(session.id)) continue; // Fail-closed: a request whose live owner died can never be answered, so // it settles as `deny` with a durable `host_restarted` reason. The run @@ -1467,27 +1464,10 @@ export class SessionManager { ); if (recoveredShellRuns > 0) recovered.add(session.id); } - let messages: StoredMessage[] = []; - let messagesReadable = true; - try { - messages = - policy.kind === 'strict' - ? await policy.stores.sessionStore.readMessagesForRecovery(session.id) - : await this.deps.store.readMessages(session.id); - } catch (error) { - if (policy.kind === 'strict') throw error; - messagesReadable = false; - } - - if (session.revisionState === 'preparing' && messagesReadable) { - if (hasRevisionUserMessage(messages)) { - await recoverOr(policy, () => this.commitRevisionVersion(session.id), undefined); - } else { - await recoverOr(policy, () => this.remove(session.id), undefined); - recovered.add(session.id); - continue; - } - } + // A revision copy still `preparing` is settled by the Host's revision + // coordinator, which reads the admission ledger and runs before this + // recovery. Deciding it a second time here — off a transcript scan, and + // ending in `remove()` — could only ever contradict it. let continuationClaimRecovered = false; const continuationAuthority = runtimeContinuationAuthority(this.deps.runtimeEventStore); @@ -1519,51 +1499,24 @@ export class SessionManager { if (runRecovery.recovered || continuationClaimRecovered) { await recoverOr(policy, () => this.updateStatus(session.id, 'active'), undefined); recovered.add(session.id); - } else if ( - !messagesReadable && - (session.status === 'running' || session.status === 'waiting_for_user') - ) { - await recoverOr(policy, () => this.updateStatus(session.id, 'active'), undefined); - recovered.add(session.id); } continue; } } - if (!messagesReadable) { - if (session.status === 'running' || session.status === 'waiting_for_user') { - // Recovery may run in BACKGROUND startup (#456): re-check for a - // run the user started while this session's recovery was in - // flight, so we never stomp a live run's status. - if (this.runtimeKernel.hasActiveRuns(session.id)) continue; - await recoverOr(policy, () => this.updateStatus(session.id, 'active'), undefined); - recovered.add(session.id); - } - continue; - } - - const recoveries = interruptedTurnRecoveries(messages); - if (recoveries.length === 0) continue; - for (const recovery of recoveries) { - await recoverOr( - policy, - () => - this.appendTurnState(session.id, recovery.turnId, 'failed', recovery.lineage, { - errorClass: recovery.errorClass, - }), - undefined, - ); - } + // No ledger and nothing to recover from it. A Session whose turns were + // interrupted before this process started is settled by the transcript + // importer, which converts a turn that never recorded how it ended into + // the failed terminal fact it actually was — this recovery has no second + // transcript to read that from. if (session.status === 'running' || session.status === 'waiting_for_user') { - // Same double-check as above: a message sent mid-recovery owns - // the session status now (its own transitions will settle it). - if (this.runtimeKernel.hasActiveRuns(session.id)) { - recovered.add(session.id); - continue; - } + // Recovery may run in BACKGROUND startup (#456): re-check for a run the + // user started while this session's recovery was in flight, so we never + // stomp a live run's status. + if (this.runtimeKernel.hasActiveRuns(session.id)) continue; await recoverOr(policy, () => this.updateStatus(session.id, 'active'), undefined); + recovered.add(session.id); } - recovered.add(session.id); } return [...recovered]; } @@ -1639,15 +1592,6 @@ export class SessionManager { }); const next = await this.deps.store.readHeader(sessionId); this.runtimeKernel.updateCachedHeader(sessionId, next); - await this.deps.store - .appendMessage(sessionId, { - type: 'system_note', - id: this.deps.newId(), - ts: this.deps.now(), - kind: 'mode_change', - data: { from: previous.permissionMode, to: mode }, - } satisfies SystemNoteMessage) - .catch(() => undefined); return headerToSummary(next); } @@ -1864,13 +1808,6 @@ export class SessionManager { const next = await this.deps.store.updateHeader(sessionId, { collaborationMode: mode, }); - await this.deps.store.appendMessage(sessionId, { - type: 'system_note', - id: this.deps.newId(), - ts: this.deps.now(), - kind: 'mode_change', - data: { dimension: 'collaboration', from, to: mode }, - } satisfies SystemNoteMessage); this.runtimeKernel.updateCachedHeader(sessionId, next); await this.runtimeKernel.disposeBackend(sessionId); return headerToSummary(next); @@ -1887,13 +1824,6 @@ export class SessionManager { throw new Error('Cannot change orchestration mode while a tool call awaits confirmation.'); } const next = await this.deps.store.updateHeader(sessionId, { orchestrationMode: mode }); - await this.deps.store.appendMessage(sessionId, { - type: 'system_note', - id: this.deps.newId(), - ts: this.deps.now(), - kind: 'mode_change', - data: { dimension: 'orchestration', from, to: mode }, - } satisfies SystemNoteMessage); this.runtimeKernel.updateCachedHeader(sessionId, next); return headerToSummary(next); } @@ -1936,7 +1866,7 @@ export class SessionManager { } if (!replay) await this.runtimeKernel.disposeBackend(sessionId); const result = await this.requirePlanStore().abandonProposal(input); - await this.finalizePlanAbandonment(sessionId, operationId, replay); + await this.finalizePlanAbandonment(sessionId); return result; } @@ -2813,19 +2743,16 @@ export class SessionManager { await this.finalizeChildWorkspacePatches(child.id); - const [runs, messages] = await Promise.all([ - this.listInvocations(child.id), - this.deps.store.readMessages(child.id), - ]); - const turnOwner = runs.find((candidate) => candidate.turnId === claim.targetTurnId); + // An invocation is what makes a Turn exist on the ledger, so the run listing + // is the whole occupancy check: a Turn with durable content has one. + const turnOwner = (await this.listInvocations(child.id)).find( + (candidate) => candidate.turnId === claim.targetTurnId, + ); if (turnOwner) { throw new Error( `Claimed graph turn ${claim.targetTurnId} is already owned by run ${turnOwner.runId}`, ); } - if (messages.some((message) => 'turnId' in message && message.turnId === claim.targetTurnId)) { - throw new Error(`Claimed graph turn ${claim.targetTurnId} already has durable messages`); - } if (child.isArchived || child.status === 'aborted') { throw new Error('Claimed graph execution target child session is terminated'); } @@ -3005,7 +2932,7 @@ export class SessionManager { prompt: string, expectedUserMessageId?: string, ): Promise { - const messages = await this.deps.store.readMessages(sessionId); + const { messages } = await this.getSessionView(sessionId); const userMessages = messages.filter( (message): message is UserMessage => message.type === 'user' && message.turnId === turnId, ); @@ -3313,18 +3240,10 @@ export class SessionManager { const snapshot = child.subagentRuntime; if (!snapshot) throw new Error('Stored child session is missing its durable runtime snapshot'); const facts = invocationListingFacts(run); - const [messages, runtimeEvents, artifacts] = await Promise.all([ - this.deps.store.readMessages(child.id), + const [runtimeEvents, artifacts] = await Promise.all([ this.deps.runtimeEventStore.readRuntimeEvents(child.id, run.runId), this.finalizeAndListChildTurnArtifacts(child.id, run.turnId, facts.status), ]); - const storedSummary = - messages - .filter( - (message): message is Extract => - message.type === 'assistant' && message.turnId === run.turnId, - ) - .at(-1)?.text ?? ''; const runtimeText = runtimeEvents.filter( ( event, @@ -3347,7 +3266,7 @@ export class SessionManager { runId: run.runId, status: agentRunStatusForSpawnResult(facts.status), permissionMode: child.permissionMode, - summary: trimSummary(durableRuntimeSummary ?? (storedSummary || partialRuntimeSummary)), + summary: trimSummary(durableRuntimeSummary ?? partialRuntimeSummary), artifactIds: artifacts.map((artifact) => artifact.id), startedAt: facts.createdAt, completedAt: facts.updatedAt, @@ -3656,6 +3575,12 @@ export class SessionManager { turnId: string; runId: string; admittedAt: number; + /** + * The message this admission owns, when the crash beat the Run that would + * have recorded it. Recovery writes it into the invocation it opens below, + * so the Turn the user sees still carries what they asked for. + */ + userMessage?: { id: string; content: MessageContent; origin?: UserMessage['origin'] }; execution: Exclude< RootExecutionDescriptor, | { kind: 'regenerate' } @@ -3837,6 +3762,34 @@ export class SessionManager { }), ); + if (input.userMessage) { + await this.deps.runtimeEventStore.appendRuntimeEvent(input.sessionId, input.runId, { + id: input.userMessage.id, + ...run, + ts: input.admittedAt, + partial: false, + role: 'user', + author: input.userMessage.origin ? 'host' : 'user', + content: { + kind: 'text', + text: input.userMessage.content.text, + ...(input.userMessage.content.displayText !== undefined + ? { displayText: input.userMessage.content.displayText } + : {}), + ...(input.userMessage.content.attachments?.length + ? { attachments: input.userMessage.content.attachments } + : {}), + ...(input.userMessage.content.directoryReferences?.length + ? { directoryReferences: input.userMessage.content.directoryReferences } + : {}), + ...(input.userMessage.content.quotes?.length + ? { quotes: input.userMessage.content.quotes } + : {}), + ...(input.userMessage.origin ? { origin: input.userMessage.origin } : {}), + }, + }); + } + const ts = this.deps.now(); const terminalEvent = buildRecoveredTerminalRuntimeEvent({ id: this.deps.newId(), @@ -3984,20 +3937,7 @@ export class SessionManager { /** Canonical, repaired source view for a Host-owned cross-Session copy. */ async readConversationCopySnapshot(sessionId: string): Promise { - const readMessagesSnapshot = this.deps.store.readMessagesSnapshot; - if (!readMessagesSnapshot) { - throw new Error('Conversation copy requires a side-effect-free message snapshot'); - } - const readMessages = readMessagesSnapshot.bind(this.deps.store); - const view = await this.getSessionView(sessionId, { readMessages }); - if (view.invocations.length > 0 || view.messages.length > 0) return view; - const messages = await readMessages(sessionId); - if (messages.length === 0) return view; - return { - ...view, - messages, - turns: deriveTurnRecords(messages), - }; + return this.getSessionView(sessionId); } async respondToSandboxBoundary( @@ -4085,13 +4025,10 @@ export class SessionManager { return await this.getMessages(sessionId); } catch (error) { if (!(error instanceof RuntimeReadModelError)) throw error; - // ShellRun hydration is a best-effort UI projection. A legacy RuntimeEvent - // incompatibility must not turn its retry loop into a permanent IPC error. - try { - return await this.deps.store.readMessages(sessionId); - } catch { - return null; - } + // ShellRun hydration is a best-effort UI projection. A ledger the read + // model cannot project yet must not turn its retry loop into a permanent + // IPC error; there is no second transcript to fall back to. + return null; } } @@ -4207,41 +4144,13 @@ export class SessionManager { this.runtimeKernel.updateCachedHeader(sessionId, next); } - private async finalizePlanAbandonment( - sessionId: string, - operationId: string | undefined, - replay: boolean, - ): Promise { + private async finalizePlanAbandonment(sessionId: string): Promise { const header = await this.deps.store.readHeader(sessionId); - const from = header.collaborationMode ?? 'agent'; - const changed = from !== 'agent'; - const next = changed - ? await this.deps.store.updateHeader(sessionId, { collaborationMode: 'agent' }) - : header; + const next = + (header.collaborationMode ?? 'agent') === 'agent' + ? header + : await this.deps.store.updateHeader(sessionId, { collaborationMode: 'agent' }); this.runtimeKernel.updateCachedHeader(sessionId, next); - - if (!changed && !replay) return; - if (!operationId) { - await this.deps.store.appendMessage(sessionId, { - type: 'system_note', - id: this.deps.newId(), - ts: this.deps.now(), - kind: 'mode_change', - data: { dimension: 'collaboration', from, to: 'agent' }, - } satisfies SystemNoteMessage); - return; - } - - const noteId = planAbandonmentNoteId(operationId); - const messages = await this.deps.store.readMessages(sessionId); - if (messages.some((message) => message.id === noteId)) return; - await this.deps.store.appendMessage(sessionId, { - type: 'system_note', - id: noteId, - ts: this.deps.now(), - kind: 'mode_change', - data: { dimension: 'collaboration', from: 'plan', to: 'agent' }, - } satisfies SystemNoteMessage); } private requirePlanStore(): PlanStore { @@ -4297,34 +4206,6 @@ export class SessionManager { } } - private async appendTurnState( - sessionId: string, - turnId: string, - status: TurnRecord['status'], - lineage: AgentRunLineage = {}, - options: { ts?: number; errorClass?: string; abortSource?: string } = {}, - ): Promise { - const ts = options.ts ?? this.deps.now(); - await this.deps.store.appendMessage( - sessionId, - buildTurnStateMessage({ - id: this.deps.newId(), - turnId, - ts, - status, - lineage, - ...(options.abortSource ? { abortSource: options.abortSource } : {}), - ...(options.errorClass !== undefined ? { errorClass: options.errorClass } : {}), - partialOutputRetained: await this.turnHasRetainedOutput(sessionId, turnId), - }), - ); - } - - private async turnHasRetainedOutput(sessionId: string, turnId: string): Promise { - const messages = await this.deps.store.readMessages(sessionId).catch(() => []); - return messagesHaveRetainedOutput(messages, turnId); - } - private async requireTurnForAction( sessionId: string, turnId: string, @@ -4341,28 +4222,43 @@ export class SessionManager { return turn; } - private async getSessionView( - sessionId: string, - projectionCache: RuntimeReadModelProjectionCache = this.deps.store, - ): Promise { - return this.readModel(projectionCache).getSessionView(sessionId); + /** + * The Session as its ledger tells it. + * + * A transcript written before the ledger owned execution facts is converted + * here, on the first read, because there is no second transcript left to read + * it from: the importer is what gives those turns an invocation to be + * projected from, so a read that skipped it would report the Session empty. + */ + private async getSessionView(sessionId: string): Promise { + await this.ensureTranscriptLedgerForRead(sessionId); + return this.readModel().getSessionView(sessionId); } - private readModel( - projectionCache: RuntimeReadModelProjectionCache = this.deps.store, - ): RuntimeReadModel { + private readModel(): RuntimeReadModel { if (!this.deps.runStore || !this.deps.runtimeEventStore) { throw new Error('RuntimeReadModel requires AgentRunStore and RuntimeEventStore'); } return new RuntimeReadModel({ runtimeEventStore: this.deps.runtimeEventStore, - projectionCache, ...(this.deps.canonicalPermissionOutcomes ? { canonicalPermissionOutcomes: this.deps.canonicalPermissionOutcomes } : {}), }); } + /** + * Convert a transcript written before the ledger owned execution facts, so a + * reader that goes straight to the ledger still sees the whole Session. + * + * Idempotent and cheap after the first call: the conversion is remembered per + * Session, and a Session born on the ledger has nothing to convert. + */ + async ensureTranscriptLedgerForRead(sessionId: string): Promise { + const repair = this.runtimeLedgerRepair; + if (repair) await this.ensureTranscriptLedger(sessionId, repair, 'compatibility'); + } + async prepareImportedSessionHistory(sessionId: string): Promise { const repair = this.runtimeLedgerRepair; if (!repair) throw new Error('Imported Session history requires canonical Runtime stores'); @@ -4379,6 +4275,14 @@ export class SessionManager { if (header.transcriptLedgerVersion === 0 && source !== 'import') { throw new Error('Imported Session history is still being prepared'); } + // Version 1 says a conversion ran, not that every legacy fact reached the + // ledger. A released build set it on the first send and went on writing + // context notes to the transcript alone, so those notes stay behind on + // Sessions it touched. Re-running the converter cannot reach them: they + // belong to turns a real run already sealed, and a sealed run refuses the + // append. They are hidden from the model and describe context, so they are + // the accepted cost of the cutover — do not read this marker as proof that + // nothing is left in `session_messages`. if (header.transcriptLedgerVersion !== 1) { await repair.materializeTranscriptLedger(header); await this.updateHeader(sessionId, { transcriptLedgerVersion: 1 }); @@ -4494,7 +4398,11 @@ export class SessionManager { ): Promise<{ hasLedger: boolean; recovered: boolean }> { if (!this.deps.runStore || !this.deps.runtimeEventStore) return { hasLedger: false, recovered: false }; - const runs = await this.listInvocations(sessionId); + // The importer may have committed only a prefix before a restart. Sealing + // that prefix here would make the next read skip the unconverted history. + const runs = (await this.listInvocations(sessionId)).filter( + (run) => !isTranscriptLedgerInvocation(run), + ); if (runs.length === 0) return { hasLedger: false, recovered: false }; const continuationAuthority = runtimeContinuationAuthority(this.deps.runtimeEventStore); const claimOwnedUnsettledRunIds = new Set(); @@ -4674,52 +4582,11 @@ export class SessionManager { return false; } - const appendedTurnState = await recoverOr( - policy, - () => - this.appendTerminalTurnStateIfNeeded( - sessionId, - inspected.invocation, - decision, - terminalTurnStatus(status), - { - ts, - ...(failureClass ? { errorClass: failureClass } : {}), - ...(abortSource ? { abortSource } : {}), - }, - policy, - ), - false, - ); - // A run that already carried a complete terminal fact and a terminal Turn - // state had nothing to recover. Saying otherwise makes recovery rewrite the - // Session status of every healthy run it walks past. - return inspected.terminalRuntimeFact === undefined || appendedTurnState; + // A run that already carried a complete terminal fact had nothing to + // recover. Saying otherwise makes recovery rewrite the Session status of + // every healthy run it walks past. + return inspected.terminalRuntimeFact === undefined; } - - private async appendTerminalTurnStateIfNeeded( - sessionId: string, - run: RuntimeInvocationRecord, - decision: AgentRunRecoveryDecision, - status: TurnRecord['status'], - options: { ts: number; errorClass?: string; abortSource?: string }, - policy: RecoveryPolicy = { kind: 'best_effort' }, - ): Promise { - if (!isSessionInlineInvocation(run.opening)) return false; - const messages = await recoverOr( - policy, - () => this.deps.store.readMessages(sessionId), - [] as StoredMessage[], - ); - const latest = latestTurnState(messages, decision.turnId); - if (latest && isTerminalTurnStatus(latest.status) && latest.status === status) return false; - await this.appendTurnState(sessionId, decision.turnId, status, decision.lineage, options); - return true; - } -} - -function planAbandonmentNoteId(operationId: string): string { - return `plan-abandonment-${createHash('sha256').update(operationId).digest('hex')}`; } function resumeFeatureDisabledPlan(): SafeBoundaryContinuationPlan { @@ -5180,113 +5047,10 @@ class ChildAgentSummaryAccumulator { } } -interface InterruptedTurnRecovery { - turnId: string; - errorClass: string; - lineage: Partial< - Pick< - UserMessageInput, - | 'parentTurnId' - | 'retriedFromTurnId' - | 'regeneratedFromTurnId' - | 'branchOfTurnId' - | 'parentSessionId' - > - >; -} - -function hasRevisionUserMessage(messages: readonly StoredMessage[]): boolean { - let boundary = -1; - for (let index = 0; index < messages.length; index += 1) { - const message = messages[index]!; - if ( - message.type === 'system_note' && - message.kind === 'session_start' && - message.data && - typeof message.data === 'object' && - 'revisionRootSessionId' in message.data - ) { - boundary = index; - } - } - return boundary >= 0 && messages.slice(boundary + 1).some((message) => message.type === 'user'); -} - -function interruptedTurnRecoveries(messages: readonly StoredMessage[]): InterruptedTurnRecovery[] { - const byTurn = new Map< - string, - { - hasAssistant: boolean; - states: Array>; - } - >(); - for (const message of messages) { - const turnId = (message as { turnId?: string }).turnId; - if (!turnId) continue; - const bucket = byTurn.get(turnId) ?? { hasAssistant: false, states: [] }; - if (message.type === 'assistant') bucket.hasAssistant = true; - if (message.type === 'turn_state') bucket.states.push(message); - byTurn.set(turnId, bucket); - } - - const recoveries: InterruptedTurnRecovery[] = []; - for (const [turnId, bucket] of byTurn) { - const latest = bucket.states.at(-1); - if (!latest) continue; - if (latest.status === 'running') { - recoveries.push({ - turnId, - errorClass: 'app_restarted', - lineage: turnStateLineage(latest), - }); - continue; - } - const failed = [...bucket.states].reverse().find((state) => state.status === 'failed'); - if (latest.status === 'completed' && !bucket.hasAssistant && failed) { - recoveries.push({ - turnId, - errorClass: failed.errorClass ?? 'unknown', - lineage: turnStateLineage(failed), - }); - } - } - return recoveries; -} - -function turnStateLineage( - state: Extract, -): Partial< - Pick< - UserMessageInput, - | 'parentTurnId' - | 'retriedFromTurnId' - | 'regeneratedFromTurnId' - | 'branchOfTurnId' - | 'parentSessionId' - > -> { - return { - ...(state.parentTurnId ? { parentTurnId: state.parentTurnId } : {}), - ...(state.retriedFromTurnId ? { retriedFromTurnId: state.retriedFromTurnId } : {}), - ...(state.regeneratedFromTurnId ? { regeneratedFromTurnId: state.regeneratedFromTurnId } : {}), - ...(state.branchOfTurnId ? { branchOfTurnId: state.branchOfTurnId } : {}), - ...(state.parentSessionId ? { parentSessionId: state.parentSessionId } : {}), - }; -} - function isTerminalRunStatus(status: RunLifecycleStatus): boolean { return status === 'completed' || status === 'failed' || status === 'cancelled'; } -function isTerminalTurnStatus(status: TurnRecord['status']): boolean { - return status === 'completed' || status === 'failed' || status === 'aborted'; -} - -function terminalTurnStatus(status: AgentRunRecoveryDecision['status']): TurnRecord['status'] { - if (status === 'cancelled') return 'aborted'; - return status; -} - function diagnosticRecoveryReason(diagnostic: Record | undefined): string { const recoveryReason = diagnostic?.recoveryReason; return typeof recoveryReason === 'string' && recoveryReason.length > 0 @@ -5294,17 +5058,6 @@ function diagnosticRecoveryReason(diagnostic: Record | undefine : 'agent_run_recovery'; } -function latestTurnState( - messages: readonly StoredMessage[], - turnId: string, -): Extract | undefined { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message?.type === 'turn_state' && message.turnId === turnId) return message; - } - return undefined; -} - function runtimeTerminalFactToRecoveryDecision( invocation: RuntimeInvocationRecord, fact: RuntimeEventTerminalFact, diff --git a/packages/runtime/src/session-projection-helpers.ts b/packages/runtime/src/session-projection-helpers.ts index 456f0ddd7a..ff12795b25 100644 --- a/packages/runtime/src/session-projection-helpers.ts +++ b/packages/runtime/src/session-projection-helpers.ts @@ -24,33 +24,9 @@ import type { SessionBlockedReason, SessionHeader, SessionStatus, - StoredMessage, TurnRecord, - TurnStateMessage, } from '@maka/core/session'; -export type TurnStateLineage = Partial< - Pick< - TurnStateMessage, - | 'parentTurnId' - | 'retriedFromTurnId' - | 'regeneratedFromTurnId' - | 'branchOfTurnId' - | 'parentSessionId' - > ->; - -export interface BuildTurnStateMessageInput { - id: string; - turnId: string; - ts: number; - status: TurnRecord['status']; - lineage?: TurnStateLineage; - errorClass?: string; - abortSource?: string; - partialOutputRetained: boolean; -} - export function buildStatusPatch( status: SessionStatus, ts: number, @@ -63,38 +39,6 @@ export function buildStatusPatch( }; } -export function buildTurnStateMessage(input: BuildTurnStateMessageInput): TurnStateMessage { - const lineage = input.lineage ?? {}; - return { - type: 'turn_state', - id: input.id, - turnId: input.turnId, - ts: input.ts, - status: input.status, - ...(lineage.parentTurnId ? { parentTurnId: lineage.parentTurnId } : {}), - ...(lineage.retriedFromTurnId ? { retriedFromTurnId: lineage.retriedFromTurnId } : {}), - ...(lineage.regeneratedFromTurnId - ? { regeneratedFromTurnId: lineage.regeneratedFromTurnId } - : {}), - ...(lineage.branchOfTurnId ? { branchOfTurnId: lineage.branchOfTurnId } : {}), - ...(lineage.parentSessionId ? { parentSessionId: lineage.parentSessionId } : {}), - ...(input.status === 'aborted' ? { abortedAt: input.ts } : {}), - ...(input.status === 'aborted' && input.abortSource ? { abortSource: input.abortSource } : {}), - ...(input.status === 'failed' ? { errorClass: input.errorClass ?? 'unknown' } : {}), - partialOutputRetained: input.partialOutputRetained, - }; -} - -export function turnHasRetainedOutput(messages: readonly StoredMessage[], turnId: string): boolean { - return messages.some( - (message) => - (message.type === 'assistant' && - message.turnId === turnId && - message.text.trim().length > 0) || - (message.type === 'tool_result' && message.turnId === turnId), - ); -} - export function normalizeStopSessionSource( source: 'stop_button' | 'graph_supervisor' | 'workhub_direct_stop' | undefined, workHubActionId?: string, diff --git a/packages/runtime/src/test-only/fake-backend.ts b/packages/runtime/src/test-only/fake-backend.ts index 213089f261..8c41be3681 100644 --- a/packages/runtime/src/test-only/fake-backend.ts +++ b/packages/runtime/src/test-only/fake-backend.ts @@ -18,7 +18,7 @@ */ import { randomUUID } from 'node:crypto'; -import type { PersistedBackendKind, SessionHeader, StoredMessage } from '@maka/core/session'; +import type { PersistedBackendKind } from '@maka/core/session'; import type { SessionEvent } from '@maka/core/events'; import type { AgentBackend, @@ -36,7 +36,6 @@ import { RuntimeInteractionInvariantError, type RuntimeUserQuestionClosureReason, } from '../interaction-authority.js'; -import type { SessionStore } from '../session-manager.js'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export const FAKE_ASK_USER_QUESTION_PROMPT = '__e2e_ask_user_question__'; @@ -76,14 +75,7 @@ export class FakeBackend implements AgentBackend { private readonly stopWaiters: Array<() => void> = []; private questionAdmissionWaiting = false; - constructor( - private readonly ctx: { - sessionId: string; - header: SessionHeader; - store: SessionStore; - appendMessage?: (message: StoredMessage) => Promise; - }, - ) { + constructor(ctx: { sessionId: string }) { this.sessionId = ctx.sessionId; } @@ -314,17 +306,6 @@ export class FakeBackend implements AgentBackend { } const ts = Date.now(); - const appendMessage = - this.ctx.appendMessage ?? - ((message: StoredMessage) => this.ctx.store.appendMessage(this.sessionId, message)); - await appendMessage({ - type: 'assistant', - id: messageId, - turnId, - ts, - text, - modelId: this.ctx.header.model, - }); yield { type: 'text_complete', id: randomUUID(), turnId, ts, messageId, text }; yield { type: 'complete', id: randomUUID(), turnId, ts: Date.now(), stopReason: 'end_turn' }; } finally { @@ -435,19 +416,7 @@ export class FakeBackend implements AgentBackend { options: [{ label: '是' }, { label: '否' }], }, ]; - const appendMessage = - this.ctx.appendMessage ?? - ((message: StoredMessage) => this.ctx.store.appendMessage(this.sessionId, message)); const startedAt = Date.now(); - await appendMessage({ - type: 'tool_call', - id: toolUseId, - turnId, - stepId, - ts: startedAt, - toolName: 'AskUserQuestion', - args: { questions }, - }); yield { type: 'tool_start', id: randomUUID(), @@ -519,15 +488,6 @@ export class FakeBackend implements AgentBackend { }; const resultContent = { kind: 'json' as const, value: result }; const resultTs = Date.now(); - await appendMessage({ - type: 'tool_result', - id: randomUUID(), - turnId, - ts: resultTs, - toolUseId, - isError: false, - content: resultContent, - }); yield { type: 'tool_result', id: randomUUID(), @@ -551,14 +511,6 @@ export class FakeBackend implements AgentBackend { }; } const completedAt = Date.now(); - await appendMessage({ - type: 'assistant', - id: messageId, - turnId, - ts: completedAt, - text, - modelId: this.ctx.header.model, - }); yield { type: 'text_complete', id: randomUUID(), turnId, ts: completedAt, messageId, text }; yield { type: 'complete', id: randomUUID(), turnId, ts: Date.now(), stopReason: 'end_turn' }; } @@ -598,19 +550,7 @@ export class FakeBackend implements AgentBackend { const stepId = randomUUID(); const expansion = { network: { enabled: true as const } }; const justification = 'Connect to the deterministic fake test endpoint.'; - const appendMessage = - this.ctx.appendMessage ?? - ((message: StoredMessage) => this.ctx.store.appendMessage(this.sessionId, message)); const startedAt = Date.now(); - await appendMessage({ - type: 'tool_call', - id: toolUseId, - turnId, - stepId, - ts: startedAt, - toolName: 'RequestSandboxBoundary', - args: { expansion, justification }, - }); yield { type: 'tool_start', id: randomUUID(), @@ -685,15 +625,6 @@ export class FakeBackend implements AgentBackend { value: { decision, status: settlement.request.status }, }; const resultTs = Date.now(); - await appendMessage({ - type: 'tool_result', - id: randomUUID(), - turnId, - ts: resultTs, - toolUseId, - isError: decision === 'deny', - content: resultContent, - }); yield { type: 'tool_result', id: randomUUID(), @@ -715,14 +646,6 @@ export class FakeBackend implements AgentBackend { text, }; const completedAt = Date.now(); - await appendMessage({ - type: 'assistant', - id: messageId, - turnId, - ts: completedAt, - text, - modelId: this.ctx.header.model, - }); yield { type: 'text_complete', id: randomUUID(), turnId, ts: completedAt, messageId, text }; yield { type: 'complete', id: randomUUID(), turnId, ts: Date.now(), stopReason: 'end_turn' }; } diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index a12fdce73e..06adbaa5ae 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -47,7 +47,6 @@ import type { ToolUncertainOutcomeSignal, UserQuestionRequestEvent, } from '@maka/core/events'; -import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session'; import type { HostedFormSettlement, HostedInteractionBridge, @@ -320,7 +319,6 @@ export interface MakaToolContext { ) => Promise; } -export type AppendMessageFn = (m: ToolCallMessage | ToolResultMessage) => Promise; export type ToolTelemetryRecorder = (record: ToolInvocationRecord) => void; /** @@ -373,7 +371,6 @@ export interface ToolRuntimeInput { header: SessionHeader; connection: RuntimeExecutionConnection; modelId: string; - appendMessage: AppendMessageFn; readExecutionBoundary: () => Promise; createSandboxBoundaryRequest?: ( input: CreateSandboxBoundaryRequest, @@ -1077,17 +1074,6 @@ export class ToolRuntime { this.input.sessionId, ) ?? DURABLE_TOOL_RESULT_PROJECTION_FAILURE; const durableOutcome = await durableAttempt?.commitOutcome(content, true, modelProjection); - const msg: ToolResultMessage = { - type: 'tool_result', - id: this.input.newId(), - turnId, - ts: this.input.now(), - toolUseId, - isError: true, - content, - ...activityIdentity, - }; - await this.input.appendMessage(msg); queue.push({ type: 'tool_result', id: durableOutcome?.id ?? this.input.newId(), @@ -1281,29 +1267,6 @@ export class ToolRuntime { queue.push(event); callEventPublished = true; }; - const callMsg: ToolCallMessage = { - type: 'tool_call', - id: toolUseId, - turnId, - ts: now, - toolName: tool.name, - ...activityIdentity, - ...(tool.activityKind ? { activityKind: tool.activityKind } : {}), - ...(tool.displayName ? { displayName: tool.displayName } : {}), - args: structuredClone(persistedArgs), - ...(ctx.providerOptions !== undefined - ? { providerOptions: structuredClone(ctx.providerOptions) } - : {}), - // Persist the same step id the tool_start event carries so the UI - // timeline and post-restart backfill can pair this call with its step. - ...(stepId !== undefined ? { stepId } : {}), - }; - let callMessageAppended = false; - const appendCallMessage = async (): Promise => { - if (callMessageAppended) return; - await this.input.appendMessage(callMsg); - callMessageAppended = true; - }; const emitToolStartedTrace = (): void => { trace?.emit('tool', 'tool_started', 'Tool execution started', { toolUseId, @@ -1320,7 +1283,6 @@ export class ToolRuntime { text: string, sandboxFailure?: Extract['sandboxFailure'], ): Promise => { - await appendCallMessage(); publishCallEvent(buildCallEvent('preflight')); emitToolStartedTrace(); await this.writeSyntheticToolResult( @@ -1623,7 +1585,6 @@ export class ToolRuntime { await disposeManagedMutationAdmission(managedMutationAdmission); throw error; } - await appendCallMessage(); publishCallEvent(buildCallEvent('dispatch')); emitToolStartedTrace(); if (durableAttempt) { @@ -1935,18 +1896,6 @@ export class ToolRuntime { }, ); } - const resultMsg: ToolResultMessage = { - type: 'tool_result', - id: this.input.newId(), - turnId, - ts: this.input.now(), - toolUseId, - isError: toolResultStatus !== 'success', - content, - durationMs, - ...activityIdentity, - }; - await this.input.appendMessage(resultMsg); queue.push({ type: 'tool_result', id: durableOutcome?.id ?? this.input.newId(), @@ -2086,18 +2035,6 @@ export class ToolRuntime { modelProjection, durationMs, ); - const resultMsg: ToolResultMessage = { - type: 'tool_result', - id: this.input.newId(), - turnId, - ts: this.input.now(), - toolUseId, - isError: true, - content: terminalFailure.content, - durationMs, - ...activityIdentity, - }; - await this.input.appendMessage(resultMsg); queue.push({ type: 'tool_result', id: durableOutcome?.id ?? this.input.newId(), diff --git a/packages/storage/package.json b/packages/storage/package.json index dc28e6f7aa..92872ea09b 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -46,6 +46,7 @@ "./session-bundle-policy": "./dist/session-bundle-policy.js", "./session-copy-cleanup": "./dist/session-copy-cleanup.js", "./session-todo-authority": "./dist/session-todo-authority.js", + "./session-message-projection": "./dist/session-message-projection.js", "./session-store": "./dist/session-store.js", "./settings-store": "./dist/settings-store.js", "./shell-run-authority": "./dist/shell-run-authority.js", diff --git a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts index 3e8a5d1610..9f5e1b3a01 100644 --- a/packages/storage/src/__tests__/invocation-opening-backfill.test.ts +++ b/packages/storage/src/__tests__/invocation-opening-backfill.test.ts @@ -462,7 +462,7 @@ describe('invocation opening fact backfill', () => { 'INSERT INTO core_agent_runs(session_id, run_id, created_at, record_json) VALUES (?, ?, ?, ?)', ).run(corrupt.sessionId, corrupt.runId, corrupt.createdAt, JSON.stringify(corrupt)); assert.throws(() => migrateSqliteRuntimeDatabase(db), /session-1\/run-corrupt-root/); - assert.equal(readUserVersion(db), SQLITE_RUNTIME_SCHEMA_VERSION - 1); + assert.equal(readUserVersion(db), 15); const openings = db .prepare( "SELECT COUNT(*) AS total FROM runtime_events WHERE event_kind = 'invocation_opened'", @@ -509,7 +509,8 @@ function rewindToHeaderEra(db: DatabaseSync): void { 'ALTER TABLE runtime_continuation_claims RENAME COLUMN target_opening_json TO target_run_header_json', ); db.exec('ALTER TABLE core_agent_runs ADD COLUMN record_json TEXT'); - db.exec(`PRAGMA user_version = ${SQLITE_RUNTIME_SCHEMA_VERSION - 1}`); + // Opening facts were introduced in v16, regardless of the current version. + db.exec('PRAGMA user_version = 15'); } function readUserVersion(db: DatabaseSync): number { diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index 39b91a7a3e..ba594bcd34 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -18,7 +18,6 @@ */ import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -637,7 +636,7 @@ describe('SQLite SessionStore', () => { } }); - test('pages the durable transcript by sequence, bytes, and a fixed watermark', async () => { + test('bounds durable message lookups by a fixed transcript watermark', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-session-transcript-pages-')); const store = createSessionStore(root); try { @@ -651,27 +650,6 @@ describe('SQLite SessionStore', () => { })); await store.appendMessages(session.id, messages); - const tail = await store.readTranscriptPageSnapshot(session.id, { - direction: 'older', - maxBytes: 64 * 1024, - maxMessages: 2, - }); - assert.equal(tail.throughSequence, 3); - assert.deepEqual( - tail.fragments.map(({ sequence }) => sequence), - [3, 2], - ); - assert.deepEqual(tail.next, { position: 1, byteOffset: null }); - - const decodedTail = await store.readTranscriptRecordsSnapshot(session.id, { - direction: 'older', - maxStoredBytes: 1, - maxMessages: 2, - }); - assert.equal(decodedTail.throughSequence, 3); - assert.deepEqual(decodedTail.records, [{ sequence: 3, message: messages[3] }]); - assert.equal(decodedTail.nextPosition, 2); - await store.appendMessage(session.id, { type: 'user', id: 'message-4', @@ -714,89 +692,23 @@ describe('SQLite SessionStore', () => { }, ], ); - assert.deepEqual( - await store.readTranscriptPageSnapshot(session.id, { - direction: 'older', - throughSequence: null, - maxBytes: 64 * 1024, - maxMessages: 2, - }), - { throughSequence: null, fragments: [], rawBytes: 0, next: null }, - ); - const older = await store.readTranscriptPageSnapshot(session.id, { - direction: 'older', - throughSequence: tail.throughSequence ?? undefined, - position: 1, - maxBytes: 64 * 1024, - maxMessages: 2, - }); - assert.equal(older.throughSequence, 3); - assert.deepEqual( - older.fragments.map(({ sequence }) => sequence), - [1, 0], - ); - assert.equal(older.next, null); - - const newer = await store.readTranscriptPageSnapshot(session.id, { - direction: 'newer', - throughSequence: 3, - position: 2, - maxBytes: 64 * 1024, - maxMessages: 10, - }); - assert.deepEqual( - newer.fragments.map(({ sequence }) => sequence), - [2, 3], - ); - assert.equal(newer.next, null); - - const oversized = await store.readTranscriptPageSnapshot(session.id, { - direction: 'older', - throughSequence: 3, - maxBytes: 1, - maxMessages: 10, - }); - assert.deepEqual( - oversized.fragments.map(({ sequence }) => sequence), - [3], - ); - assert.equal(oversized.fragments[0]!.data.byteLength, 1); - assert.ok(oversized.fragments[0]!.totalBytes > 1); - assert.deepEqual(oversized.next, { - position: 3, - byteOffset: oversized.fragments[0]!.byteOffset, - }); - const fragments = [...oversized.fragments]; - let continuation: { - readonly position: number; - readonly byteOffset: number | null; - } | null = oversized.next; - while (continuation?.position === 3 && continuation.byteOffset !== null) { - const page = await store.readTranscriptPageSnapshot(session.id, { - direction: 'older', - throughSequence: 3, - position: continuation.position, - byteOffset: continuation.byteOffset, - maxBytes: 7, - maxMessages: 10, - }); - fragments.push(...page.fragments); - continuation = page.next; - } - const reconstructed = Buffer.concat( - fragments - .filter((fragment) => fragment.sequence === 3) - .sort((left, right) => left.byteOffset - right.byteOffset) - .map((fragment) => fragment.data), - ); - assert.deepEqual(JSON.parse(reconstructed.toString('utf8')), messages[3]); + assert.deepEqual(await store.readMessages(session.id), [ + ...messages, + { + type: 'user', + id: 'message-4', + turnId: 'turn-4', + ts: 5, + text: 'appended after the watermark', + }, + ]); } finally { await store.close?.(); await rm(root, { recursive: true, force: true }); } }); - test('pages both new chunked messages and legacy inline v22 records', async () => { + test('reads both new chunked messages and legacy inline v22 records', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-session-transcript-chunks-')); const message = { type: 'user' as const, @@ -818,28 +730,7 @@ describe('SQLite SessionStore', () => { const session = await store.create(makeInput()); sessionId = session.id; await store.appendMessages(session.id, [message, smallMessage]); - const fragments = []; - let position = 0; - let byteOffset: number | undefined; - do { - const page = await store.readTranscriptPageSnapshot(session.id, { - direction: 'newer', - throughSequence: 0, - position, - ...(byteOffset === undefined ? {} : { byteOffset }), - maxBytes: 50_000, - maxMessages: 1, - }); - fragments.push(...page.fragments); - position = page.next?.position ?? 1; - byteOffset = page.next?.byteOffset ?? undefined; - } while (position === 0); - assert.deepEqual( - JSON.parse(Buffer.concat(fragments.map(({ data }) => data)).toString('utf8')), - message, - ); - assert.equal(new Set(fragments.map(({ payloadDigest }) => payloadDigest)).size, 1); - assert.match(fragments[0]?.payloadDigest ?? '', /^sha256:[0-9a-f]{64}$/); + assert.deepEqual(await store.readMessages(session.id), [message, smallMessage]); } finally { await store.close?.(); } @@ -873,17 +764,6 @@ describe('SQLite SessionStore', () => { const migrated = createSessionStore(root); try { - const page = await migrated.readTranscriptPageSnapshot(sessionId, { - direction: 'older', - throughSequence: 0, - maxBytes: 50_000, - maxMessages: 1, - }); - assert.equal(page.fragments[0]?.data.byteLength, 50_000); - assert.equal( - page.fragments[0]?.totalBytes, - Buffer.byteLength(JSON.stringify(message), 'utf8'), - ); assert.deepEqual(await migrated.readMessages(sessionId), [message, smallMessage]); } finally { await migrated.close?.(); @@ -892,7 +772,7 @@ describe('SQLite SessionStore', () => { await rm(root, { recursive: true, force: true }); }); - test('rejects corrupt chunked messages on paged and ordinary reads', async () => { + test('rejects corrupt chunked messages on ordinary reads', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-session-transcript-corruption-')); const store = createSessionStore(root); let sessionId = ''; @@ -928,47 +808,6 @@ describe('SQLite SessionStore', () => { const corrupted = createSessionStore(root); try { - await assert.rejects( - corrupted.readTranscriptPageSnapshot(sessionId, { - direction: 'newer', - throughSequence: 0, - position: 0, - byteOffset: 64 * 1024, - maxBytes: 1_000, - maxMessages: 1, - }), - /incompatible/i, - ); - const rewritten = new DatabaseSync(path); - try { - const chunk = rewritten - .prepare( - ` - SELECT data FROM session_message_chunks - WHERE session_id = ? AND sequence = 0 AND chunk_index = 1 - `, - ) - .get(sessionId) as { data: Uint8Array }; - rewritten - .prepare( - ` - UPDATE session_message_chunks SET sha256 = ? - WHERE session_id = ? AND sequence = 0 AND chunk_index = 1 - `, - ) - .run(createHash('sha256').update(chunk.data).digest('hex'), sessionId); - } finally { - rewritten.close(); - } - const page = await corrupted.readTranscriptPageSnapshot(sessionId, { - direction: 'newer', - throughSequence: 0, - position: 0, - byteOffset: 64 * 1024, - maxBytes: 1_000, - maxMessages: 1, - }); - assert.match(page.fragments[0]?.payloadDigest ?? '', /^sha256:[0-9a-f]{64}$/); await assert.rejects(corrupted.readMessages(sessionId), /incompatible/i); } finally { await corrupted.close?.(); @@ -1060,319 +899,6 @@ describe('SQLite SessionStore', () => { } }); - test('pages turn contributions at a fixed transcript watermark', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-session-turn-contributions-')); - const store = createSessionStore(root); - try { - const session = await store.create(makeInput()); - await store.appendMessages(session.id, [ - { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'one' }, - { - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 2, - text: 'answer', - modelId: 'model-1', - }, - { - type: 'turn_state', - id: 'state-1', - turnId: 'turn-1', - ts: 3, - status: 'completed', - partialOutputRetained: true, - }, - { type: 'user', id: 'user-2', turnId: 'turn-2', ts: 4, text: 'two' }, - ]); - - const first = await store.readTurnContributionsSnapshot(session.id, null, 0, 1); - assert.equal(first.throughSequence, 3); - assert.equal(first.nextPosition, 3); - assert.deepEqual(first.contributions, [ - { - turnId: 'turn-1', - firstSequence: 0, - latestState: { - sequence: 2, - message: { - type: 'turn_state', - id: 'state-1', - turnId: 'turn-1', - ts: 3, - status: 'completed', - partialOutputRetained: true, - }, - }, - userPromptPreview: 'one', - hasAssistantMessage: true, - hasAssistantOutput: true, - hasToolResult: false, - hasFailedToolResult: false, - hasAbortNote: false, - }, - ]); - - await store.appendMessage(session.id, { - type: 'assistant', - id: 'assistant-2', - turnId: 'turn-2', - ts: 5, - text: 'later', - modelId: 'model-1', - }); - const second = await store.readTurnContributionsSnapshot( - session.id, - first.throughSequence, - first.nextPosition!, - 1, - ); - assert.equal(second.throughSequence, 3); - assert.deepEqual( - second.contributions.map((entry) => entry.turnId), - ['turn-2'], - ); - assert.equal(second.nextPosition, null); - - await store.appendMessage(session.id, { - type: 'assistant', - id: 'assistant-large', - turnId: 'turn-large', - ts: 6, - text: 'x'.repeat(70 * 1024), - modelId: 'model-1', - }); - const chunked = await store.readTurnContributionsSnapshot(session.id, null, 0, 128); - assert.deepEqual( - chunked.contributions.find((entry) => entry.turnId === 'turn-large'), - { - turnId: 'turn-large', - firstSequence: 5, - latestState: null, - userPromptPreview: null, - hasAssistantMessage: true, - hasAssistantOutput: true, - hasToolResult: false, - hasFailedToolResult: false, - hasAbortNote: false, - }, - ); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); - - test('bounds turn contribution source scanning independently of turn count', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-session-turn-source-bound-')); - const store = createSessionStore(root); - try { - const session = await store.create(makeInput()); - await store.appendMessages( - session.id, - Array.from({ length: 1_025 }, (_, index) => ({ - type: 'assistant' as const, - id: `assistant-${index}`, - turnId: 'turn-1', - ts: index, - text: 'x', - modelId: 'model-1', - })), - ); - - const first = await store.readTurnContributionsSnapshot(session.id, null, 0, 128); - assert.equal(first.nextPosition, 1_024); - assert.equal(first.contributions.length, 1); - const second = await store.readTurnContributionsSnapshot( - session.id, - first.throughSequence, - first.nextPosition!, - 128, - ); - assert.equal(second.nextPosition, null); - assert.equal(second.contributions.length, 1); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); - - test('samples a bounded prompt landmark index across the durable transcript', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-session-turn-landmarks-')); - const store = createSessionStore(root); - try { - const session = await store.create(makeInput()); - await store.appendMessages( - session.id, - Array.from({ length: 40 }, (_, index) => [ - { - type: 'user' as const, - id: `user-${index}`, - turnId: `turn-${index}`, - ts: index * 2, - text: index === 20 ? 'x'.repeat(70 * 1024) : `prompt ${index}`, - }, - { - type: 'assistant' as const, - id: `assistant-${index}`, - turnId: `turn-${index}`, - ts: index * 2 + 1, - text: 'answer', - modelId: 'model-1', - }, - ]).flat(), - ); - - const snapshot = await store.readTurnLandmarksSnapshot(session.id, 8); - - assert.equal(snapshot.throughSequence, 79); - assert.ok(snapshot.landmarks.length <= 8); - assert.ok(snapshot.landmarks.length > 1); - assert.equal( - snapshot.landmarks.some((landmark) => landmark.turnId === 'turn-20'), - false, - ); - assert.deepEqual( - [...snapshot.landmarks].sort((left, right) => left.sequence - right.sequence), - snapshot.landmarks, - ); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); - - test('keeps every prompt landmark when long turns fit within the landmark limit', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-session-turn-landmarks-long-turns-')); - const store = createSessionStore(root); - try { - const session = await store.create(makeInput()); - await store.appendMessages( - session.id, - Array.from({ length: 3 }, (_, turnIndex) => [ - { - type: 'user' as const, - id: `user-${turnIndex}`, - turnId: `turn-${turnIndex}`, - ts: turnIndex * 10_000, - text: `prompt ${turnIndex}`, - }, - ...Array.from({ length: turnIndex === 0 ? 1_000 : 4_000 }, (_, messageIndex) => ({ - type: 'assistant' as const, - id: `assistant-${turnIndex}-${messageIndex}`, - turnId: `turn-${turnIndex}`, - ts: turnIndex * 10_000 + messageIndex + 1, - text: 'x', - modelId: 'model-1', - })), - ]).flat(), - ); - const database = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); - try { - const insert = database.prepare(` - INSERT INTO core_root_turn_admissions(session_id, turn_id, admitted_at, record_json) - VALUES (?, ?, ?, ?) - `); - for (let turnIndex = 0; turnIndex < 3; turnIndex += 1) { - insert.run( - session.id, - `turn-${turnIndex}`, - turnIndex, - JSON.stringify({ userMessageId: `user-${turnIndex}` }), - ); - } - } finally { - database.close(); - } - - const snapshot = await store.readTurnLandmarksSnapshot(session.id, 64); - - assert.deepEqual( - snapshot.landmarks.map((landmark) => landmark.turnId), - ['turn-0', 'turn-1', 'turn-2'], - ); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); - - test('keeps legacy prompts when newer turns have indexed admissions', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-session-turn-landmarks-mixed-')); - const store = createSessionStore(root); - try { - const session = await store.create(makeInput()); - await store.appendMessages( - session.id, - Array.from({ length: 10 }, (_, index) => [ - { - type: 'user' as const, - id: `user-${index}`, - turnId: `turn-${index}`, - ts: index * 2, - text: `prompt ${index}`, - }, - { - type: 'assistant' as const, - id: `assistant-${index}`, - turnId: `turn-${index}`, - ts: index * 2 + 1, - text: 'answer', - modelId: 'model-1', - }, - ]).flat(), - ); - const database = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); - try { - database - .prepare(` - INSERT INTO core_root_turn_admissions(session_id, turn_id, admitted_at, record_json) - VALUES (?, ?, ?, ?) - `) - .run(session.id, 'turn-9', 9, JSON.stringify({ userMessageId: 'user-9' })); - } finally { - database.close(); - } - - const snapshot = await store.readTurnLandmarksSnapshot(session.id, 8); - - assert.equal(snapshot.landmarks.length, 8); - assert.equal(snapshot.landmarks[0]?.turnId, 'turn-0'); - assert.equal(snapshot.landmarks.at(-1)?.turnId, 'turn-9'); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); - - test('clears unread when the current read marker is already the latest visible message', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-session-read-marker-')); - const store = createSessionStore(root); - try { - const session = await store.create(makeInput()); - await store.appendMessage(session.id, { - type: 'assistant', - id: 'message-1', - turnId: 'turn-1', - ts: 20, - text: 'already read', - modelId: 'fake-model', - }); - await store.updateHeader(session.id, { - lastReadMessageId: 'message-1', - hasUnread: true, - }); - - const updated = await store.markSessionReadThroughMessage(session.id, 'message-1'); - - assert.equal(updated.header.lastReadMessageId, 'message-1'); - assert.equal(updated.header.hasUnread, false); - } finally { - await store.close?.(); - await rm(root, { recursive: true, force: true }); - } - }); - test('reads back a legacy fake-backend session instead of migrating or rejecting it', async () => { // #3211: `'fake'` was retired as a live backend but never migrated out of // storage. Narrowing the header validator would make these rows decode as diff --git a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts index 5bdd3984b7..b4e3548893 100644 --- a/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts +++ b/packages/storage/src/__tests__/sqlite-runtime-schema.test.ts @@ -155,7 +155,7 @@ describe('SQLite runtime schema migration', () => { migrateSqliteRuntimeDatabase(db); - assert.equal(SQLITE_RUNTIME_SCHEMA_VERSION, 16); + assert.equal(SQLITE_RUNTIME_SCHEMA_VERSION, 17); assert.equal( ( db diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 906c5d6ace..3d5df5370d 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -281,7 +281,7 @@ describe('SqliteSessionMetadataStore', () => { const store = createSqliteSessionMetadataStore(path); try { await assert.rejects( - () => store.readMessagesForRecovery('session-1'), + () => store.readMessages('session-1'), (error: unknown) => error instanceof StoredSessionMessageIncompatibleError && error.code === 'stored_session_message_incompatible' && @@ -365,7 +365,7 @@ describe('SqliteSessionMetadataStore', () => { } }); - test('materializes an accepted steering draft when it is handed off', async () => { + test('retires an accepted steering draft when it is handed off', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { await store.create(fullHeader({ id: 'session-1', connectionLocked: false })); @@ -424,27 +424,12 @@ describe('SqliteSessionMetadataStore', () => { messageIds: ['message-1'], turnId: 'turn-1', }); - assert.deepEqual( - (await store.readMessages('session-1')).map((message) => ({ - id: message.id, - type: message.type, - turnId: message.turnId, - text: message.type === 'user' ? message.text : undefined, - steeringEventId: message.type === 'user' ? message.steeringEventId : undefined, - })), - [ - { - id: 'message-1', - type: 'user', - turnId: 'turn-1', - text: 'submitted', - steeringEventId: 'message-1', - }, - ], - ); - assert.equal((await store.read('session-1')).header.lastMessageAt, 10); - assert.equal((await store.readCatalogRecord('session-1')).lastMessagePreview, 'submitted'); - assert.equal((await store.read('session-1')).header.connectionLocked, true); + // Handoff retires the admission and nothing else: the message itself is a + // RuntimeEvent, and its catalog facts come from the run that wrote it. + assert.deepEqual(await store.readMessages('session-1'), []); + assert.equal((await store.read('session-1')).header.lastMessageAt, 3); + assert.equal((await store.readCatalogRecord('session-1')).lastMessagePreview, undefined); + assert.equal((await store.read('session-1')).header.connectionLocked, false); assert.deepEqual(await store.listMessageAdmissions('session-1'), []); } finally { store.close(); @@ -560,229 +545,6 @@ describe('SqliteSessionMetadataStore', () => { } }); - test('materializes a proven Root message when its admission is absent', async () => { - const store = createSqliteSessionMetadataStore(':memory:'); - try { - await store.create(fullHeader({ id: 'session-legacy-root' })); - - await markMessagesHandedOffWithProvenRoots(store, { - sessionId: 'session-legacy-root', - messageIds: ['message-legacy-root'], - turnId: 'turn-legacy-root', - provenRootMessages: [ - { - messageId: 'message-legacy-root', - content: { text: 'retained by the legacy Root', displayText: 'legacy display' }, - admittedAt: 17, - }, - ], - }); - - assert.deepEqual(await store.readMessages('session-legacy-root'), [ - { - type: 'user', - id: 'message-legacy-root', - turnId: 'turn-legacy-root', - ts: 17, - text: 'retained by the legacy Root', - displayText: 'legacy display', - steeringEventId: 'message-legacy-root', - }, - ]); - assert.deepEqual(await store.listMessageAdmissions('session-legacy-root'), []); - } finally { - store.close(); - } - }); - - test('inserts proven Root messages before existing output from their Turn', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-legacy-root-order-')); - const path = join(root, 'state.sqlite'); - const store = createSqliteSessionMetadataStore(path); - try { - await store.create(fullHeader({ id: 'session-legacy-order' })); - const legacyOutput = 'existing chunked output '.repeat(4_096); - await store.appendMessages( - 'session-legacy-order', - [ - { - type: 'assistant', - id: 'message-prior-output', - turnId: 'turn-prior', - ts: 10, - text: 'prior output', - modelId: 'fake-model', - }, - { - type: 'assistant', - id: 'message-legacy-output', - turnId: 'turn-legacy-order', - ts: 18, - text: legacyOutput, - modelId: 'fake-model', - }, - { - type: 'user', - id: 'message-newer-user', - turnId: 'turn-newer', - ts: 30, - text: 'newest preview', - }, - ], - { lastMessageAt: 30, lastMessagePreview: 'newest preview' }, - ); - - await markMessagesHandedOffWithProvenRoots(store, { - sessionId: 'session-legacy-order', - messageIds: ['message-legacy-followup', 'message-legacy-steering'], - turnId: 'turn-legacy-order', - provenRootMessages: [ - { - messageId: 'message-legacy-followup', - content: { text: 'legacy follow-up' }, - admittedAt: 17, - }, - { - messageId: 'message-legacy-steering', - content: { text: 'legacy steering' }, - admittedAt: 17, - }, - ], - }); - - assert.deepEqual( - (await store.readMessages('session-legacy-order')).map((message) => message.id), - [ - 'message-prior-output', - 'message-legacy-followup', - 'message-legacy-steering', - 'message-legacy-output', - 'message-newer-user', - ], - ); - assert.equal((await store.read('session-legacy-order')).header.lastMessageAt, 30); - const shiftedOutput = (await store.readMessages('session-legacy-order')).find( - (message) => message.id === 'message-legacy-output', - ); - assert.equal(shiftedOutput?.type, 'assistant'); - assert.equal( - shiftedOutput?.type === 'assistant' ? shiftedOutput.text : undefined, - legacyOutput, - ); - assert.equal( - (await store.readCatalogRecord('session-legacy-order')).lastMessagePreview, - 'newest preview', - ); - const audit = new DatabaseSync(path, { readOnly: true }); - try { - assert.deepEqual(audit.prepare('PRAGMA foreign_key_check').all(), []); - } finally { - audit.close(); - } - } finally { - store.close(); - await rm(root, { recursive: true, force: true }); - } - }); - - test('places a proven Root message before an equally-timed newer transcript row', async () => { - const store = createSqliteSessionMetadataStore(':memory:'); - try { - await store.create(fullHeader({ id: 'session-legacy-time-tie' })); - await store.appendMessages( - 'session-legacy-time-tie', - [ - { - type: 'user', - id: 'message-newer-time-tie', - turnId: 'turn-newer-time-tie', - ts: 17, - text: 'newer same-millisecond preview', - }, - ], - { lastMessageAt: 17, lastMessagePreview: 'newer same-millisecond preview' }, - ); - - await markMessagesHandedOffWithProvenRoots(store, { - sessionId: 'session-legacy-time-tie', - messageIds: ['message-legacy-time-tie'], - turnId: 'turn-legacy-time-tie', - provenRootMessages: [ - { - messageId: 'message-legacy-time-tie', - content: { text: 'legacy same-millisecond source' }, - admittedAt: 17, - }, - ], - }); - - assert.deepEqual( - (await store.readMessages('session-legacy-time-tie')).map((message) => message.id), - ['message-legacy-time-tie', 'message-newer-time-tie'], - ); - assert.equal( - (await store.readCatalogRecord('session-legacy-time-tie')).lastMessagePreview, - 'newer same-millisecond preview', - ); - } finally { - store.close(); - } - }); - - test('keeps ordinary admission handoff append semantics when Root proof is also supplied', async () => { - const store = createSqliteSessionMetadataStore(':memory:'); - try { - await store.create(fullHeader({ id: 'session-ordinary-handoff-order' })); - await store.appendMessages( - 'session-ordinary-handoff-order', - [ - { - type: 'assistant', - id: 'message-existing-ordinary-output', - turnId: 'turn-ordinary-handoff-order', - ts: 20, - text: 'existing output', - modelId: 'fake-model', - }, - ], - { lastMessageAt: 20, lastMessagePreview: 'existing output' }, - ); - await store.commitMessageAdmission({ - sessionId: 'session-ordinary-handoff-order', - turnId: 'turn-ordinary-handoff-order', - runId: 'run-ordinary-handoff-order', - messageId: 'message-ordinary-admission', - content: { text: 'ordinary admission' }, - submittedContentDigest: messageContentDigest({ text: 'ordinary admission' }), - submittedPlacement: 'current_turn', - placement: 'current_turn', - disposition: 'steering', - skillInvocation: { loaded: [], failed: [], receipts: [] }, - admittedAt: 10, - }); - - await markMessagesHandedOffWithProvenRoots(store, { - sessionId: 'session-ordinary-handoff-order', - messageIds: ['message-ordinary-admission'], - turnId: 'turn-ordinary-handoff-order', - provenRootMessages: [ - { - messageId: 'message-ordinary-admission', - content: { text: 'ordinary admission' }, - admittedAt: 10, - }, - ], - }); - - assert.deepEqual( - (await store.readMessages('session-ordinary-handoff-order')).map((message) => message.id), - ['message-existing-ordinary-output', 'message-ordinary-admission'], - ); - } finally { - store.close(); - } - }); - test('rejects an admission handed off to a different Turn', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { @@ -819,181 +581,23 @@ describe('SqliteSessionMetadataStore', () => { } }); - test('rejects fully materialized proven Root sources in a conflicting order', async () => { - const store = createSqliteSessionMetadataStore(':memory:'); - try { - await store.create(fullHeader({ id: 'session-existing-source-order' })); - await store.appendMessages( - 'session-existing-source-order', - [ - { - type: 'user', - id: 'message-existing-source-b', - turnId: 'turn-existing-source-order', - ts: 25, - text: 'source b', - }, - { - type: 'user', - id: 'message-existing-source-a', - turnId: 'turn-existing-source-order', - ts: 25, - text: 'source a', - }, - ], - { lastMessageAt: 25, lastMessagePreview: 'source a' }, - ); - - await assert.rejects( - markMessagesHandedOffWithProvenRoots(store, { - sessionId: 'session-existing-source-order', - messageIds: ['message-existing-source-a', 'message-existing-source-b'], - turnId: 'turn-existing-source-order', - provenRootMessages: [ - { - messageId: 'message-existing-source-a', - content: { text: 'source a' }, - admittedAt: 25, - }, - { - messageId: 'message-existing-source-b', - content: { text: 'source b' }, - admittedAt: 25, - }, - ], - }), - /source order conflict/, - ); - } finally { - store.close(); - } - }); - - test('rejects a partial proven Root group that already crosses newer history', async () => { - const store = createSqliteSessionMetadataStore(':memory:'); - try { - await store.create(fullHeader({ id: 'session-partial-source-order' })); - await store.appendMessages( - 'session-partial-source-order', - [ - { - type: 'user', - id: 'message-partial-source-a', - turnId: 'turn-partial-source-order', - ts: 15, - text: 'source a', - }, - { - type: 'user', - id: 'message-partial-newer-tail', - turnId: 'turn-partial-newer', - ts: 30, - text: 'newer tail', - }, - { - type: 'user', - id: 'message-partial-source-c', - turnId: 'turn-partial-source-order', - ts: 15, - text: 'source c', - }, - ], - { lastMessageAt: 30, lastMessagePreview: 'newer tail' }, - ); - - await assert.rejects( - markMessagesHandedOffWithProvenRoots(store, { - sessionId: 'session-partial-source-order', - messageIds: [ - 'message-partial-source-a', - 'message-partial-source-b', - 'message-partial-source-c', - ], - turnId: 'turn-partial-source-order', - provenRootMessages: [ - { - messageId: 'message-partial-source-a', - content: { text: 'source a' }, - admittedAt: 15, - }, - { - messageId: 'message-partial-source-b', - content: { text: 'source b' }, - admittedAt: 15, - }, - { - messageId: 'message-partial-source-c', - content: { text: 'source c' }, - admittedAt: 15, - }, - ], - }), - /source order conflict/, - ); - assert.deepEqual( - (await store.readMessages('session-partial-source-order')).map((message) => message.id), - ['message-partial-source-a', 'message-partial-newer-tail', 'message-partial-source-c'], - ); - } finally { - store.close(); - } - }); - - test('rejects an unsafe proven Root tail insertion range', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-legacy-root-overflow-')); - const path = join(root, 'state.sqlite'); - const store = createSqliteSessionMetadataStore(path); - try { - await store.create(fullHeader({ id: 'session-legacy-overflow' })); - await store.appendMessages( - 'session-legacy-overflow', - [ - { - type: 'assistant', - id: 'message-overflow-anchor', - turnId: 'turn-overflow-anchor', - ts: 1, - text: 'anchor', - modelId: 'fake-model', - }, - ], - { lastMessageAt: 1, lastMessagePreview: 'anchor' }, - ); - const database = new DatabaseSync(path); - try { - database - .prepare('UPDATE session_messages SET sequence = ? WHERE session_id = ? AND sequence = 0') - .run(Number.MAX_SAFE_INTEGER - 1, 'session-legacy-overflow'); - } finally { - database.close(); - } - - await assert.rejects( - markMessagesHandedOffWithProvenRoots(store, { - sessionId: 'session-legacy-overflow', - messageIds: ['message-overflow-a', 'message-overflow-b'], - turnId: 'turn-legacy-overflow', - provenRootMessages: [ - { messageId: 'message-overflow-a', content: { text: 'a' }, admittedAt: 2 }, - { messageId: 'message-overflow-b', content: { text: 'b' }, admittedAt: 2 }, - ], - }), - /sequence overflow/, - ); - assert.deepEqual( - (await store.readMessages('session-legacy-overflow')).map((message) => message.id), - ['message-overflow-anchor'], - ); - } finally { - store.close(); - await rm(root, { recursive: true, force: true }); - } - }); - - test('repeats a proven Root message handoff without duplicating its transcript', async () => { + test('repeats a proven Root message handoff after its admission is gone', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { await store.create(fullHeader({ id: 'session-legacy-repeat' })); + await store.commitMessageAdmission({ + sessionId: 'session-legacy-repeat', + turnId: 'turn-legacy-repeat', + runId: 'run-legacy-repeat', + messageId: 'message-legacy-repeat', + content: { text: 'a single durable message' }, + submittedContentDigest: messageContentDigest({ text: 'a single durable message' }), + submittedPlacement: 'current_turn', + placement: 'current_turn', + disposition: 'steering', + skillInvocation: { loaded: [], failed: [], receipts: [] }, + admittedAt: 18, + }); const input = { sessionId: 'session-legacy-repeat', messageIds: ['message-legacy-repeat'], @@ -1001,7 +605,7 @@ describe('SqliteSessionMetadataStore', () => { provenRootMessages: [ { messageId: 'message-legacy-repeat', - content: { text: 'a single durable transcript message' }, + content: { text: 'a single durable message' }, admittedAt: 18, }, ], @@ -1010,14 +614,8 @@ describe('SqliteSessionMetadataStore', () => { await markMessagesHandedOffWithProvenRoots(store, input); await markMessagesHandedOffWithProvenRoots(store, input); - assert.deepEqual( - (await store.readMessages('session-legacy-repeat')).map((message) => ({ - id: message.id, - turnId: message.turnId, - ts: message.ts, - })), - [{ id: 'message-legacy-repeat', turnId: 'turn-legacy-repeat', ts: 18 }], - ); + assert.deepEqual(await store.listMessageAdmissions('session-legacy-repeat'), []); + assert.deepEqual(await store.readMessages('session-legacy-repeat'), []); } finally { store.close(); } @@ -1080,102 +678,6 @@ describe('SqliteSessionMetadataStore', () => { } }); - test('rejects proven Root repeats with an existing transcript content or Turn conflict', async () => { - const store = createSqliteSessionMetadataStore(':memory:'); - try { - await store.create(fullHeader({ id: 'session-legacy-conflict' })); - await markMessagesHandedOffWithProvenRoots(store, { - sessionId: 'session-legacy-conflict', - messageIds: ['message-legacy-conflict'], - turnId: 'turn-legacy-conflict', - provenRootMessages: [ - { - messageId: 'message-legacy-conflict', - content: { text: 'canonical text' }, - admittedAt: 20, - }, - ], - }); - - await assert.rejects( - markMessagesHandedOffWithProvenRoots(store, { - sessionId: 'session-legacy-conflict', - messageIds: ['message-legacy-conflict'], - turnId: 'turn-legacy-conflict', - provenRootMessages: [ - { - messageId: 'message-legacy-conflict', - content: { text: 'different text' }, - admittedAt: 20, - }, - ], - }), - /transcript identity conflict/, - ); - await assert.rejects( - markMessagesHandedOffWithProvenRoots(store, { - sessionId: 'session-legacy-conflict', - messageIds: ['message-legacy-conflict'], - turnId: 'turn-legacy-conflict-different', - provenRootMessages: [ - { - messageId: 'message-legacy-conflict', - content: { text: 'canonical text' }, - admittedAt: 20, - }, - ], - }), - /transcript Turn conflict/, - ); - } finally { - store.close(); - } - }); - - test('keeps an admission as the content and timestamp authority during handoff', async () => { - const store = createSqliteSessionMetadataStore(':memory:'); - try { - await store.create(fullHeader({ id: 'session-admission-authority' })); - await store.commitMessageAdmission({ - sessionId: 'session-admission-authority', - turnId: 'turn-admission-authority', - runId: 'run-admission-authority', - messageId: 'message-admission-authority', - content: { text: 'admission authority', displayText: 'submitted display' }, - submittedContentDigest: messageContentDigest({ text: 'admission authority' }), - submittedPlacement: 'current_turn', - placement: 'current_turn', - disposition: 'steering', - skillInvocation: { loaded: [], failed: [], receipts: [] }, - admittedAt: 21, - }); - - await markMessagesHandedOffWithProvenRoots(store, { - sessionId: 'session-admission-authority', - messageIds: ['message-admission-authority'], - turnId: 'turn-admission-authority', - provenRootMessages: [ - { - messageId: 'message-admission-authority', - content: { text: 'admission authority', displayText: 'submitted display' }, - admittedAt: 99, - }, - ], - }); - - assert.deepEqual( - (await store.readMessages('session-admission-authority')).map((message) => ({ - text: message.type === 'user' ? message.text : undefined, - ts: message.ts, - })), - [{ text: 'admission authority', ts: 21 }], - ); - assert.deepEqual(await store.listMessageAdmissions('session-admission-authority'), []); - } finally { - store.close(); - } - }); - test('rejects proven Root fallback content that drifts from an admission', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { @@ -1288,7 +790,7 @@ describe('SqliteSessionMetadataStore', () => { } }); - test('removes the accepted payload after transcript handoff', async () => { + test('removes the accepted payload without writing a transcript row', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-message-handoff-')); const path = join(root, 'state.sqlite'); const store = createSqliteSessionMetadataStore(path); @@ -1332,7 +834,7 @@ describe('SqliteSessionMetadataStore', () => { 'SELECT COUNT(*) AS count FROM session_messages WHERE session_id = ? AND message_id = ?', ) .get('session-1', 'message-1')?.count, - 1, + 0, ); } finally { persisted.close(); @@ -1425,14 +927,7 @@ describe('SqliteSessionMetadataStore', () => { }); assert.equal(await store.readMessageAdmission('session-1', 'message-1'), undefined); - assert.deepEqual( - (await store.readMessages('session-1')).map((message) => ({ - id: message.id, - turnId: message.turnId, - text: message.type === 'user' ? message.text : undefined, - })), - [{ id: 'message-1', turnId: 'turn-2', text: content.text }], - ); + assert.deepEqual(await store.readMessages('session-1'), []); } finally { store.close(); await rm(root, { recursive: true, force: true }); @@ -1605,7 +1100,7 @@ describe('SqliteSessionMetadataStore', () => { } }); - test('materializes an accepted follow-up under its successor root', async () => { + test('retires an accepted follow-up under its successor root', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { await store.create(fullHeader({ id: 'session-followup-admission' })); @@ -1626,23 +1121,22 @@ describe('SqliteSessionMetadataStore', () => { }); assert.equal(admission.disposition, 'followup'); assert.deepEqual(await store.readMessages('session-followup-admission'), []); - await store.markMessagesHandedOff({ + const handoff = { sessionId: 'session-followup-admission', messageIds: ['message-followup'], turnId: 'turn-successor', - }); - await store.markMessagesHandedOff({ - sessionId: 'session-followup-admission', - messageIds: ['message-followup'], - turnId: 'turn-successor', - }); - assert.deepEqual( - (await store.readMessages('session-followup-admission')).map((message) => ({ - id: message.id, - turnId: message.turnId, - })), - [{ id: 'message-followup', turnId: 'turn-successor' }], - ); + provenRootMessages: [ + { + messageId: 'message-followup', + content: { text: 'queued before the successor root' }, + admittedAt: 11, + }, + ], + }; + await markMessagesHandedOffWithProvenRoots(store, handoff); + await markMessagesHandedOffWithProvenRoots(store, handoff); + assert.deepEqual(await store.listMessageAdmissions('session-followup-admission'), []); + assert.deepEqual(await store.readMessages('session-followup-admission'), []); } finally { store.close(); } diff --git a/packages/storage/src/__tests__/workhub-message-assignment.test.ts b/packages/storage/src/__tests__/workhub-message-assignment.test.ts index a3e69d8160..1faa540c70 100644 --- a/packages/storage/src/__tests__/workhub-message-assignment.test.ts +++ b/packages/storage/src/__tests__/workhub-message-assignment.test.ts @@ -33,6 +33,7 @@ import { type WorkHubDelegationStopResolvedMessage, type WorkHubDelegationSupersededMessage, } from '@maka/core/session'; +import { createSqliteAgentRunStore } from '../agent-run-store.js'; import { createSessionStore, isSessionNotFoundError } from '../session-store.js'; test('atomically commits one WorkHub assignment and target admission', async () => { @@ -82,11 +83,7 @@ test('atomically commits one WorkHub assignment and target admission', async () ]); const coordination = await store.readHeaderSnapshot(WORKHUB_COORDINATION_SESSION_ID); assert.equal(coordination.lastMessageAt, request.assignment.ts); - await store.markMessagesHandedOff({ - sessionId: target.id, - messageIds: [request.admission.messageId], - turnId: request.admission.turnId, - }); + await handOffToRootTurn(store, root, request); const replayAfterConsumption = await store.assignWorkHubMessage(request); assert.equal(replayAfterConsumption.kind, 'existing'); assert.deepEqual(replayAfterConsumption.assignment, request.assignment); @@ -126,11 +123,7 @@ test('scans every target Message lifecycle once and preserves Coordination order assignmentRequest('unrelated-action', unrelated.id, 'Login', 'unrelated-turn'), ); await store.assignWorkHubMessage(middle); - await store.markMessagesHandedOff({ - sessionId: target.id, - messageIds: [middle.admission.messageId], - turnId: middle.admission.turnId, - }); + await handOffToRootTurn(store, root, middle); await store.assignWorkHubMessage(newest); assert.equal( await store.claimMessageAdmissionCancellation( @@ -169,11 +162,7 @@ test('keeps target assignments reachable when their Message lifecycle changes', .sort((left, right) => left.admission.messageId.localeCompare(right.admission.messageId)); for (const request of requests) await store.assignWorkHubMessage(request); - await store.markMessagesHandedOff({ - sessionId: target.id, - messageIds: [requests[1]!.admission.messageId], - turnId: requests[1]!.admission.turnId, - }); + await handOffToRootTurn(store, root, requests[1]!); assert.equal( await store.claimMessageAdmissionCancellation( target.id, @@ -722,6 +711,49 @@ function terminalSuffix(delegationId: string): string { return createHash('sha256').update(delegationId, 'utf8').digest('hex').slice(0, 48); } +/** + * Hand a Message off the way a Turn does: the Root admission that consumed it + * is what keeps its identity durable once the pending admission is retired. + */ +async function handOffToRootTurn( + store: ReturnType, + root: string, + request: AssignmentRequest, +): Promise { + const runStore = createSqliteAgentRunStore(root); + try { + await runStore.admitRootTurn({ + sessionId: request.admission.sessionId, + turnId: request.admission.turnId, + proposedRunId: request.admission.runId, + proposedUserMessageId: request.admission.messageId, + execution: { + kind: 'external_message', + inputDigest: request.admission.submittedContentDigest, + }, + previousRootTurnId: null, + normalizedInput: request.admission.content, + sourceMessages: [ + { + messageId: request.admission.messageId, + content: request.admission.content, + submittedContentDigest: request.admission.submittedContentDigest, + placement: request.admission.placement, + disposition: request.admission.disposition, + }, + ], + admittedAt: request.admission.admittedAt, + }); + } finally { + runStore.close?.(); + } + await store.markMessagesHandedOff({ + sessionId: request.admission.sessionId, + messageIds: [request.admission.messageId], + turnId: request.admission.turnId, + }); +} + type AssignmentRequest = ReturnType; function assignmentRequest( diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index 27c8e3541f..507727bd0d 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -20,6 +20,7 @@ import type { AgentRunEvent, AgentRunEventType, AgentRunProjectionKey } from '@maka/core/agent-run'; import type { RuntimeEvent, ToolBoundaryProtocol } from '@maka/core/runtime-event'; import type { RuntimeContinuationAuthorityStore } from '@maka/core/runtime-event-store'; +import type { RuntimeTranscriptQueries } from './runtime-transcript-query.js'; import type { RuntimeInvocationPageInput, RuntimeInvocationPageResult, @@ -83,10 +84,7 @@ export { normalizeRootTurnAdmissionPayload, rootTurnAdmissionRecordFits, } from './agent-run-store.js'; -export { - isSessionNotFoundError, - SessionReadMarkerMessageNotFoundError, -} from './session-store.js'; +export { isSessionNotFoundError } from './session-store.js'; export { SessionMetadataConflictError, SessionMetadataVersionConflictError, @@ -131,11 +129,17 @@ export type { SessionTranscriptRecordScanRequest, SessionTranscriptStoragePage, SessionTranscriptStorageFragment, + SessionTurnContribution, + SessionTurnContributionPage, + SessionTurnLandmark, + SessionTurnLandmarkSnapshot, } from './session-store.js'; export type ExecutionSessionWriter = SessionAuthorityStore; +export type { RuntimeTranscriptSource, RuntimeTranscriptTurn } from './runtime-transcript-query.js'; export type ExecutionAgentRunWriter = DurableAgentRunStore; export type ExecutionRuntimeEventWriter = DurableRuntimeEventStore & + RuntimeTranscriptQueries & RuntimeContinuationAuthorityStore & { readonly toolBoundaryProtocol: ToolBoundaryProtocol; commitToolPrepared(input: CommitToolPreparedInput): Promise; @@ -224,6 +228,10 @@ export interface ExecutionRuntimeEventReader { ): Promise>; readImmutableRuntimeEvents(sessionId: string, runId: string): Promise; readSessionRuntimeEvents(sessionId: string): Promise; + /** Session-wide events with the ordinal that fixes their transcript order. */ + readSessionRuntimeEventEntries( + sessionId: string, + ): Promise>; } interface ExecutionStoresReaderBase { @@ -423,35 +431,22 @@ async function createExecutionStoresForWrite run(() => sessionStore.readCatalogRecord(sessionId)), probeSessionRemoval: (sessionId) => run(() => sessionStore.probeSessionRemoval(sessionId)), readMessagesSnapshot: (sessionId) => run(() => sessionStore.readMessagesSnapshot(sessionId)), - readTranscriptPageSnapshot: (sessionId, request) => - run(() => sessionStore.readTranscriptPageSnapshot(sessionId, request)), - readTranscriptRecordsSnapshot: (sessionId, request) => - run(() => sessionStore.readTranscriptRecordsSnapshot(sessionId, request)), readTranscriptMessagesSnapshot: (sessionId, request) => run(() => sessionStore.readTranscriptMessagesSnapshot(sessionId, request)), readTranscriptHighWaterSnapshot: (sessionId) => run(() => sessionStore.readTranscriptHighWaterSnapshot(sessionId)), - readTurnContributionsSnapshot: (sessionId, throughSequence, position, maxContributions) => - run(() => - sessionStore.readTurnContributionsSnapshot( - sessionId, - throughSequence, - position, - maxContributions, - ), - ), - readTurnLandmarksSnapshot: (sessionId, maxLandmarks) => - run(() => sessionStore.readTurnLandmarksSnapshot(sessionId, maxLandmarks)), - readMessagesForRecovery: (sessionId) => - run(() => sessionStore.readMessagesForRecovery(sessionId)), listTurnsSnapshot: (sessionId) => run(() => sessionStore.listTurnsSnapshot(sessionId)), readHeader: (sessionId) => run(() => sessionStore.readHeader(sessionId)), readMessages: (sessionId) => run(() => sessionStore.readMessages(sessionId)), + readMessagesAfter: (sessionId, request) => + run(() => sessionStore.readMessagesAfter(sessionId, request)), listTurns: (sessionId) => run(() => sessionStore.listTurns(sessionId)), appendMessage: (sessionId, message) => run(() => sessionStore.appendMessage(sessionId, message)), appendMessages: (sessionId, messages) => run(() => sessionStore.appendMessages(sessionId, messages)), + commitMessageCatalogProjection: (sessionId, message) => + run(() => sessionStore.commitMessageCatalogProjection(sessionId, message)), commitMessageAdmission: (admission) => run(() => sessionStore.commitMessageAdmission(admission)), readMessageAdmission: (sessionId, messageId) => @@ -475,8 +470,6 @@ async function createExecutionStoresForWrite sessionStore.updateHeaderVersioned(sessionId, patch, expectedRevision)), updateSessionConfiguration: (sessionId, input) => run(() => sessionStore.updateSessionConfiguration(sessionId, input)), - markSessionReadThroughMessage: (sessionId, messageId) => - run(() => sessionStore.markSessionReadThroughMessage(sessionId, messageId)), setFlagged: (sessionId, isFlagged) => run(() => sessionStore.setFlagged(sessionId, isFlagged)), rename: (sessionId, name) => run(() => sessionStore.rename(sessionId, name)), @@ -572,6 +565,16 @@ async function createExecutionStoresForWrite runtimeEventStore.readSessionRuntimeEvents(sessionId)), readSessionRuntimeEventEntries: (sessionId) => run(() => runtimeEventStore.readSessionRuntimeEventEntries(sessionId)), + readTranscriptSourceHighWater: (sessionId) => + run(() => runtimeEventStore.readTranscriptSourceHighWater(sessionId)), + readTranscriptSource: (sessionId, request) => + run(() => runtimeEventStore.readTranscriptSource(sessionId, request)), + readTranscriptTurns: (sessionId, throughOrdinal, position, limit) => + run(() => + runtimeEventStore.readTranscriptTurns(sessionId, throughOrdinal, position, limit), + ), + readTranscriptLandmarks: (sessionId, throughOrdinal, limit) => + run(() => runtimeEventStore.readTranscriptLandmarks(sessionId, throughOrdinal, limit)), claimContinuation: (input) => run(() => runtimeEventStore.claimContinuation(input)), readContinuationClaimByBoundary: (boundaryDigest) => run(() => runtimeEventStore.readContinuationClaimByBoundary(boundaryDigest)), @@ -685,6 +688,8 @@ async function openExecutionStoresForRead runtimeEventStore.readInvocation(sessionId, invocationId)), readSessionRuntimeEvents: (sessionId) => run(() => runtimeEventStore.readSessionRuntimeEvents(sessionId)), + readSessionRuntimeEventEntries: (sessionId) => + run(() => runtimeEventStore.readSessionRuntimeEventEntries(sessionId)), }, }; freezeExecutionStoresFacade(stores); diff --git a/packages/storage/src/runtime-event-persistence.ts b/packages/storage/src/runtime-event-persistence.ts index 893e734e50..9141585e98 100644 --- a/packages/storage/src/runtime-event-persistence.ts +++ b/packages/storage/src/runtime-event-persistence.ts @@ -65,6 +65,10 @@ export interface RuntimeEventReadStore { ): Promise>; readImmutableRuntimeEvents(sessionId: string, runId: string): Promise; readSessionRuntimeEvents(sessionId: string): Promise; + /** Session-wide events with the ordinal that fixes their transcript order. */ + readSessionRuntimeEventEntries( + sessionId: string, + ): Promise>; } export async function openRuntimeEventPersistence(input: { @@ -112,6 +116,8 @@ export async function openRuntimeEventReadPersistence(input: { readImmutableRuntimeEvents: (sessionId: string, runId: string) => store.readImmutableRuntimeEvents(sessionId, runId), readSessionRuntimeEvents: (sessionId: string) => store.readSessionRuntimeEvents(sessionId), + readSessionRuntimeEventEntries: (sessionId: string) => + store.readSessionRuntimeEventEntries(sessionId), }), close: () => store.close(), }; diff --git a/packages/storage/src/runtime-transcript-query.ts b/packages/storage/src/runtime-transcript-query.ts new file mode 100644 index 0000000000..ee05fd4809 --- /dev/null +++ b/packages/storage/src/runtime-transcript-query.ts @@ -0,0 +1,436 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { DatabaseSync } from 'node:sqlite'; +import { + decodeRuntimeEvent, + isTerminalRuntimeEvent, + type RuntimeEvent, +} from '@maka/core/runtime-event'; +import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; +import type { AssistantStepContentKind } from '@maka/core/session'; + +/** SQL counterpart of isTerminalRuntimeEvent; shared with the ledger store. */ +export const TERMINAL_RUNTIME_EVENT_SQL = `( + json_extract(payload_json, '$.actions.endInvocation') = 1 + OR json_extract(payload_json, '$.status') IN ('completed', 'failed', 'aborted', 'cancelled') +)`; + +export const TRANSCRIPT_MESSAGE_KEY_SQL = `CASE WHEN event_kind = 'function_call' + THEN json_extract(payload_json, '$.refs.stepId') + ELSE COALESCE(json_extract(payload_json, '$.refs.providerEventId'), json_extract(payload_json, '$.refs.storedMessageId'), event_id) END`; +export const TRANSCRIPT_STORED_ID_SQL = `COALESCE(json_extract(payload_json, '$.refs.storedMessageId'), json_extract(payload_json, '$.refs.providerEventId'), json_extract(payload_json, '$.content.id'), event_id)`; +/** Small indexed facts needed by a terminal row and the Turn index. */ +export const TRANSCRIPT_OUTPUT_SHAPE_SQL = `CASE + WHEN event_kind = 'text' AND json_extract(payload_json, '$.role') = 'model' + THEN CASE WHEN TRIM(json_extract(payload_json, '$.content.text'), char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288,65279)) <> '' THEN 3 ELSE 1 END + WHEN event_kind = 'function_response' THEN CASE WHEN json_extract(payload_json, '$.content.isError') = 1 THEN 12 ELSE 4 END + ELSE 0 END`; + +export interface RuntimeTranscriptSource { + readonly ordinal: number; + readonly event: RuntimeEvent; + /** Only this message's thinking, in ledger order around its text event. */ + readonly events: readonly RuntimeEvent[]; + readonly invocation: RuntimeInvocationRecord; + readonly contentOrder?: readonly AssistantStepContentKind[]; + readonly permissionRequest?: RuntimeEvent; + readonly toolName?: string; + readonly hasRetainedOutput: boolean; +} + +export interface RuntimeTranscriptPosition { + readonly direction: 'older' | 'newer'; + readonly throughOrdinal: number; + readonly position: number; + /** Exact lookup for the bounded live-to-durable handoff. */ + readonly messageId?: string; +} + +export interface RuntimeTranscriptTurn { + readonly firstOrdinal: number; + readonly terminalOrdinal: number; + readonly invocation: RuntimeInvocationRecord; + readonly user?: { ordinal: number; event: RuntimeEvent }; + readonly hasAssistantMessage: boolean; + readonly hasAssistantOutput: boolean; + readonly hasToolResult: boolean; + readonly hasFailedToolResult: boolean; + readonly hasAbortNote: boolean; +} + +export interface RuntimeTranscriptQueries { + readTranscriptSourceHighWater(sessionId: string): Promise; + readTranscriptSource( + sessionId: string, + request: RuntimeTranscriptPosition, + ): Promise; + readTranscriptTurns( + sessionId: string, + throughOrdinal: number, + position: number, + limit: number, + ): Promise; + readTranscriptLandmarks( + sessionId: string, + throughOrdinal: number, + limit: number, + ): Promise; +} + +const messageKey = (alias: string) => + TRANSCRIPT_MESSAGE_KEY_SQL.replaceAll('event_kind', `${alias}.event_kind`) + .replaceAll('payload_json', `${alias}.payload_json`) + .replaceAll('event_id', `${alias}.event_id`); +const terminal = (alias: string) => + TERMINAL_RUNTIME_EVENT_SQL.replaceAll('payload_json', `${alias}.payload_json`); +const opening = `COALESCE(json_extract(opened.payload_json, '$.content'), legacy.opening_json)`; +const joins = ` + FROM runtime_session_event_ordinals o + JOIN runtime_events e ON e.event_id = o.event_id + LEFT JOIN runtime_events opened ON opened.invocation_id = e.invocation_id AND opened.event_kind = 'invocation_opened' + LEFT JOIN runtime_legacy_invocation_openings legacy ON legacy.invocation_id = e.invocation_id`; +const settledInline = ` + ${opening} IS NOT NULL + AND (json_extract(${opening}, '$.lineage.parentRunId') IS NULL + OR (json_extract(${opening}, '$.source.kind') = 'continuation' + AND json_extract(${opening}, '$.lineage.agentId') IS NULL)) + AND EXISTS ( + SELECT 1 FROM runtime_events ended + JOIN runtime_session_event_ordinals ending ON ending.event_id = ended.event_id + WHERE ended.invocation_id = e.invocation_id AND ${terminal('ended')} + AND ending.ordinal <= :throughOrdinal + )`; +// Thinking is context for its text row. An orphan must still reach the +// projector, which reports the missing text instead of silently dropping it. +const transcriptSource = `( + (json_extract(e.payload_json, '$.content') IS NOT NULL + AND e.event_kind <> 'invocation_opened' + AND (e.event_kind <> 'thinking' OR NOT EXISTS ( + SELECT 1 FROM runtime_events text + WHERE text.invocation_id = e.invocation_id AND text.event_kind = 'text' + AND json_extract(text.payload_json, '$.role') = 'model' + AND COALESCE(json_extract(text.payload_json, '$.refs.storedMessageId'), json_extract(text.payload_json, '$.refs.providerEventId'), text.event_id) = ${messageKey('e')} + ))) + OR json_extract(e.payload_json, '$.actions.permissionDecision') IS NOT NULL + OR json_extract(e.payload_json, '$.actions.permissionAnswerAccepted') IS NOT NULL + OR json_extract(e.payload_json, '$.actions.tokenUsage') IS NOT NULL + OR ${terminal('e')} +)`; + +type SourceRow = { + ordinal: number; + event_id: string; + run_id: string; + invocation_id: string; + event_seq: number; +}; + +/** Queries select ledger positions before loading any message payload. No transcript is persisted. */ +export class RuntimeTranscriptQuery { + constructor( + private readonly db: DatabaseSync, + private readonly invocation: (sessionId: string, runId: string) => RuntimeInvocationRecord, + ) {} + + highWater(sessionId: string): number | null { + return ( + this.sourceRow(sessionId, { + direction: 'older', + throughOrdinal: Number.MAX_SAFE_INTEGER, + position: Number.MAX_SAFE_INTEGER, + })?.ordinal ?? null + ); + } + + private sourceRow(sessionId: string, request: RuntimeTranscriptPosition): SourceRow | undefined { + assertOrdinal(request.throughOrdinal); + assertOrdinal(request.position); + if (request.direction !== 'older' && request.direction !== 'newer') + throw new Error('Invalid transcript direction'); + return this.db + .prepare(` + SELECT o.ordinal, e.event_id, e.run_id, e.invocation_id, e.event_seq ${joins} + WHERE o.session_id = :sessionId AND o.ordinal <= :throughOrdinal + ${ + request.messageId === undefined + ? '' + : `AND e.event_id IN ( + SELECT event_id FROM runtime_events WHERE session_id = :sessionId AND (${TRANSCRIPT_STORED_ID_SQL}) = :messageId + UNION SELECT event_id FROM runtime_events WHERE session_id = :sessionId AND event_id = :noticeEventId + )` + } + AND o.ordinal ${request.direction === 'older' ? '<=' : '>='} :position + AND ${settledInline} AND ${transcriptSource} + ORDER BY o.ordinal ${request.direction === 'older' ? 'DESC' : 'ASC'} LIMIT 1 + `) + .get({ + sessionId, + throughOrdinal: request.throughOrdinal, + position: request.position, + ...(request.messageId === undefined + ? {} + : { + messageId: request.messageId, + noticeEventId: request.messageId.endsWith(':step-limit-notice') + ? request.messageId.slice(0, -':step-limit-notice'.length) + : null, + }), + }) as SourceRow | undefined; + } + + source(sessionId: string, request: RuntimeTranscriptPosition): RuntimeTranscriptSource | null { + const row = this.sourceRow(sessionId, request); + if (!row) return null; + const event = this.event(row.event_id); + const invocation = this.invocation(sessionId, row.run_id); + let primary = event; + if (event.content?.kind === 'thinking') { + const text = this.db + .prepare(` + SELECT 1 FROM runtime_events WHERE invocation_id = ? AND event_kind = 'text' + AND json_extract(payload_json, '$.role') = 'model' + AND COALESCE(json_extract(payload_json, '$.refs.storedMessageId'), json_extract(payload_json, '$.refs.providerEventId'), event_id) = ? LIMIT 1 + `) + .get( + row.invocation_id, + event.refs?.providerEventId ?? event.refs?.storedMessageId ?? event.id, + ); + if (text) { + // Its thinking is attached at the text position; any actions still own + // their rows at this event's position, exactly once. + const { content: _content, ...actionsOnly } = event; + primary = actionsOnly; + } + } + const events: Array<{ event: RuntimeEvent; sequence: number }> = [ + { event: primary, sequence: row.event_seq }, + ]; + let contentOrder: AssistantStepContentKind[] | undefined; + if (event.role === 'model' && event.content?.kind === 'text') { + const id = event.refs?.storedMessageId ?? event.refs?.providerEventId ?? event.id; + const thinking = this.db + .prepare(` + SELECT e.event_id, e.event_seq FROM runtime_events e + WHERE e.invocation_id = ? AND e.event_kind = 'thinking' AND ${messageKey('e')} = ? + ORDER BY e.event_seq + `) + .all(row.invocation_id, id) as Array<{ event_id: string; event_seq: number }>; + for (const item of thinking) { + const { actions: _actions, status: _status, ...context } = this.event(item.event_id); + events.push({ event: context, sequence: item.event_seq }); + } + const kinds = this.db + .prepare(` + SELECT CASE e.event_kind WHEN 'function_call' THEN 'tools' ELSE e.event_kind END AS kind, + MIN(e.event_seq) AS first_sequence FROM runtime_events e + WHERE e.invocation_id = :invocationId AND e.event_seq <= :sequence + AND e.event_kind IN ('text', 'thinking', 'function_call') AND json_extract(e.payload_json, '$.role') = 'model' + AND ${messageKey('e')} = :messageId + GROUP BY kind ORDER BY first_sequence + `) + .all({ invocationId: row.invocation_id, sequence: row.event_seq, messageId: id }) as Array<{ + kind: AssistantStepContentKind; + }>; + contentOrder = kinds.map((item) => item.kind); + } + const requestId = + event.actions?.permissionDecision?.requestId ?? + event.actions?.permissionAnswerAccepted?.requestId; + const permissionRow = requestId + ? (this.db + .prepare(` + SELECT event_id FROM runtime_events + WHERE invocation_id = ? AND event_seq <= ? AND json_extract(payload_json, '$.actions.permissionRequest.requestId') = ? + ORDER BY event_seq DESC LIMIT 1 + `) + .get(row.invocation_id, row.event_seq, requestId) as { event_id: string } | undefined) + : undefined; + const permissionRequest = permissionRow ? this.event(permissionRow.event_id) : undefined; + const toolUseId = + event.refs?.toolCallId ?? permissionRequest?.actions?.permissionRequest?.toolUseId; + const toolRow = + requestId && toolUseId + ? (this.db + .prepare(` + SELECT json_extract(payload_json, '$.content.name') AS name FROM runtime_events + WHERE invocation_id = ? AND event_seq <= ? AND json_extract(payload_json, '$.content.id') = ? + AND event_kind IN ('function_call', 'function_response') + ORDER BY event_seq DESC LIMIT 1 + `) + .get(row.invocation_id, row.event_seq, toolUseId) as { name: string } | undefined) + : undefined; + const hasRetainedOutput = + isTerminalRuntimeEvent(event) && this.hasShape(row.invocation_id, row.ordinal, 0, '3,4,12'); + return { + ordinal: row.ordinal, + event, + invocation, + events: events.sort((a, b) => a.sequence - b.sequence).map((item) => item.event), + ...(contentOrder ? { contentOrder } : {}), + ...(permissionRequest ? { permissionRequest } : {}), + ...(toolRow?.name ? { toolName: toolRow.name } : {}), + hasRetainedOutput, + }; + } + + turns( + sessionId: string, + throughOrdinal: number, + position: number, + limit: number, + ): RuntimeTranscriptTurn[] { + assertOrdinal(throughOrdinal); + assertOrdinal(position); + const rows = this.db + .prepare(` + SELECT e.run_id, e.invocation_id, o.ordinal ${joins} + WHERE o.session_id = :sessionId AND o.ordinal <= :throughOrdinal + AND e.event_seq = 1 AND ${settledInline} + AND EXISTS (SELECT 1 FROM runtime_events tail JOIN runtime_session_event_ordinals t ON t.event_id = tail.event_id + WHERE tail.invocation_id = e.invocation_id AND t.ordinal >= :position AND t.ordinal <= :throughOrdinal AND ${transcriptSource.replaceAll('e.', 'tail.')}) + ORDER BY o.ordinal LIMIT :limit + `) + .all({ sessionId, throughOrdinal, position, limit }) as SourceRow[]; + return rows.map((row) => this.turn(sessionId, row, throughOrdinal, position)); + } + + landmarks(sessionId: string, throughOrdinal: number, limit: number): RuntimeTranscriptTurn[] { + assertOrdinal(throughOrdinal); + if (limit < 1) return []; + const rows = this.db + .prepare(` + WITH candidates AS ( + SELECT e.run_id, e.invocation_id, o.ordinal, + ROW_NUMBER() OVER (ORDER BY o.ordinal) - 1 AS rank, COUNT(*) OVER () AS total + ${joins} WHERE o.session_id = :sessionId AND o.ordinal <= :throughOrdinal + AND e.event_seq = 1 AND ${settledInline} + ), samples(n) AS ( + SELECT 0 UNION ALL SELECT n + 1 FROM samples WHERE n + 1 < :limit + ) + SELECT DISTINCT run_id, invocation_id, ordinal FROM candidates + JOIN samples ON rank = CASE WHEN :limit = 1 THEN total - 1 + ELSE CAST(n * (total - 1) / (:limit - 1) AS INTEGER) END + ORDER BY ordinal + `) + .all({ sessionId, throughOrdinal, limit }) as SourceRow[]; + return rows.map((row) => this.turn(sessionId, row, throughOrdinal, 0)); + } + + private turn( + sessionId: string, + row: SourceRow, + throughOrdinal: number, + position: number, + ): RuntimeTranscriptTurn { + const bounds = this.db + .prepare(` + SELECT o.ordinal AS first + FROM runtime_events e JOIN runtime_session_event_ordinals o ON o.event_id = e.event_id + WHERE e.invocation_id = ? AND o.ordinal >= ? AND o.ordinal <= ? AND ${transcriptSource} + ORDER BY e.event_seq LIMIT 1 + `) + .get(row.invocation_id, position, throughOrdinal) as { first: number }; + const user = this.db + .prepare(` + SELECT o.ordinal, e.event_id FROM runtime_events e JOIN runtime_session_event_ordinals o ON o.event_id = e.event_id + WHERE e.invocation_id = ? AND o.ordinal >= ? AND o.ordinal <= ? + AND e.event_kind = 'text' AND json_extract(e.payload_json, '$.role') = 'user' + ORDER BY e.event_seq LIMIT 1 + `) + .get(row.invocation_id, position, throughOrdinal) as + | { ordinal: number; event_id: string } + | undefined; + const ended = this.db + .prepare(` + SELECT o.ordinal FROM runtime_events e JOIN runtime_session_event_ordinals o ON o.event_id = e.event_id + WHERE e.invocation_id = ? AND o.ordinal <= ? AND ${terminal('e')} + ORDER BY o.ordinal DESC LIMIT 1 + `) + .get(row.invocation_id, throughOrdinal) as { ordinal: number }; + return { + firstOrdinal: bounds.first, + terminalOrdinal: ended.ordinal, + invocation: this.invocation(sessionId, row.run_id), + ...(user ? { user: { ordinal: user.ordinal, event: this.event(user.event_id) } } : {}), + ...this.flags(row.invocation_id, throughOrdinal, position), + }; + } + + private flags(invocationId: string, throughOrdinal: number, position: number) { + return { + hasAssistantMessage: this.hasShape(invocationId, throughOrdinal, position, '1,3'), + hasAssistantOutput: this.hasShape(invocationId, throughOrdinal, position, '3'), + hasToolResult: this.hasShape(invocationId, throughOrdinal, position, '4,12'), + hasFailedToolResult: this.hasShape(invocationId, throughOrdinal, position, '12'), + hasAbortNote: false, + }; + } + + private hasShape( + invocationId: string, + throughOrdinal: number, + position: number, + shapes: string, + ): boolean { + return ( + this.db + .prepare(` + SELECT 1 FROM runtime_events JOIN runtime_session_event_ordinals o USING (event_id) + WHERE invocation_id = ? AND (${TRANSCRIPT_OUTPUT_SHAPE_SQL}) IN (${shapes}) + AND o.ordinal >= ? AND o.ordinal <= ? LIMIT 1 + `) + .get(invocationId, position, throughOrdinal) !== undefined + ); + } + + private event(id: string): RuntimeEvent { + const row = this.db + .prepare( + 'SELECT event_id, session_id, invocation_id, run_id, turn_id, payload_json FROM runtime_events WHERE event_id = ?', + ) + .get(id) as + | { + event_id: string; + session_id: string; + invocation_id: string; + run_id: string; + turn_id: string; + payload_json: string; + } + | undefined; + if (!row) throw new Error(`Transcript RuntimeEvent ${id} is missing`); + const event = decodeRuntimeEvent(JSON.parse(row.payload_json)); + if ( + event.id !== row.event_id || + event.sessionId !== row.session_id || + event.invocationId !== row.invocation_id || + event.runId !== row.run_id || + event.turnId !== row.turn_id + ) { + throw new Error(`Transcript RuntimeEvent ${id} has inconsistent storage identity`); + } + return event; + } +} + +function assertOrdinal(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) + throw new Error('Invalid transcript event ordinal'); +} diff --git a/packages/storage/src/session-message-projection.ts b/packages/storage/src/session-message-projection.ts index 5284e5720a..94fc0fb3a2 100644 --- a/packages/storage/src/session-message-projection.ts +++ b/packages/storage/src/session-message-projection.ts @@ -18,6 +18,7 @@ */ import type { StoredMessage, UserMessage } from '@maka/core/session'; +import type { SessionTurnContribution } from './session-store.js'; export function projectSessionCatalogMessages(messages: readonly StoredMessage[]): { readonly lastMessageAt?: number; @@ -87,3 +88,43 @@ function truncatePreview(text: string, maxLength = 96): string { if (chars.length <= maxLength) return text; return `${chars.slice(0, maxLength - 1).join('')}…`; } + +/** + * One Turn's summary, folded message by message in transcript order. + * + * Both transcript authorities fold the same way: the sqlite catalog over its + * own rows, and the ledger reader over the messages a run projects. + */ +export function foldTurnContribution( + current: SessionTurnContribution | undefined, + turnId: string, + sequence: number, + message: StoredMessage, +): SessionTurnContribution { + const contribution = current ?? { + turnId, + firstSequence: sequence, + latestState: null, + userPromptPreview: null, + hasAssistantMessage: false, + hasAssistantOutput: false, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }; + const userPrompt = message.type === 'user' ? (message.displayText ?? message.text).trim() : ''; + return { + ...contribution, + latestState: message.type === 'turn_state' ? { sequence, message } : contribution.latestState, + userPromptPreview: contribution.userPromptPreview ?? (userPrompt || null), + hasAssistantMessage: contribution.hasAssistantMessage || message.type === 'assistant', + hasAssistantOutput: + contribution.hasAssistantOutput || + (message.type === 'assistant' && message.text.trim().length > 0), + hasToolResult: contribution.hasToolResult || message.type === 'tool_result', + hasFailedToolResult: + contribution.hasFailedToolResult || (message.type === 'tool_result' && message.isError), + hasAbortNote: + contribution.hasAbortNote || (message.type === 'system_note' && message.kind === 'abort'), + }; +} diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index eb05f3fb1f..d065667d7d 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -82,6 +82,7 @@ import { type StoredMessage, type TurnRecord, type TurnStateMessage, + type AssistantMessage, type UserMessage, type WorkHubDelegationAssignedMessage, type WorkHubDelegationReplacementAbortedMessage, @@ -97,12 +98,7 @@ import type { MessageAdmissionStore, PendingMessageAdmission, } from './message-admission-store.js'; -import { - isVisibleSessionMessage, - lastMessagePreviewForMessages, - latestVisibleMessageAt, - projectSessionCatalogMessages, -} from './session-message-projection.js'; +import { projectSessionCatalogMessages } from './session-message-projection.js'; export { projectSessionCatalogMessages }; const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; @@ -127,18 +123,6 @@ export function isSessionNotFoundError(error: unknown): error is SessionNotFound return error instanceof SessionNotFoundError; } -export class SessionReadMarkerMessageNotFoundError extends Error { - readonly name = 'SessionReadMarkerMessageNotFoundError'; - readonly code = 'session_read_marker_message_not_found'; - - constructor( - readonly sessionId: string, - readonly messageId: string, - ) { - super(`Session read marker message does not exist: ${messageId}`); - } -} - export interface SessionHeaderSnapshot { readonly header: SessionHeader; readonly revision: number; @@ -241,6 +225,33 @@ export interface SessionTranscriptMessageLookupRequest { readonly maxMessages: number; } +/** + * One forward page of a Session's legacy rows, for the converter that lifts + * them onto the ledger. Nothing else reads `session_messages` any more, so this + * is a migration scan rather than a transcript read. + */ +export interface SessionMessageScanRequest { + /** Exclusive lower bound; omit to start at the first row. */ + readonly afterSequence?: number; + readonly maxStoredBytes: number; + readonly maxMessages: number; +} + +export interface SessionMessageScanRecord { + readonly sequence: number; + readonly message: StoredMessage; +} + +export interface SessionMessageScanPage { + readonly records: readonly SessionMessageScanRecord[]; + /** + * The Session's last legacy sequence. It rides along with every page so the + * converter can place a turn relative to the whole transcript without a read + * that is proportional to it. + */ + readonly highWaterSequence: number | null; +} + export interface SessionTranscriptPageRequest { readonly direction: 'older' | 'newer'; /** Inclusive durable high-water mark. Omit only for the first read. */ @@ -318,33 +329,23 @@ export interface SessionStore { listForRecovery(): Promise; /** Read only the durable header without triggering connection-lock self-healing. */ readHeaderSnapshot(sessionId: string): Promise; - /** Read durable messages without triggering connection-lock self-healing. */ readMessagesSnapshot(sessionId: string): Promise; - /** Read one byte-bounded page directly from the durable append-only ledger. */ - readTranscriptPageSnapshot( - sessionId: string, - request: SessionTranscriptPageRequest, - ): Promise; readTranscriptHighWaterSnapshot(sessionId: string): Promise; - readTurnContributionsSnapshot( - sessionId: string, - throughSequence: number | null, - position: number, - maxContributions: number, - ): Promise; - readTurnLandmarksSnapshot( - sessionId: string, - maxLandmarks: number, - ): Promise; - /** Read durable messages for startup recovery. */ - readMessagesForRecovery(sessionId: string): Promise; - /** Derive durable turns without triggering connection-lock self-healing. */ listTurnsSnapshot(sessionId: string): Promise; readHeader(sessionId: string): Promise; readMessages(sessionId: string): Promise; + readMessagesAfter( + sessionId: string, + request: SessionMessageScanRequest, + ): Promise; listTurns(sessionId: string): Promise; appendMessage(sessionId: string, message: StoredMessage): Promise; appendMessages(sessionId: string, messages: StoredMessage[]): Promise; + /** Commit the Session-list facts a durable message carries. */ + commitMessageCatalogProjection( + sessionId: string, + message: UserMessage | AssistantMessage, + ): Promise; updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise; setFlagged(sessionId: string, isFlagged: boolean): Promise; rename(sessionId: string, name: string): Promise; @@ -354,11 +355,6 @@ export interface SessionStore { } export interface SessionAuthorityStore extends SessionStore, MessageAdmissionStore { - /** Decode a bounded ledger range for an authority-owned wire projection. */ - readTranscriptRecordsSnapshot( - sessionId: string, - request: SessionTranscriptRecordScanRequest, - ): Promise; /** Read a bounded set of durable messages at an inclusive transcript watermark. */ readTranscriptMessagesSnapshot( sessionId: string, @@ -470,10 +466,6 @@ export interface SessionAuthorityStore extends SessionStore, MessageAdmissionSto sessionId: string, input: UpdateSessionConfigurationRequest, ): Promise; - markSessionReadThroughMessage( - sessionId: string, - messageId: string, - ): Promise; probeSessionRemoval(sessionId: string): Promise; setSessionsArchivedVersioned( sessions: readonly VersionedSessionIdentity[], @@ -898,41 +890,9 @@ class SqliteSessionStore implements SessionAuthorityStore { async list(filter?: SessionListFilter): Promise { await this.ensureReady(); - const records = (await this.metadata.list(filter, 'ordinary')).filter( - (record) => record.header.conversationCopy?.state !== 'preparing', - ); - const withPreviews: Array<{ - record: SessionMetadataRecord; - previewMessages: StoredMessage[]; - }> = []; - for (const record of records) { - const previewMessages = await this.metadata.readPreviewMessages(record.header.id); - withPreviews.push({ record, previewMessages }); - } - withPreviews.sort((a, b) => { - const aLastMessageAt = maxTimestamp( - a.record.header.lastMessageAt, - latestVisibleMessageAt(a.previewMessages), - ); - const bLastMessageAt = maxTimestamp( - b.record.header.lastMessageAt, - latestVisibleMessageAt(b.previewMessages), - ); - const tsDelta = (bLastMessageAt ?? 0) - (aLastMessageAt ?? 0); - return tsDelta !== 0 ? tsDelta : a.record.header.id.localeCompare(b.record.header.id); - }); - - const summaries: SessionSummary[] = []; - for (let index = 0; index < withPreviews.length; index += 1) { - const { record, previewMessages } = withPreviews[index]!; - const { header } = record; - let messages = previewMessages.slice(-10); - if (index < 3) { - messages = (await this.metadata.readMessages(header.id)).slice(-10); - } - summaries.push(toSummary(header, messages)); - } - return summaries; + return (await this.metadata.list(filter, 'ordinary')) + .filter((record) => record.header.conversationCopy?.state !== 'preparing') + .map((record) => toCatalogSummary(record.header, record.lastMessagePreview)); } async listCatalogPage( @@ -965,11 +925,7 @@ class SqliteSessionStore implements SessionAuthorityStore { } async listForRecovery(): Promise { - const headers = await this.listHeaders(); - for (const header of headers) { - await this.metadata.readMessagesForRecovery(header.id); - } - return headers; + return this.listHeaders(); } async listHeaders(): Promise { @@ -1006,14 +962,6 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.readMessages(sessionId); } - async readTranscriptPageSnapshot( - sessionId: string, - request: SessionTranscriptPageRequest, - ): Promise { - await this.ensureReady(); - return this.metadata.readTranscriptPage(sessionId, request); - } - async readTranscriptMessagesSnapshot( sessionId: string, request: SessionTranscriptMessageLookupRequest, @@ -1022,47 +970,11 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.metadata.readTranscriptMessages(sessionId, request); } - async readTranscriptRecordsSnapshot( - sessionId: string, - request: SessionTranscriptRecordScanRequest, - ): Promise { - await this.ensureReady(); - return this.metadata.readTranscriptRecords(sessionId, request); - } - async readTranscriptHighWaterSnapshot(sessionId: string): Promise { await this.ensureReady(); return this.metadata.readTranscriptHighWater(sessionId); } - async readTurnContributionsSnapshot( - sessionId: string, - throughSequence: number | null, - position: number, - maxContributions: number, - ): Promise { - await this.ensureReady(); - return this.metadata.readTurnContributions( - sessionId, - throughSequence, - position, - maxContributions, - ); - } - - async readTurnLandmarksSnapshot( - sessionId: string, - maxLandmarks: number, - ): Promise { - await this.ensureReady(); - return this.metadata.readTurnLandmarks(sessionId, maxLandmarks); - } - - async readMessagesForRecovery(sessionId: string): Promise { - await this.ensureReady(); - return this.metadata.readMessagesForRecovery(sessionId); - } - async listTurnsSnapshot(sessionId: string): Promise { return deriveTurnRecords(await this.readMessagesSnapshot(sessionId)); } @@ -1075,6 +987,14 @@ class SqliteSessionStore implements SessionAuthorityStore { return this.readMessagesSnapshot(sessionId); } + async readMessagesAfter( + sessionId: string, + request: SessionMessageScanRequest, + ): Promise { + await this.ensureReady(); + return this.metadata.readMessagesAfter(sessionId, request); + } + async listTurns(sessionId: string): Promise { return deriveTurnRecords(await this.readMessages(sessionId)); } @@ -1094,6 +1014,15 @@ class SqliteSessionStore implements SessionAuthorityStore { for (const listener of this.transcriptChangeListeners) listener(sessionId); } + /** @see SqliteSessionMetadataStore.commitMessageCatalogProjection */ + async commitMessageCatalogProjection( + sessionId: string, + message: UserMessage | AssistantMessage, + ): Promise { + await this.ensureReady(); + await this.metadata.commitMessageCatalogProjection(sessionId, message); + } + async commitMessageAdmission( admission: PendingMessageAdmission, ): Promise { @@ -1177,42 +1106,6 @@ class SqliteSessionStore implements SessionAuthorityStore { return projectHeaderSnapshot(await this.metadata.updateSessionConfiguration(sessionId, input)); } - async markSessionReadThroughMessage( - sessionId: string, - messageId: string, - ): Promise { - for (let attempt = 0; attempt < 3; attempt += 1) { - const record = await this.readHeaderRecordSnapshot(sessionId); - const messages = await this.readMessagesSnapshot(sessionId); - const visibleMessages = messages.filter(isVisibleSessionMessage); - const targetIndex = visibleMessages.findIndex((message) => message.id === messageId); - if (targetIndex < 0) { - throw new SessionReadMarkerMessageNotFoundError(sessionId, messageId); - } - const currentIndex = - record.header.lastReadMessageId === undefined - ? -1 - : visibleMessages.findIndex((message) => message.id === record.header.lastReadMessageId); - const hasUnread = targetIndex < visibleMessages.length - 1; - if ( - targetIndex < currentIndex || - (targetIndex === currentIndex && record.header.hasUnread === hasUnread) - ) { - return record; - } - try { - return await this.updateHeaderVersioned( - sessionId, - { lastReadMessageId: messageId, hasUnread }, - record.revision, - ); - } catch (error) { - if (!(error instanceof SessionMetadataVersionConflictError) || attempt === 2) throw error; - } - } - throw new Error('Session read marker retry loop did not terminate'); - } - async probeSessionRemoval(sessionId: string): Promise { await this.ensureReady(); return projectRemovalProbe(await this.metadata.probeRemoval(sessionId)); @@ -1392,6 +1285,11 @@ function buildSessionHeader( collaborationMode: input.collaborationMode ?? 'agent', orchestrationMode: input.orchestrationMode ?? 'default', ...(input.thinkingLevel !== undefined ? { thinkingLevel: input.thinkingLevel } : {}), + // Born on the ledger: a Session created here records its execution facts as + // RuntimeEvents from its first turn, so there is no transcript to convert. + // Only an imported transcript (staged at 0) and a Session written before + // this field existed have anything for the converter to do. + transcriptLedgerVersion: 1, schemaVersion: 1, }; assertValidSessionLineage(header); @@ -1639,10 +1537,8 @@ function projectStableSessionCreateProbe( : probe; } -function toSummary(header: SessionHeader, messages: StoredMessage[] = []): SessionSummary { - const preview = lastMessagePreviewForMessages(messages); - const derivedLastMessageAt = latestVisibleMessageAt(messages); - const lastMessageAt = maxTimestamp(header.lastMessageAt, derivedLastMessageAt); +function toSummary(header: SessionHeader): SessionSummary { + const lastMessageAt = header.lastMessageAt; return { id: header.id, cwd: header.cwd, @@ -1653,7 +1549,6 @@ function toSummary(header: SessionHeader, messages: StoredMessage[] = []): Sessi labels: header.labels, hasUnread: header.hasUnread, lastMessageAt, - ...(preview ? { lastMessagePreview: preview } : {}), status: header.status, ...(header.blockedReason ? { blockedReason: header.blockedReason } : {}), ...(header.statusUpdatedAt !== undefined ? { statusUpdatedAt: header.statusUpdatedAt } : {}), @@ -1697,12 +1592,6 @@ function toCatalogSummary( }; } -function maxTimestamp(left: number | undefined, right: number | undefined): number | undefined { - if (left === undefined) return right; - if (right === undefined) return left; - return Math.max(left, right); -} - function normalizeSessionName(name: string): string { return name === 'New Session' ? DEFAULT_SESSION_NAME : name; } diff --git a/packages/storage/src/sqlite-runtime-schema.ts b/packages/storage/src/sqlite-runtime-schema.ts index a10532f026..3419761fa9 100644 --- a/packages/storage/src/sqlite-runtime-schema.ts +++ b/packages/storage/src/sqlite-runtime-schema.ts @@ -25,12 +25,18 @@ import { } from './legacy-run-header.js'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import { + TERMINAL_RUNTIME_EVENT_SQL, + TRANSCRIPT_MESSAGE_KEY_SQL, + TRANSCRIPT_OUTPUT_SHAPE_SQL, + TRANSCRIPT_STORED_ID_SQL, +} from './runtime-transcript-query.js'; import { buildInvocationOpenedEvent, buildSyntheticTerminalRuntimeEvent, } from '@maka/core/runtime-invocation'; -export const SQLITE_RUNTIME_SCHEMA_VERSION = 16; +export const SQLITE_RUNTIME_SCHEMA_VERSION = 17; export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY = 'runtime_recovery_authority'; export const RUNTIME_RECOVERY_AUTHORITY_CAPABILITY_VERSION = 1; export const RUNTIME_CONTINUATION_AUTHORITY_CAPABILITY = 'runtime_continuation_authority'; @@ -579,6 +585,17 @@ const MIGRATIONS: ReadonlyMap = new Map([ ALTER TABLE runtime_continuation_claims_v16 RENAME TO runtime_continuation_claims; `, ], + [ + 17, + ` + CREATE INDEX IF NOT EXISTS runtime_events_transcript_message ON runtime_events(invocation_id, (${TRANSCRIPT_MESSAGE_KEY_SQL}), event_seq); + CREATE INDEX IF NOT EXISTS runtime_events_transcript_output ON runtime_events(invocation_id, (${TRANSCRIPT_OUTPUT_SHAPE_SQL}), event_seq); + CREATE INDEX IF NOT EXISTS runtime_events_transcript_request ON runtime_events(invocation_id, json_extract(payload_json, '$.actions.permissionRequest.requestId'), event_seq); + CREATE INDEX IF NOT EXISTS runtime_events_transcript_tool ON runtime_events(invocation_id, json_extract(payload_json, '$.content.id'), event_seq); + CREATE INDEX IF NOT EXISTS runtime_events_terminal ON runtime_events(invocation_id, event_seq) WHERE ${TERMINAL_RUNTIME_EVENT_SQL}; + CREATE INDEX IF NOT EXISTS runtime_events_transcript_stored_id ON runtime_events(session_id, (${TRANSCRIPT_STORED_ID_SQL})); + `, + ], ]); /** diff --git a/packages/storage/src/sqlite-runtime-store.ts b/packages/storage/src/sqlite-runtime-store.ts index c71dcbfb01..c54fdcf934 100644 --- a/packages/storage/src/sqlite-runtime-store.ts +++ b/packages/storage/src/sqlite-runtime-store.ts @@ -129,24 +129,18 @@ import { import type { OperationalStateDatabaseLease } from './operational-state-store.js'; import { immutableSteeringMessageId, isRuntimeStorageSafeId } from './runtime-event-invariants.js'; import { assertNoReservedWorkspaceAuthorityAppend } from './runtime-event-authority.js'; +import { + RuntimeTranscriptQuery, + TERMINAL_RUNTIME_EVENT_SQL, + type RuntimeTranscriptPosition, + type RuntimeTranscriptSource, + type RuntimeTranscriptTurn, +} from './runtime-transcript-query.js'; export { SQLITE_RUNTIME_SCHEMA_VERSION } from './sqlite-runtime-schema.js'; export type { ToolRecoveryMode } from '@maka/core/runtime-event'; -/** - * `isTerminalRuntimeEvent` asked in SQL. - * - * The TypeScript predicate stays the authority; this only lets a query find the - * terminal event without decoding every row it passes over. Both have to say the - * same thing, so the SQL half is written once here instead of at each query. - */ -const TERMINAL_RUNTIME_EVENT_SQL = `( - json_extract(payload_json, '$.actions.endInvocation') = 1 - OR json_extract(payload_json, '$.status') - IN ('completed', 'failed', 'aborted', 'cancelled') - )`; - const RUNTIME_EVENT_SCAN_BATCH_SIZE = 128; const RUNTIME_PARTIAL_SEGMENT_TARGET_BYTES = 64 * 1024; @@ -548,6 +542,52 @@ export class SqliteRuntimeStore return this.readRuntimeEventsSync(sessionId, runId); } + private transcriptQuery(): RuntimeTranscriptQuery { + return new RuntimeTranscriptQuery(this.db, (sessionId, runId) => { + const opening = this.readInvocationOpeningsSync(sessionId, { direction: 'asc', runId }).at(0); + if (!opening) throw new Error(`Transcript invocation ${runId} is missing`); + return this.completeInvocationRecordSync(opening); + }); + } + + async readTranscriptSourceHighWater(sessionId: string): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + return this.readTransaction(() => this.transcriptQuery().highWater(sessionId)); + } + + async readTranscriptSource( + sessionId: string, + request: RuntimeTranscriptPosition, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + return this.readTransaction(() => this.transcriptQuery().source(sessionId, request)); + } + + async readTranscriptTurns( + sessionId: string, + throughOrdinal: number, + position: number, + limit: number, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertInvocationSearchLimit(limit); + return this.readTransaction(() => + this.transcriptQuery().turns(sessionId, throughOrdinal, position, limit), + ); + } + + async readTranscriptLandmarks( + sessionId: string, + throughOrdinal: number, + limit: number, + ): Promise { + assertRuntimeStorageSafeId(sessionId, 'Invalid session id'); + assertInvocationSearchLimit(limit); + return this.readTransaction(() => + this.transcriptQuery().landmarks(sessionId, throughOrdinal, limit), + ); + } + /** * Enumerate a Session's invocations: the opening fact names each one, and its * highest-sequence event says whether it ended. @@ -688,6 +728,8 @@ export class SqliteRuntimeStore 1 AS from_events FROM runtime_events WHERE session_id = :sessionId AND event_kind = 'invocation_opened' + ${options.runId === undefined ? '' : 'AND run_id = :runId'} + ${options.invocationId === undefined ? '' : 'AND invocation_id = :invocationId'} UNION ALL SELECT NULL, @@ -699,6 +741,8 @@ export class SqliteRuntimeStore 0 FROM runtime_legacy_invocation_openings AS legacy WHERE legacy.session_id = :sessionId + ${options.runId === undefined ? '' : 'AND legacy.run_id = :runId'} + ${options.invocationId === undefined ? '' : 'AND legacy.invocation_id = :invocationId'} AND NOT EXISTS ( SELECT 1 FROM runtime_events WHERE runtime_events.invocation_id = legacy.invocation_id diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index a9bbe0e089..7d0b4dd659 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -91,6 +91,8 @@ import { type SessionHeader, type SessionHeaderPatch, type StoredMessage, + type AssistantMessage, + type UserMessage, type SubagentSessionParent, type WorkHubActionClaim, type WorkHubActionClaimOutcome, @@ -140,20 +142,16 @@ import { normalizeSessionHeader, SessionNotFoundError, type ExternalSessionImportLookupResult, + type SessionMessageScanPage, + type SessionMessageScanRecord, + type SessionMessageScanRequest, type SessionTranscriptMessageLookupRequest, - type SessionTranscriptPageRequest, - type SessionTranscriptRecordScanPage, - type SessionTranscriptRecordScanRequest, - type SessionTranscriptStoragePage, - type SessionTurnContribution, - type SessionTurnContributionPage, - type SessionTurnLandmarkSnapshot, } from './session-store.js'; import { isDiscardableConversationCopy, isValidConversationCopyTransition, } from './session-conversation-copy.js'; -import { catalogPreviewForUserMessage } from './session-message-projection.js'; +import { projectSessionCatalogMessages } from './session-message-projection.js'; import { configureSqliteSessionMetadataDatabase, migrateSqliteSessionMetadataDatabase, @@ -175,9 +173,6 @@ import { export { SQLITE_SESSION_METADATA_SCHEMA_VERSION } from './sqlite-session-metadata-schema.js'; const SQLITE_TRANSCRIPT_MESSAGE_LOOKUP_BATCH_SIZE = 256; -const SQLITE_TURN_CONTRIBUTION_MAX_SOURCE_MESSAGES = 1_024; -const SQLITE_TURN_CONTRIBUTION_MAX_SOURCE_BYTES = 4 * 1024 * 1024; -const SQLITE_TURN_LANDMARK_LEGACY_NEIGHBOR_MESSAGES = 32; // Each target Session binds three parameters in the linkage query. Stay well // inside SQLite's bound-parameter limit. const WORKHUB_TARGET_LINKAGE_MAX_SESSIONS = 256; @@ -1386,10 +1381,14 @@ export class SqliteSessionMetadataStore { }); } + /** + * Session records in catalog order, each carrying the projection the Session + * list shows. + */ async list( filter: SessionListFilter | undefined, roleScope: SessionMetadataRoleScope, - ): Promise { + ): Promise { this.assertOpen(); const { where, parameters } = buildSessionListPredicate(filter ?? {}); if (roleScope === 'ordinary') { @@ -1404,18 +1403,22 @@ export class SqliteSessionMetadataStore { const rows = this.db .prepare( ` - SELECT session_id, payload_json, metadata_version, committed_at + SELECT + metadata.session_id, + metadata.payload_json, + metadata.metadata_version, + metadata.committed_at, + COALESCE(projection.activity_at, 0) AS activity_at, + projection.last_message_preview FROM session_metadata metadata + LEFT JOIN session_catalog_projection projection + ON projection.session_id = metadata.session_id ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''} - ORDER BY ( - SELECT activity_at - FROM session_catalog_projection projection - WHERE projection.session_id = metadata.session_id - ) DESC, session_id ASC + ORDER BY activity_at DESC, metadata.session_id ASC `, ) - .all(...parameters) as unknown as SessionMetadataRow[]; - return rows.map(decodeRecord); + .all(...parameters) as unknown as SessionMetadataCatalogRow[]; + return rows.map(decodeCatalogRecord); } async listCatalogPage( @@ -2152,11 +2155,12 @@ export class SqliteSessionMetadataStore { return this.readTransaction(() => { type Row = { session_id?: unknown; message_id?: unknown }; const list = targets.map(() => '?').join(', '); - // One Message moves between these lifecycle tables. Combine every target's - // identities once, then resolve activity from the canonical Coordination - // ledger in this same read transaction. That avoids rebuilding the target - // set once per page or once per candidate, without introducing another - // durable representation. + // One Message moves between these lifecycle tables — pending, admitted + // into a Turn, cancelled. Combine every target's identities once, then + // resolve activity from the canonical Coordination ledger in this same + // read transaction. That avoids rebuilding the target set once per page + // or once per candidate, without introducing another durable + // representation. const rows = this.db .prepare( ` @@ -2168,7 +2172,7 @@ export class SqliteSessionMetadataStore { AND length(message_id) = 52 UNION SELECT session_id, message_id - FROM session_messages + FROM core_root_source_message_proofs WHERE session_id IN (${list}) AND message_id GLOB 'whm_*' AND length(message_id) = 52 @@ -2306,29 +2310,6 @@ export class SqliteSessionMetadataStore { provenSteeringMessages.set(normalized.messageId, normalized); } this.transaction(() => { - const lastSequenceRow = this.db - .prepare( - 'SELECT COALESCE(MAX(sequence), -1) AS last_sequence FROM session_messages WHERE session_id = ?', - ) - .get(input.sessionId) as { last_sequence?: unknown }; - if ( - typeof lastSequenceRow.last_sequence !== 'number' || - !Number.isSafeInteger(lastSequenceRow.last_sequence) || - lastSequenceRow.last_sequence < -1 - ) { - throw new SessionMetadataConflictError('Invalid Session message sequence'); - } - const lastSequence = lastSequenceRow.last_sequence; - const historicalMissingMessages = new Map< - string, - { readonly message: StoredMessage; readonly json: string } - >(); - const ordinaryMissingMessages = new Map< - string, - { readonly message: StoredMessage; readonly json: string } - >(); - const historicalMessageIdSet = new Set(); - const existingSequences = new Map(); for (const messageId of unique) { const fallback = provenRootMessages.get(messageId); const steeringProof = provenSteeringMessages.get(messageId); @@ -2372,9 +2353,6 @@ export class SqliteSessionMetadataStore { ) { throw new SessionMetadataConflictError('Message admission fallback content conflict'); } - if (admission === undefined && fallback !== undefined) { - historicalMessageIdSet.add(messageId); - } if ( !admission && this.db @@ -2385,74 +2363,8 @@ export class SqliteSessionMetadataStore { ) { throw new SessionMetadataConflictError('Message admission is already cancelled'); } - const rows = this.db - .prepare( - ` - SELECT message.sequence, message.record_json, payload.record_bytes, payload.sha256 - FROM session_messages AS message - LEFT JOIN session_message_payloads AS payload - ON payload.session_id = message.session_id AND payload.sequence = message.sequence - WHERE message.session_id = ? AND message.message_id = ? - `, - ) - .all(input.sessionId, messageId) as Array<{ - sequence?: unknown; - record_json?: unknown; - record_bytes?: unknown; - sha256?: unknown; - }>; - if (rows.length > 1) { - throw new SessionMetadataConflictError( - 'Message admission transcript identity is ambiguous', - ); - } - if (rows.length === 0) { - const source = admission ?? fallback; - if (!source) throw new SessionMetadataConflictError('Message admission does not exist'); - const message = decodeCanonicalMessage({ - type: 'user', - id: messageId, - turnId: input.turnId, - ts: steeringProof?.eventTs ?? source.admittedAt, - ...source.content, - steeringEventId: steeringProof?.eventId ?? messageId, - }); - const json = JSON.stringify(message); - (historicalMessageIdSet.has(messageId) - ? historicalMissingMessages - : ordinaryMissingMessages - ).set(messageId, { message, json }); - } else { - const sequence = rows[0]?.sequence; - if (typeof sequence !== 'number' || !Number.isSafeInteger(sequence)) { - throw new SessionMetadataConflictError('Invalid Message transcript sequence'); - } - const row = rows[0]!; - const recordJson = readStoredMessageRecordJson(this.db, input.sessionId, sequence, row); - const message = decodeStoredMessage(JSON.parse(recordJson) as unknown); - const expectedSource = admission ?? fallback ?? steeringProof; - if ( - message.type !== 'user' || - message.id !== messageId || - (expectedSource !== undefined && - !messageContentsEqual(normalizeMessageContent(message), expectedSource.content)) - ) { - throw new SessionMetadataConflictError( - 'Message admission transcript identity conflict', - ); - } - if (message.turnId !== input.turnId) { - throw new SessionMetadataConflictError('Message admission transcript Turn conflict'); - } - if ( - steeringProof !== undefined && - (message.ts !== steeringProof.eventTs || - message.turnId !== steeringProof.executionTurnId || - message.steeringEventId !== steeringProof.eventId) - ) { - throw new SessionMetadataConflictError('Proven steering transcript identity conflict'); - } - existingSequences.set(messageId, sequence); + if (admission === undefined && fallback === undefined && steeringProof === undefined) { + throw new SessionMetadataConflictError('Message admission does not exist'); } if (admission) { const deleted = this.db @@ -2463,120 +2375,29 @@ export class SqliteSessionMetadataStore { } } } - let tailLatest: StoredMessage | undefined; - if (historicalMessageIdSet.size > 0) { - const transcript = this.readSessionMessageOrderingSync(input.sessionId); - let previousExistingSequence = -1; - const historicalMessageIds = unique.filter((messageId) => - historicalMessageIdSet.has(messageId), - ); - for (const messageId of historicalMessageIds) { - const sequence = existingSequences.get(messageId); - if (sequence === undefined) continue; - const admittedAt = provenRootMessages.get(messageId)!.admittedAt; - const blockingRow = transcript.find( - ({ sequence: candidateSequence, message }) => - candidateSequence > previousExistingSequence && - candidateSequence < sequence && - !historicalMessageIdSet.has(message.id) && - (message.turnId === input.turnId || message.ts >= admittedAt), - ); - if (sequence <= previousExistingSequence || blockingRow !== undefined) { - throw new SessionMetadataConflictError( - 'Message admission transcript source order conflict', - ); - } - previousExistingSequence = sequence; - } - - const insertionGroups: Array<{ - readonly boundary: number; - readonly entries: Array<{ readonly message: StoredMessage; readonly json: string }>; - }> = []; - let pending: Array<{ readonly message: StoredMessage; readonly json: string }> = []; - let previousAnchor = -1; - for (const messageId of historicalMessageIds) { - const missing = historicalMissingMessages.get(messageId); - if (missing) { - pending.push(missing); - continue; - } - const boundary = existingSequences.get(messageId); - if (pending.length > 0 && boundary !== undefined) { - const admittedAt = Math.min(...pending.map(({ message }) => message.ts)); - const earlierBoundary = transcript.find( - ({ sequence, message }) => - sequence > previousAnchor && - sequence < boundary && - !historicalMessageIdSet.has(message.id) && - (message.turnId === input.turnId || message.ts >= admittedAt), - ); - if (earlierBoundary) { - throw new SessionMetadataConflictError( - 'Message admission transcript source order conflict', - ); - } - insertionGroups.push({ boundary, entries: pending }); - pending = []; - } - if (boundary !== undefined) previousAnchor = boundary; - } - if (pending.length > 0) { - const admittedAt = Math.min(...pending.map(({ message }) => message.ts)); - const repairBoundary = transcript.find( - ({ sequence, message }) => - sequence > previousAnchor && - !historicalMessageIdSet.has(message.id) && - (message.turnId === input.turnId || message.ts >= admittedAt), - )?.sequence; - insertionGroups.push({ - boundary: repairBoundary ?? lastSequence + 1, - entries: pending, - }); - } + }); + } - for (const group of insertionGroups.reverse()) { - this.shiftSessionMessageSuffixSync(input.sessionId, group.boundary, group.entries.length); - this.insertSessionMessagesSync(input.sessionId, group.boundary, group.entries); - if (group.boundary === lastSequence + 1) { - tailLatest = group.entries.at(-1)?.message; - } - } - } - if (ordinaryMissingMessages.size > 0) { - const ordinaryEntries = unique.flatMap((messageId) => { - const entry = ordinaryMissingMessages.get(messageId); - return entry ? [entry] : []; - }); - const currentLastSequenceRow = this.db - .prepare( - 'SELECT COALESCE(MAX(sequence), -1) AS last_sequence FROM session_messages WHERE session_id = ?', - ) - .get(input.sessionId) as { last_sequence?: unknown }; - const currentLastSequence = currentLastSequenceRow.last_sequence; - if ( - typeof currentLastSequence !== 'number' || - !Number.isSafeInteger(currentLastSequence) || - currentLastSequence < -1 - ) { - throw new SessionMetadataConflictError('Invalid Session message sequence'); - } - this.insertSessionMessagesSync(input.sessionId, currentLastSequence + 1, ordinaryEntries); - tailLatest = ordinaryEntries.at(-1)?.message; - } - if (tailLatest?.type === 'user') { - this.updateCatalogProjectionSync( - input.sessionId, - { - lastMessageAt: tailLatest.ts, - lastMessagePreview: catalogPreviewForUserMessage(tailLatest), - }, - false, - true, - ); - } else if (historicalMissingMessages.size > 0 || ordinaryMissingMessages.size > 0) { - this.updateCatalogProjectionSync(input.sessionId, {}, false, true); - } + /** + * The catalog facts a durable message carries, committed without a transcript + * row to carry them: the Session list's preview line, its time, and the + * connection lock a Session takes on its first user message. + */ + async commitMessageCatalogProjection( + sessionId: string, + message: UserMessage | AssistantMessage, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + this.transaction(() => { + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + this.updateCatalogProjectionSync( + sessionId, + projectSessionCatalogMessages([message]), + false, + message.type === 'user' && !record.header.connectionLocked, + ); }); } @@ -2734,127 +2555,57 @@ export class SqliteSessionMetadataStore { return this.readMessagesWith(sessionId, decodeStoredMessage); } - async readTranscriptPage( + async readMessagesAfter( sessionId: string, - request: SessionTranscriptPageRequest, - ): Promise { + request: SessionMessageScanRequest, + ): Promise { this.assertOpen(); assertSafeSessionId(sessionId); - assertTranscriptPageRequest(request); + if (!Number.isSafeInteger(request.maxMessages) || request.maxMessages < 1) { + throw new Error('Invalid Session message count limit'); + } + if (!Number.isSafeInteger(request.maxStoredBytes) || request.maxStoredBytes < 1) { + throw new Error('Invalid Session message byte limit'); + } return this.readTransaction(() => { if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); - const highWaterRow = this.db - .prepare('SELECT MAX(sequence) AS high_water FROM session_messages WHERE session_id = ?') - .get(sessionId) as { high_water?: unknown }; - const actualHighWater = nullableStoredMessageSequence(highWaterRow.high_water, sessionId); - const throughSequence = - request.throughSequence === undefined ? actualHighWater : request.throughSequence; - if (throughSequence === null) { - return { - throughSequence: null, - fragments: [], - rawBytes: 0, - next: null, - }; - } - if (actualHighWater === null || throughSequence > actualHighWater) { - throw new Error(`Session transcript watermark is ahead of durable storage: ${sessionId}`); - } - const position = request.position ?? (request.direction === 'older' ? throughSequence : 0); - const comparison = request.direction === 'older' ? '<=' : '>='; - const order = request.direction === 'older' ? 'DESC' : 'ASC'; const rows = this.db - .prepare( - ` - SELECT message.sequence, - coalesce(payload.record_bytes, length(CAST(message.record_json AS BLOB))) AS total_bytes, - payload.record_bytes IS NOT NULL AS chunked, - payload.sha256 AS payload_sha256 - FROM session_messages AS message - LEFT JOIN session_message_payloads AS payload - ON payload.session_id = message.session_id AND payload.sequence = message.sequence - WHERE message.session_id = ? - AND message.sequence <= ? - AND message.sequence ${comparison} ? - ORDER BY message.sequence ${order} + .prepare(` + SELECT sequence, record_json + FROM session_messages + WHERE session_id = ? AND sequence > ? + ORDER BY sequence LIMIT ? - `, - ) - .all(sessionId, throughSequence, position, request.maxMessages + 1) as Array<{ + `) + .all(sessionId, request.afterSequence ?? -1, request.maxMessages) as Array<{ sequence?: unknown; - total_bytes?: unknown; - chunked?: unknown; - payload_sha256?: unknown; + record_json?: unknown; }>; - const slices: TranscriptRecordSlice[] = []; - let rawBytes = 0; - let next: { position: number; byteOffset: number | null } | null = null; + const records: SessionMessageScanRecord[] = []; + let storedBytes = 0; for (const row of rows) { - if (slices.length >= request.maxMessages || rawBytes >= request.maxBytes) break; const sequence = requireStoredMessageSequence(row.sequence, sessionId); - const totalBytes = requireTranscriptRecordByteLength(row.total_bytes, sessionId, sequence); - const chunked = row.chunked === 1; - const payloadDigest = chunked - ? requireTranscriptPayloadDigest(row.payload_sha256, sessionId, sequence) - : null; - const continued = sequence === position && request.byteOffset !== undefined; - const edge = continued - ? request.byteOffset! - : request.direction === 'older' - ? totalBytes - : 0; - if ( - (request.direction === 'older' && (edge < 1 || edge > totalBytes)) || - (request.direction === 'newer' && (edge < 0 || edge >= totalBytes)) - ) { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence); - } - const available = request.maxBytes - rawBytes; - const byteOffset = request.direction === 'older' ? Math.max(0, edge - available) : edge; - const byteLength = - request.direction === 'older' - ? edge - byteOffset - : Math.min(totalBytes - edge, available); - const complete = - request.direction === 'older' ? byteOffset === 0 : byteOffset + byteLength === totalBytes; - slices.push({ - sequence, - byteOffset, - totalBytes, - byteLength, - chunked, - payloadDigest, - }); - rawBytes += byteLength; - if (!complete) { - next = { - position: sequence, - byteOffset: request.direction === 'older' ? byteOffset : byteOffset + byteLength, - }; - break; + const recordJson = String(row.record_json); + // The first record of a page is always taken, so a single row larger + // than the budget still makes progress instead of stalling the scan. + if (records.length > 0 && storedBytes + recordJson.length > request.maxStoredBytes) break; + storedBytes += recordJson.length; + try { + records.push({ + sequence, + message: decodeStoredMessage(JSON.parse(recordJson) as unknown), + }); + } catch (error) { + throw new StoredSessionMessageIncompatibleError(sessionId, sequence, { cause: error }); } } - if (next === null && slices.length > 0 && slices.length < rows.length) { - const sequence = slices.at(-1)!.sequence; - next = { - position: sequence + (request.direction === 'older' ? -1 : 1), - byteOffset: null, - }; - } - const dataBySequence = readTranscriptSlices(this.db, sessionId, slices); - const fragments = slices.map( - ({ sequence, byteOffset, totalBytes, byteLength, payloadDigest }) => { - const data = dataBySequence.get(sequence); - if (!data || data.byteLength !== byteLength) { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence); - } - if (byteOffset === 0 && byteLength === totalBytes) { - validateTranscriptRecord(data, sessionId, sequence); - } - return { sequence, byteOffset, totalBytes, payloadDigest, data }; - }, - ); - return { throughSequence, fragments, rawBytes, next }; + const highWater = this.db + .prepare('SELECT MAX(sequence) AS high_water FROM session_messages WHERE session_id = ?') + .get(sessionId) as { high_water?: unknown }; + return { + records, + highWaterSequence: nullableStoredMessageSequence(highWater.high_water, sessionId), + }; }); } @@ -2965,86 +2716,6 @@ export class SqliteSessionMetadataStore { }); } - async readTranscriptRecords( - sessionId: string, - request: SessionTranscriptRecordScanRequest, - ): Promise { - this.assertOpen(); - assertSafeSessionId(sessionId); - assertTranscriptRecordScanRequest(request); - return this.readTransaction(() => { - if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); - const highWaterRow = this.db - .prepare('SELECT MAX(sequence) AS high_water FROM session_messages WHERE session_id = ?') - .get(sessionId) as { high_water?: unknown }; - const actualHighWater = nullableStoredMessageSequence(highWaterRow.high_water, sessionId); - const throughSequence = - request.throughSequence === undefined ? actualHighWater : request.throughSequence; - if (throughSequence === null) { - return { throughSequence: null, records: [], nextPosition: null }; - } - if (actualHighWater === null || throughSequence > actualHighWater) { - throw new Error(`Session transcript watermark is ahead of durable storage: ${sessionId}`); - } - const position = request.position ?? (request.direction === 'older' ? throughSequence : 0); - const comparison = request.direction === 'older' ? '<=' : '>='; - const order = request.direction === 'older' ? 'DESC' : 'ASC'; - const rows = this.db - .prepare( - ` - SELECT message.sequence, - coalesce(payload.record_bytes, length(CAST(message.record_json AS BLOB))) AS stored_bytes - FROM session_messages AS message - LEFT JOIN session_message_payloads AS payload - ON payload.session_id = message.session_id AND payload.sequence = message.sequence - WHERE message.session_id = ? - AND message.sequence <= ? - AND message.sequence ${comparison} ? - ORDER BY message.sequence ${order} - LIMIT ? - `, - ) - .all(sessionId, throughSequence, position, request.maxMessages + 1) as Array<{ - sequence?: unknown; - stored_bytes?: unknown; - }>; - const selected: number[] = []; - let storedBytes = 0; - for (const row of rows) { - if (selected.length >= request.maxMessages) break; - const sequence = requireStoredMessageSequence(row.sequence, sessionId); - const bytes = requireTranscriptRecordByteLength(row.stored_bytes, sessionId, sequence); - if (selected.length > 0 && storedBytes + bytes > request.maxStoredBytes) break; - selected.push(sequence); - storedBytes += bytes; - } - const decoded = new Map(); - for (const row of readStoredMessageRows(this.db, sessionId, selected)) { - try { - decoded.set(row.sequence, decodeStoredMessage(JSON.parse(row.recordJson) as unknown)); - } catch (error) { - throw new StoredSessionMessageIncompatibleError(sessionId, row.sequence, { - cause: error, - }); - } - } - const records = selected.map((sequence) => { - const message = decoded.get(sequence); - if (!message) throw new StoredSessionMessageIncompatibleError(sessionId, sequence); - return { sequence, message }; - }); - const last = selected.at(-1); - return { - throughSequence, - records, - nextPosition: - last !== undefined && rows.length > selected.length - ? last + (request.direction === 'older' ? -1 : 1) - : null, - }; - }); - } - async readTranscriptHighWater(sessionId: string): Promise { this.assertOpen(); assertSafeSessionId(sessionId); @@ -3055,349 +2726,6 @@ export class SqliteSessionMetadataStore { return nullableStoredMessageSequence(row.high_water, sessionId); } - async readTurnContributions( - sessionId: string, - throughSequence: number | null, - position: number, - maxContributions: number, - ): Promise { - this.assertOpen(); - assertSafeSessionId(sessionId); - if ( - (throughSequence !== null && - (!Number.isSafeInteger(throughSequence) || throughSequence < 0)) || - !Number.isSafeInteger(position) || - position < 0 || - !Number.isSafeInteger(maxContributions) || - maxContributions < 1 || - maxContributions > 128 - ) { - throw new Error('Invalid Session turn contribution request'); - } - return this.readTransaction(() => { - if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); - const highWaterRow = this.db - .prepare('SELECT MAX(sequence) AS high_water FROM session_messages WHERE session_id = ?') - .get(sessionId) as { high_water?: unknown }; - const actualHighWater = nullableStoredMessageSequence(highWaterRow.high_water, sessionId); - const fixedThrough = throughSequence ?? actualHighWater; - if (fixedThrough === null) { - return { throughSequence: null, contributions: [], nextPosition: null }; - } - if (actualHighWater === null || fixedThrough > actualHighWater) { - throw new Error(`Session turn watermark is ahead of durable storage: ${sessionId}`); - } - const contributions = new Map(); - let nextPosition: number | null = position; - let sourceMessages = 0; - let sourceBytes = 0; - while (nextPosition <= fixedThrough) { - const rows = this.db - .prepare( - ` - SELECT message.sequence, message.record_json, payload.record_bytes, payload.sha256 - FROM session_messages AS message - LEFT JOIN session_message_payloads AS payload - ON payload.session_id = message.session_id AND payload.sequence = message.sequence - WHERE message.session_id = ? - AND message.sequence >= ? - AND message.sequence <= ? - ORDER BY message.sequence ASC - LIMIT 128 - `, - ) - .all(sessionId, nextPosition, fixedThrough) as StoredSessionMessagePayloadRow[]; - if (rows.length === 0) { - throw new StoredSessionMessageIncompatibleError(sessionId, nextPosition); - } - for (const row of rows) { - const sequence = requireStoredMessageSequence(row.sequence, sessionId); - const recordBytes = storedMessageRecordBytes(row, sessionId, sequence); - if ( - sourceMessages > 0 && - (sourceMessages >= SQLITE_TURN_CONTRIBUTION_MAX_SOURCE_MESSAGES || - sourceBytes + recordBytes > SQLITE_TURN_CONTRIBUTION_MAX_SOURCE_BYTES) - ) { - return { - throughSequence: fixedThrough, - contributions: [...contributions.values()], - nextPosition: sequence, - }; - } - const message = decodeStoredMessageRecordRow(this.db, sessionId, row); - sourceMessages += 1; - sourceBytes += recordBytes; - if (!('turnId' in message) || typeof message.turnId !== 'string') { - nextPosition = sequence + 1; - continue; - } - const turnId = message.turnId; - if (turnId && !contributions.has(turnId) && contributions.size >= maxContributions) { - nextPosition = sequence; - return { - throughSequence: fixedThrough, - contributions: [...contributions.values()], - nextPosition, - }; - } - contributions.set( - turnId, - foldTurnContribution(contributions.get(turnId), turnId, sequence, message), - ); - nextPosition = sequence + 1; - } - } - return { - throughSequence: fixedThrough, - contributions: [...contributions.values()], - nextPosition: null, - }; - }); - } - - async readTurnLandmarks( - sessionId: string, - maxLandmarks: number, - ): Promise { - this.assertOpen(); - assertSafeSessionId(sessionId); - if (!Number.isSafeInteger(maxLandmarks) || maxLandmarks < 1 || maxLandmarks > 64) { - throw new Error('Invalid Session turn landmark limit'); - } - return this.readTransaction(() => { - if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); - const throughRow = this.db - .prepare( - ` - SELECT sequence AS through_sequence - FROM session_messages - WHERE session_id = ? - ORDER BY sequence DESC - LIMIT 1 - `, - ) - .get(sessionId) as { through_sequence?: unknown } | undefined; - const throughSequence = nullableStoredMessageSequence( - throughRow?.through_sequence, - sessionId, - ); - if (throughSequence === null) { - return { throughSequence: null, landmarks: [] }; - } - - const promptRows = this.db - .prepare( - ` - SELECT admission.admitted_at, message.sequence - FROM core_root_turn_admissions AS admission - JOIN session_messages AS message - ON message.session_id = admission.session_id - AND message.message_id = json_extract(admission.record_json, '$.userMessageId') - LEFT JOIN session_message_payloads AS payload - ON payload.session_id = message.session_id AND payload.sequence = message.sequence - WHERE admission.session_id = ? AND payload.sequence IS NULL - ORDER BY admission.admitted_at ASC, admission.turn_id ASC - LIMIT ? - `, - ) - .all(sessionId, maxLandmarks + 1) as TurnLandmarkCandidateRow[]; - const selected = new Set(); - if (promptRows.length <= maxLandmarks) { - for (const row of promptRows) { - selected.add(requireStoredMessageSequence(row.sequence, sessionId)); - } - } - - const forward = this.db.prepare(` - SELECT admission.admitted_at, message.sequence - FROM core_root_turn_admissions AS admission - JOIN session_messages AS message - ON message.session_id = admission.session_id - AND message.message_id = json_extract(admission.record_json, '$.userMessageId') - LEFT JOIN session_message_payloads AS payload - ON payload.session_id = message.session_id AND payload.sequence = message.sequence - WHERE admission.session_id = ? AND admission.admitted_at >= ? AND payload.sequence IS NULL - ORDER BY admission.admitted_at ASC, admission.turn_id ASC - LIMIT 1 - `); - const backward = this.db.prepare(` - SELECT admission.admitted_at, message.sequence - FROM core_root_turn_admissions AS admission - JOIN session_messages AS message - ON message.session_id = admission.session_id - AND message.message_id = json_extract(admission.record_json, '$.userMessageId') - LEFT JOIN session_message_payloads AS payload - ON payload.session_id = message.session_id AND payload.sequence = message.sequence - WHERE admission.session_id = ? AND admission.admitted_at < ? AND payload.sequence IS NULL - ORDER BY admission.admitted_at DESC, admission.turn_id DESC - LIMIT 1 - `); - if (promptRows.length > maxLandmarks) { - const firstAdmittedAt = requireTurnLandmarkAdmittedAt(promptRows[0]?.admitted_at); - const lastRow = this.db - .prepare( - ` - SELECT admitted_at - FROM core_root_turn_admissions - WHERE session_id = ? - ORDER BY admitted_at DESC, turn_id DESC - LIMIT 1 - `, - ) - .get(sessionId) as TurnLandmarkCandidateRow | undefined; - const lastAdmittedAt = requireTurnLandmarkAdmittedAt(lastRow?.admitted_at); - for (let index = 0; index < maxLandmarks; index += 1) { - const target = - maxLandmarks === 1 - ? lastAdmittedAt - : firstAdmittedAt + - Math.floor(((lastAdmittedAt - firstAdmittedAt) * index) / (maxLandmarks - 1)); - const candidates = [ - ...(forward.all(sessionId, target) as TurnLandmarkCandidateRow[]), - ...(backward.all(sessionId, target) as TurnLandmarkCandidateRow[]), - ]; - let nearest: TurnLandmarkCandidateRow | undefined; - for (const candidate of candidates) { - const admittedAt = requireTurnLandmarkAdmittedAt(candidate.admitted_at); - if ( - nearest === undefined || - Math.abs(admittedAt - target) < - Math.abs(requireTurnLandmarkAdmittedAt(nearest.admitted_at) - target) - ) { - nearest = candidate; - } - } - if (nearest) selected.add(requireStoredMessageSequence(nearest.sequence, sessionId)); - } - } - const firstIndexedSequence = - promptRows.length > 0 - ? requireStoredMessageSequence(promptRows[0]?.sequence, sessionId) - : null; - const legacyThrough = - firstIndexedSequence === null ? throughSequence : firstIndexedSequence - 1; - if (legacyThrough >= 0) { - const firstRow = this.db - .prepare( - ` - SELECT sequence AS first_sequence - FROM session_messages - WHERE session_id = ? - ORDER BY sequence ASC - LIMIT 1 - `, - ) - .get(sessionId) as { first_sequence?: unknown } | undefined; - const firstSequence = nullableStoredMessageSequence(firstRow?.first_sequence, sessionId); - if (firstSequence === null || firstSequence > legacyThrough) { - throw new StoredSessionMessageIncompatibleError(sessionId, legacyThrough); - } - const forwardLegacy = this.db.prepare(` - SELECT message.sequence, message.message_type, payload.sequence AS payload_sequence - FROM session_messages AS message - LEFT JOIN session_message_payloads AS payload - ON payload.session_id = message.session_id AND payload.sequence = message.sequence - WHERE message.session_id = ? AND message.sequence >= ? AND message.sequence <= ? - ORDER BY message.sequence ASC - LIMIT ${SQLITE_TURN_LANDMARK_LEGACY_NEIGHBOR_MESSAGES} - `); - const backwardLegacy = this.db.prepare(` - SELECT message.sequence, message.message_type, payload.sequence AS payload_sequence - FROM session_messages AS message - LEFT JOIN session_message_payloads AS payload - ON payload.session_id = message.session_id AND payload.sequence = message.sequence - WHERE message.session_id = ? AND message.sequence < ? AND message.sequence >= ? - ORDER BY message.sequence DESC - LIMIT ${SQLITE_TURN_LANDMARK_LEGACY_NEIGHBOR_MESSAGES} - `); - const targetCount = Math.min(maxLandmarks, legacyThrough - firstSequence + 1); - for (let index = 0; index < targetCount; index += 1) { - const target = - targetCount === 1 - ? legacyThrough - : firstSequence + - Math.floor(((legacyThrough - firstSequence) * index) / (targetCount - 1)); - const candidates = [ - ...(forwardLegacy.all(sessionId, target, legacyThrough) as LegacyTurnLandmarkRow[]), - ...(backwardLegacy.all(sessionId, target, firstSequence) as LegacyTurnLandmarkRow[]), - ]; - let nearest: number | undefined; - for (const candidate of candidates) { - const sequence = requireStoredMessageSequence(candidate.sequence, sessionId); - if (candidate.message_type !== 'user' || candidate.payload_sequence !== null) continue; - if (nearest === undefined || Math.abs(sequence - target) < Math.abs(nearest - target)) { - nearest = sequence; - } - } - if (nearest !== undefined) selected.add(nearest); - } - } - - const selectedSequences = [...selected].sort((left, right) => left - right); - const sampledSequences = - selectedSequences.length <= maxLandmarks - ? selectedSequences - : Array.from( - { length: maxLandmarks }, - (_, index) => - selectedSequences[ - maxLandmarks === 1 - ? selectedSequences.length - 1 - : Math.floor(((selectedSequences.length - 1) * index) / (maxLandmarks - 1)) - ]!, - ); - const landmarks = readStoredMessageRows(this.db, sessionId, sampledSequences).flatMap( - ({ sequence, recordJson }) => { - let message: StoredMessage; - try { - message = decodeStoredMessage(JSON.parse(recordJson) as unknown); - } catch (error) { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence, { cause: error }); - } - if (message.type !== 'user') { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence); - } - const label = (message.displayText ?? message.text).trim(); - return label ? [{ turnId: message.turnId, sequence, label }] : []; - }, - ); - return { throughSequence, landmarks }; - }); - } - - async readMessagesForRecovery(sessionId: string): Promise { - return this.readMessagesWith(sessionId, decodeStoredMessage); - } - - async readPreviewMessages(sessionId: string, limit = 10): Promise { - this.assertOpen(); - assertSafeSessionId(sessionId); - if (!Number.isSafeInteger(limit) || limit < 1 || limit > 128) { - throw new Error('Session message preview limit must be between 1 and 128'); - } - if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); - const sequences = ( - this.db - .prepare( - ` - SELECT sequence FROM session_messages - WHERE session_id = ? ORDER BY sequence DESC LIMIT ? - `, - ) - .all(sessionId, limit) as Array<{ sequence?: unknown }> - ) - .map((row) => requireStoredMessageSequence(row.sequence, sessionId)) - .reverse(); - return readStoredMessageRows( - this.db, - sessionId, - sequences, - sequences.map(() => '?').join(', '), - ).map((row) => - decodeStoredMessageRow({ sequence: row.sequence, record_json: row.recordJson }, sessionId), - ); - } - async beginCatalogProjectionWrite(): Promise { this.assertOpen(); this.transaction(() => { @@ -5481,84 +4809,6 @@ export class SqliteSessionMetadataStore { return row ? decodeStoredMessageRecordRow(this.db, sessionId, row) : undefined; } - private readSessionMessageOrderingSync( - sessionId: string, - ): Array<{ readonly sequence: number; readonly message: StoredMessage }> { - const rows = this.db - .prepare( - ` - SELECT message.sequence, message.record_json, payload.record_bytes, payload.sha256 - FROM session_messages AS message - LEFT JOIN session_message_payloads AS payload - ON payload.session_id = message.session_id AND payload.sequence = message.sequence - WHERE message.session_id = ? - ORDER BY message.sequence - `, - ) - .all(sessionId) as StoredSessionMessagePayloadRow[]; - return rows.map((row) => { - const sequence = requireStoredMessageSequence(row.sequence, sessionId); - const recordJson = readStoredMessageRecordJson(this.db, sessionId, sequence, row); - return { - sequence, - message: decodeStoredMessage(JSON.parse(recordJson) as unknown), - }; - }); - } - - private shiftSessionMessageSuffixSync( - sessionId: string, - firstSequence: number, - amount: number, - ): void { - if (!Number.isSafeInteger(firstSequence) || firstSequence < 0) { - throw new SessionMetadataConflictError('Invalid transcript insertion sequence'); - } - if (!Number.isSafeInteger(amount) || amount < 1) { - throw new SessionMetadataConflictError('Invalid transcript insertion size'); - } - const sequences = ( - this.db - .prepare( - ` - SELECT sequence - FROM session_messages - WHERE session_id = ? AND sequence >= ? - ORDER BY sequence DESC - `, - ) - .all(sessionId, firstSequence) as Array<{ sequence?: unknown }> - ).map((row) => requireStoredMessageSequence(row.sequence, sessionId)); - const highest = sequences[0]; - if (highest !== undefined && highest > Number.MAX_SAFE_INTEGER - amount) { - throw new SessionMetadataConflictError('Session message sequence overflow'); - } - if (sequences.length === 0) return; - - this.db.exec('PRAGMA defer_foreign_keys = ON'); - const moveChunks = this.db.prepare( - 'UPDATE session_message_chunks SET sequence = ? WHERE session_id = ? AND sequence = ?', - ); - const movePayload = this.db.prepare( - 'UPDATE session_message_payloads SET sequence = ? WHERE session_id = ? AND sequence = ?', - ); - const moveMessage = this.db.prepare( - 'UPDATE session_messages SET sequence = ? WHERE session_id = ? AND sequence = ?', - ); - for (const sequence of sequences) { - const shifted = sequence + amount; - moveChunks.run(shifted, sessionId, sequence); - const payload = movePayload.run(shifted, sessionId, sequence); - if (payload.changes !== 0 && payload.changes !== 1) { - throw new SessionMetadataConflictError('Message payload sequence is ambiguous'); - } - const message = moveMessage.run(shifted, sessionId, sequence); - if (message.changes !== 1) { - throw new SessionMetadataConflictError('Message transcript sequence changed during repair'); - } - } - } - private insertSessionMessagesSync( sessionId: string, firstSequence: number, @@ -7109,41 +6359,6 @@ interface StoredSessionMessagePayloadRow { readonly sha256?: unknown; } -interface TurnLandmarkCandidateRow { - readonly sequence?: unknown; - readonly admitted_at?: unknown; -} - -interface LegacyTurnLandmarkRow { - readonly sequence?: unknown; - readonly message_type?: unknown; - readonly payload_sequence?: unknown; -} - -function requireTurnLandmarkAdmittedAt(value: unknown): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { - throw new Error('Invalid root Turn admission timestamp'); - } - return value; -} - -function storedMessageRecordBytes( - row: StoredSessionMessagePayloadRow, - sessionId: string, - sequence: number, -): number { - if (row.record_bytes !== null) { - return requireTranscriptRecordByteLength(row.record_bytes, sessionId, sequence); - } - if ( - typeof row.record_json !== 'string' || - row.record_json === SQLITE_SESSION_MESSAGE_CHUNK_MARKER - ) { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence); - } - return Buffer.byteLength(row.record_json, 'utf8'); -} - function decodeStoredMessageRecordRow( db: DatabaseSync, sessionId: string, @@ -7233,40 +6448,6 @@ function sameWorkHubAssignmentRequest( ); } -function foldTurnContribution( - current: SessionTurnContribution | undefined, - turnId: string, - sequence: number, - message: StoredMessage, -): SessionTurnContribution { - const contribution = current ?? { - turnId, - firstSequence: sequence, - latestState: null, - userPromptPreview: null, - hasAssistantMessage: false, - hasAssistantOutput: false, - hasToolResult: false, - hasFailedToolResult: false, - hasAbortNote: false, - }; - const userPrompt = message.type === 'user' ? (message.displayText ?? message.text).trim() : ''; - return { - ...contribution, - latestState: message.type === 'turn_state' ? { sequence, message } : contribution.latestState, - userPromptPreview: contribution.userPromptPreview ?? (userPrompt || null), - hasAssistantMessage: contribution.hasAssistantMessage || message.type === 'assistant', - hasAssistantOutput: - contribution.hasAssistantOutput || - (message.type === 'assistant' && message.text.trim().length > 0), - hasToolResult: contribution.hasToolResult || message.type === 'tool_result', - hasFailedToolResult: - contribution.hasFailedToolResult || (message.type === 'tool_result' && message.isError), - hasAbortNote: - contribution.hasAbortNote || (message.type === 'system_note' && message.kind === 'abort'), - }; -} - function readStoredMessageRows( db: DatabaseSync, sessionId: string, @@ -7355,26 +6536,6 @@ function nullableStoredMessageSequence(value: unknown, sessionId: string): numbe return requireStoredMessageSequence(value, sessionId); } -interface TranscriptRecordSlice { - readonly sequence: number; - readonly byteOffset: number; - readonly totalBytes: number; - readonly byteLength: number; - readonly chunked: boolean; - readonly payloadDigest: `sha256:${string}` | null; -} - -function requireTranscriptPayloadDigest( - value: unknown, - sessionId: string, - sequence: number, -): `sha256:${string}` { - if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence); - } - return `sha256:${value}`; -} - function requireTranscriptRecordByteLength( value: unknown, sessionId: string, @@ -7385,207 +6546,3 @@ function requireTranscriptRecordByteLength( } return value as number; } - -function readTranscriptSlices( - db: DatabaseSync, - sessionId: string, - slices: readonly TranscriptRecordSlice[], -): Map { - if (slices.length === 0) return new Map(); - const chunkedSlices = slices.filter((slice) => slice.chunked); - const values = chunkedSlices.map(() => '(?, ?, ?)').join(', '); - const parameters = chunkedSlices.flatMap((slice) => [ - slice.sequence, - Math.floor(slice.byteOffset / SQLITE_SESSION_MESSAGE_CHUNK_BYTES), - Math.floor((slice.byteOffset + slice.byteLength - 1) / SQLITE_SESSION_MESSAGE_CHUNK_BYTES), - ]); - const rows = - chunkedSlices.length === 0 - ? [] - : (db - .prepare( - ` - WITH requested(sequence, first_chunk, last_chunk) AS (VALUES ${values}) - SELECT requested.sequence, chunk.chunk_index, chunk.data, chunk.sha256 - FROM requested - INNER JOIN session_message_chunks AS chunk - ON chunk.session_id = ? - AND chunk.sequence = requested.sequence - AND chunk.chunk_index BETWEEN requested.first_chunk AND requested.last_chunk - ORDER BY requested.sequence, chunk.chunk_index - `, - ) - .all(...parameters, sessionId) as Array<{ - sequence?: unknown; - chunk_index?: unknown; - data?: unknown; - sha256?: unknown; - }>); - const rowsBySequence = new Map(); - for (const row of rows) { - const sequence = requireStoredMessageSequence(row.sequence, sessionId); - const grouped = rowsBySequence.get(sequence); - if (grouped) grouped.push(row); - else rowsBySequence.set(sequence, [row]); - } - const result = new Map(); - for (const slice of slices) { - if (!slice.chunked) continue; - const selected = rowsBySequence.get(slice.sequence) ?? []; - const firstChunk = Math.floor(slice.byteOffset / SQLITE_SESSION_MESSAGE_CHUNK_BYTES); - const lastChunk = Math.floor( - (slice.byteOffset + slice.byteLength - 1) / SQLITE_SESSION_MESSAGE_CHUNK_BYTES, - ); - if (selected.length !== lastChunk - firstChunk + 1) { - throw new StoredSessionMessageIncompatibleError(sessionId, slice.sequence); - } - const chunks: Buffer[] = []; - for (let index = 0; index < selected.length; index += 1) { - const row = selected[index]!; - if ( - row.chunk_index !== firstChunk + index || - !(row.data instanceof Uint8Array) || - typeof row.sha256 !== 'string' - ) { - throw new StoredSessionMessageIncompatibleError(sessionId, slice.sequence); - } - const chunk = Buffer.from(row.data); - if (createHash('sha256').update(chunk).digest('hex') !== row.sha256) { - throw new StoredSessionMessageIncompatibleError(sessionId, slice.sequence); - } - chunks.push(chunk); - } - const joined = Buffer.concat(chunks); - const start = slice.byteOffset - firstChunk * SQLITE_SESSION_MESSAGE_CHUNK_BYTES; - const data = joined.subarray(start, start + slice.byteLength); - if (data.byteLength !== slice.byteLength) { - throw new StoredSessionMessageIncompatibleError(sessionId, slice.sequence); - } - result.set(slice.sequence, data); - } - const inlineSlices = slices.filter((slice) => !slice.chunked); - if (inlineSlices.length > 0) { - const inlineValues = inlineSlices.map(() => '(?, ?, ?)').join(', '); - const inlineParameters = inlineSlices.flatMap((slice) => [ - slice.sequence, - slice.byteOffset + 1, - slice.byteLength, - ]); - const inlineRows = db - .prepare( - ` - WITH requested(sequence, byte_start, byte_length) AS (VALUES ${inlineValues}) - SELECT requested.sequence, - substr(CAST(message.record_json AS BLOB), requested.byte_start, requested.byte_length) - AS data - FROM requested - INNER JOIN session_messages AS message - ON message.session_id = ? AND message.sequence = requested.sequence - `, - ) - .all(...inlineParameters, sessionId) as Array<{ - sequence?: unknown; - data?: unknown; - }>; - for (const row of inlineRows) { - const sequence = requireStoredMessageSequence(row.sequence, sessionId); - if (!(row.data instanceof Uint8Array)) { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence); - } - result.set(sequence, Buffer.from(row.data)); - } - } - return result; -} - -function validateTranscriptRecord( - data: string | Buffer, - sessionId: string, - sequence: number, -): void { - try { - decodeStoredMessage( - markPersisted( - JSON.parse(typeof data === 'string' ? data : data.toString('utf8')), - ), - ); - } catch (error) { - throw new StoredSessionMessageIncompatibleError(sessionId, sequence, { - cause: error, - }); - } -} - -function assertTranscriptPageRequest(request: SessionTranscriptPageRequest): void { - if (request.direction !== 'older' && request.direction !== 'newer') { - throw new Error('Invalid Session transcript page direction'); - } - if ( - request.throughSequence !== undefined && - request.throughSequence !== null && - (!Number.isSafeInteger(request.throughSequence) || request.throughSequence < 0) - ) { - throw new Error('Invalid Session transcript watermark'); - } - if ( - request.position !== undefined && - (!Number.isSafeInteger(request.position) || request.position < 0) - ) { - throw new Error('Invalid Session transcript position'); - } - if ( - request.byteOffset !== undefined && - (request.position === undefined || - !Number.isSafeInteger(request.byteOffset) || - request.byteOffset < 0) - ) { - throw new Error('Invalid Session transcript byte offset'); - } - if ( - !Number.isSafeInteger(request.maxBytes) || - request.maxBytes < 1 || - request.maxBytes > 1024 * 1024 - ) { - throw new Error('Session transcript page byte limit must be between 1 and 1048576'); - } - if ( - !Number.isSafeInteger(request.maxMessages) || - request.maxMessages < 1 || - request.maxMessages > 256 - ) { - throw new Error('Session transcript page message limit must be between 1 and 256'); - } -} - -function assertTranscriptRecordScanRequest(request: SessionTranscriptRecordScanRequest): void { - if (request.direction !== 'older' && request.direction !== 'newer') { - throw new Error('Invalid Session transcript record direction'); - } - if ( - request.throughSequence !== undefined && - request.throughSequence !== null && - (!Number.isSafeInteger(request.throughSequence) || request.throughSequence < 0) - ) { - throw new Error('Invalid Session transcript watermark'); - } - if ( - request.position !== undefined && - (!Number.isSafeInteger(request.position) || request.position < 0) - ) { - throw new Error('Invalid Session transcript position'); - } - if ( - !Number.isSafeInteger(request.maxStoredBytes) || - request.maxStoredBytes < 1 || - request.maxStoredBytes > 16 * 1024 * 1024 - ) { - throw new Error('Invalid Session transcript record byte limit'); - } - if ( - !Number.isSafeInteger(request.maxMessages) || - request.maxMessages < 1 || - request.maxMessages > 256 - ) { - throw new Error('Invalid Session transcript record count limit'); - } -}