diff --git a/apps/daemon/src/__tests__/conversation-store.test.ts b/apps/daemon/src/__tests__/conversation-store.test.ts index 9c100c489..d60cd6104 100644 --- a/apps/daemon/src/__tests__/conversation-store.test.ts +++ b/apps/daemon/src/__tests__/conversation-store.test.ts @@ -209,7 +209,7 @@ describe('SQLite conversation store', () => { ).toEqual(prompt('p-1')); }); - it('round-trips bindings and re-captures by (turn, history)', async () => { + it('round-trips bindings: replay rows re-capture and yield to a live one, which nothing overwrites', async () => { const { database } = await databaseWithSessions('s-1'); const store = createConversationStore(database.client); await seedIntent(store); @@ -220,18 +220,26 @@ describe('SQLite conversation store', () => { checkpoint: '{"uuid":"a"}', capturedFrom: 'live', }); + // A cold read can land first; the live capture that follows replaces it. + await store.saveBinding({ ...live, checkpoint: '{"uuid":"early"}', capturedFrom: 'replay' }); await store.saveBinding(live); - await store.saveBinding({ ...live, historyId: 'native-2', capturedFrom: 'replay' }); - const recaptured = ProviderTurnBindingSchema.parse({ + // Neither a later live capture nor a cold-read replay may move the first live cut. + await store.saveBinding( + ProviderTurnBindingSchema.parse({ ...live, runId: 'run-9', checkpoint: '{"uuid":"b"}' }), + ); + await store.saveBinding({ ...live, checkpoint: '{"uuid":"c"}', capturedFrom: 'replay' }); + const replay = ProviderTurnBindingSchema.parse({ ...live, - runId: 'run-9', - checkpoint: '{"uuid":"b"}', + historyId: 'native-2', + capturedFrom: 'replay', }); + await store.saveBinding(replay); + const recaptured = { ...replay, checkpoint: '{"uuid":"d"}' }; await store.saveBinding(recaptured); expect( await createConversationStore(database.client).listBindings(TurnIdSchema.parse('t-prompted')), - ).toEqual([recaptured, { ...live, historyId: 'native-2', capturedFrom: 'replay' }]); + ).toEqual([live, recaptured]); }); it('round-trips operations through every state', async () => { diff --git a/apps/daemon/src/conversation-store.ts b/apps/daemon/src/conversation-store.ts index 73103d1a5..1fa67902f 100644 --- a/apps/daemon/src/conversation-store.ts +++ b/apps/daemon/src/conversation-store.ts @@ -16,7 +16,7 @@ import { PromptRecordSchema, ProviderTurnBindingSchema, } from '@linkcode/schema'; -import { and, asc, count, eq, inArray, isNotNull, isNull, notInArray } from 'drizzle-orm'; +import { and, asc, count, eq, inArray, isNotNull, isNull, ne, notInArray } from 'drizzle-orm'; import type { DaemonDatabaseClient } from './db/database'; import { conversationOperations, @@ -83,6 +83,8 @@ export function createConversationStore(db: DaemonDatabaseClient): ConversationS .onConflictDoUpdate({ target: [providerTurnBindings.turnId, providerTurnBindings.historyId], set: binding, + // The first live capture stands: a later live or replay write cannot move the cut. + setWhere: ne(providerTurnBindings.capturedFrom, 'live'), }) .run(); return Promise.resolve(); diff --git a/packages/foundation/schema/src/model/history.ts b/packages/foundation/schema/src/model/history.ts index 46bd022c8..112365f8c 100644 --- a/packages/foundation/schema/src/model/history.ts +++ b/packages/foundation/schema/src/model/history.ts @@ -10,7 +10,12 @@ export const AgentHistoryCapabilitiesSchema = z.object({ read: z.boolean(), /** Adapter can resume a live session from a known provider-local history id. */ resume: z.boolean(), - /** Adapter can fork provider history before a historical user prompt for replacement. */ + /** Adapter can fork provider history right after a turn's captured checkpoint — the one fork + * primitive every provider actually has ("before prompt T" ≡ "after parent(T)"). Per-turn + * availability additionally depends on a captured, still-valid checkpoint. */ + forkAfterTurn: z.boolean().optional(), + /** The legacy `history.branch` capability (≤v79 clients); stays true for a harness whose + * turn-level cut is still unverified while `forkAfterTurn` is false. Retired at the floor bump. */ branch: z.boolean().optional(), }); export type AgentHistoryCapabilities = z.infer; diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index a796bcb8f..79ad886ab 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -44,6 +44,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they - **`teardown()`** (idempotent) sweeps liveness on cancel/stop/abnormal-end: resolves every pending permission ask `{outcome:'cancelled'}` and forces every non-terminal tool to `failed`. A cancelled turn never leaves a stuck tool or a hung permission. - **`streamDelta(id, fullText, kind)`** turns a provider's CUMULATIVE per-item text into incremental deltas keyed by item id. opencode reports cumulative and MUST use it; claude/pi/codex emit true incremental deltas and call `emitAssistantText`/`emitThought` directly (codex additionally keeps a per-item length ledger so `item/completed` can backstop deltas the stream dropped). Mixing the two double-renders or drops text. - **`freshSegment()`** opens fresh `messageId` AND `thoughtId` cursors; call it at turn start and after EVERY tool call (`buildConversation` buckets `agent-message-chunk` by `messageId`; `message-grouping.test.ts` guards it). A message's `messageId` must stay STABLE across all its deltas (the Pi adapter once minted a new id per delta and broke dedup). +- **`emitCheckpoint(historyId, branchPoint, turn)`** mints the turn's provider fork checkpoint (an `onCheckpoint` subscriber, never an agent event; the engine persists it as the turn's `provider_turn_bindings` row and `branchHistory` later forks at it). Emit it BEFORE the turn's `stop`/`idle`, and only for a genuinely completed turn; the engine keeps the FIRST live binding per `(turn, history)`, so a second mint for the same turn is ignored. Branch points: claude = the last main-agent assistant frame's `uuid` (a chain-correct inclusive cut on the EXPECTATION — unverified pending CODE-632's live multi-block turn — that the SDK streams one frame per persisted row; NOT equal to the next user row's `parentUuid` when a Stop hook ran, since a `system/stop_hook_summary` row then sits between; both cut validly and nothing may equate them); codex = the completed `turn/completed` id (`thread/fork lastTurnId` is inclusive); pi = `sessionManager.getLeafId()`; opencode has no "after" cut — its `session.fork {messageID}` cuts BEFORE a message, so the first user `message.updated` FIRST SEEN inside each turn is minted as a `preceding` checkpoint (the engine binds it to the parent turn); every user id is recorded on sight (pre-seeded from `session.messages` on resume), so a mid-turn compaction (`CompactionPart` on a later user message), a re-emitted settled prompt, or an idle straggler re-emitted inside a later turn never mints. opencode advertises `branch` (the legacy cold-read fork) but not `forkAfterTurn` until `session.fork` cut inclusivity is verified on a live server. `branchHistory` must throw `HistoryCheckpointInvalidError` for a cut the provider no longer honours (row gone from the raw transcript, JSON-RPC refusal, vanished opencode message, missing pi entry) — the engine maps it to a typed `unsupported`; codex `history_mode: "paginated"` rollouts (CODE-645) are refused there AND mint no replay cursors, so they stay fork-dark end to end. - **`onCommand(name, args)` / `onShellCommand(command)`** (CODE-161) back the `command` / `shell-command` AgentInput variants; both default-reject (`` `${kind}: slash/shell commands are not supported` ``). `AGENT_INPUT_CAPABILITIES` is the complete per-kind source of truth, which Base emits as `capabilities-update` at start; draft composers use the same matrix before a live event stream exists. **`emitCommands(commands)`** advertises the slash-command catalog (`available-commands-update`, full-replace). A missing catalog means discovery is still unavailable and host validation owns an early typed command; an emitted empty catalog is authoritative, so a completed-but-failed discovery must publish `[]` instead of leaving validation fail-open. The engine caches and replays both capabilities and the latest catalog on `session.attach`, prevalidates command/shell inputs before echoing them, and broadcasts an `input_rejected` error when dispatch fails. Adapters must NOT re-emit the user's invocation. ## Slash commands & shell passthrough (CODE-161) diff --git a/packages/host/agent-adapter/src/__tests__/base.test.ts b/packages/host/agent-adapter/src/__tests__/base.test.ts index 6fdb3ea29..c121f67e3 100644 --- a/packages/host/agent-adapter/src/__tests__/base.test.ts +++ b/packages/host/agent-adapter/src/__tests__/base.test.ts @@ -8,6 +8,9 @@ import type { } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; import { BaseAgentAdapter } from '../base'; +import type { HistoryCheckpoint } from '../history-branch'; +import { encodeHistoryBranchCursor } from '../history-branch'; +import { asHistoryId } from '../history-util'; /** Minimal concrete adapter that exposes the protected emit/permission surface for testing. */ class TestAdapter extends BaseAgentAdapter { @@ -44,6 +47,9 @@ class TestAdapter extends BaseAgentAdapter { title(value: string): void { this.emitTitle(value); } + checkpoint(branchPoint: string, turn?: HistoryCheckpoint['turn']): void { + this.emitCheckpoint(asHistoryId('hist-1'), branchPoint, turn); + } askQuestion(signal?: AbortSignal): Promise { return this.requestQuestion( { toolCallId: 't1' }, @@ -319,6 +325,57 @@ describe('BaseAgentAdapter command/shell defaults', () => { }); }); +describe('BaseAgentAdapter fork checkpoints', () => { + it('hands checkpoints to onCheckpoint subscribers as branch cursors, never as agent events', () => { + const a = new TestAdapter(); + const seen: HistoryCheckpoint[] = []; + a.onCheckpoint((checkpoint) => seen.push(checkpoint)); + a.checkpoint('entry-9'); + a.checkpoint('msg-next', 'preceding'); + + expect(seen).toEqual([ + { + historyId: 'hist-1', + cursor: encodeHistoryBranchCursor('pi', asHistoryId('hist-1'), 'entry-9'), + turn: 'ending', + }, + { + historyId: 'hist-1', + cursor: encodeHistoryBranchCursor('pi', asHistoryId('hist-1'), 'msg-next'), + turn: 'preceding', + }, + ]); + expect(a.seen).toEqual([]); + }); + + it('drops checkpoint subscribers on stop', async () => { + const a = new TestAdapter(); + const seen: HistoryCheckpoint[] = []; + a.onCheckpoint((checkpoint) => seen.push(checkpoint)); + await a.stop(); + a.checkpoint('entry-9'); + expect(seen).toEqual([]); + }); +}); + +describe('BaseAgentAdapter turn contract', () => { + // The engine's dispatch rescue commits a turn whose send() outlives the timer as long as the + // session is visibly `running`, so that status must only ever come from a turn-starting input. + it('emits no status for control inputs — running is reserved for genuine turn execution', async () => { + const a = new TestAdapter(); + const controls = [ + { type: 'set-mode', modeId: 'plan' }, + { type: 'set-approval-policy', policyId: 'default' }, + { type: 'set-model', model: 'test/model' }, + { type: 'set-effort', effort: 'high' }, + { type: 'permission-response', requestId: 'unknown', outcome: { outcome: 'cancelled' } }, + { type: 'question-response', requestId: 'unknown', outcome: { outcome: 'cancelled' } }, + ] as const; + await Promise.allSettled(controls.map((input) => a.send(input))); + expect(a.seen.filter((event) => event.type === 'status')).toEqual([]); + }); +}); + describe('BaseAgentAdapter initial effort', () => { it('validates and applies initial effort before starting the provider', async () => { const a = new EffortTestAdapter(); diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-checkpoint.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-checkpoint.test.ts new file mode 100644 index 000000000..bea1c103c --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/claude-code-checkpoint.test.ts @@ -0,0 +1,222 @@ +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; +import type { AgentEvent, StartOptions } from '@linkcode/schema'; +import { describe, expect, it, vi } from 'vitest'; +import type { HistoryCheckpoint } from '../history-branch'; +import { encodeHistoryBranchCursor, HistoryCheckpointInvalidError } from '../history-branch'; +import { asHistoryId } from '../history-util'; +import type { ClaudeTranscriptSupplement } from '../native/claude-code'; +import { buildClaudeTranscriptSupplement, ClaudeCodeAdapter } from '../native/claude-code'; + +/** + * Fork checkpoints: the cut claude's `forkSession` needs is the uuid of the row the + * next user row hangs off — the turn's last main-agent assistant frame on a linear history — and + * a fork must re-verify that row still exists in the raw transcript before cutting. + */ + +const SESSION = 'sid-fork'; + +function forkedChild() { + return Promise.resolve({ sessionId: 'sid-child' }); +} + +class TestClaude extends ClaudeCodeAdapter { + forkSession = vi.fn(forkedChild); + supplementUuids: string[] = []; + started: StartOptions[] = []; + + feed(value: object): void { + this.handleMessage(value as SDKMessage); + } + + protected override loadSdk(): Promise { + return Promise.resolve({ forkSession: this.forkSession } as T); + } + + protected override readTranscriptSupplement(): Promise { + return Promise.resolve({ + records: new Map(), + droppedRows: [], + parentUuidByUuid: new Map(this.supplementUuids.map((uuid) => [uuid, null])), + toolUses: new Map(), + toolUseResults: new Map(), + toolUsePatches: new Map(), + }); + } + + protected override onStart(opts: StartOptions): Promise { + this.started.push(opts); + return Promise.resolve(); + } +} + +function assistantFrame( + uuid: string, + parentToolUseId: string | null = null, + apiMessageId = `api-${uuid}`, +): object { + return { + type: 'assistant', + session_id: SESSION, + uuid, + parent_tool_use_id: parentToolUseId, + message: { + id: apiMessageId, + model: 'claude-test', + content: [{ type: 'text', text: 'hi' }], + }, + }; +} + +function resultFrame(subtype: 'success' | 'error_during_execution'): object { + return { + type: 'result', + subtype, + session_id: SESSION, + uuid: `result-${subtype}`, + stop_reason: 'end_turn', + usage: {}, + total_cost_usd: 0, + ...(subtype !== 'success' && { errors: ['boom'] }), + }; +} + +function harness() { + const adapter = new TestClaude(); + const events: AgentEvent[] = []; + const checkpoints: HistoryCheckpoint[] = []; + adapter.onEvent((event) => events.push(event)); + adapter.onCheckpoint((checkpoint) => { + checkpoints.push(checkpoint); + events.push({ type: 'title-update', title: 'checkpoint-marker' }); + }); + return { adapter, events, checkpoints }; +} + +describe('ClaudeCodeAdapter live fork checkpoints', () => { + it('mints the last main-agent assistant uuid at a successful result, before the stop', () => { + const { adapter, events, checkpoints } = harness(); + + adapter.feed(assistantFrame('row-a')); + adapter.feed(assistantFrame('row-b')); + // A subagent frame is not a main-chain row: the next user row never hangs off it. + adapter.feed(assistantFrame('row-sub', 'toolu_agent')); + adapter.feed(resultFrame('success')); + + expect(checkpoints).toEqual([ + { + historyId: SESSION, + cursor: encodeHistoryBranchCursor('claude-code', asHistoryId(SESSION), 'row-b'), + turn: 'ending', + }, + ]); + const marker = events.findIndex( + (event) => event.type === 'title-update' && event.title === 'checkpoint-marker', + ); + const stop = events.findIndex((event) => event.type === 'stop'); + expect(marker).toBeGreaterThanOrEqual(0); + expect(stop).toBeGreaterThan(marker); + }); + + it('mints the LAST frame of a multi-block API message — one frame per persisted row', () => { + const { adapter, checkpoints } = harness(); + + // The CLI persists one transcript row per content block, each with its own uuid, all sharing + // the API `message.id`; the next user row hangs off the last of them. + adapter.feed(assistantFrame('row-b1', null, 'api-multi')); + adapter.feed(assistantFrame('row-b2', null, 'api-multi')); + adapter.feed(resultFrame('success')); + + expect(checkpoints.map((checkpoint) => JSON.parse(checkpoint.cursor).branchPoint)).toEqual([ + 'row-b2', + ]); + }); + + it('mints nothing for a failed result', () => { + const { adapter, checkpoints } = harness(); + + adapter.feed(assistantFrame('row-a')); + adapter.feed(resultFrame('error_during_execution')); + + expect(checkpoints).toEqual([]); + }); +}); + +describe('claude fork cuts across a Stop hook summary row', () => { + const start: StartOptions = { kind: 'claude-code', cwd: '/repo' }; + const row = (value: object) => JSON.stringify(value); + const transcript = [ + row({ type: 'user', uuid: 'u0', parentUuid: null, message: { role: 'user', content: 'q' } }), + row({ type: 'assistant', uuid: 'row-a', parentUuid: 'u0', message: { role: 'assistant' } }), + row({ type: 'system', subtype: 'stop_hook_summary', uuid: 'row-s', parentUuid: 'row-a' }), + row({ + type: 'user', + uuid: 'u1', + parentUuid: 'row-s', + message: { role: 'user', content: 'q2' }, + }), + ]; + + it('the cold-read cursor is the system row, the live checkpoint the assistant row — both cut', async () => { + const supplement = buildClaudeTranscriptSupplement(transcript); + expect(supplement.parentUuidByUuid.get('u1')).toBe('row-s'); + + const cuts = ['row-s', 'row-a']; + for (let i = 0, len = cuts.length; i < len; i++) { + const cut = cuts[i]; + const adapter = new TestClaude(); + adapter.supplementUuids = [...supplement.parentUuidByUuid.keys()]; + // eslint-disable-next-line no-await-in-loop -- one fork per cut, sequential by construction + await adapter.branchHistory( + { + historyId: asHistoryId(SESSION), + cursor: encodeHistoryBranchCursor('claude-code', asHistoryId(SESSION), cut), + }, + start, + ); + expect(adapter.forkSession).toHaveBeenCalledWith(SESSION, { + upToMessageId: cut, + dir: '/repo', + }); + } + }); +}); + +describe('ClaudeCodeAdapter.branchHistory checkpoint validity', () => { + const start: StartOptions = { kind: 'claude-code', cwd: '/repo' }; + + it('forks through the checkpoint row when the transcript still has it', async () => { + const adapter = new TestClaude(); + adapter.supplementUuids = ['row-a', 'row-b']; + + await adapter.branchHistory( + { + historyId: asHistoryId(SESSION), + cursor: encodeHistoryBranchCursor('claude-code', asHistoryId(SESSION), 'row-b'), + }, + start, + ); + + expect(adapter.forkSession).toHaveBeenCalledWith(SESSION, { + upToMessageId: 'row-b', + dir: '/repo', + }); + expect(adapter.started).toEqual([start]); + }); + + it('refuses typed, without forking or starting, when the row is gone (rewritten or deleted transcript)', async () => { + const adapter = new TestClaude(); + adapter.supplementUuids = ['row-a']; + + await expect( + adapter.branchHistory( + { + historyId: asHistoryId(SESSION), + cursor: encodeHistoryBranchCursor('claude-code', asHistoryId(SESSION), 'row-b'), + }, + start, + ), + ).rejects.toBeInstanceOf(HistoryCheckpointInvalidError); + expect(adapter.forkSession).not.toHaveBeenCalled(); + expect(adapter.started).toEqual([]); + }); +}); diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts index b941ee802..e4eba595a 100644 --- a/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts +++ b/packages/host/agent-adapter/src/__tests__/claude-code-compaction.test.ts @@ -159,6 +159,9 @@ describe('buildClaudeTranscriptSupplement', () => { convRow('user', 'u1'), ]); expect(supplement.droppedRows.map((r) => r.uuid)).toEqual(['u0', 'a0']); + // A sidechain row can never be a fork cut (forkSession drops it); meta rows stay chain rows. + expect(supplement.parentUuidByUuid.has('side0')).toBe(false); + expect(supplement.parentUuidByUuid.has('meta0')).toBe(true); expect(supplement.droppedRows[0]).toMatchObject({ type: 'user', session_id: 'sid-1', diff --git a/packages/host/agent-adapter/src/__tests__/codex-history-files.test.ts b/packages/host/agent-adapter/src/__tests__/codex-history-files.test.ts index 73cac2a3b..c701a59ee 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-history-files.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-history-files.test.ts @@ -103,6 +103,20 @@ describe('codex rollout file reads', () => { }); }); + it('carries session_meta.history_mode on the summary (the fork pre-check reads it)', async () => { + const [meta, ...rest] = rolloutLines(THREAD_ID); + const paginated = JSON.parse(meta) as { payload: Record }; + paginated.payload.history_mode = 'paginated'; + await writeRollout(`sessions/2026/08/01/rollout-2026-08-01T10-00-00-${THREAD_ID}.jsonl`, [ + JSON.stringify(paginated), + ...rest, + ]); + + const found = await findCodexTranscript(asHistoryId(THREAD_ID), home); + expect(found?.historyMode).toBe('paginated'); + expect(found?.metadata?.historyMode).toBe('paginated'); + }); + it('skips corrupt lines and ignores empty files', async () => { const path = await writeRollout( `sessions/2026/08/01/rollout-2026-08-01T10-00-00-${THREAD_ID}.jsonl`, diff --git a/packages/host/agent-adapter/src/__tests__/codex-history.test.ts b/packages/host/agent-adapter/src/__tests__/codex-history.test.ts index 740a023a8..3a0380aa3 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-history.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-history.test.ts @@ -24,6 +24,42 @@ function base64WithByteLength(bytes: number): string { } describe('mapCodexHistoryEvents', () => { + it('mints no branch cursors for a paginated rollout the pinned app-server cannot fork', () => { + const rows = [ + { type: 'session_meta', payload: { id: 'thread', history_mode: 'paginated' } }, + { type: 'turn_context', payload: { turn_id: 'turn-1' } }, + responseItem({ + type: 'message', + id: 'first-prompt', + role: 'user', + content: [{ type: 'input_text', text: 'first' }], + }), + { type: 'turn_context', payload: { turn_id: 'turn-2' } }, + responseItem({ + type: 'message', + id: 'second-prompt', + role: 'user', + content: [{ type: 'input_text', text: 'second' }], + }), + ]; + + const prompts = mapCodexHistoryEvents(HID, rows).filter( + (event) => event.event.type === 'user-message', + ); + expect(prompts).toHaveLength(2); + expect( + prompts.map((entry) => entry.event.type === 'user-message' && entry.event.branchCursor), + ).toEqual([undefined, undefined]); + + const legacy = mapCodexHistoryEvents(HID, [ + { type: 'session_meta', payload: { id: 'thread', history_mode: 'legacy' } }, + ...rows.slice(1), + ]).filter((event) => event.event.type === 'user-message'); + expect( + legacy.every((entry) => entry.event.type === 'user-message' && entry.event.branchCursor), + ).toBe(true); + }); + it('maps each prompt to the previous completed turn', () => { const events = mapCodexHistoryEvents(HID, [ { type: 'turn_context', payload: { turn_id: 'turn-1' } }, diff --git a/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts b/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts index dc6869c02..cccf00882 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-shell.test.ts @@ -1,11 +1,13 @@ import type { AgentEvent, EffortLevel, StartOptions } from '@linkcode/schema'; import { textBlock } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; -import { encodeHistoryBranchCursor } from '../history-branch'; +import type { HistoryCheckpoint } from '../history-branch'; +import { encodeHistoryBranchCursor, HistoryCheckpointInvalidError } from '../history-branch'; import { asHistoryId } from '../history-util'; import { CodexAdapter } from '../native/codex'; import type { CodexServerHandle } from '../native/codex/adapter'; import type { CodexAppServerOptions } from '../native/codex/app-server'; +import type { CodexTranscriptSummary } from '../native/codex/history'; function reasoningEfforts(...efforts: string[]) { return efforts.map((reasoningEffort) => ({ reasoningEffort, description: reasoningEffort })); @@ -114,6 +116,11 @@ class TestCodex extends CodexAdapter { emptyModelList = false; rejectMethod: string | undefined; threadResponse: unknown; + /** The rollout summary a fork pre-checks; undefined = no rollout on disk (fork proceeds). */ + transcriptSummary: CodexTranscriptSummary | undefined; + protected override findTranscript(): Promise { + return Promise.resolve(this.transcriptSummary); + } protected override startAppServer( opts: Omit, ): Promise { @@ -163,6 +170,66 @@ describe('CodexAdapter history branching', () => { }); }); + it('refuses a paginated rollout typed, before spawning a fork server', async () => { + const adapter = new TestCodex(); + adapter.transcriptSummary = { id: 'source-thread', historyMode: 'paginated' }; + const historyId = asHistoryId('source-thread'); + + await expect( + adapter.branchHistory( + { historyId, cursor: encodeHistoryBranchCursor('codex', historyId, 'turn-7') }, + start, + ), + ).rejects.toBeInstanceOf(HistoryCheckpointInvalidError); + expect(adapter.fakeServers).toHaveLength(0); + }); + + it('maps a thread/fork JSON-RPC refusal to the typed checkpoint error', async () => { + const adapter = new TestCodex(); + adapter.rejectMethod = 'thread/fork'; + const historyId = asHistoryId('source-thread'); + + await expect( + adapter.branchHistory( + { historyId, cursor: encodeHistoryBranchCursor('codex', historyId, 'turn-gone') }, + start, + ), + ).rejects.toBeInstanceOf(HistoryCheckpointInvalidError); + expect(adapter.fakeServers).toHaveLength(1); + expect(adapter.fakeServers[0].closed).toBe(true); + }); + + it('mints the completed turn id as the fork checkpoint (lastTurnId is inclusive)', async () => { + const adapter = new TestCodex(); + const checkpoints: HistoryCheckpoint[] = []; + adapter.onCheckpoint((checkpoint) => checkpoints.push(checkpoint)); + const events: AgentEvent[] = []; + adapter.onEvent((event) => events.push(event)); + await adapter.start(start); + const server = adapter.fakeServers[0]; + + await adapter.send({ type: 'shell-command', command: 'echo hi' }); + driveShellTurn(server, { + itemId: 'item-1', + turnId: 'turn-9', + itemStatus: 'completed', + exitCode: 0, + }); + + expect(checkpoints).toEqual([ + { + historyId: 'thread-1', + cursor: encodeHistoryBranchCursor('codex', asHistoryId('thread-1'), 'turn-9'), + turn: 'ending', + }, + ]); + expect(events.at(-2)).toEqual({ type: 'stop', stopReason: 'end_turn' }); + + server.notify('turn/started', { turn: { id: 'turn-10' } }); + server.notify('turn/completed', { turn: { id: 'turn-10', status: 'interrupted' } }); + expect(checkpoints).toHaveLength(1); + }); + it('starts an empty thread for the first-prompt cursor', async () => { const adapter = new TestCodex(); const historyId = asHistoryId('source-thread'); diff --git a/packages/host/agent-adapter/src/__tests__/grok-build.test.ts b/packages/host/agent-adapter/src/__tests__/grok-build.test.ts index c7d0d2689..21606d6a7 100644 --- a/packages/host/agent-adapter/src/__tests__/grok-build.test.ts +++ b/packages/host/agent-adapter/src/__tests__/grok-build.test.ts @@ -131,8 +131,14 @@ describe('GrokBuildAdapter', () => { vi.restoreAllMocks(); }); - it('does not advertise provider-history branching', () => { - expect(new GrokBuildAdapter().historyCapabilities.branch).toBe(false); + it('advertises no history capability at all — fork included', () => { + expect(new GrokBuildAdapter().historyCapabilities).toEqual({ + list: false, + read: false, + resume: false, + forkAfterTurn: false, + branch: false, + }); }); it('fails start when no CLI is resolved', async () => { diff --git a/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts b/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts index 919b75b14..07c085536 100644 --- a/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts +++ b/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts @@ -1,7 +1,11 @@ import type { AgentEvent, AgentHistoryId } from '@linkcode/schema'; import type { Session } from '@opencode-ai/sdk/v2'; import { noop } from 'foxts/noop'; +import { wait } from 'foxts/wait'; import { describe, expect, it, vi } from 'vitest'; +import type { HistoryCheckpoint } from '../history-branch'; +import { encodeHistoryBranchCursor, HistoryCheckpointInvalidError } from '../history-branch'; +import { asHistoryId } from '../history-util'; import { OpenCodeAdapter } from '../native/opencode'; import type { OpencodeHistoryServerLike } from '../native/opencode/history-server'; import { FakeEventStream } from './fake-event-stream'; @@ -297,7 +301,7 @@ describe('OpenCodeAdapter.readHistory', () => { }); }); -function makeLiveClient(resumedSession: Session | null) { +function makeLiveClient(resumedSession: Session | null, userMessageIds: string[] = []) { const stream = new FakeEventStream(); return { stream, @@ -306,6 +310,14 @@ function makeLiveClient(resumedSession: Session | null) { get: vi.fn(() => Promise.resolve(resumedSession ? { data: resumedSession } : { error: { status: 404 } }), ), + messages: vi.fn(() => + Promise.resolve({ + data: userMessageIds.map((id) => ({ + info: { id, sessionID: resumedSession?.id, role: 'user' }, + parts: [], + })), + }), + ), promptAsync: vi.fn(() => Promise.resolve({ data: null })), }, command: { list: vi.fn(() => Promise.resolve({ data: [] })) }, @@ -314,6 +326,24 @@ function makeLiveClient(resumedSession: Session | null) { }; } +function userMessageUpdated(sessionID: string, id: string) { + return { + id: `e-${id}`, + type: 'message.updated' as const, + properties: { + sessionID, + info: { + id, + sessionID, + role: 'user' as const, + time: { created: 0 }, + agent: 'build', + model: { providerID: 'openai', modelID: 'gpt-5.5' }, + }, + }, + }; +} + function sessionRefs(events: AgentEvent[]): Array> { return events.filter( (e): e is Extract => e.type === 'session-ref', @@ -348,6 +378,40 @@ describe('OpenCodeAdapter.resumeHistory', () => { ); }); + it('never re-mints a resumed session’s settled prompts as fork checkpoints', async () => { + const resumed = makeSession({ id: 'ses-9', directory: '/tmp/original' }); + const client = makeLiveClient(resumed, ['msg-old']); + sdkMock.createOpencode = () => + Promise.resolve({ client, server: { url: 'http://fake', close: vi.fn() } }); + const adapter = new OpenCodeAdapter(); + adapter.onEvent(noop); + const checkpoints: HistoryCheckpoint[] = []; + adapter.onCheckpoint((checkpoint) => checkpoints.push(checkpoint)); + await adapter.resumeHistory( + { historyId: 'ses-9' as AgentHistoryId }, + { kind: 'opencode', cwd: '/tmp/elsewhere' }, + ); + expect(client.session.messages).toHaveBeenCalledWith({ + sessionID: 'ses-9', + directory: '/tmp/original', + }); + + await adapter.send({ type: 'prompt', content: [{ type: 'text', text: 'go' }] }); + // The settled prompt re-emitted AFTER the dispatch, before the new prompt's own message: it + // is already checkpointed, so the turn's one cut lands on the new prompt. + client.stream.push(userMessageUpdated('ses-9', 'msg-old')); + client.stream.push(userMessageUpdated('ses-9', 'msg-new')); + await wait(0); + + expect(checkpoints).toEqual([ + { + historyId: 'ses-9', + cursor: encodeHistoryBranchCursor('opencode', asHistoryId('ses-9'), 'msg-new'), + turn: 'preceding', + }, + ]); + }); + it('rejects when the history id is unknown', async () => { const client = makeLiveClient(null); sdkMock.createOpencode = () => @@ -372,9 +436,11 @@ describe('OpenCodeAdapter.branchHistory', () => { directory: source.directory, }); const fork = vi.fn(() => Promise.resolve({ data: child })); + const message = vi.fn(() => Promise.resolve({ data: { info: {}, parts: [] } })); sdkMock.createOpencodeClient = () => ({ session: { get: vi.fn(() => Promise.resolve({ data: source })), + message, fork, }, }); @@ -397,6 +463,11 @@ describe('OpenCodeAdapter.branchHistory', () => { { kind: 'opencode', cwd: '/different/repo' }, ); + expect(message).toHaveBeenCalledWith({ + sessionID: 'ses-source', + messageID: 'msg-target', + directory: '/canonical/repo', + }); expect(fork).toHaveBeenCalledWith({ sessionID: 'ses-source', messageID: 'msg-target', @@ -409,6 +480,60 @@ describe('OpenCodeAdapter.branchHistory', () => { ); }); + it('refuses typed, without forking, when the checkpoint message is gone from the server', async () => { + const source = makeSession({ id: 'ses-source', directory: '/canonical/repo' }); + const fork = vi.fn(); + sdkMock.createOpencodeClient = () => ({ + session: { + get: vi.fn(() => Promise.resolve({ data: source })), + message: vi.fn(() => + Promise.resolve({ error: { name: 'NotFoundError', data: { message: 'gone' } } }), + ), + fork, + }, + }); + + await expect( + new HistoryTestAdapter().branchHistory( + { + historyId: 'ses-source' as AgentHistoryId, + cursor: JSON.stringify({ + version: 1, + kind: 'opencode', + historyId: 'ses-source', + branchPoint: 'msg-vanished', + }), + }, + { kind: 'opencode', cwd: '/tmp/repo' }, + ), + ).rejects.toBeInstanceOf(HistoryCheckpointInvalidError); + expect(fork).not.toHaveBeenCalled(); + }); + + it('refuses typed when the source session itself is unreadable', async () => { + sdkMock.createOpencodeClient = () => ({ + session: { + get: vi.fn(() => Promise.resolve({ error: { name: 'NotFoundError' } })), + fork: vi.fn(), + }, + }); + + await expect( + new HistoryTestAdapter().branchHistory( + { + historyId: 'ses-source' as AgentHistoryId, + cursor: JSON.stringify({ + version: 1, + kind: 'opencode', + historyId: 'ses-source', + branchPoint: 'msg-target', + }), + }, + { kind: 'opencode', cwd: '/tmp/repo' }, + ), + ).rejects.toBeInstanceOf(HistoryCheckpointInvalidError); + }); + it('rejects a cursor minted for another source before calling the provider', async () => { const get = vi.fn(); sdkMock.createOpencodeClient = () => ({ session: { get } }); diff --git a/packages/host/agent-adapter/src/__tests__/opencode.test.ts b/packages/host/agent-adapter/src/__tests__/opencode.test.ts index 4a5faacd1..2b7355d5b 100644 --- a/packages/host/agent-adapter/src/__tests__/opencode.test.ts +++ b/packages/host/agent-adapter/src/__tests__/opencode.test.ts @@ -2,6 +2,9 @@ import type { AgentEvent } from '@linkcode/schema'; import { noop } from 'foxts/noop'; import { wait } from 'foxts/wait'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { HistoryCheckpoint } from '../history-branch'; +import { encodeHistoryBranchCursor } from '../history-branch'; +import { asHistoryId } from '../history-util'; import { OpenCodeAdapter } from '../native/opencode'; const sdkMock = vi.hoisted( @@ -155,6 +158,24 @@ function pushIdle(): void { }); } +function userMessageUpdated(id: string, eventId: string) { + return { + id: eventId, + type: 'message.updated' as const, + properties: { + sessionID: 'sess-1', + info: { + id, + sessionID: 'sess-1', + role: 'user' as const, + time: { created: 0 }, + agent: 'build', + model: { providerID: 'openai', modelID: 'gpt-5.5' }, + }, + }, + }; +} + /** The server's on-stream acknowledgement that the active turn is running — always precedes the * turn's own error/idle on the real stream (verified live on 1.17.11). */ function pushBusy(): void { @@ -388,6 +409,59 @@ describe('OpenCodeAdapter.consumeEvents', () => { ]); }); + it('keeps turn-level forks dark while the legacy branch path stays advertised', () => { + expect(new OpenCodeAdapter().historyCapabilities).toEqual({ + list: true, + read: true, + resume: true, + forkAfterTurn: false, + branch: true, + }); + }); + + it('mints exactly one preceding checkpoint per turn: the prompt’s own user message, first seen inside it', async () => { + const { adapter, events } = await makeAdapter(); + const checkpoints: HistoryCheckpoint[] = []; + adapter.onCheckpoint((checkpoint) => checkpoints.push(checkpoint)); + + // A user message outside any turn (a resumed session's straggler) cuts nothing. + client.stream.push(userMessageUpdated('msg-stale', 'e-stale')); + await drained(); + await adapter.send({ type: 'prompt', content: [{ type: 'text', text: 'first' }] }); + pushBusy(); + // The straggler re-emitted inside the turn, ahead of the prompt's own message: seen before + // the turn, so it is not this turn's cut. + client.stream.push(userMessageUpdated('msg-stale', 'e-stale-again')); + client.stream.push(userMessageUpdated('msg-user-1', 'e-u1')); + // A mid-turn compaction materializes as a second user message, and a settled prompt can be + // re-emitted late (observed on 1.17.11): neither may move the cut past the turn's own prompt. + client.stream.push(userMessageUpdated('msg-compaction', 'e-compaction')); + client.stream.push(userMessageUpdated('msg-user-1', 'e-u1-again')); + pushIdle(); + await vi.waitFor(() => expect(stops(events)).toHaveLength(1)); + + await adapter.send({ type: 'prompt', content: [{ type: 'text', text: 'second' }] }); + // The compaction message never minted, yet re-emitted in the next turn it is still not that + // turn's prompt. + client.stream.push(userMessageUpdated('msg-compaction', 'e-compaction-again')); + client.stream.push(userMessageUpdated('msg-user-1', 'e-u1-late')); + client.stream.push(userMessageUpdated('msg-user-2', 'e-u2')); + await drained(); + + expect(checkpoints).toEqual([ + { + historyId: 'sess-1', + cursor: encodeHistoryBranchCursor('opencode', asHistoryId('sess-1'), 'msg-user-1'), + turn: 'preceding', + }, + { + historyId: 'sess-1', + cursor: encodeHistoryBranchCursor('opencode', asHistoryId('sess-1'), 'msg-user-2'), + turn: 'preceding', + }, + ]); + }); + it('skips parts of a user message, so the prompt text is not replayed as agent output', async () => { const { events } = await makeAdapter(); diff --git a/packages/host/agent-adapter/src/__tests__/pi.test.ts b/packages/host/agent-adapter/src/__tests__/pi.test.ts index 570fcf876..cce865cca 100644 --- a/packages/host/agent-adapter/src/__tests__/pi.test.ts +++ b/packages/host/agent-adapter/src/__tests__/pi.test.ts @@ -1,6 +1,9 @@ import type { AgentSession, AgentSessionEvent } from '@earendil-works/pi-coding-agent'; import type { AgentEvent } from '@linkcode/schema'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { HistoryCheckpoint } from '../history-branch'; +import { encodeHistoryBranchCursor } from '../history-branch'; +import { asHistoryId } from '../history-util'; import { PiAdapter } from '../native/pi'; import { agentRuntimeProber } from '../probe'; @@ -14,6 +17,8 @@ const resources = { }; const session = { + sessionId: 'pi-session-1', + sessionManager: { getLeafId: () => 'leaf-after-turn' }, abort: vi.fn(), bindExtensions: vi.fn(), dispose: vi.fn(), @@ -169,6 +174,43 @@ describe('PiAdapter lifecycle', () => { ]); }); + it('mints the session leaf as the settled turn’s fork checkpoint, before the stop', async () => { + const { adapter, events } = await startedAdapter(); + const checkpoints: HistoryCheckpoint[] = []; + adapter.onCheckpoint((checkpoint) => { + checkpoints.push(checkpoint); + events.push({ type: 'title-update', title: 'checkpoint-marker' }); + }); + await adapter.send({ type: 'prompt', content: [{ type: 'text', text: 'hello' }] }); + + emit({ type: 'agent_end', messages: [assistant('stop')], willRetry: false }); + emit({ type: 'agent_settled' }); + + expect(checkpoints).toEqual([ + { + historyId: 'pi-session-1', + cursor: encodeHistoryBranchCursor('pi', asHistoryId('pi-session-1'), 'leaf-after-turn'), + turn: 'ending', + }, + ]); + expect(events.map((event) => event.type)).toEqual(['status', 'title-update', 'stop', 'status']); + }); + + it('mints no checkpoint for an aborted or failed turn', async () => { + const { adapter } = await startedAdapter(); + const checkpoints: HistoryCheckpoint[] = []; + adapter.onCheckpoint((checkpoint) => checkpoints.push(checkpoint)); + + await adapter.send({ type: 'prompt', content: [{ type: 'text', text: 'hello' }] }); + emit({ type: 'agent_end', messages: [assistant('error', 'boom')], willRetry: false }); + emit({ type: 'agent_settled' }); + await adapter.send({ type: 'prompt', content: [{ type: 'text', text: 'again' }] }); + emit({ type: 'agent_end', messages: [assistant('aborted')], willRetry: false }); + emit({ type: 'agent_settled' }); + + expect(checkpoints).toEqual([]); + }); + it('unwinds a prompt rejected after announcing running', async () => { prompt.mockImplementationOnce(() => { emit({ diff --git a/packages/host/agent-adapter/src/adapter.ts b/packages/host/agent-adapter/src/adapter.ts index 746b4ed55..d0560a16b 100644 --- a/packages/host/agent-adapter/src/adapter.ts +++ b/packages/host/agent-adapter/src/adapter.ts @@ -15,6 +15,7 @@ import type { StartOptions, } from '@linkcode/schema'; import type { Unsubscribe } from '@linkcode/transport'; +import type { HistoryCheckpoint } from './history-branch'; export type AgentStartCatalogOptions = Partial>; @@ -60,11 +61,15 @@ export interface AgentAdapter { readHistory(opts: AgentHistoryReadContext): Promise; /** Start/resume a live adapter session from a provider-local history id, if supported. */ resumeHistory(opts: AgentHistoryResumeOptions, startOpts: StartOptions): Promise; - /** Start this adapter on provider history forked before the cursor's historical prompt. */ + /** Start this adapter on provider history forked right after the cursor's checkpoint (before a + * historical prompt ≡ after its predecessor). A cursor that no longer names a live position + * must reject with `HistoryCheckpointInvalidError`, never fork at a guessed cut. */ branchHistory?(opts: AgentHistoryBranchOptions, startOpts: StartOptions): Promise; send(input: AgentInput): Promise; /** Subscribe to events normalized by the abstraction layer. */ onEvent(cb: (e: AgentEvent) => void): Unsubscribe; + /** Subscribe to live fork checkpoints (adapter-opaque; the engine persists them per turn). */ + onCheckpoint?(cb: (checkpoint: HistoryCheckpoint) => void): Unsubscribe; stop(): Promise; } diff --git a/packages/host/agent-adapter/src/base.ts b/packages/host/agent-adapter/src/base.ts index d67541dea..57c4d0a89 100644 --- a/packages/host/agent-adapter/src/base.ts +++ b/packages/host/agent-adapter/src/base.ts @@ -39,6 +39,8 @@ import type { AgentAdapter, AgentHistoryReadContext, AgentStartCatalogOptions } import { nextMessageId, nextRequestId } from './adapter'; import type { ProviderErrorDetails } from './gateway-error'; import { linkCodeGatewayError } from './gateway-error'; +import type { HistoryCheckpoint } from './history-branch'; +import { encodeHistoryBranchCursor } from './history-branch'; type PermissionResolver = (outcome: PermissionOutcome) => void; type QuestionResolver = (outcome: QuestionOutcome) => void; @@ -67,10 +69,12 @@ export abstract class BaseAgentAdapter implements AgentAdapter { list: false, read: false, resume: false, + forkAfterTurn: false, branch: false, }; protected readonly events = new Listeners(); + private readonly checkpoints = new Listeners(); protected opts: StartOptions | null = null; /** Last announced provider-local id — `emitSessionRef` dedupes against it. */ private sessionRef: AgentHistoryId | null = null; @@ -166,6 +170,10 @@ export abstract class BaseAgentAdapter implements AgentAdapter { return this.events.add(cb); } + onCheckpoint(cb: (checkpoint: HistoryCheckpoint) => void): Unsubscribe { + return this.checkpoints.add(cb); + } + async stop(): Promise { try { await this.onStop(); @@ -176,6 +184,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { this.teardown(); this.emitStatus('stopped'); this.events.clear(); + this.checkpoints.clear(); this.toolCalls.clear(); } @@ -347,6 +356,21 @@ export abstract class BaseAgentAdapter implements AgentAdapter { this.sessionRef = historyId; this.emit({ type: 'session-ref', historyId }); } + /** Mint the fork checkpoint for a turn: `branchPoint` is what this adapter's `branchHistory` + * forks after (claude row uuid, codex turn id, pi leaf entry id) or, for `preceding`, the + * successor's id it forks before (opencode). Encoded like a history branch cursor, so replayed + * and live checkpoints compare and fork identically. */ + protected emitCheckpoint( + historyId: AgentHistoryId, + branchPoint: string, + turn: HistoryCheckpoint['turn'] = 'ending', + ): void { + this.checkpoints.emit({ + historyId, + cursor: encodeHistoryBranchCursor(this.kind, historyId, branchPoint), + turn, + }); + } protected emitUsage(usage: TokenUsage): void { this.emit({ type: 'token-usage', usage }); } diff --git a/packages/host/agent-adapter/src/history-branch.ts b/packages/host/agent-adapter/src/history-branch.ts index a42624ce5..e2a4b4a6a 100644 --- a/packages/host/agent-adapter/src/history-branch.ts +++ b/packages/host/agent-adapter/src/history-branch.ts @@ -8,6 +8,26 @@ interface HistoryBranchCursorPayload { branchPoint: string | null; } +/** A provider fork point minted by a live adapter; never crosses the wire. `cursor` is what + * `branchHistory` accepts and forks the history right after the described turn. */ +export interface HistoryCheckpoint { + readonly historyId: AgentHistoryId; + readonly cursor: string; + /** `ending`: the turn settling now (emitted before its stop/idle). `preceding`: the turn before + * the one whose dispatch just revealed the cut — opencode's cut is the successor's message id. */ + readonly turn: 'ending' | 'preceding'; +} + +/** A fork was refused because its checkpoint no longer names a live provider position (deleted or + * rewritten history, an unforkable rollout). Nothing was created; the engine maps it to a typed + * `unsupported` instead of ever aiming a fork at a guessed cut. */ +export class HistoryCheckpointInvalidError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'HistoryCheckpointInvalidError'; + } +} + export function encodeHistoryBranchCursor( kind: AgentKind, historyId: AgentHistoryId, diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index b77c19372..c38bb870c 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -67,7 +67,11 @@ import type { AgentStartCatalogOptions, BrowserToolset, BrowserToolsetFactory } import { AUTH_FAILED_ERROR_CODE, renderBrowserToolResult } from '../adapter'; import { BaseAgentAdapter } from '../base'; import { claudeCodeEnv, readAgentCredential } from '../credential'; -import { decodeHistoryBranchCursor, encodeHistoryBranchCursor } from '../history-branch'; +import { + decodeHistoryBranchCursor, + encodeHistoryBranchCursor, + HistoryCheckpointInvalidError, +} from '../history-branch'; import { asHistoryId, asMessageId, @@ -448,6 +452,7 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { list: true, read: true, resume: true, + forkAfterTurn: true, branch: true, }; @@ -456,6 +461,12 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { private processEnvironment: NodeJS.ProcessEnv | null = null; /** True from prompt dispatch until its terminal `result`; a Query EOF while set is a failed turn. */ private turnActive = false; + /** Transcript row uuid of the turn's last main-agent assistant frame — a chain-correct inclusive + * fork cut on the expectation, unverified on a live multi-block turn, that the SDK streams one + * frame per persisted row. It is NOT the next user row's `parentUuid` whenever a Stop hook ran + * (every LinkCode query registers one): a `system/stop_hook_summary` row then sits between, so + * the cold-read cursor and this live checkpoint differ yet both fork validly. */ + private lastAssistantUuid: string | undefined; /** Distinguishes an explicit adapter stop from an unexpected Query EOF. */ private stopped = false; /** Session id to resume *once*, when the persistent Query starts from saved history — not updated @@ -664,6 +675,14 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { ): Promise { const predecessor = decodeHistoryBranchCursor(opts.cursor, this.kind, opts.historyId); if (predecessor !== null) { + // forkSession would throw on an unknown uuid too, but untyped; the raw transcript is the + // authority on whether the checkpoint row still exists (deleted or rewritten history). + const supplement = await this.readTranscriptSupplement(opts.historyId); + if (!supplement.parentUuidByUuid.has(predecessor)) { + throw new HistoryCheckpointInvalidError( + `claude-code: checkpoint ${predecessor} is no longer in transcript ${opts.historyId}`, + ); + } const mod = await this.loadSdk( '@anthropic-ai/claude-agent-sdk', () => import('@anthropic-ai/claude-agent-sdk'), @@ -821,6 +840,7 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { parent_tool_use_id: null, }; this.turnActive = true; + this.lastAssistantUuid = undefined; this.emitStatus('running'); try { if (this.inputQueue) { @@ -1375,6 +1395,7 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { this.handleSubagentAssistant(msg.message, msg.parent_tool_use_id); return; } + this.lastAssistantUuid = msg.uuid; const message = msg.message; // Every assistant frame carries the served model — the source of truth for a mid-session switch // (`init` fires only at Query creation, so it can't catch a live `setModel`). @@ -1507,6 +1528,9 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { cacheCreationTokens: numberField(usage, 'cache_creation_input_tokens'), totalCostUsd: msg.total_cost_usd, }); + if (this.lastSessionRef && this.lastAssistantUuid) { + this.emitCheckpoint(asHistoryId(this.lastSessionRef), this.lastAssistantUuid); + } this.emitStop(mapClaudeStop(msg.stop_reason)); } else if (cancelling) { // This non-success result is the fallout of our own onCancel()'s interrupt(), not a real @@ -1792,8 +1816,9 @@ export interface ClaudeTranscriptSupplement { * summary, whose `parentUuid` is null — `logicalParentUuid` is ignored). In file (= chronological) * order; rows the SDK still returns (the preserved segment) are deduped by uuid at read time. */ droppedRows: SessionMessage[]; - /** Message uuid → raw transcript predecessor. The SDK projection strips `parentUuid`, but Claude - * requires the predecessor message id when forking immediately before a historical prompt. */ + /** Main-chain message uuid → raw transcript predecessor. The SDK projection strips `parentUuid`, + * but Claude requires the predecessor message id when forking immediately before a historical + * prompt. Sidechain rows are absent: `forkSession` drops them, so a cut through one is invalid. */ parentUuidByUuid: Map; /** tool_use_id → announce snapshot. Cursor pages can begin at the matching result row, after * the stateful mapper's in-page announce map has been reset. */ @@ -1839,7 +1864,9 @@ export function buildClaudeTranscriptSupplement( if (!isRecord(parsed) || typeof parsed.uuid !== 'string' || parsed.uuid.length === 0) continue; const row = parsed; const uuid = parsed.uuid; - parentUuidByUuid.set(uuid, typeof row.parentUuid === 'string' ? row.parentUuid : null); + if (row.isSidechain !== true) { + parentUuidByUuid.set(uuid, typeof row.parentUuid === 'string' ? row.parentUuid : null); + } if (row.type === 'system' && row.subtype === 'compact_boundary') { boundaries += 1; const meta = isRecord(row.compactMetadata) ? row.compactMetadata : {}; diff --git a/packages/host/agent-adapter/src/native/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index 660a84c80..b79b51462 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -5,6 +5,7 @@ import type { AgentCommand, AgentHistoryBranchOptions, AgentHistoryCapabilities, + AgentHistoryId, AgentHistoryListOptions, AgentHistoryListResult, AgentHistoryReadOptions, @@ -34,7 +35,7 @@ import { AUTH_FAILED_ERROR_CODE } from '../../adapter'; import { BaseAgentAdapter } from '../../base'; import type { AgentCredential } from '../../credential'; import { codexEnv, readAgentCredential } from '../../credential'; -import { decodeHistoryBranchCursor } from '../../history-branch'; +import { decodeHistoryBranchCursor, HistoryCheckpointInvalidError } from '../../history-branch'; import { asHistoryId, asMessageId, @@ -53,11 +54,13 @@ import type { CodexAppServerOptions } from './app-server'; import { CodexAppServer, resolveCodexBinaryPath } from './app-server'; import type { CodexSandboxMode } from './config'; import { codexConfiguredModel, codexConfiguredSandbox } from './config'; +import type { CodexTranscriptSummary } from './history'; import { codexHome, codexIndexEntryToSession, codexSummaryToSession, findCodexTranscript, + isCodexRolloutForkable, mapCodexHistoryEvents, readCodexIndex, readCodexTranscriptSummaries, @@ -440,10 +443,13 @@ export function decisionFromOutcome( */ export class CodexAdapter extends BaseAgentAdapter { readonly kind = 'codex' as const; + // `thread/fork {threadId, lastTurnId}` is live-verified inclusive on the 0.144.6 pin; + // paginated-mode rollouts go dark per history (see `branchHistory` and history.ts). override readonly historyCapabilities: AgentHistoryCapabilities = { list: true, read: true, resume: true, + forkAfterTurn: true, branch: true, }; @@ -592,6 +598,14 @@ export class CodexAdapter extends BaseAgentAdapter { } const processEnvironment = await resolveCodexEnvironment(startOpts.cwd); + // A paginated rollout would fail thread/fork (and resume) with -32601 on the pinned + // app-server; refuse typed before spawning one. + const summary = await this.findTranscript(opts.historyId, codexHome(processEnvironment)); + if (summary && !isCodexRolloutForkable(summary)) { + throw new HistoryCheckpointInvalidError( + `codex: thread ${opts.historyId} was written in ${summary.historyMode} history mode, which this codex app-server cannot fork`, + ); + } const credentialEnv = codexEnv(readAgentCredential(startOpts.config)); const serverEnvironment = credentialEnv ? { ...processEnvironment, ...credentialEnv } @@ -603,10 +617,22 @@ export class CodexAdapter extends BaseAgentAdapter { }); let childThreadId: string; try { - const response = await server.request('thread/fork', { - threadId: opts.historyId, - lastTurnId: branchPoint, - }); + let response: unknown; + try { + response = await server.request('thread/fork', { + threadId: opts.historyId, + lastTurnId: branchPoint, + }); + } catch (error) { + // A JSON-RPC refusal (unknown thread or turn) creates nothing; a dead connection rethrows. + if (isRecord(error) && typeof error.code === 'number') { + throw new HistoryCheckpointInvalidError( + `codex: thread/fork refused checkpoint ${branchPoint}: ${extractErrorMessage(error)}`, + { cause: error }, + ); + } + throw error; + } const thread = isRecord(response) ? recordField(response, 'thread') : undefined; childThreadId = thread ? (stringField(thread, 'id') ?? '') : ''; if (!childThreadId) throw new Error('codex: thread/fork returned no thread id'); @@ -904,6 +930,14 @@ export class CodexAdapter extends BaseAgentAdapter { return codexConfiguredModel(environment); } + /** Test seam — the rollout summary a fork pre-checks for the paginated history-mode wall. */ + protected findTranscript( + historyId: AgentHistoryId, + home: string, + ): Promise { + return findCodexTranscript(historyId, home); + } + private async openThread(): Promise { const opts = nullthrow(this.opts, 'codex: session not started'); const processEnvironment = nullthrow( @@ -1359,6 +1393,8 @@ export class CodexAdapter extends BaseAgentAdapter { } else if (status === 'interrupted') { this.emitStop('cancelled'); } else { + // `thread/fork {lastTurnId}` is inclusive: the completed turn's own id is the cut after it. + if (id && this.threadId) this.emitCheckpoint(asHistoryId(this.threadId), id); this.emitStop('end_turn'); } this.teardown(); diff --git a/packages/host/agent-adapter/src/native/codex/app-server.ts b/packages/host/agent-adapter/src/native/codex/app-server.ts index 6b0e51e95..ee6729ac1 100644 --- a/packages/host/agent-adapter/src/native/codex/app-server.ts +++ b/packages/host/agent-adapter/src/native/codex/app-server.ts @@ -226,7 +226,10 @@ export class CodexAppServer { if ('error' in message) { const error = message.error; const detail = isRecord(error) && typeof error.message === 'string' ? error.message : line; - pending.reject(new Error(`codex: ${detail}`)); + // The JSON-RPC code travels on the rejection: a method-level refusal (unknown thread, + // unsupported rollout) is distinguishable from a dead connection only by its presence. + const code = isRecord(error) && typeof error.code === 'number' ? error.code : undefined; + pending.reject(Object.assign(new Error(`codex: ${detail}`), { code })); } else { pending.resolve(message.result); } diff --git a/packages/host/agent-adapter/src/native/codex/history.ts b/packages/host/agent-adapter/src/native/codex/history.ts index 5e194fc9d..a03e01a25 100644 --- a/packages/host/agent-adapter/src/native/codex/history.ts +++ b/packages/host/agent-adapter/src/native/codex/history.ts @@ -255,7 +255,7 @@ interface CodexIndexEntry { updatedAt?: number; } -interface CodexTranscriptSummary { +export interface CodexTranscriptSummary { id: string; path?: string; title?: string; @@ -264,9 +264,18 @@ interface CodexTranscriptSummary { createdAt?: number; updatedAt?: number; messageCount?: number; + /** `session_meta.history_mode`: `paginated` rollouts (codex ≥0.150.x) fail resume AND fork + * with -32601 on the pinned 0.144.6, so fork goes dark for them. Re-check on any pin bump. */ + historyMode?: string; metadata?: Record; } +const CODEX_PAGINATED_HISTORY_MODE = 'paginated'; + +export function isCodexRolloutForkable(summary: Pick) { + return summary.historyMode !== CODEX_PAGINATED_HISTORY_MODE; +} + interface DirectoryEntry { name: string; isDirectory(): boolean; @@ -432,6 +441,7 @@ async function readCodexTranscriptSummary( let threadSource: string | undefined; let modelProvider: string | undefined; let gitBranch: string | undefined; + let historyMode: string | undefined; const rowCount = await forEachJsonlRow(path, (row) => { const rowType = stringField(row, 'type'); @@ -459,6 +469,7 @@ async function readCodexTranscriptSummary( threadSource = stringField(payload, 'thread_source') ?? threadSource; cliVersion = stringField(payload, 'cli_version') ?? cliVersion; modelProvider = stringField(payload, 'model_provider') ?? modelProvider; + historyMode = stringField(payload, 'history_mode') ?? historyMode; const git = recordField(payload, 'git'); if (git) gitBranch = stringField(git, 'branch') ?? gitBranch; createdAt = timestampMs(payload.timestamp) ?? createdAt; @@ -518,6 +529,7 @@ async function readCodexTranscriptSummary( updatedAt: indexEntry?.updatedAt ?? updatedAt ?? (fileStat ? Math.trunc(fileStat.mtimeMs) : undefined), messageCount, + historyMode, metadata: compactRecord({ source: 'codex-local-jsonl', transcriptPath: path, @@ -527,6 +539,7 @@ async function readCodexTranscriptSummary( threadSource, modelProvider, gitBranch, + historyMode, }), }; } @@ -647,6 +660,7 @@ export function mapCodexHistoryEvents( let currentTurnId: string | null = null; let previousTurnId: string | null = null; let userPromptCount = 0; + let forkable = true; // Records the snapshot as the call's latest state (settle reads it back as `existing`) AND // builds the history event — both announce and settle go through it, so the latest wins. @@ -656,6 +670,14 @@ export function mapCodexHistoryEvents( }; rows.forEach((row, index) => { + if (stringField(row, 'type') === 'session_meta') { + const payload = recordField(row, 'payload'); + // No branch cursors for a rollout the pinned app-server cannot fork: capability-dark. + forkable = isCodexRolloutForkable({ + historyMode: payload ? stringField(payload, 'history_mode') : undefined, + }); + return; + } if (stringField(row, 'type') === 'turn_context') { const payload = recordField(row, 'payload'); const turnId = payload ? stringField(payload, 'turn_id') : undefined; @@ -743,7 +765,7 @@ export function mapCodexHistoryEvents( : textHistoryEvent(historyId, role, itemId, payload, timestampMs(row.timestamp)); if (event) { if (event.event.type === 'user-message') { - if (userPromptCount === 0 || previousTurnId !== null) { + if (forkable && (userPromptCount === 0 || previousTurnId !== null)) { event.event.branchCursor = encodeHistoryBranchCursor('codex', historyId, previousTurnId); } userPromptCount += 1; diff --git a/packages/host/agent-adapter/src/native/opencode/adapter.ts b/packages/host/agent-adapter/src/native/opencode/adapter.ts index ba443a865..7e25ba4c4 100644 --- a/packages/host/agent-adapter/src/native/opencode/adapter.ts +++ b/packages/host/agent-adapter/src/native/opencode/adapter.ts @@ -36,7 +36,7 @@ import type { AgentHistoryReadContext, AgentStartCatalogOptions } from '../../ad import { AUTH_FAILED_ERROR_CODE, nextToolCallId } from '../../adapter'; import { BaseAgentAdapter } from '../../base'; import { readAgentCredential } from '../../credential'; -import { decodeHistoryBranchCursor } from '../../history-branch'; +import { decodeHistoryBranchCursor, HistoryCheckpointInvalidError } from '../../history-branch'; import { asHistoryId, boundedLimit, cursorFromTotal, cursorOffset } from '../../history-util'; import { contentToText, @@ -231,10 +231,14 @@ function opencodeAgentPolicies( */ export class OpenCodeAdapter extends BaseAgentAdapter { readonly kind = 'opencode' as const; + // `session.fork {messageID}` cut inclusivity is mock-verified only (no binary on the verifying + // machine): turn-level forks stay dark until a live server confirms the cut excludes the message; + // the legacy `history.branch` path keeps the cold-read cut it always shipped with. override readonly historyCapabilities: AgentHistoryCapabilities = { list: true, read: true, resume: true, + forkAfterTurn: false, branch: true, }; @@ -283,6 +287,14 @@ export class OpenCodeAdapter extends BaseAgentAdapter { * `message.part.updated` for the user's own prompt text too (observed live on 1.17.11), and * replaying it would double-render the prompt as an agent bubble. Cleared at each turn settle. */ private readonly userMessageIds = new Set(); + /** Every user message id seen on the stream (or pre-seeded from a resumed session's messages) — + * never cleared per turn: only an id first seen inside a turn may mint its `preceding` cut, so a + * re-emitted settled prompt or a skipped compaction message cannot cut inside an earlier turn. */ + private readonly seenUserMessageIds = new Set(); + /** True once the active turn minted its `preceding` checkpoint: only the turn's own prompt cuts + * before it — a mid-turn compaction lands as a later user message (`CompactionPart`) and would + * aim the parent's fork past this turn's prompt. */ + private turnCheckpointMinted = false; /** Provider the spawn-time credential injection scoped to (null = nothing injected): the only * provider a mid-session set-model may target while a per-account credential is in play. */ private credentialProviderId: string | null = null; @@ -369,6 +381,20 @@ export class OpenCodeAdapter extends BaseAgentAdapter { this.directory = got.data.directory; this.sessionTitle = got.data.title.trim() || null; if (this.sessionTitle) this.emitTitle(this.sessionTitle); + // Settled prompts can be re-emitted on the stream; every existing user message counts as + // seen so the next turn's cut can only be its own prompt. + const messages = okOrThrow( + await this.client.session.messages({ + sessionID: got.data.id, + directory: got.data.directory, + }), + 'opencode: session.messages', + ); + const existing = messages.data ?? []; + for (let i = 0, len = existing.length; i < len; i++) { + const { info } = existing[i]; + if (info.role === 'user') this.seenUserMessageIds.add(info.id); + } // A resumed session continues under its recorded control state unless the caller overrode // it: the Session record tracks the last-used model/agent (live-verified on 1.18.2 — both // fields update after every turn), so the next turn resends what the session last ran with. @@ -485,6 +511,7 @@ export class OpenCodeAdapter extends BaseAgentAdapter { this.turnStarted = false; this.cancelling = false; this.turnFailed = false; + this.turnCheckpointMinted = false; this.emitStatus('running'); return this.turnEpoch; } @@ -669,11 +696,24 @@ export class OpenCodeAdapter extends BaseAgentAdapter { 'opencode: history branch cursor has no target prompt', ); const childId = await this.withHistoryClient(async (client) => { - const source = okOrThrow( - await client.session.get({ sessionID: opts.historyId }), - 'opencode: session.get', - ); - invariant(source.data, 'opencode: session.get returned no session'); + const source = await client.session.get({ sessionID: opts.historyId }); + if (source.error !== undefined || !source.data) { + throw new HistoryCheckpointInvalidError( + `opencode: session ${opts.historyId} is no longer readable on the server`, + ); + } + // The cut semantics on an unknown messageID are unverified: prove the message still exists + // before forking, or a vanished checkpoint could copy the whole session. + const target = await client.session.message({ + sessionID: opts.historyId, + messageID, + directory: source.data.directory, + }); + if (target.error !== undefined || !target.data) { + throw new HistoryCheckpointInvalidError( + `opencode: checkpoint ${messageID} is no longer in session ${opts.historyId}`, + ); + } const forked = okOrThrow( await client.session.fork({ sessionID: opts.historyId, @@ -964,6 +1004,19 @@ export class OpenCodeAdapter extends BaseAgentAdapter { const { info } = ev.properties; if (info.role === 'user') { this.userMessageIds.add(info.id); + // `session.fork {messageID}` cuts BEFORE the message, so a prompt's own id is the + // checkpoint of the turn that preceded it (a tip has none until its successor) — + // only when first seen inside the turn: an id seen earlier (an idle straggler, a + // compaction message) re-emitted now would cut inside an earlier turn. + if ( + this.turnActive && + !this.turnCheckpointMinted && + !this.seenUserMessageIds.has(info.id) + ) { + this.turnCheckpointMinted = true; + this.emitCheckpoint(asHistoryId(this.sessionId), info.id, 'preceding'); + } + this.seenUserMessageIds.add(info.id); this.reflectTurnModel(`${info.model.providerID}/${info.model.modelID}`); } else { this.reflectTurnModel(`${info.providerID}/${info.modelID}`); diff --git a/packages/host/agent-adapter/src/native/pi/adapter.ts b/packages/host/agent-adapter/src/native/pi/adapter.ts index 9438efe50..6a1ef5822 100644 --- a/packages/host/agent-adapter/src/native/pi/adapter.ts +++ b/packages/host/agent-adapter/src/native/pi/adapter.ts @@ -33,7 +33,7 @@ import { renderBrowserToolResult } from '../../adapter'; import { BaseAgentAdapter } from '../../base'; import type { AgentCredential } from '../../credential'; import { readAgentCredential } from '../../credential'; -import { decodeHistoryBranchCursor } from '../../history-branch'; +import { decodeHistoryBranchCursor, HistoryCheckpointInvalidError } from '../../history-branch'; import { asHistoryId } from '../../history-util'; import { agentRuntimeProber } from '../../probe'; import { @@ -244,6 +244,7 @@ export class PiAdapter extends BaseAgentAdapter { list: true, read: true, resume: true, + forkAfterTurn: true, branch: true, }; @@ -297,7 +298,9 @@ export class PiAdapter extends BaseAgentAdapter { const predecessor = decodeHistoryBranchCursor(opts.cursor, 'pi', opts.historyId); const pi = await this.importSdk(); const file = await findPiSessionFile(opts.historyId); - if (!file) throw new Error(`pi: history '${opts.historyId}' was not found`); + if (!file) { + throw new HistoryCheckpointInvalidError(`pi: history '${opts.historyId}' was not found`); + } const sourceManager = pi.SessionManager.open(file); if (predecessor === null) { this.pendingBranchManager = pi.SessionManager.create( @@ -307,7 +310,9 @@ export class PiAdapter extends BaseAgentAdapter { ); } else { if (!sourceManager.getEntry(predecessor)) { - throw new Error(`pi: history branch predecessor '${predecessor}' was not found`); + throw new HistoryCheckpointInvalidError( + `pi: checkpoint '${predecessor}' is no longer in history '${opts.historyId}'`, + ); } sourceManager.createBranchedSession(predecessor); this.pendingBranchManager = sourceManager; @@ -725,7 +730,13 @@ export class PiAdapter extends BaseAgentAdapter { if (this.finalOutcome.stopReason === 'aborted') this.emitStop('cancelled'); else if (this.finalOutcome.stopReason === 'error') { this.emitProviderError(this.finalOutcome.errorMessage ?? 'Pi agent failed'); - } else this.emitStop('end_turn'); + } else { + // The session file is a tree: the leaf after a settled turn is what `createBranchedSession` + // branches from, and the next user entry's `parentId`. + const leafId = this.session?.sessionManager.getLeafId(); + if (leafId && this.session) this.emitCheckpoint(asHistoryId(this.session.sessionId), leafId); + this.emitStop('end_turn'); + } this.emitStatus('idle'); } } diff --git a/packages/host/engine/src/__tests__/conversation-projection.test.ts b/packages/host/engine/src/__tests__/conversation-projection.test.ts index ff97fc751..1c1e3258b 100644 --- a/packages/host/engine/src/__tests__/conversation-projection.test.ts +++ b/packages/host/engine/src/__tests__/conversation-projection.test.ts @@ -16,6 +16,7 @@ import type { Transport } from '@linkcode/transport'; import { Cause, Effect, Exit } from 'effect'; import { noop } from 'foxts/noop'; import { describe, expect, it } from 'vitest'; +import { ConversationCheckpointService } from '../conversation/checkpoint-service'; import { InMemoryConversationStore } from '../conversation/conversation-store'; import type { JournaledEvent } from '../conversation/live-journal'; import { ConversationLiveJournals } from '../conversation/live-journal'; @@ -90,7 +91,7 @@ async function makeService(opts: { const service = new ConversationProjectionService( turns, records, - history, + new ConversationCheckpointService(turns, records, history), opts.journals, () => opts.openRequests ?? [], ); @@ -351,7 +352,7 @@ describe('conversation projection attribution gate', () => { }; } - function providerUser(itemId: string, command: string): AgentHistoryEvent { + function providerUser(itemId: string, command: string, cursor?: string): AgentHistoryEvent { return { historyId: asHistoryId('hist-1'), itemId, @@ -359,6 +360,7 @@ describe('conversation projection attribution gate', () => { type: 'user-message', messageId: itemId as MessageId, content: [{ type: 'text', text: `$ ${command}` }], + ...(cursor !== undefined && { branchCursor: cursor }), }, }; } @@ -491,6 +493,244 @@ describe('conversation projection attribution gate', () => { expect(placeholderTurnIds(result.events)).toEqual(['turn-a', 'turn-b']); }); + /** A session older than its turn rows: its recorded turns ran on a later run than the first. */ + function hiddenHistoryRecord(activeLeafTurnId: TurnId): SessionRecord { + const record = makeRecord(activeLeafTurnId, true); + return { + ...record, + runs: [ + { runId: 'run-0' as RunId, startedAt: 0, historyId: asHistoryId('hist-1') }, + ...record.runs, + ], + }; + } + + it('aligns the host turns to the corpus tail behind hidden pre-graph history', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: hiddenHistoryRecord('turn-b' as TurnId), + historyEvents: [ + providerUser('u-h', 'hidden'), + providerAnswer('ans-h', 'answer hidden'), + providerUser('u-a', 'a'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-b', 'b'), + providerAnswer('ans-b', 'answer b'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); + await store.saveTurn(shellTurn('turn-b', 'turn-a', 'b', 'completed')); + + const result = await Effect.runPromise(service.read({ sessionId })); + + // The hidden head renders unattributed (as a cold read would); every host turn verified. + expect(answers(result.events)).toEqual([ + ['ans-h', undefined], + ['ans-a', 'turn-a'], + ['ans-b', 'turn-b'], + ]); + expect(placeholderTurnIds(result.events)).toEqual([]); + }); + + it('attributes nothing behind hidden history when one suffix position mismatches', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: hiddenHistoryRecord('turn-b' as TurnId), + historyEvents: [ + providerUser('u-h', 'hidden'), + providerAnswer('ans-h', 'answer hidden'), + providerUser('u-x', 'x'), + providerAnswer('ans-x', 'answer x'), + providerUser('u-b', 'b'), + providerAnswer('ans-b', 'answer b'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); + await store.saveTurn(shellTurn('turn-b', 'turn-a', 'b', 'completed')); + + const result = await Effect.runPromise(service.read({ sessionId })); + + // Anchored at the end there is no verified prefix to keep: the alignment itself is unproven. + expect(answers(result.events)).toEqual([]); + expect(placeholderTurnIds(result.events)).toEqual(['turn-a', 'turn-b']); + }); + + it('aligns positionally from the end, so an identical earlier prompt cannot claim a host turn', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: hiddenHistoryRecord('turn-b' as TurnId), + historyEvents: [ + providerUser('u-a1', 'a'), + providerAnswer('ans-a1', 'answer a, the hidden one'), + providerUser('u-a2', 'a'), + providerAnswer('ans-a2', 'answer a'), + providerUser('u-b', 'b'), + providerAnswer('ans-b', 'answer b'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); + await store.saveTurn(shellTurn('turn-b', 'turn-a', 'b', 'completed')); + + const result = await Effect.runPromise(service.read({ sessionId })); + + expect(answers(result.events)).toEqual([ + ['ans-a1', undefined], + ['ans-a2', 'turn-a'], + ['ans-b', 'turn-b'], + ]); + }); + + it('attributes nothing behind hidden history when a later suffix position mismatches', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: hiddenHistoryRecord('turn-b' as TurnId), + historyEvents: [ + providerUser('u-h', 'hidden'), + providerAnswer('ans-h', 'answer hidden'), + providerUser('u-a', 'a'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-x', 'x'), + providerAnswer('ans-x', 'answer x'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); + await store.saveTurn(shellTurn('turn-b', 'turn-a', 'b', 'completed')); + + const result = await Effect.runPromise(service.read({ sessionId })); + + // Position 0 verifies, but end-anchored a verified prefix proves nothing about the offset. + expect(answers(result.events)).toEqual([]); + expect(placeholderTurnIds(result.events)).toEqual(['turn-a', 'turn-b']); + }); + + it('attributes nothing when the corpus grew past the host’s last turn and the prompts repeat', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: hiddenHistoryRecord('turn-b' as TurnId), + historyEvents: [ + providerUser('u-h', 'go', 'before-h'), + providerAnswer('ans-h', 'answer hidden'), + providerUser('u-a', 'go', 'before-a'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-b', 'go', 'before-b'), + providerAnswer('ans-b', 'answer b'), + providerUser('u-cli', 'go', 'before-cli'), + providerAnswer('ans-cli', 'answer from the CLI'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'go', 'completed')); + await store.saveTurn(shellTurn('turn-b', 'turn-a', 'go', 'completed')); + + const result = await Effect.runPromise(service.read({ sessionId })); + + // The tail [u-b, u-cli] verifies as well as the truth [u-a, u-b]: ambiguous, so nothing + // attributes and no replay binding — which no later capture would correct — is backfilled. + expect(answers(result.events)).toEqual([]); + expect(placeholderTurnIds(result.events)).toEqual(['turn-a', 'turn-b']); + expect(await store.listBindings('turn-a' as TurnId)).toEqual([]); + }); + + it('attributes nothing when the peeled live row could equally be the host’s last turn', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: hiddenHistoryRecord('turn-b' as TurnId), + historyEvents: [ + providerUser('u-h', 'go'), + providerAnswer('ans-h', 'answer hidden'), + providerUser('u-a', 'go'), + providerAnswer('ans-a', 'answer a'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'go', 'completed')); + await store.saveTurn(shellTurn('turn-b', 'turn-a', 'go', 'running')); + + const result = await Effect.runPromise(service.read({ sessionId })); + + // The in-flight prompt may not be persisted yet: `u-a` is either the live row (then `u-h` + // is turn-a's) or turn-a's own row behind hidden history — nothing attributes. + expect(answers(result.events)).toEqual([]); + expect(placeholderTurnIds(result.events)).toEqual(['turn-a']); + }); + + it('never aligns to the corpus tail once a failed turn sits on the lineage', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: hiddenHistoryRecord('turn-b' as TurnId), + historyEvents: [ + providerUser('u-h', 'go'), + providerAnswer('ans-h', 'answer hidden'), + providerUser('u-a', 'go'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-f', 'go'), + providerAnswer('ans-f', 'answer before the failure'), + providerUser('u-b', 'done'), + providerAnswer('ans-b', 'answer b'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'go', 'completed')); + await store.saveTurn(shellTurn('turn-f', 'turn-a', 'go', 'failed')); + await store.saveTurn(shellTurn('turn-b', 'turn-f', 'done', 'completed')); + + const result = await Effect.runPromise(service.read({ sessionId })); + + // The failed turn's prompt did reach the provider, so the tail [u-f, u-b] verifies against + // [turn-a, turn-b] and would hand turn-a the failed turn's answer. A failed turn leaves the + // row count unknowable, so end-anchoring is off for the lineage. + expect(answers(result.events)).toEqual([]); + expect(placeholderTurnIds(result.events)).toEqual(['turn-a', 'turn-b']); + }); + + it('still tolerates the in-flight turn’s own trailing row behind hidden history', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: hiddenHistoryRecord('turn-c' as TurnId), + historyEvents: [ + providerUser('u-h', 'hidden'), + providerAnswer('ans-h', 'answer hidden'), + providerUser('u-a', 'a'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-b', 'b'), + providerAnswer('ans-b', 'answer b'), + providerUser('u-c', 'c'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); + await store.saveTurn(shellTurn('turn-b', 'turn-a', 'b', 'completed')); + await store.saveTurn(shellTurn('turn-c', 'turn-b', 'c', 'running')); + + const result = await Effect.runPromise(service.read({ sessionId })); + + expect(answers(result.events)).toEqual([ + ['ans-h', undefined], + ['ans-a', 'turn-a'], + ['ans-b', 'turn-b'], + ]); + expect(placeholderTurnIds(result.events)).toEqual([]); + }); + + it('never assumes hidden history on a created session’s first run: extra rows attribute nothing', async () => { + const { service, store } = await makeService({ + journals: new ConversationLiveJournals(), + record: makeRecord('turn-b' as TurnId, true), + historyEvents: [ + providerUser('u-h', 'hidden'), + providerAnswer('ans-h', 'answer hidden'), + providerUser('u-a', 'a'), + providerAnswer('ans-a', 'answer a'), + providerUser('u-b', 'b'), + providerAnswer('ans-b', 'answer b'), + ], + }); + await store.saveTurn(shellTurn('turn-a', null, 'a', 'completed')); + await store.saveTurn(shellTurn('turn-b', 'turn-a', 'b', 'completed')); + + const result = await Effect.runPromise(service.read({ sessionId })); + + // Nothing can precede a root on the first run, so more rows than turns is a count anomaly. + expect(answers(result.events)).toEqual([]); + expect(placeholderTurnIds(result.events)).toEqual(['turn-a', 'turn-b']); + }); + it('attributes the matching prefix and degrades from the first mismatch onward', async () => { const { service, store } = await makeService({ journals: new ConversationLiveJournals(), diff --git a/packages/host/engine/src/__tests__/conversation-store.test.ts b/packages/host/engine/src/__tests__/conversation-store.test.ts index d6d7ca5ed..7198cdae6 100644 --- a/packages/host/engine/src/__tests__/conversation-store.test.ts +++ b/packages/host/engine/src/__tests__/conversation-store.test.ts @@ -89,22 +89,26 @@ describe('InMemoryConversationStore', () => { expect(await store.listTurns(SessionIdSchema.parse('s-1'))).toEqual([running]); }); - it('upserts bindings by (turnId, historyId)', async () => { + it('re-captures replay bindings, lets a live one replace them, and never overwrites a live one', async () => { const store = new InMemoryConversationStore(); - const binding = ProviderTurnBindingSchema.parse({ + const live = ProviderTurnBindingSchema.parse({ turnId: 't-1', runId: 'run-1', historyId: 'native-1', checkpoint: '{"uuid":"a"}', capturedFrom: 'live', }); - await store.saveBinding(binding); - const recaptured = { ...binding, checkpoint: '{"uuid":"b"}', capturedFrom: 'replay' as const }; + // A cold read can land first; the live capture that follows replaces it. + await store.saveBinding({ ...live, checkpoint: '{"uuid":"early"}', capturedFrom: 'replay' }); + await store.saveBinding(live); + await store.saveBinding({ ...live, checkpoint: '{"uuid":"b"}', capturedFrom: 'replay' }); + await store.saveBinding({ ...live, checkpoint: '{"uuid":"c"}' }); + const replay = { ...live, historyId: 'native-2', capturedFrom: 'replay' as const }; + await store.saveBinding(replay); + const recaptured = { ...replay, checkpoint: '{"uuid":"d"}' }; await store.saveBinding(recaptured); - const other = { ...binding, historyId: 'native-2' }; - await store.saveBinding(other); - expect(await store.listBindings(TurnIdSchema.parse('t-1'))).toEqual([recaptured, other]); + expect(await store.listBindings(TurnIdSchema.parse('t-1'))).toEqual([live, recaptured]); }); it('deleteSession keeps prompts still referenced by another session and drops the rest', async () => { diff --git a/packages/host/engine/src/__tests__/engine-conversation-read.test.ts b/packages/host/engine/src/__tests__/engine-conversation-read.test.ts index f636c2297..b7a5aabbd 100644 --- a/packages/host/engine/src/__tests__/engine-conversation-read.test.ts +++ b/packages/host/engine/src/__tests__/engine-conversation-read.test.ts @@ -17,6 +17,7 @@ import { } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { describe, expect, it } from 'vitest'; +import { InMemoryConversationStore } from '../conversation/conversation-store'; import { FakeAdapter, createSessionHarness as harness, @@ -63,6 +64,12 @@ function userRow(itemId: string, text: string): AgentHistoryEvent { }); } +/** A provider user row carrying the adapter-opaque cursor that forks right before it. */ +function cursorRow(itemId: string, text: string, branchCursor: string): AgentHistoryEvent { + const row = userRow(itemId, text); + return { ...row, event: { ...row.event, branchCursor } as AgentEvent }; +} + function assistantRow(itemId: string, text: string): AgentHistoryEvent { return historyEvent(itemId, { type: 'agent-message', @@ -236,6 +243,139 @@ describe('conversation.read', () => { expect(shared.lastReadOpts?.cwd).toBe('/repo'); }); + it('backfills replay bindings for attributed turns without overwriting a live capture', async () => { + const shared: SharedHistory = { events: [], failRead: false }; + const conversationStore = new InMemoryConversationStore(); + const h = harness( + undefined, + () => new HistoryFakeAdapter(shared), + undefined, + undefined, + undefined, + undefined, + { conversationStore }, + ); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + const adapter = nullthrow(h.adapters[0]); + adapter.emit({ type: 'session-ref', historyId: HISTORY_ID }); + const texts = ['first', 'second', 'third']; + for (let i = 0, len = texts.length; i < len; i++) { + const text = texts[i]; + // eslint-disable-next-line no-await-in-loop -- turns are sequential by construction + await h.inject({ + kind: 'turn.submit', + clientReqId: `s-${text}`, + sessionId, + operationId: OperationIdSchema.parse(`op-${text}`), + input: { type: 'prompt', blocks: [{ type: 'text', text }] }, + }); + if (text === 'first') { + adapter.emitCheckpoint({ + historyId: HISTORY_ID, + cursor: 'live-after-first', + turn: 'ending', + }); + } + adapter.emit({ type: 'stop', stopReason: 'end_turn' }); + adapter.emit({ type: 'status', status: 'idle' }); + // eslint-disable-next-line no-await-in-loop -- settle the store hops before the next turn + await settleEngineTasks(); + } + const [first, second, third] = await conversationStore.listTurns(sessionId); + shared.events = [ + cursorRow('u1', 'first', 'before-first'), + cursorRow('u2', 'second', 'before-second'), + cursorRow('u3', 'third', 'before-third'), + ]; + await h.inject({ kind: 'session.stop', clientReqId: 'stop', sessionId }); + + await h.inject({ kind: 'conversation.read', clientReqId: 'rr', sessionId }); + await settleEngineTasks(); + + expect(readResult(h.sent, 'rr').events).not.toContainEqual( + expect.objectContaining({ type: 'history-unavailable' }), + ); + expect(await conversationStore.listBindings(first.turnId)).toEqual([ + expect.objectContaining({ checkpoint: 'live-after-first', capturedFrom: 'live' }), + ]); + expect(await conversationStore.listBindings(second.turnId)).toEqual([ + { + turnId: second.turnId, + runId: second.runId, + historyId: HISTORY_ID, + checkpoint: 'before-third', + capturedFrom: 'replay', + }, + ]); + // The lineage tip has no successor row, hence no replay cut (fork stays unavailable). + expect(await conversationStore.listBindings(third.turnId)).toEqual([]); + }); + + it('backfills nothing past a fingerprint mismatch, and never the live row across the gap', async () => { + const shared: SharedHistory = { events: [], failRead: false }; + const conversationStore = new InMemoryConversationStore(); + const h = harness( + undefined, + () => new HistoryFakeAdapter(shared), + undefined, + undefined, + undefined, + undefined, + { conversationStore }, + ); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'r1', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + const adapter = nullthrow(h.adapters[0]); + adapter.emit({ type: 'session-ref', historyId: HISTORY_ID }); + const texts = ['first', 'second']; + for (let i = 0, len = texts.length; i < len; i++) { + const text = texts[i]; + // eslint-disable-next-line no-await-in-loop -- turns are sequential by construction + await h.inject({ + kind: 'turn.submit', + clientReqId: `s-${text}`, + sessionId, + operationId: OperationIdSchema.parse(`op-${text}`), + input: { type: 'prompt', blocks: [{ type: 'text', text }] }, + }); + adapter.emit({ type: 'stop', stopReason: 'end_turn' }); + adapter.emit({ type: 'status', status: 'idle' }); + // eslint-disable-next-line no-await-in-loop -- settle the store hops before the next turn + await settleEngineTasks(); + } + await h.inject({ + kind: 'turn.submit', + clientReqId: 's-third', + sessionId, + operationId: OperationIdSchema.parse('op-third'), + input: { type: 'prompt', blocks: [{ type: 'text', text: 'third' }] }, + }); + const [first, second] = await conversationStore.listTurns(sessionId); + // Position 2 mismatches the host prompt; position 3 is the in-flight turn's own row. + shared.events = [ + cursorRow('u1', 'first', 'before-first'), + cursorRow('u2', 'not the second prompt', 'before-mismatch'), + cursorRow('u3', 'third', 'before-third'), + ]; + + await h.inject({ kind: 'conversation.read', clientReqId: 'rr', sessionId }); + await settleEngineTasks(); + + expect(await conversationStore.listBindings(first.turnId)).toEqual([]); + expect(await conversationStore.listBindings(second.turnId)).toEqual([]); + }); + it('refreshes a stale corpus captured before the newest settle', async () => { const shared: SharedHistory = { events: [], failRead: false }; const h = await startedHarness(() => new HistoryFakeAdapter(shared)); diff --git a/packages/host/engine/src/__tests__/engine-session-records.test.ts b/packages/host/engine/src/__tests__/engine-session-records.test.ts index 8998c30d1..250418a0b 100644 --- a/packages/host/engine/src/__tests__/engine-session-records.test.ts +++ b/packages/host/engine/src/__tests__/engine-session-records.test.ts @@ -14,6 +14,7 @@ import { MessageIdSchema, textBlock } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { describe, expect, it, vi } from 'vitest'; import { InMemoryProviderConfigStore } from '../agent/provider-config'; +import { InMemoryConversationStore } from '../conversation/conversation-store'; import type { SessionStore } from '../session/session-store'; import { InMemorySessionStore } from '../session/session-store'; import { InMemoryWorkspaceStore } from '../workspace/workspace-store'; @@ -54,6 +55,7 @@ class BranchingHistoryAdapter extends FakeAdapter { list: false, read: true, resume: true, + forkAfterTurn: true, branch: true, }; branchedFrom: AgentHistoryBranchOptions | null = null; @@ -424,11 +426,12 @@ describe('engine session records', () => { await vi.waitFor(() => expect(startedId(h.sent, 'rewrite-running')).toBe(sourceSessionId)); expect(sourceAdapter.stopped).toBe(true); - const replacementAdapter = h.adapters[2] as BranchingHistoryAdapter; - expect(replacementAdapter.branchedFrom).toEqual({ - historyId: 'native-source', - cursor: 'opaque-original-cursor', - }); + // The first prompt on a created session's first run has nothing before it: its rewrite starts + // a fresh provider session instead of forking at a guessed cut. + const replacementAdapter = nullthrow(h.adapters.at(-1)) as BranchingHistoryAdapter; + expect(replacementAdapter.branchedFrom).toBeNull(); + expect(replacementAdapter.resumedFrom).toBeNull(); + expect(replacementAdapter.startedWith).not.toBeNull(); expect(replacementAdapter.sentInputs).toEqual([ { type: 'prompt', content: [textBlock('edited prompt')] }, ]); @@ -458,7 +461,16 @@ describe('engine session records', () => { it('rewrites an earlier live prompt from its original provider history', async () => { const store = new InMemorySessionStore(); - const h = harness(store, () => new BranchingHistoryAdapter()); + const conversationStore = new InMemoryConversationStore(); + const h = harness( + store, + () => new BranchingHistoryAdapter(), + undefined, + undefined, + undefined, + undefined, + { conversationStore }, + ); await h.engine.start(); await h.inject({ kind: 'session.start', @@ -514,6 +526,23 @@ describe('engine session records', () => { }); await vi.waitFor(() => expect(startedId(h.sent, 'rewrite-later')).toBe(sourceSessionId)); + // No live checkpoint was captured, so the cut before the later prompt is replayed from the + // provider's own row — and persisted as the original turn's replay binding. + const forked = h.adapters.filter( + (adapter) => (adapter as BranchingHistoryAdapter).branchedFrom !== null, + ) as BranchingHistoryAdapter[]; + expect(forked.map((adapter) => adapter.branchedFrom)).toEqual([ + { historyId: 'native-source', cursor: 'opaque-later-cursor' }, + ]); + const [originalTurn] = await conversationStore.listTurns(sourceSessionId); + expect(await conversationStore.listBindings(originalTurn.turnId)).toEqual([ + expect.objectContaining({ + historyId: 'native-source', + checkpoint: 'opaque-later-cursor', + capturedFrom: 'replay', + }), + ]); + await h.inject({ kind: 'history.branch', clientReqId: 'rewrite-original', @@ -524,14 +553,91 @@ describe('engine session records', () => { }); await vi.waitFor(() => expect(startedId(h.sent, 'rewrite-original')).toBe(sourceSessionId)); - expect((h.adapters[4] as BranchingHistoryAdapter).branchedFrom).toEqual({ - historyId: 'native-source', - cursor: 'opaque-original-cursor', - }); + // The original prompt is the first on the created session's first run: its rewrite starts + // fresh, no fork. + const last = nullthrow(h.adapters.at(-1)) as BranchingHistoryAdapter; + expect(last.branchedFrom).toBeNull(); + expect(last.startedWith).not.toBeNull(); + expect(last.sentInputs).toEqual([ + { type: 'prompt', content: [textBlock('edited original prompt')] }, + ]); const [record] = await store.load(); expect(record.runs).toHaveLength(3); }); + it('records a live-cursor rewrite under the edited turn’s parent and forks at that parent’s checkpoint', async () => { + const store = new InMemorySessionStore(); + const conversationStore = new InMemoryConversationStore(); + const h = harness( + store, + () => new BranchingHistoryAdapter(), + undefined, + undefined, + undefined, + undefined, + { conversationStore }, + ); + await h.engine.start(); + await h.inject({ + kind: 'session.start', + clientReqId: 'start-source', + opts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sourceSessionId = startedId(h.sent, 'start-source'); + h.adapters[0].emit({ type: 'session-ref', historyId: asHistoryId('native-source') }); + const texts = ['one', 'two', 'three']; + for (let i = 0, len = texts.length; i < len; i++) { + const text = texts[i]; + // eslint-disable-next-line no-await-in-loop -- turns are sequential by construction + await h.inject({ + kind: 'agent.input', + clientReqId: `prompt-${text}`, + sessionId: sourceSessionId, + input: { type: 'prompt', content: [textBlock(text)] }, + }); + h.adapters[0].emitCheckpoint({ + historyId: asHistoryId('native-source'), + cursor: `after-${text}`, + turn: 'ending', + }); + h.adapters[0].emit({ type: 'status', status: 'idle' }); + // eslint-disable-next-line no-await-in-loop -- settle the store hops before the next turn + await tick(); + } + const [one, two] = await conversationStore.listTurns(sourceSessionId); + const secondPrompt = agentEvents(h.sent, sourceSessionId).findLast( + (event) => + event.type === 'user-message' && + event.branchCursor !== undefined && + event.content[0]?.type === 'text' && + event.content[0].text === 'two', + ); + if (secondPrompt?.type !== 'user-message' || secondPrompt.branchCursor === undefined) { + throw new Error('live prompt has no branch cursor'); + } + + await h.inject({ + kind: 'history.branch', + clientReqId: 'rewrite-two', + sourceSessionId, + sourceMessageId: secondPrompt.messageId, + branchCursor: secondPrompt.branchCursor, + content: [textBlock('two, edited')], + }); + await vi.waitFor(() => expect(startedId(h.sent, 'rewrite-two')).toBe(sourceSessionId)); + + const forked = nullthrow( + h.adapters.find((adapter) => (adapter as BranchingHistoryAdapter).branchedFrom !== null), + ) as BranchingHistoryAdapter; + expect(forked.branchedFrom).toEqual({ historyId: 'native-source', cursor: 'after-one' }); + const turns = await conversationStore.listTurns(sourceSessionId); + const replacement = nullthrow(turns.find((turn) => turn.siblingOrdinal === 2)); + // A sibling of the edited turn — not of the active leaf, which was its child. + expect(replacement.parentTurnId).toBe(one.turnId); + expect(two.parentTurnId).toBe(one.turnId); + expect((await store.load())[0].activeLeafTurnId).toBe(replacement.turnId); + }); + it('does not rewind or start a replacement when stopping the running turn fails', async () => { const store = new InMemorySessionStore(); const h = harness(store, () => new RejectingStopBranchAdapter()); diff --git a/packages/host/engine/src/__tests__/engine-turn-submit.test.ts b/packages/host/engine/src/__tests__/engine-turn-submit.test.ts index 6b0762cf1..100e2bf82 100644 --- a/packages/host/engine/src/__tests__/engine-turn-submit.test.ts +++ b/packages/host/engine/src/__tests__/engine-turn-submit.test.ts @@ -1,6 +1,18 @@ import { setImmediate as nextLoopTurn } from 'node:timers/promises'; -import { asHistoryId } from '@linkcode/agent-adapter'; -import type { AgentHistoryResumeOptions, AgentInput, TurnId, WirePayload } from '@linkcode/schema'; +import { asHistoryId, HistoryCheckpointInvalidError } from '@linkcode/agent-adapter'; +import type { + AgentHistoryBranchOptions, + AgentHistoryCapabilities, + AgentHistoryReadOptions, + AgentHistoryReadResult, + AgentHistoryResumeOptions, + AgentInput, + MessageId, + SessionId, + StartOptions, + TurnId, + WirePayload, +} from '@linkcode/schema'; import { AttachmentIdSchema, OperationIdSchema, @@ -72,6 +84,122 @@ class SilentHangingSendAdapter extends FakeAdapter { } } +class ForkingAdapter extends FakeAdapter { + override readonly historyCapabilities: AgentHistoryCapabilities = { + list: false, + read: true, + resume: true, + forkAfterTurn: true, + branch: true, + }; + branchedFrom: AgentHistoryBranchOptions | null = null; + failFork: Error | undefined; + + branchHistory(opts: AgentHistoryBranchOptions, startOpts: StartOptions): Promise { + if (this.failFork) return Promise.reject(this.failFork); + this.branchedFrom = opts; + this.startedWith = startOpts; + this.emit({ type: 'session-ref', historyId: asHistoryId('native-child') }); + return Promise.resolve(); + } +} + +/** send() spans the whole turn (pi/grok): `running`, then a gate, then the turn's own checkpoint, + * stop, and idle — all before it resolves. */ +class GatedWholeTurnAdapter extends ForkingAdapter { + release: () => void = noop; + + override async send(input: AgentInput): Promise { + this.sentInputs.push(input); + if (input.type !== 'prompt') return; + this.emit({ type: 'status', status: 'running' }); + await new Promise((resolve) => { + this.release = resolve; + }); + const text = input.content[0]?.type === 'text' ? input.content[0].text : 'prompt'; + this.emitCheckpoint({ + historyId: asHistoryId('native-1'), + cursor: `after-${text}`, + turn: 'ending', + }); + this.emit({ type: 'stop', stopReason: 'end_turn' }); + this.emit({ type: 'status', status: 'idle' }); + } +} + +/** The real adapters' rejection shape: `running` at dispatch, a bare `idle` on the provider's + * refusal, then the send rejects (opencode promptAsync error, claude createQuery throw, pi). */ +class RunIdleRejectAdapter extends FakeAdapter { + override send(input: AgentInput): Promise { + this.sentInputs.push(input); + this.emit({ type: 'status', status: 'running' }); + this.emit({ type: 'status', status: 'idle' }); + return Promise.reject(new Error('provider refused the prompt')); + } +} + +/** A forking adapter whose provider refuses cuts on the histories in `dead` — a deleted transcript. */ +class DeadHistoryForkingAdapter extends ForkingAdapter { + constructor(private readonly dead: ReadonlySet) { + super(); + } + + override branchHistory(opts: AgentHistoryBranchOptions, startOpts: StartOptions): Promise { + if (this.dead.has(opts.historyId)) { + return Promise.reject( + new HistoryCheckpointInvalidError(`claude-code: transcript ${opts.historyId} is gone`), + ); + } + return super.branchHistory(opts, startOpts); + } +} + +class LegacyBranchOnlyAdapter extends ForkingAdapter { + override readonly historyCapabilities: AgentHistoryCapabilities = { + list: false, + read: true, + resume: true, + forkAfterTurn: false, + branch: true, + }; +} + +type HistoryRow = { text: string; cursor?: string }; + +/** Cold reads return the lineage's own prompts (one row set, or one per history id); a row + * without a cursor models a rollout the provider cannot fork (codex `history_mode: paginated`). */ +class AlignedHistoryAdapter extends ForkingAdapter { + constructor(private readonly rows: HistoryRow[] | Record) { + super(); + } + + override readHistory(opts: AgentHistoryReadOptions): Promise { + const rows = Array.isArray(this.rows) ? this.rows : (this.rows[opts.historyId] ?? []); + return Promise.resolve({ + session: { historyId: opts.historyId, kind: this.kind, cwd: '/repo' }, + events: rows.map((row, index) => ({ + historyId: opts.historyId, + itemId: `u${index}`, + event: { + type: 'user-message' as const, + messageId: `u${index}` as MessageId, + content: [{ type: 'text' as const, text: row.text }], + ...(row.cursor !== undefined && { branchCursor: row.cursor }), + }, + })), + }); + } +} + +function forkedAdapter(adapters: FakeAdapter[]): ForkingAdapter { + return nullthrow( + adapters.find( + (adapter): adapter is ForkingAdapter => + adapter instanceof ForkingAdapter && adapter.branchedFrom !== null, + ), + ); +} + /** First start is a normal adapter; the first relaunch is `make()`; later ones are normal. */ function secondAdapter(make: () => FakeAdapter): () => FakeAdapter { let index = 0; @@ -97,6 +225,49 @@ function failure(sent: WirePayload[], replyTo: string) { return reply; } +/** The live echo of prompt `text`, as a ≤v79 client would hand it back to `history.branch`. */ +function livePrompt(sent: WirePayload[], sessionId: SessionId, text: string) { + const event = sent + .flatMap((payload) => + payload.kind === 'agent.event' && payload.sessionId === sessionId ? [payload.event] : [], + ) + .findLast( + (candidate) => + candidate.type === 'user-message' && + candidate.branchCursor !== undefined && + candidate.content[0]?.type === 'text' && + candidate.content[0].text === text, + ); + if (event?.type !== 'user-message' || event.branchCursor === undefined) { + throw new Error(`no live prompt echo for ${text}`); + } + return { sourceMessageId: event.messageId, branchCursor: event.branchCursor }; +} + +/** Every fork the harness performed, in order. */ +function forks(adapters: FakeAdapter[]): AgentHistoryBranchOptions[] { + return adapters.flatMap((adapter) => + adapter instanceof ForkingAdapter && adapter.branchedFrom !== null + ? [adapter.branchedFrom] + : [], + ); +} + +/** A session older than its turn rows: provider history exists, no turn was ever recorded. */ +async function preExistingSession(rows: HistoryRow[] | Record) { + const h = await startedHarness(() => new AlignedHistoryAdapter(rows)); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + await h.inject({ kind: 'session.stop', clientReqId: 'stop-0', sessionId: h.sessionId }); + // The first post-upgrade prompt: a graph root on the resume run, hidden history behind it. + await submitPrompt(h, 's1', 'first'); + await vi.waitFor(() => submittedTurnId(h.sent, 's1')); + const resumed = nullthrow(h.adapters[1]); + resumed.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + resumed.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + return { ...h, firstTurnId: submittedTurnId(h.sent, 's1') }; +} + async function startedHarness(makeAdapter: () => FakeAdapter = () => new FakeAdapter()) { const conversationStore = new InMemoryConversationStore(); const h = harness( @@ -134,6 +305,21 @@ function submitPrompt( }); } +/** Two settled turns on `native-1`, each with a live `ending` checkpoint. */ +async function twoCheckpointedTurns(h: Awaited>) { + await submitPrompt(h, 's1', 'first'); + const firstTurnId = submittedTurnId(h.sent, 's1'); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emitCheckpoint({ historyId: asHistoryId('native-1'), cursor: 'cp-1', turn: 'ending' }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + await submitPrompt(h, 's2', 'second'); + h.adapter.emitCheckpoint({ historyId: asHistoryId('native-1'), cursor: 'cp-2', turn: 'ending' }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + return firstTurnId; +} + describe('turn.submit saga', () => { it('submits a plain send onto a live session and commits the turn', async () => { const h = await startedHarness(); @@ -280,6 +466,428 @@ describe('turn.submit saga', () => { expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(2); }); + it('forks after the parent turn’s live checkpoint into a new run and records the sibling', async () => { + const h = await startedHarness(() => new ForkingAdapter()); + const firstTurnId = await twoCheckpointedTurns(h); + + await submitPrompt(h, 's3', 'edited second', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + await vi.waitFor(() => submittedTurnId(h.sent, 's3')); + + const forked = forkedAdapter(h.adapters); + expect(forked.branchedFrom).toEqual({ historyId: 'native-1', cursor: 'cp-1' }); + expect(forked.sentInputs).toEqual([ + { type: 'prompt', content: [{ type: 'text', text: 'edited second' }] }, + ]); + expect(h.adapter.stopped).toBe(true); + const forkedTurnId = submittedTurnId(h.sent, 's3'); + const turns = await h.conversationStore.listTurns(h.sessionId); + const forkedTurn = nullthrow(turns.find((turn) => turn.turnId === forkedTurnId)); + expect(forkedTurn).toMatchObject({ + parentTurnId: firstTurnId, + siblingOrdinal: 2, + state: 'running', + }); + const [record] = await h.store.load(); + expect(record.runs.at(-1)).toMatchObject({ + runId: forkedTurn.runId, + baseTurnId: firstTurnId, + historyId: 'native-child', + }); + expect(record.activeLeafTurnId).toBe(forkedTurnId); + }); + + it('refuses typed at fork time when the checkpoint is no longer valid, leaving a failed sibling', async () => { + const h = await startedHarness(() => { + const adapter = new ForkingAdapter(); + adapter.failFork = new HistoryCheckpointInvalidError( + 'claude-code: checkpoint row-b is no longer in transcript native-1', + ); + return adapter; + }); + const firstTurnId = await twoCheckpointedTurns(h); + + await submitPrompt(h, 's3', 'edited second', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + await vi.waitFor(() => failure(h.sent, 's3')); + + expect(failure(h.sent, 's3')).toMatchObject({ + code: 'unsupported', + message: 'The provider no longer honours this fork checkpoint', + }); + const turns = await h.conversationStore.listTurns(h.sessionId); + expect(turns).toHaveLength(3); + expect( + turns.find((turn) => turn.siblingOrdinal === 2 && turn.parentTurnId === firstTurnId), + ).toMatchObject({ state: 'failed' }); + expect(h.adapters.every((adapter) => (adapter as ForkingAdapter).branchedFrom === null)).toBe( + true, + ); + }); + + it('falls back to the parent’s binding on the current history when its own run’s history is dead', async () => { + const dead = new Set(); + const h = await startedHarness(() => new DeadHistoryForkingAdapter(dead)); + const firstTurnId = await twoCheckpointedTurns(h); + await submitPrompt(h, 's3', 'edited second', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + await vi.waitFor(() => submittedTurnId(h.sent, 's3')); + forkedAdapter(h.adapters).emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + // The child history carries the parent's copy; a cold read of it backfilled the parent there. + const turns = await h.conversationStore.listTurns(h.sessionId); + const { runId } = nullthrow(turns.find((turn) => turn.turnId === firstTurnId)); + await h.conversationStore.saveBinding({ + turnId: firstTurnId, + runId, + historyId: 'native-child', + checkpoint: 'child-cp-1', + capturedFrom: 'replay', + }); + dead.add('native-1'); + + await submitPrompt(h, 's4', 'edited again', { + parentTurnId: firstTurnId, + expectedGraphRevision: 3, + }); + await vi.waitFor(() => submittedTurnId(h.sent, 's4')); + + // The live capture on the parent's own history is tried first; its refusal moves the fork to + // the same turn's cut on the history the session actually runs on. + expect(forks(h.adapters)).toEqual([ + { historyId: 'native-1', cursor: 'cp-1' }, + { historyId: 'native-child', cursor: 'child-cp-1' }, + ]); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(4); + }); + + it('picks the parent’s binding on the current history, not the first stored one, when its own run captured none', async () => { + const h = await startedHarness(() => new ForkingAdapter()); + await submitPrompt(h, 's1', 'first'); + const firstTurnId = submittedTurnId(h.sent, 's1'); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + await submitPrompt(h, 's2', 'new root', { parentTurnId: null, expectedGraphRevision: 1 }); + await vi.waitFor(() => submittedTurnId(h.sent, 's2')); + const fresh = nullthrow(h.adapters[1]); + fresh.emit({ type: 'session-ref', historyId: asHistoryId('native-2') }); + fresh.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + const turns = await h.conversationStore.listTurns(h.sessionId); + const { runId } = nullthrow(turns.find((turn) => turn.turnId === firstTurnId)); + const replay = { turnId: firstTurnId, runId, capturedFrom: 'replay' as const }; + await h.conversationStore.saveBinding({ + ...replay, + historyId: 'native-stale', + checkpoint: 'stale-cp', + }); + await h.conversationStore.saveBinding({ + ...replay, + historyId: 'native-2', + checkpoint: 'current-cp', + }); + + await submitPrompt(h, 's3', 'continue the old version', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + await vi.waitFor(() => submittedTurnId(h.sent, 's3')); + + expect(forks(h.adapters)).toEqual([{ historyId: 'native-2', cursor: 'current-cp' }]); + }); + + it('refuses a fork on a harness without forkAfterTurn even when a checkpoint exists', async () => { + // The opencode shape: the legacy `branch` path stays advertised, turn-level forks are dark. + const h = await startedHarness(() => new LegacyBranchOnlyAdapter()); + const firstTurnId = await twoCheckpointedTurns(h); + expect(await h.conversationStore.listBindings(firstTurnId)).toHaveLength(1); + + await submitPrompt(h, 's3', 'edited second', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + + expect(failure(h.sent, 's3')).toMatchObject({ + code: 'unsupported', + message: 'claude-code: forking from an earlier turn is not supported', + }); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(2); + }); + + it('continues an inactive tip by resuming the history its own run wrote to', async () => { + const h = await startedHarness(); + await submitPrompt(h, 's1', 'first'); + const firstTurnId = submittedTurnId(h.sent, 's1'); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + await submitPrompt(h, 's2', 'new root', { parentTurnId: null, expectedGraphRevision: 1 }); + await vi.waitFor(() => submittedTurnId(h.sent, 's2')); + const fresh = nullthrow(h.adapters[1]); + fresh.emit({ type: 'session-ref', historyId: asHistoryId('native-2') }); + fresh.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + + await submitPrompt(h, 's3', 'continue the old version', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + await vi.waitFor(() => submittedTurnId(h.sent, 's3')); + + const resumed = nullthrow(h.adapters.find((adapter) => adapter.resumedFrom === 'native-1')); + expect(resumed.sentInputs).toEqual([ + { type: 'prompt', content: [{ type: 'text', text: 'continue the old version' }] }, + ]); + const turns = await h.conversationStore.listTurns(h.sessionId); + expect(turns.find((turn) => turn.turnId === submittedTurnId(h.sent, 's3'))).toMatchObject({ + parentTurnId: firstTurnId, + siblingOrdinal: 1, + state: 'running', + }); + }); + + it('forks — never resumes — an inactive tip that has a live checkpoint', async () => { + const h = await startedHarness(() => new ForkingAdapter()); + await submitPrompt(h, 's1', 'first'); + const firstTurnId = submittedTurnId(h.sent, 's1'); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emitCheckpoint({ + historyId: asHistoryId('native-1'), + cursor: 'cp-1', + turn: 'ending', + }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + await submitPrompt(h, 's2', 'new root', { parentTurnId: null, expectedGraphRevision: 1 }); + await vi.waitFor(() => submittedTurnId(h.sent, 's2')); + const fresh = nullthrow(h.adapters[1]); + fresh.emit({ type: 'session-ref', historyId: asHistoryId('native-2') }); + fresh.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + + await submitPrompt(h, 's3', 'continue the old version', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + await vi.waitFor(() => submittedTurnId(h.sent, 's3')); + + // The tip's history may have grown outside LinkCode since, so a tip with a checkpoint goes + // through the checkpoint, not through a resume of "its" history. + expect(forkedAdapter(h.adapters).branchedFrom).toEqual({ + historyId: 'native-1', + cursor: 'cp-1', + }); + expect(h.adapters.some((adapter) => adapter.resumedFrom === 'native-1')).toBe(false); + }); + + it('a send rejected after `running` and `idle` fails the turn — never a phantom success', async () => { + const h = await startedHarness(() => new RunIdleRejectAdapter()); + + await submitPrompt(h, 's1', 'doomed'); + + expect(failure(h.sent, 's1').code).toBe('operation_failed'); + const operation = await h.conversationStore.getOperation(OperationIdSchema.parse('op-s1')); + expect(operation?.state).toBe('failed'); + expect((await h.conversationStore.listTurns(h.sessionId))[0].state).toBe('failed'); + expect(h.sent.filter((p) => p.kind === 'conversation.graph.changed')).toHaveLength(0); + const [record] = await h.store.load(); + expect(record.activeLeafTurnId).toBeUndefined(); + + // The same through the legacy input path. + await h.inject({ + kind: 'agent.input', + clientReqId: 'legacy', + sessionId: h.sessionId, + input: { type: 'prompt', content: [{ type: 'text', text: 'doomed again' }] }, + }); + expect(failure(h.sent, 'legacy').code).toBe('operation_failed'); + expect((await h.conversationStore.listTurns(h.sessionId)).map((turn) => turn.state)).toEqual([ + 'failed', + 'failed', + ]); + expect(await h.conversationStore.listOpenOperations(h.sessionId)).toHaveLength(0); + expect(h.sent.filter((p) => p.kind === 'conversation.graph.changed')).toHaveLength(0); + }); + + it('starts a new root fresh when the created session’s earlier run never wrote provider history', async () => { + const h = await startedHarness(() => new ForkingAdapter()); + // Run 1 dies before its first prompt, so the graph root lands on run 2. + await h.inject({ kind: 'session.stop', clientReqId: 'stop-0', sessionId: h.sessionId }); + await submitPrompt(h, 's1', 'first'); + await vi.waitFor(() => submittedTurnId(h.sent, 's1')); + const second = nullthrow(h.adapters[1]); + second.emit({ type: 'session-ref', historyId: asHistoryId('native-2') }); + second.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + + await submitPrompt(h, 's2', 'new root', { parentTurnId: null, expectedGraphRevision: 1 }); + await vi.waitFor(() => submittedTurnId(h.sent, 's2')); + + // No earlier run left provider rows behind the root, so there is no hidden history to fork after. + const fresh = nullthrow(h.adapters[2]) as ForkingAdapter; + expect(fresh.startedWith).not.toBeNull(); + expect(fresh.branchedFrom).toBeNull(); + expect(fresh.resumedFrom).toBeNull(); + expect(fresh.sentInputs).toEqual([ + { type: 'prompt', content: [{ type: 'text', text: 'new root' }] }, + ]); + }); + + it('refuses to resume a checkpoint-less inactive tip on a forking harness', async () => { + const h = await startedHarness(() => new ForkingAdapter()); + await submitPrompt(h, 's1', 'first'); + const firstTurnId = submittedTurnId(h.sent, 's1'); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + await submitPrompt(h, 's2', 'new root', { parentTurnId: null, expectedGraphRevision: 1 }); + await vi.waitFor(() => submittedTurnId(h.sent, 's2')); + const fresh = nullthrow(h.adapters[1]); + fresh.emit({ type: 'session-ref', historyId: asHistoryId('native-2') }); + fresh.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + + await submitPrompt(h, 's3', 'continue the old version', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + + // Its own run's history may have grown outside LinkCode (or the capture was lost): a blind + // resume would land the prompt on unknown content. + expect(failure(h.sent, 's3')).toMatchObject({ + code: 'unsupported', + message: 'This turn has no provider checkpoint to continue from', + }); + expect(h.adapters.some((adapter) => adapter.resumedFrom === 'native-1')).toBe(false); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(2); + }); + + it('tracks a whole-turn send as running before it resolves, so its own stop settles it and it stays forkable', async () => { + const h = await startedHarness(() => new GatedWholeTurnAdapter()); + const adapter = h.adapter as GatedWholeTurnAdapter; + adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + await submitPrompt(h, 's1', 'first'); + + // Tracked off the adapter's `running` while send() is still in flight: the status frame is + // stamped with the turn — but nothing is committed until the dispatch resolves. + await vi.waitFor(() => expect(adapter.sentInputs).toHaveLength(1)); + const [preparing] = await h.conversationStore.listTurns(h.sessionId); + expect(preparing.state).toBe('preparing'); + expect(h.sent).toContainEqual( + expect.objectContaining({ + kind: 'agent.event', + turnId: preparing.turnId, + event: { type: 'status', status: 'running' }, + }), + ); + expect(h.sent.filter((p) => p.kind === 'conversation.graph.changed')).toHaveLength(0); + adapter.release(); + await vi.waitFor(() => submittedTurnId(h.sent, 's1')); + const firstTurnId = submittedTurnId(h.sent, 's1'); + await settleEngineTasks(); + // One commit, and the turn's own stop (held until then) settled THIS turn with its checkpoint. + expect((await h.conversationStore.listTurns(h.sessionId))[0].state).toBe('completed'); + expect(await h.conversationStore.listBindings(firstTurnId)).toEqual([ + expect.objectContaining({ checkpoint: 'after-first', capturedFrom: 'live' }), + ]); + expect(h.sent.filter((p) => p.kind === 'conversation.graph.changed')).toHaveLength(1); + + await submitPrompt(h, 's2', 'second'); + await vi.waitFor(() => expect(adapter.sentInputs).toHaveLength(2)); + adapter.release(); + await vi.waitFor(() => submittedTurnId(h.sent, 's2')); + await settleEngineTasks(); + // Stopping the idle session leaves the finished turns alone — nothing is stranded `running`. + await h.inject({ kind: 'session.stop', clientReqId: 'stop', sessionId: h.sessionId }); + expect((await h.conversationStore.listTurns(h.sessionId)).map((turn) => turn.state)).toEqual([ + 'completed', + 'completed', + ]); + + await submitPrompt(h, 's3', 'edited second', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + await vi.waitFor(() => expect(forkedAdapter(h.adapters).sentInputs).toHaveLength(1)); + (forkedAdapter(h.adapters) as GatedWholeTurnAdapter).release(); + await vi.waitFor(() => submittedTurnId(h.sent, 's3')); + expect(forkedAdapter(h.adapters).branchedFrom).toEqual({ + historyId: 'native-1', + cursor: 'after-first', + }); + }); + + it('replays the fork cut from an aligned cold read when no live checkpoint was captured', async () => { + const h = await startedHarness( + () => + new AlignedHistoryAdapter([ + { text: 'first', cursor: 'before-first' }, + { text: 'second', cursor: 'before-second' }, + ]), + ); + await submitPrompt(h, 's1', 'first'); + const firstTurnId = submittedTurnId(h.sent, 's1'); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + await submitPrompt(h, 's2', 'second'); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + + await submitPrompt(h, 's3', 'edited second', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + await vi.waitFor(() => submittedTurnId(h.sent, 's3')); + + // "Before the second prompt" is "after the first turn": the successor row's own cursor. + expect(forkedAdapter(h.adapters).branchedFrom).toEqual({ + historyId: 'native-1', + cursor: 'before-second', + }); + expect(await h.conversationStore.listBindings(firstTurnId)).toEqual([ + expect.objectContaining({ + historyId: 'native-1', + checkpoint: 'before-second', + capturedFrom: 'replay', + }), + ]); + }); + + it('keeps a fork unavailable when the cold read mints no cursors (a paginated codex rollout)', async () => { + const h = await startedHarness( + () => new AlignedHistoryAdapter([{ text: 'first' }, { text: 'second' }]), + ); + await submitPrompt(h, 's1', 'first'); + const firstTurnId = submittedTurnId(h.sent, 's1'); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + await submitPrompt(h, 's2', 'second'); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + + await submitPrompt(h, 's3', 'edited second', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + await vi.waitFor(() => failure(h.sent, 's3')); + + expect(failure(h.sent, 's3')).toMatchObject({ + code: 'unsupported', + message: 'This turn has no provider checkpoint to fork from', + }); + expect(await h.conversationStore.listTurns(h.sessionId)).toHaveLength(2); + expect(await h.conversationStore.listBindings(firstTurnId)).toEqual([]); + }); + it('persists the intent before dispatch and replays the stored failure', async () => { const h = await startedHarness(() => new RejectingTurnAdapter()); @@ -379,6 +987,179 @@ describe('turn.submit saga', () => { }); }); + it('forks the first post-upgrade prompt of a pre-existing session after its hidden history — never fresh', async () => { + const h = await preExistingSession([ + { text: 'hidden one', cursor: 'before-hidden' }, + { text: 'first', cursor: 'before-first' }, + ]); + + await submitPrompt(h, 's2', 'edited first', { parentTurnId: null, expectedGraphRevision: 1 }); + await vi.waitFor(() => submittedTurnId(h.sent, 's2')); + + // The cut is the root's own row on the provider: everything before it stays in context. + expect(forks(h.adapters)).toEqual([{ historyId: 'native-1', cursor: 'before-first' }]); + const turns = await h.conversationStore.listTurns(h.sessionId); + expect(turns.find((turn) => turn.turnId === submittedTurnId(h.sent, 's2'))).toMatchObject({ + parentTurnId: null, + siblingOrdinal: 2, + state: 'running', + }); + }); + + it('refuses typed, never fresh, when a pre-existing session’s hidden history cannot be aligned', async () => { + const h = await preExistingSession([ + { text: 'hidden one', cursor: 'before-hidden' }, + { text: 'not the first prompt', cursor: 'before-other' }, + ]); + + await submitPrompt(h, 's2', 'edited first', { parentTurnId: null, expectedGraphRevision: 1 }); + + expect(failure(h.sent, 's2')).toMatchObject({ + code: 'unsupported', + message: 'This turn has no provider checkpoint to fork from', + }); + expect(forks(h.adapters)).toEqual([]); + // No fresh start either: the only adapters ever started are the original and the resume. + expect( + h.adapters.filter((adapter) => adapter.startedWith !== null || adapter.resumedFrom !== null), + ).toHaveLength(2); + }); + + it('legacy rewrite of a pre-existing session’s first recorded prompt forks after the hidden history, and again after an edit', async () => { + const h = await preExistingSession({ + 'native-1': [ + { text: 'hidden one', cursor: 'before-hidden' }, + { text: 'first', cursor: 'before-first' }, + ], + 'native-child': [ + { text: 'hidden one', cursor: 'before-hidden' }, + { text: 'first, edited', cursor: 'before-edited' }, + ], + }); + const original = livePrompt(h.sent, h.sessionId, 'first'); + + await h.inject({ + kind: 'history.branch', + clientReqId: 'rewrite-1', + sourceSessionId: h.sessionId, + ...original, + content: [{ type: 'text', text: 'first, edited' }], + }); + await vi.waitFor(() => + expect(h.sent).toContainEqual( + expect.objectContaining({ kind: 'session.started', replyTo: 'rewrite-1' }), + ), + ); + nullthrow(h.adapters.at(-1)).emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + + // The original root is off the active path now; it shares its predecessor with the active + // root, so its cut is that sibling's row on the current (forked) history. + await h.inject({ + kind: 'history.branch', + clientReqId: 'rewrite-2', + sourceSessionId: h.sessionId, + ...original, + content: [{ type: 'text', text: 'first, edited again' }], + }); + await vi.waitFor(() => + expect(h.sent).toContainEqual( + expect.objectContaining({ kind: 'session.started', replyTo: 'rewrite-2' }), + ), + ); + + expect(forks(h.adapters)).toEqual([ + { historyId: 'native-1', cursor: 'before-first' }, + { historyId: 'native-child', cursor: 'before-edited' }, + ]); + const turns = await h.conversationStore.listTurns(h.sessionId); + expect(turns.map((turn) => [turn.parentTurnId, turn.siblingOrdinal])).toEqual([ + [null, 1], + [null, 2], + [null, 3], + ]); + }); + + it('forks the first post-import prompt of an imported-then-continued session at its own row', async () => { + const conversationStore = new InMemoryConversationStore(); + const h = harness( + new InMemorySessionStore(), + () => + new AlignedHistoryAdapter([ + { text: 'imported one', cursor: 'before-imported' }, + { text: 'first', cursor: 'before-first' }, + ]), + undefined, + undefined, + undefined, + undefined, + { conversationStore }, + ); + await h.engine.start(); + await h.inject({ + kind: 'history.resume', + clientReqId: 'r1', + agentKind: 'claude-code', + historyId: asHistoryId('native-1'), + startOpts: { kind: 'claude-code', cwd: '/repo' }, + }); + const sessionId = startedId(h.sent, 'r1'); + const imported = { ...h, conversationStore, sessionId, adapter: nullthrow(h.adapters[0]) }; + await submitPrompt(imported, 's1', 'first'); + imported.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + + await submitPrompt(imported, 's2', 'edited first', { + parentTurnId: null, + expectedGraphRevision: 1, + }); + await vi.waitFor(() => submittedTurnId(h.sent, 's2')); + + expect(forks(h.adapters)).toEqual([{ historyId: 'native-1', cursor: 'before-first' }]); + }); + + it('forks at the live checkpoint even when the cold-read cursor names a different row', async () => { + // claude: a Stop hook summary row sits between the last assistant row (the live checkpoint) + // and the next user row (whose parentUuid is the cold-read cursor); both are valid cuts. + const h = await startedHarness( + () => + new AlignedHistoryAdapter([ + { text: 'first', cursor: 'before-first' }, + { text: 'second', cursor: 'system-row' }, + ]), + ); + await submitPrompt(h, 's1', 'first'); + const firstTurnId = submittedTurnId(h.sent, 's1'); + h.adapter.emit({ type: 'session-ref', historyId: asHistoryId('native-1') }); + h.adapter.emitCheckpoint({ + historyId: asHistoryId('native-1'), + cursor: 'assistant-row', + turn: 'ending', + }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + await submitPrompt(h, 's2', 'second'); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + // A read attributes the lineage and backfills replay cuts — never over the live one. + await h.inject({ kind: 'conversation.read', clientReqId: 'rr', sessionId: h.sessionId }); + await settleEngineTasks(); + expect(await h.conversationStore.listBindings(firstTurnId)).toEqual([ + expect.objectContaining({ checkpoint: 'assistant-row', capturedFrom: 'live' }), + ]); + + await submitPrompt(h, 's3', 'edited second', { + parentTurnId: firstTurnId, + expectedGraphRevision: 2, + }); + await vi.waitFor(() => submittedTurnId(h.sent, 's3')); + + expect(forkedAdapter(h.adapters).branchedFrom).toEqual({ + historyId: 'native-1', + cursor: 'assistant-row', + }); + }); + it('resumes a cold session for a plain send with an addressable new run', async () => { const h = await startedHarness(); await submitPrompt(h, 's1', 'first'); diff --git a/packages/host/engine/src/__tests__/engine-turn-tracking.test.ts b/packages/host/engine/src/__tests__/engine-turn-tracking.test.ts index d447b71bb..181cc08c4 100644 --- a/packages/host/engine/src/__tests__/engine-turn-tracking.test.ts +++ b/packages/host/engine/src/__tests__/engine-turn-tracking.test.ts @@ -43,11 +43,26 @@ class HangingSendAdapter extends FakeAdapter { } } +/** send() spans the whole turn (pi-style): checkpoint, stop, and idle land before it resolves. */ +class WholeTurnCheckpointAdapter extends FakeAdapter { + override async send(input: AgentInput): Promise { + this.sentInputs.push(input); + if (input.type !== 'prompt') return; + this.emit({ type: 'status', status: 'running' }); + await Promise.resolve(); + this.emitCheckpoint({ historyId: asHistoryId('native-1'), cursor: 'cp-whole', turn: 'ending' }); + this.emit({ type: 'stop', stopReason: 'end_turn' }); + this.emit({ type: 'status', status: 'idle' }); + } +} + +/** The opencode shape: the legacy `history.branch` path without turn-level forks. */ class BranchingAdapter extends FakeAdapter { override readonly historyCapabilities: AgentHistoryCapabilities = { list: false, read: true, resume: true, + forkAfterTurn: false, branch: true, }; @@ -84,6 +99,110 @@ async function startedHarness(makeAdapter: () => FakeAdapter = () => new FakeAda }; } +async function legacyPrompt(h: Awaited>, id: string) { + await h.inject({ + kind: 'agent.input', + clientReqId: id, + sessionId: h.sessionId, + input: { type: 'prompt', content: [textBlock(id)] }, + }); +} + +describe('live checkpoint capture', () => { + it('persists an ending checkpoint as the running turn’s live binding', async () => { + const h = await startedHarness(); + await legacyPrompt(h, 'first'); + const [turn] = await h.conversationStore.listTurns(h.sessionId); + + h.adapter.emitCheckpoint({ + historyId: asHistoryId('native-1'), + cursor: 'cp-1', + turn: 'ending', + }); + h.adapter.emit({ type: 'stop', stopReason: 'end_turn' }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + + expect(await h.conversationStore.listBindings(turn.turnId)).toEqual([ + { + turnId: turn.turnId, + runId: turn.runId, + historyId: 'native-1', + checkpoint: 'cp-1', + capturedFrom: 'live', + }, + ]); + }); + + it('keeps the first live checkpoint of a (turn, history) when a later one arrives', async () => { + const h = await startedHarness(); + await legacyPrompt(h, 'first'); + const [turn] = await h.conversationStore.listTurns(h.sessionId); + + h.adapter.emitCheckpoint({ + historyId: asHistoryId('native-1'), + cursor: 'cp-1', + turn: 'ending', + }); + // A compaction or a re-emitted prompt minting after the real cut must not move it. + h.adapter.emitCheckpoint({ + historyId: asHistoryId('native-1'), + cursor: 'cp-late', + turn: 'ending', + }); + await settleEngineTasks(); + + expect(await h.conversationStore.listBindings(turn.turnId)).toEqual([ + expect.objectContaining({ checkpoint: 'cp-1', capturedFrom: 'live' }), + ]); + }); + + it('binds a preceding checkpoint to the parent turn and nothing to a root', async () => { + const h = await startedHarness(); + await legacyPrompt(h, 'first'); + const [first] = await h.conversationStore.listTurns(h.sessionId); + h.adapter.emitCheckpoint({ + historyId: asHistoryId('native-1'), + cursor: 'msg-first', + turn: 'preceding', + }); + h.adapter.emit({ type: 'status', status: 'idle' }); + await settleEngineTasks(); + expect(await h.conversationStore.listBindings(first.turnId)).toEqual([]); + + await legacyPrompt(h, 'second'); + h.adapter.emitCheckpoint({ + historyId: asHistoryId('native-1'), + cursor: 'msg-second', + turn: 'preceding', + }); + await settleEngineTasks(); + + expect(await h.conversationStore.listBindings(first.turnId)).toEqual([ + expect.objectContaining({ turnId: first.turnId, checkpoint: 'msg-second' }), + ]); + const second = (await h.conversationStore.listTurns(h.sessionId)).find( + (turn) => turn.turnId !== first.turnId, + ); + expect(await h.conversationStore.listBindings(nullthrow(second).turnId)).toEqual([]); + }); + + it('binds a checkpoint minted inside a whole-turn send to the turn being dispatched', async () => { + const h = await startedHarness(() => new WholeTurnCheckpointAdapter()); + await legacyPrompt(h, 'first'); + await settleEngineTasks(); + + const [turn] = await h.conversationStore.listTurns(h.sessionId); + expect(await h.conversationStore.listBindings(turn.turnId)).toEqual([ + expect.objectContaining({ + turnId: turn.turnId, + checkpoint: 'cp-whole', + capturedFrom: 'live', + }), + ]); + }); +}); + describe('legacy input turn tracking', () => { it('persists a turn for a legacy prompt and completes it on idle', async () => { const h = await startedHarness(); @@ -498,4 +617,31 @@ describe('commitRunning idempotence', () => { expect(result).toEqual(stored); expect(result.state).toBe('succeeded'); }); + + it('binds a live checkpoint only for the run that owns the dispatching or running turn', async () => { + const { store, turns, intent } = await turnServiceFixture(); + const checkpoint = { + historyId: asHistoryId('native-1'), + cursor: 'cp-1', + turn: 'ending' as const, + }; + + turns.bindLiveCheckpoint(sessionId, RunIdSchema.parse('run-replaced'), checkpoint); + await settleEngineTasks(); + expect(await store.listBindings(intent.turn.turnId)).toEqual([]); + + // Dispatching (persisted, not yet committed): the pi-style pre-commit settle. + turns.bindLiveCheckpoint(sessionId, RunIdSchema.parse('run-1'), checkpoint); + await settleEngineTasks(); + expect(await store.listBindings(intent.turn.turnId)).toHaveLength(1); + + // Failed before commit: the intent is no longer dispatching, so nothing binds to it. + await Effect.runPromise(turns.resolveFailed(intent, { code: 'timeout', message: 'slow' })); + turns.bindLiveCheckpoint(sessionId, RunIdSchema.parse('run-1'), { + ...checkpoint, + historyId: asHistoryId('native-2'), + }); + await settleEngineTasks(); + expect(await store.listBindings(intent.turn.turnId)).toHaveLength(1); + }); }); diff --git a/packages/host/engine/src/__tests__/fixtures/session-harness.ts b/packages/host/engine/src/__tests__/fixtures/session-harness.ts index 9226112e6..6a01112c2 100644 --- a/packages/host/engine/src/__tests__/fixtures/session-harness.ts +++ b/packages/host/engine/src/__tests__/fixtures/session-harness.ts @@ -1,4 +1,4 @@ -import type { AdapterFactory, AgentAdapter } from '@linkcode/agent-adapter'; +import type { AdapterFactory, AgentAdapter, HistoryCheckpoint } from '@linkcode/agent-adapter'; import type { AgentCapabilities, AgentEvent, @@ -43,6 +43,7 @@ export class FakeAdapter implements AgentAdapter { stopped = false; readonly sentInputs: AgentInput[] = []; private readonly listeners = new Set<(event: AgentEvent) => void>(); + private readonly checkpointListeners = new Set<(checkpoint: HistoryCheckpoint) => void>(); start(opts: StartOptions): Promise { this.startedWith = opts; @@ -88,6 +89,13 @@ export class FakeAdapter implements AgentAdapter { }; } + onCheckpoint(cb: (checkpoint: HistoryCheckpoint) => void): () => void { + this.checkpointListeners.add(cb); + return () => { + this.checkpointListeners.delete(cb); + }; + } + stop(): Promise { this.stopped = true; return Promise.resolve(); @@ -96,6 +104,10 @@ export class FakeAdapter implements AgentAdapter { emit(event: AgentEvent): void { for (const cb of this.listeners) cb(event); } + + emitCheckpoint(checkpoint: HistoryCheckpoint): void { + for (const cb of this.checkpointListeners) cb(checkpoint); + } } /** Let the fire-and-forget handle()/persist chains settle. */ diff --git a/packages/host/engine/src/__tests__/history-service.test.ts b/packages/host/engine/src/__tests__/history-service.test.ts index 83e7ba4be..ea747cb40 100644 --- a/packages/host/engine/src/__tests__/history-service.test.ts +++ b/packages/host/engine/src/__tests__/history-service.test.ts @@ -1,11 +1,32 @@ -import { MessageIdSchema, textBlock } from '@linkcode/schema'; +import { HistoryCheckpointInvalidError } from '@linkcode/agent-adapter'; +import type { AgentHistoryBranchOptions, AgentHistoryCapabilities } from '@linkcode/schema'; +import { MessageIdSchema } from '@linkcode/schema'; import { Effect } from 'effect'; import { describe, expect, it } from 'vitest'; import { RESOURCE_CONTEXT_SENTINEL } from '../resource/service'; import { HistoryService } from '../session/history-service'; -import { promptContentFingerprint } from '../session/live-session'; import type { FakeHistoryState } from './fixtures/history-adapter'; -import { fakeHistoryFactory, historyId } from './fixtures/history-adapter'; +import { FakeHistoryAdapter, fakeHistoryFactory, historyId } from './fixtures/history-adapter'; + +class ForkingHistoryAdapter extends FakeHistoryAdapter { + override readonly historyCapabilities: AgentHistoryCapabilities; + readonly branched: AgentHistoryBranchOptions[] = []; + failWith: Error | undefined; + + constructor( + state: FakeHistoryState, + capabilities: Pick, + ) { + super('codex', state); + this.historyCapabilities = { list: true, read: true, resume: true, ...capabilities }; + } + + override branchHistory(opts: AgentHistoryBranchOptions): Promise { + if (this.failWith) return Promise.reject(this.failWith); + this.branched.push(opts); + return Promise.resolve(); + } +} describe('HistoryService', () => { it('caches list results until forceRefresh', async () => { @@ -100,71 +121,52 @@ describe('HistoryService', () => { }); }); - it('resolves live prompt offsets against fresh provider history', async () => { - const events = ['first-cursor', 'second-cursor'].map((branchCursor, index) => ({ - historyId, - itemId: `u${index + 1}`, - event: { - type: 'user-message' as const, - messageId: MessageIdSchema.parse(`u${index + 1}`), - content: [{ type: 'text' as const, text: `prompt ${index + 1}` }], - branchCursor, - }, - })); - const state: FakeHistoryState = { - listCalls: 0, - readCalls: 0, - resumeCalls: 0, - events, - }; - const service = new HistoryService(fakeHistoryFactory(state)); + describe('branch', () => { + const start = { kind: 'codex' as const, cwd: '/repo' }; + const opts = { historyId, cursor: 'opaque-cursor' }; + + it('is gated on the legacy branch capability, not on forkAfterTurn', async () => { + const state: FakeHistoryState = { listCalls: 0, readCalls: 0, resumeCalls: 0 }; + const service = new HistoryService(fakeHistoryFactory(state)); + const turnForksOnly = new ForkingHistoryAdapter(state, { forkAfterTurn: true }); + + const failure = await Effect.runPromise( + service.branch(turnForksOnly, opts, start).pipe(Effect.flip), + ); + expect(failure).toMatchObject({ _tag: 'RequestError', code: 'unsupported' }); + expect(turnForksOnly.branched).toEqual([]); + + // The opencode shape: turn-level forks dark, the legacy cold-read fork still shipped. + const legacyOnly = new ForkingHistoryAdapter(state, { branch: true }); + await Effect.runPromise(service.branch(legacyOnly, opts, start)); + expect(legacyOnly.branched).toEqual([opts]); + }); - await expect( - Effect.runPromise( - service.resolveLiveBranchCursor( - 'codex', - historyId, - '/repo', - 0, - promptContentFingerprint([textBlock('prompt 2')]), - ), - ), - ).resolves.toBe('second-cursor'); - await expect( - Effect.runPromise( - service.resolveLiveBranchCursor( - 'codex', - historyId, - '/repo', - 0, - promptContentFingerprint([textBlock('prompt 1')]), - ), - ), - ).resolves.toBe('first-cursor'); - await expect( - Effect.runPromise( - service.resolveLiveBranchCursor( - 'codex', - historyId, - '/repo', - 0, - promptContentFingerprint([ - textBlock('prompt 2'), - { type: 'image', mimeType: 'image/png', data: 'AA==' }, - ]), - ), - ), - ).resolves.toBe('second-cursor'); - await expect( - Effect.runPromise( - service.resolveLiveBranchCursor( - 'codex', - historyId, - '/repo', - 0, - promptContentFingerprint([textBlock('different prompt')]), - ), - ), - ).rejects.toThrow('The prompt does not match the latest provider history'); + it('maps an invalid checkpoint to a fixed typed unsupported and keeps other failures opaque', async () => { + const state: FakeHistoryState = { listCalls: 0, readCalls: 0, resumeCalls: 0 }; + const service = new HistoryService(fakeHistoryFactory(state)); + + const invalid = new ForkingHistoryAdapter(state, { branch: true }); + invalid.failWith = new HistoryCheckpointInvalidError('codex: thread/fork refused turn-9'); + const refused = await Effect.runPromise( + service.branch(invalid, opts, start).pipe(Effect.flip), + ); + // Provider ids stay in the daemon log; the wire carries the fixed message only. + expect(refused).toMatchObject({ + _tag: 'RequestError', + code: 'unsupported', + message: 'The provider no longer honours this fork checkpoint', + }); + + const broken = new ForkingHistoryAdapter(state, { branch: true }); + broken.failWith = new Error('secret provider transcript path'); + const failure = await Effect.runPromise( + service.branch(broken, opts, start).pipe(Effect.flip), + ); + expect(failure).toMatchObject({ + _tag: 'OperationError', + publicMessage: 'Failed to branch agent history', + }); + }); }); }); diff --git a/packages/host/engine/src/conversation/checkpoint-service.ts b/packages/host/engine/src/conversation/checkpoint-service.ts new file mode 100644 index 000000000..ef640ad04 --- /dev/null +++ b/packages/host/engine/src/conversation/checkpoint-service.ts @@ -0,0 +1,276 @@ +import { asHistoryId } from '@linkcode/agent-adapter'; +import type { + AgentHistoryBranchOptions, + AgentHistoryEvent, + AgentHistoryId, + ContentBlock, + ConversationTurn, + ProviderTurnBinding, + SessionRecord, + TurnId, +} from '@linkcode/schema'; +import { Effect } from 'effect'; +import { OperationError } from '../failure'; +import type { HistoryBranchCut, HistoryService } from '../session/history-service'; +import { promptContentFingerprint } from '../session/live-session'; +import type { SessionRecordRegistry } from '../session/session-record-registry'; +import type { CorpusAttribution } from './lineage-attribution'; +import { attributeCorpus, hasHiddenPrefix, pathToLeaf } from './lineage-attribution'; +import type { ConversationTurnService } from './turn-service'; +import { TERMINAL_TURN_STATES } from './turn-service'; + +/** What `branchHistory` needs: the provider history and the adapter-opaque cut inside it, plus + * the turn's cut on the current history should the provider no longer honour the first. */ +export type ForkCut = HistoryBranchCut; + +/** + * Resolves provider fork cuts from per-turn bindings — live-captured at turn end, or replayed + * from a cold read of the active lineage under the same positional gate the projection renders + * with. A cut is never guessed: no binding and no verified replay row means no fork. + */ +export class ConversationCheckpointService { + constructor( + private readonly turns: ConversationTurnService, + private readonly records: SessionRecordRegistry, + private readonly history: HistoryService, + ) {} + + /** The cut that forks provider history right after `parentTurnId` ("before any child"). */ + forkCutAfter( + record: SessionRecord, + parentTurnId: TurnId, + ): Effect.Effect { + const { turns } = this; + const boundCut = this.boundCut.bind(this); + const replayCutBefore = this.replayCutBefore.bind(this); + return Effect.gen(function* () { + const sessionTurns = yield* turns.listTurns(record.sessionId); + const parent = sessionTurns.find((turn) => turn.turnId === parentTurnId); + if (!parent) return; + const bound = yield* boundCut(record, parent); + if (bound) return bound; + const path = activePath(record, sessionTurns); + const index = path.findIndex((turn) => turn.turnId === parentTurnId); + if (index < 0 || index + 1 >= path.length) return; + return yield* replayCutBefore(record, path, path[index + 1]); + }); + } + + /** The cut that forks provider history right before `turnId`'s prompt ("after its parent"). */ + forkCutBefore( + record: SessionRecord, + turnId: TurnId, + ): Effect.Effect { + const { turns } = this; + const boundCut = this.boundCut.bind(this); + const replayCutBefore = this.replayCutBefore.bind(this); + return Effect.gen(function* () { + const sessionTurns = yield* turns.listTurns(record.sessionId); + const turn = sessionTurns.find((candidate) => candidate.turnId === turnId); + if (!turn) return; + const parent = sessionTurns.find((candidate) => candidate.turnId === turn.parentTurnId); + if (parent) { + const bound = yield* boundCut(record, parent); + if (bound) return bound; + } + return yield* replayCutBefore(record, activePath(record, sessionTurns), turn); + }); + } + + /** + * Attribute the active lineage (`path` root→active leaf, `contents` per path turn) to the + * latest provider history under the §9 gate. Side effect: every attributed turn whose + * successor row carries a provider cursor gains a `replay` binding on that history, unless a + * binding already exists there — a live capture is never overwritten by a cold read. + */ + attributeActiveLineage( + record: SessionRecord, + path: readonly ConversationTurn[], + contents: ReadonlyArray, + ): Effect.Effect { + const { records } = this; + const readCorpus = this.readCorpus.bind(this); + const backfill = this.backfill.bind(this); + return Effect.gen(function* () { + const historyId = records.historyId(record.sessionId); + const expectsProvider = settledWithProvider(path); + if (historyId === undefined || expectsProvider.length === 0) return; + const corpus = yield* readCorpus(record, historyId); + if (corpus === undefined) return; + const hostFingerprints: Array = []; + let liveFingerprint: string | undefined; + for (let i = 0, len = path.length; i < len; i++) { + const content = contents[i]; + if (!TERMINAL_TURN_STATES.has(path[i].state)) { + if (content) liveFingerprint = promptContentFingerprint(content); + } else if (path[i].state !== 'failed') { + hostFingerprints.push(content && promptContentFingerprint(content)); + } + } + const attribution = attributeCorpus( + corpus, + hostFingerprints, + liveFingerprint, + // A failed turn may or may not have left provider rows, so the count behind the corpus + // tail is unknowable: end-anchored alignment is off for that lineage. + hasHiddenPrefix(record, path[0]) && !path.some((turn) => turn.state === 'failed'), + ); + yield* backfill(expectsProvider, attribution, historyId); + return attribution; + }); + } + + /** The full provider corpus behind the TTL cache, or undefined when unreadable — unsupported + * harness, failed read (an unforkable rollout), deleted transcript — so callers degrade to + * prompt-only. */ + readCorpus( + record: SessionRecord, + historyId: AgentHistoryId, + ): Effect.Effect { + const { history } = this; + const { cwd, kind, sessionId } = record; + // A cached corpus captured before the newest settle can miss that turn's rows (or hold its + // partial answer) — bypass it so a post-settle read never attributes a stale slice. + const freshAfter = this.turns.lastSettledAt(sessionId); + return Effect.gen(function* () { + const events: AgentHistoryEvent[] = []; + const seenCursors = new Set(); + let cursor: string | undefined; + do { + // cwd is load-bearing for codex: its rollout home resolves through the project env. + const result = yield* history.read(kind, { + historyId, + cwd, + cursor, + freshAfter, + limit: 1000, + }); + for (let i = 0, len = result.events.length; i < len; i++) events.push(result.events[i]); + cursor = result.cursor; + if (cursor !== undefined) { + if (seenCursors.has(cursor)) { + return yield* Effect.fail( + new OperationError({ + subsystem: 'agent', + operation: 'conversation.read.history', + publicMessage: 'Provider history read returned a repeated cursor', + cause: undefined, + }), + ); + } + seenCursors.add(cursor); + } + } while (cursor !== undefined); + return events; + }).pipe( + Effect.catch((error) => + (error instanceof OperationError + ? Effect.logWarning( + 'Provider history unavailable for conversation read', + { sessionId, operation: error.operation }, + error.cause, + ) + : Effect.void + ).pipe(Effect.as(undefined)), + ), + ); + } + + /** `parent`'s persisted binding: the history its own run wrote to first (the live capture), the + * current history's — the one known to be alive — behind it as the fallback, or as the pick when + * the own run captured none. */ + private boundCut( + record: SessionRecord, + parent: ConversationTurn, + ): Effect.Effect { + return this.turns.listBindings(parent.turnId).pipe( + Effect.map((bindings) => { + const own = record.runs.find((run) => run.runId === parent.runId)?.historyId; + const current = this.records.historyId(record.sessionId); + const onOwn = bindings.find((binding) => binding.historyId === own); + const onCurrent = bindings.find((binding) => binding.historyId === current); + const pick = onOwn ?? onCurrent ?? bindings.at(0); + if (pick === undefined) return; + return { + ...toCut(pick), + ...(onCurrent !== undefined && onCurrent !== pick && { fallback: toCut(onCurrent) }), + }; + }), + ); + } + + /** Replay: `target`'s own user row on the active lineage carries the provider cursor that forks + * right before it. Only the active lineage aligns positionally (§9); a target off it shares its + * predecessor with the path's turn under the same parent ("before T" is "after parent(T)"), so + * that sibling's row names the same cut on the current history. */ + private replayCutBefore( + record: SessionRecord, + path: ConversationTurn[], + target: ConversationTurn, + ): Effect.Effect { + const { records, turns } = this; + const attributeActiveLineage = this.attributeActiveLineage.bind(this); + return Effect.gen(function* () { + const anchor = + path.find((turn) => turn.turnId === target.turnId) ?? + path.find((turn) => turn.parentTurnId === target.parentTurnId); + if (anchor === undefined) return; + const contents: Array = []; + for (let i = 0, len = path.length; i < len; i++) { + contents.push(yield* turns.hostUserContent(path[i])); + } + const attribution = yield* attributeActiveLineage(record, path, contents); + const historyId = records.historyId(record.sessionId); + if (attribution === undefined || historyId === undefined) return; + const position = settledWithProvider(path).findIndex((turn) => turn.turnId === anchor.turnId); + const row = + position >= 0 + ? attribution.attributed[position]?.userRow + : TERMINAL_TURN_STATES.has(anchor.state) + ? undefined + : attribution.trailingLive; + const cursor = row?.event.type === 'user-message' ? row.event.branchCursor : undefined; + return cursor === undefined ? undefined : { historyId, cursor }; + }); + } + + private backfill( + expectsProvider: readonly ConversationTurn[], + attribution: CorpusAttribution, + historyId: AgentHistoryId, + ): Effect.Effect { + const { turns } = this; + return Effect.gen(function* () { + const { attributed, trailingLive } = attribution; + for (let j = 0, len = attributed.length; j < len; j++) { + const successor = j + 1 < len ? attributed[j + 1].userRow : trailingLive; + const cursor = + successor?.event.type === 'user-message' ? successor.event.branchCursor : undefined; + if (cursor === undefined) continue; + const turn = expectsProvider[j]; + const existing = yield* turns.listBindings(turn.turnId); + if (existing.some((binding) => binding.historyId === historyId)) continue; + yield* turns.saveReplayBinding({ + turnId: turn.turnId, + runId: turn.runId, + historyId, + checkpoint: cursor, + capturedFrom: 'replay', + }); + } + }); + } +} + +function toCut(binding: ProviderTurnBinding): AgentHistoryBranchOptions { + return { historyId: asHistoryId(binding.historyId), cursor: binding.checkpoint }; +} + +function activePath(record: SessionRecord, turns: ConversationTurn[]): ConversationTurn[] { + return pathToLeaf(new Map(turns.map((turn) => [turn.turnId, turn])), record.activeLeafTurnId); +} + +/** The path turns that expect provider rows: settled, and not failed (nothing durable ran). */ +function settledWithProvider(path: readonly ConversationTurn[]): ConversationTurn[] { + return path.filter((turn) => TERMINAL_TURN_STATES.has(turn.state) && turn.state !== 'failed'); +} diff --git a/packages/host/engine/src/conversation/conversation-store.ts b/packages/host/engine/src/conversation/conversation-store.ts index ae14162d5..1bd500951 100644 --- a/packages/host/engine/src/conversation/conversation-store.ts +++ b/packages/host/engine/src/conversation/conversation-store.ts @@ -39,7 +39,8 @@ export interface ConversationStore { saveTurn(turn: ConversationTurn): Promise; getPrompt(promptId: PromptId): Promise; listBindings(turnId: TurnId): Promise; - /** Upsert by `(turnId, historyId)` — one binding per provider history, re-captured on re-read. */ + /** One binding per `(turnId, historyId)`: a `replay` row is re-captured on re-read, a `live` row + * is never overwritten — the first live capture is the cut, whatever arrives later. */ saveBinding(binding: ProviderTurnBinding): Promise; getOperation(operationId: OperationId): Promise; /** Open operations, for the per-session admit gate and boot recovery (no argument = all). */ @@ -91,7 +92,10 @@ export class InMemoryConversationStore implements ConversationStore { } saveBinding(binding: ProviderTurnBinding): Promise { - this.bindings.set(`${binding.turnId}\0${binding.historyId}`, structuredClone(binding)); + const key = `${binding.turnId}\0${binding.historyId}`; + if (this.bindings.get(key)?.capturedFrom !== 'live') { + this.bindings.set(key, structuredClone(binding)); + } return Promise.resolve(); } diff --git a/packages/host/engine/src/conversation/lineage-attribution.ts b/packages/host/engine/src/conversation/lineage-attribution.ts new file mode 100644 index 000000000..f5a88826c --- /dev/null +++ b/packages/host/engine/src/conversation/lineage-attribution.ts @@ -0,0 +1,163 @@ +import type { AgentHistoryEvent, ConversationTurn, SessionRecord, TurnId } from '@linkcode/schema'; +import { RequestError } from '../failure'; +import { promptContentFingerprint } from '../session/live-session'; + +export interface ProviderPartition { + readonly userRow: AgentHistoryEvent; + readonly rest: AgentHistoryEvent[]; +} + +export interface CorpusAttribution { + /** Partition i is the i-th settled path turn's provider content: a prefix of the candidates, or + * all of them when the corpus tail was aligned behind hidden pre-graph history. */ + readonly attributed: ProviderPartition[]; + /** Rows before the first attributed partition — those ahead of the first user row, plus the + * hidden history's own partitions — rendered unattributed, as a cold read would. */ + readonly leading: AgentHistoryEvent[]; + /** The in-flight turn's own user row, when a trailing extra partition fingerprint-verified as it. */ + readonly trailingLive?: AgentHistoryEvent; +} + +/** Whether provider rows can precede the lineage's root turn: an imported transcript, or a created + * session whose earlier runs wrote provider history before the root was recorded (a session older + * than its turn rows). A run that died before its first prompt left nothing behind. */ +export function hasHiddenPrefix(record: SessionRecord, root: ConversationTurn): boolean { + if (record.origin.type !== 'created') return true; + const index = record.runs.findIndex((run) => run.runId === root.runId); + // A root whose run cannot be placed (a pre-runId record) takes the safe direction. + if (index < 0) return true; + return record.runs.slice(0, index).some((run) => run.historyId !== undefined); +} + +/** Root→leaf path through `parentTurnId`; a broken chain fails loud rather than rendering wrong. */ +export function pathToLeaf( + byId: Map, + leafTurnId: TurnId | undefined, +): ConversationTurn[] { + if (leafTurnId === undefined) return []; + const path: ConversationTurn[] = []; + const seen = new Set(); + let currentId: TurnId | null = leafTurnId; + while (currentId !== null) { + if (seen.has(currentId)) { + throw new RequestError({ code: 'conflict', message: 'The turn graph contains a cycle' }); + } + seen.add(currentId); + const turn = byId.get(currentId); + if (!turn) { + throw new RequestError({ code: 'conflict', message: `Missing turn in path: ${currentId}` }); + } + path.push(turn); + currentId = turn.parentTurnId; + } + return path.reverse(); +} + +/** + * The attribution gate (§9 discipline): positions attribute only while each partition's user row + * fingerprint-matches the host prompt at that position — fingerprints verify an alignment, they + * never search for one. With as many partitions as settled host turns the alignment is anchored at + * the START and the FIRST mismatch degrades that turn and every later one to placeholders, never + * resynced positionally. With MORE partitions — allowed only where hidden pre-graph history can + * exist — the host turns align to the LAST partitions, every position must verify, and no other + * offset may verify in full (repeated prompts let a shifted alignment verify too), else nothing + * attributes; the unmatched head is hidden history. One trailing extra partition is tolerated only + * when it fingerprint-verifies as the in-flight turn's own row (the live tail owns it). + */ +export function attributeCorpus( + corpus: readonly AgentHistoryEvent[], + hostFingerprints: ReadonlyArray, + liveFingerprint: string | undefined, + hiddenPrefixAllowed = false, +): CorpusAttribution { + const none = { attributed: [], leading: [] }; + const split = partitionAtUserRows(corpus); + const { partitions } = split; + let candidates = partitions; + let trailingLive: AgentHistoryEvent | undefined; + const trailing = partitions.at(-1); + if ( + trailing !== undefined && + liveFingerprint !== undefined && + partitions.length > hostFingerprints.length && + userRowFingerprint(trailing.userRow) === liveFingerprint + ) { + trailingLive = trailing.userRow; + candidates = partitions.slice(0, -1); + } + const hidden = candidates.length - hostFingerprints.length; + if (hidden < 0 || (!hiddenPrefixAllowed && hidden > 0)) return none; + const aligned = candidates.slice(hidden); + const attributed: ProviderPartition[] = []; + for (let i = 0, len = aligned.length; i < len; i++) { + if (!positionVerifies(aligned[i], hostFingerprints[i])) break; + attributed.push(aligned[i]); + } + if (attributed.length === 0) return none; + // Anchored at the end (hidden rows ahead, or the live row peeled where hidden rows may exist), + // one mismatch leaves the whole alignment unproven — and so does any other offset that verifies + // in full: the replay binding a wrong alignment backfills is never corrected. + if (hiddenPrefixAllowed && (trailingLive !== undefined || hidden > 0)) { + if (attributed.length !== aligned.length) return none; + for (let k = 0, last = partitions.length - hostFingerprints.length; k <= last; k++) { + if (k !== hidden && windowVerifies(partitions, k, hostFingerprints)) return none; + } + } + const leading = [...split.leading]; + for (let i = 0; i < hidden; i++) { + const partition = candidates[i]; + leading.push(partition.userRow); + for (let j = 0, len = partition.rest.length; j < len; j++) leading.push(partition.rest[j]); + } + return { + attributed, + leading, + // The live row is the successor of the LAST settled turn only when every position verified. + ...(attributed.length === aligned.length && trailingLive !== undefined && { trailingLive }), + }; +} + +function userRowFingerprint(entry: AgentHistoryEvent): string | undefined { + return entry.event.type === 'user-message' + ? promptContentFingerprint(entry.event.content) + : undefined; +} + +function positionVerifies( + partition: ProviderPartition, + hostFingerprint: string | undefined, +): boolean { + return hostFingerprint !== undefined && userRowFingerprint(partition.userRow) === hostFingerprint; +} + +/** Whether the host turns verify against the partitions starting at `offset`, every position. */ +function windowVerifies( + partitions: readonly ProviderPartition[], + offset: number, + hostFingerprints: ReadonlyArray, +): boolean { + for (let i = 0, len = hostFingerprints.length; i < len; i++) { + if (!positionVerifies(partitions[offset + i], hostFingerprints[i])) return false; + } + return true; +} + +/** Splits a provider corpus at its user rows: partition i is user row i plus what follows it. */ +function partitionAtUserRows(corpus: readonly AgentHistoryEvent[]): { + leading: AgentHistoryEvent[]; + partitions: ProviderPartition[]; +} { + const leading: AgentHistoryEvent[] = []; + const partitions: ProviderPartition[] = []; + for (let i = 0, len = corpus.length; i < len; i++) { + const entry = corpus[i]; + if (entry.event.type === 'user-message') { + partitions.push({ userRow: entry, rest: [] }); + } else { + const current = partitions.at(-1); + if (current === undefined) leading.push(entry); + else current.rest.push(entry); + } + } + return { leading, partitions }; +} diff --git a/packages/host/engine/src/conversation/projection-service.ts b/packages/host/engine/src/conversation/projection-service.ts index cfa9e9fc7..2dd8b9004 100644 --- a/packages/host/engine/src/conversation/projection-service.ts +++ b/packages/host/engine/src/conversation/projection-service.ts @@ -3,7 +3,6 @@ import { boundedLimit } from '@linkcode/agent-adapter'; import type { AgentEvent, AgentHistoryEvent, - AgentHistoryId, ContentBlock, ConversationGraphTurn, ConversationReadItem, @@ -20,14 +19,16 @@ import { TurnIdSchema, } from '@linkcode/schema'; import { Effect } from 'effect'; -import { OperationError, RequestError } from '../failure'; -import type { HistoryService } from '../session/history-service'; -import { promptContentFingerprint } from '../session/live-session'; +import type { OperationError } from '../failure'; +import { RequestError } from '../failure'; import type { SessionRecordRegistry } from '../session/session-record-registry'; +import type { ConversationCheckpointService } from './checkpoint-service'; +import type { ProviderPartition } from './lineage-attribution'; +import { pathToLeaf } from './lineage-attribution'; import type { ConversationLiveJournals } from './live-journal'; import { inflightChunkKey } from './live-journal'; import type { ConversationTurnService } from './turn-service'; -import { TERMINAL_TURN_STATES } from './turn-service'; +import { TERMINAL_TURN_STATES, turnInputText } from './turn-service'; export interface ConversationGraphResult { readonly sessionId: SessionId; @@ -59,11 +60,6 @@ const WHITESPACE_RUN_RE = /\s+/g; * (the history-util.ts byte-budget rationale applies verbatim). */ const READ_PAGE_BYTE_BUDGET = MAX_ATTACHMENT_TOTAL_BASE64_LENGTH; -interface ProviderPartition { - readonly userRow: AgentHistoryEvent; - readonly rest: AgentHistoryEvent[]; -} - /** * Composes the root→leaf conversation projection: user rows from the durable ConversationStore * (host truth — never provider history, never the journal), assistant/tool events from provider @@ -74,7 +70,7 @@ export class ConversationProjectionService { constructor( private readonly turns: ConversationTurnService, private readonly records: SessionRecordRegistry, - private readonly history: HistoryService, + private readonly checkpoints: ConversationCheckpointService, private readonly journals: ConversationLiveJournals, /** Authoritative open interactive requests of the live session (the CODE-35 backstop). */ private readonly openRequests: (sessionId: SessionId) => AgentEvent[], @@ -197,40 +193,21 @@ export class ConversationProjectionService { path: ConversationTurn[], isActiveLineage: boolean, ): Effect.Effect { - const { records } = this; - const readProviderEvents = this.readProviderEvents.bind(this); - const hostUserContent = this.hostUserContent.bind(this); + const { checkpoints, turns } = this; return Effect.gen(function* () { const items: ConversationReadItem[] = []; const contents: (ContentBlock[] | undefined)[] = []; for (let i = 0, len = path.length; i < len; i++) { - contents.push(yield* hostUserContent(path[i])); + contents.push(yield* turns.hostUserContent(path[i])); } - const cold = path.filter((turn) => TERMINAL_TURN_STATES.has(turn.state)); - // A failed turn expects no provider rows (nothing durable ran) and gets no placeholder. - const expectsProvider = cold.filter((turn) => turn.state !== 'failed'); - const liveIndex = path.findIndex((turn) => !TERMINAL_TURN_STATES.has(turn.state)); - const historyId = records.historyId(record.sessionId); let attributed: ProviderPartition[] = []; let leading: AgentHistoryEvent[] = []; - if (isActiveLineage && historyId !== undefined && expectsProvider.length > 0) { - const corpus = yield* readProviderEvents(record, historyId); - if (corpus !== undefined) { - const hostFingerprints: (string | undefined)[] = []; - for (let i = 0, len = path.length; i < len; i++) { - const turn = path[i]; - if (!TERMINAL_TURN_STATES.has(turn.state) || turn.state === 'failed') continue; - const content = contents[i]; - hostFingerprints.push(content && promptContentFingerprint(content)); - } - let liveFingerprint: string | undefined; - if (liveIndex >= 0) { - const liveContent = contents[liveIndex]; - if (liveContent) liveFingerprint = promptContentFingerprint(liveContent); - } - const result = attributeCorpus(corpus, hostFingerprints, liveFingerprint); - attributed = result.attributed; - leading = result.leading; + if (isActiveLineage) { + // Reading the corpus also backfills replay bindings for the attributed turns. + const attribution = yield* checkpoints.attributeActiveLineage(record, path, contents); + if (attribution !== undefined) { + attributed = attribution.attributed; + leading = attribution.leading; } } for (let i = 0, len = leading.length; i < len; i++) { @@ -336,86 +313,10 @@ export class ConversationProjectionService { return { tail, watermark }; } - /** The full provider corpus behind the TTL cache, or undefined when unreadable — unsupported - * harness, failed read (CODE-645), deleted transcript — so the caller degrades to prompt-only. */ - private readProviderEvents( - record: SessionRecord, - historyId: AgentHistoryId, - ): Effect.Effect { - const { history } = this; - const { cwd, kind, sessionId } = record; - // A cached corpus captured before the newest settle can miss that turn's rows (or hold its - // partial answer) — bypass it so a post-settle read never attributes a stale slice. - const freshAfter = this.turns.lastSettledAt(sessionId); - return Effect.gen(function* () { - const events: AgentHistoryEvent[] = []; - const seenCursors = new Set(); - let cursor: string | undefined; - do { - // cwd is load-bearing for codex: its rollout home resolves through the project env. - const result = yield* history.read(kind, { - historyId, - cwd, - cursor, - freshAfter, - limit: 1000, - }); - for (let i = 0, len = result.events.length; i < len; i++) events.push(result.events[i]); - cursor = result.cursor; - if (cursor !== undefined) { - if (seenCursors.has(cursor)) { - return yield* Effect.fail( - new OperationError({ - subsystem: 'agent', - operation: 'conversation.read.history', - publicMessage: 'Provider history read returned a repeated cursor', - cause: undefined, - }), - ); - } - seenCursors.add(cursor); - } - } while (cursor !== undefined); - return events; - }).pipe( - Effect.catch((error) => - (error instanceof OperationError - ? Effect.logWarning( - 'Provider history unavailable for conversation read', - { sessionId, operation: error.operation }, - error.cause, - ) - : Effect.void - ).pipe(Effect.as(undefined)), - ), - ); - } - - /** The turn's user-row content from host truth; undefined for migrated null-prompt turns - * (which render as placeholders until per-turn bindings land, CODE-632). */ - private hostUserContent( - turn: ConversationTurn, - ): Effect.Effect { - const input = turn.input; - if (input.type === 'command' || input.type === 'shell-command') { - return Effect.succeed([{ type: 'text' as const, text: inputText(input) }]); - } - if (input.promptId === null) return Effect.undefined; - return this.turns.getPrompt(input.promptId).pipe( - Effect.map((prompt) => { - if (!prompt) return; - // attachment_ref blocks join the projection when the attachment store lands. - return prompt.blocks.flatMap((block) => - block.type === 'text' ? [{ type: 'text' as const, text: block.text }] : [], - ); - }), - ); - } - private inputSummary(turn: ConversationTurn): Effect.Effect { const input = turn.input; if (input.type === 'command' || input.type === 'shell-command') { - return Effect.succeed(truncateSummary(inputText(input))); + return Effect.succeed(truncateSummary(turnInputText(input))); } if (input.promptId === null) return Effect.undefined; return this.turns.getPrompt(input.promptId).pipe( @@ -534,93 +435,6 @@ function itemBytes(item: ConversationReadItem): number { return Buffer.byteLength(JSON.stringify(item), 'utf8'); } -/** Root→leaf path through `parentTurnId`; a broken chain fails loud rather than rendering wrong. */ -function pathToLeaf( - byId: Map, - leafTurnId: TurnId | undefined, -): ConversationTurn[] { - if (leafTurnId === undefined) return []; - const path: ConversationTurn[] = []; - const seen = new Set(); - let currentId: TurnId | null = leafTurnId; - while (currentId !== null) { - if (seen.has(currentId)) { - throw new RequestError({ code: 'conflict', message: 'The turn graph contains a cycle' }); - } - seen.add(currentId); - const turn = byId.get(currentId); - if (!turn) { - throw new RequestError({ code: 'conflict', message: `Missing turn in path: ${currentId}` }); - } - path.push(turn); - currentId = turn.parentTurnId; - } - return path.reverse(); -} - -/** - * The attribution gate (§9 discipline): positions attribute only while each partition's user row - * fingerprint-matches the host prompt at that position; the FIRST mismatch degrades that turn and - * every later one to placeholders — alignment is lost past a mismatch, never resynced positionally. - * One trailing extra partition is tolerated only when it fingerprint-verifies as the in-flight - * turn's own row (the live tail owns it); any other count anomaly attributes nothing. - */ -function attributeCorpus( - corpus: readonly AgentHistoryEvent[], - hostFingerprints: ReadonlyArray, - liveFingerprint: string | undefined, -): { attributed: ProviderPartition[]; leading: AgentHistoryEvent[] } { - const none = { attributed: [], leading: [] }; - const split = partitionAtUserRows(corpus); - let candidates = split.partitions; - const trailing = candidates.at(-1); - if (trailing !== undefined && candidates.length === hostFingerprints.length + 1) { - if (liveFingerprint === undefined || userRowFingerprint(trailing.userRow) !== liveFingerprint) { - return none; - } - candidates = candidates.slice(0, -1); - } - if (candidates.length !== hostFingerprints.length) return none; - const attributed: ProviderPartition[] = []; - for (let i = 0, len = candidates.length; i < len; i++) { - const hostFingerprint = hostFingerprints[i]; - if ( - hostFingerprint === undefined || - userRowFingerprint(candidates[i].userRow) !== hostFingerprint - ) { - break; - } - attributed.push(candidates[i]); - } - return { attributed, leading: attributed.length > 0 ? split.leading : [] }; -} - -function userRowFingerprint(entry: AgentHistoryEvent): string | undefined { - return entry.event.type === 'user-message' - ? promptContentFingerprint(entry.event.content) - : undefined; -} - -/** Splits a provider corpus at its user rows: partition i is user row i plus what follows it. */ -function partitionAtUserRows(corpus: readonly AgentHistoryEvent[]): { - leading: AgentHistoryEvent[]; - partitions: ProviderPartition[]; -} { - const leading: AgentHistoryEvent[] = []; - const partitions: ProviderPartition[] = []; - for (let i = 0, len = corpus.length; i < len; i++) { - const entry = corpus[i]; - if (entry.event.type === 'user-message') { - partitions.push({ userRow: entry, rest: [] }); - } else { - const current = partitions.at(-1); - if (current === undefined) leading.push(entry); - else current.rest.push(entry); - } - } - return { leading, partitions }; -} - function projectedItem( turn: ConversationTurn | undefined, entry: AgentHistoryEvent, @@ -646,14 +460,6 @@ function projectedUserRow(turn: ConversationTurn, content: ContentBlock[]): Conv }; } -function inputText( - input: Extract, -): string { - return input.type === 'command' - ? `/${input.name}${input.arguments === undefined ? '' : ` ${input.arguments}`}` - : `$ ${input.command}`; -} - function truncateSummary(text: string): string { return text.length > INPUT_SUMMARY_MAX_LENGTH ? `${text.slice(0, INPUT_SUMMARY_MAX_LENGTH - 1)}…` diff --git a/packages/host/engine/src/conversation/turn-service.ts b/packages/host/engine/src/conversation/turn-service.ts index f3a966cde..492351eac 100644 --- a/packages/host/engine/src/conversation/turn-service.ts +++ b/packages/host/engine/src/conversation/turn-service.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import type { HistoryCheckpoint } from '@linkcode/agent-adapter'; import type { ContentBlock, ConversationOperation, @@ -84,6 +85,11 @@ export const TERMINAL_TURN_STATES = new Set([ interface RunningTurn { readonly turn: ConversationTurn; sawError: boolean; + /** False while only tracked off the adapter's `running`, before the dispatch commits it. */ + committed: boolean; + /** A settle that landed before the commit; the commit writes it behind its own row, a failed + * dispatch discards it with the turn. */ + settledAs?: ConversationTurnState; } /** @@ -95,6 +101,9 @@ interface RunningTurn { export class ConversationTurnService { /** The running turn per session; settles are addressed by the turn's own runId. */ private readonly running = new Map(); + /** The persisted intent between its persist and its commit/failure; the adapter's `running` + * status promotes it to the running turn ({@link noteRunning}). */ + private readonly dispatching = new Map(); /** When a turn last flipped terminal — the projection's cache-freshness bound: a provider * corpus captured before the newest settle may be missing that turn's rows. */ private readonly settledAt = new Map(); @@ -134,6 +143,32 @@ export class ConversationTurnService { return storeOperation('conversation.bindings.list', () => this.store.listBindings(turnId)); } + /** A binding derived from a cold read (`capturedFrom: 'replay'`); the store keeps an existing + * live capture over it. */ + saveReplayBinding(binding: ProviderTurnBinding): Effect.Effect { + return storeOperation('conversation.binding.save', () => this.store.saveBinding(binding)); + } + + /** The turn's user-row content from host truth; undefined for migrated null-prompt turns. */ + hostUserContent( + turn: ConversationTurn, + ): Effect.Effect { + const input = turn.input; + if (input.type === 'command' || input.type === 'shell-command') { + return Effect.succeed([{ type: 'text' as const, text: turnInputText(input) }]); + } + if (input.promptId === null) return Effect.undefined; + return this.getPrompt(input.promptId).pipe( + Effect.map((prompt) => { + if (!prompt) return; + // attachment_ref blocks join the projection when the attachment store lands. + return prompt.blocks.flatMap((block) => + block.type === 'text' ? [{ type: 'text' as const, text: block.text }] : [], + ); + }), + ); + } + deleteSession(sessionId: SessionId): Effect.Effect { return storeOperation('conversation.delete-session', () => this.store.deleteSession(sessionId), @@ -141,6 +176,7 @@ export class ConversationTurnService { Effect.tap(() => Effect.sync(() => { this.running.delete(sessionId); + this.dispatching.delete(sessionId); this.settledAt.delete(sessionId); }), ), @@ -198,10 +234,22 @@ export class ConversationTurnService { ), ), ); - return { turn: persisted, operation }; + const intent = { turn: persisted, operation }; + this.dispatching.set(spec.sessionId, intent); + return intent; }); } + /** The adapter announced `running` for `runId`'s dispatching turn: track it in memory only. A + * whole-turn send() (pi, grok) settles before it resolves — tracked late, its own stop would + * settle its predecessor. The durable commit stays with the dispatch resolution: an adapter that + * emits `running` and then rejects the send must resolve `failed`, never a phantom turn. */ + noteRunning(sessionId: SessionId, runId: RunId): void { + const intent = this.dispatching.get(sessionId); + if (intent?.turn.runId !== runId) return; + this.track(intent.turn, false); + } + /** The provider accepted the dispatch: one transaction stores the success and flips the turn to * `running`, and ONLY the call that transitioned the row runs the side effects — a concurrent * commit (dispatch-timer rescue vs the send continuation) must move the graph exactly once. @@ -221,7 +269,7 @@ export class ConversationTurnService { Effect.flatMap((transitioned) => transitioned ? Effect.sync(() => { - this.trackRunning(turn); + this.trackCommitted(turn); const graphRevision = this.records.commitGraphMove(turn.sessionId, turn.turnId); if (graphRevision !== undefined) { this.transport.send( @@ -271,8 +319,11 @@ export class ConversationTurnService { } return stored; } - const running = this.running.get(intent.turn.sessionId); - if (running?.turn.turnId === intent.turn.turnId) this.running.delete(intent.turn.sessionId); + const { sessionId, turnId } = intent.turn; + if (this.running.get(sessionId)?.turn.turnId === turnId) this.running.delete(sessionId); + if (this.dispatching.get(sessionId)?.turn.turnId === turnId) { + this.dispatching.delete(sessionId); + } return { ...operation, error }; }); } @@ -338,6 +389,25 @@ export class ConversationTurnService { return this.runningFor(sessionId, runId)?.turn.turnId; } + /** Persist a live fork checkpoint as the binding of the turn it describes: `ending` → the turn + * `runId` is executing, `preceding` → that turn's parent (a root has none). A checkpoint from a + * run that is neither dispatching nor running a turn (a replaced adapter) binds nothing. */ + bindLiveCheckpoint(sessionId: SessionId, runId: RunId, checkpoint: HistoryCheckpoint): void { + const dispatching = this.dispatching.get(sessionId)?.turn; + const turn = + dispatching?.runId === runId ? dispatching : this.runningFor(sessionId, runId)?.turn; + if (!turn) return; + const turnId = checkpoint.turn === 'ending' ? turn.turnId : turn.parentTurnId; + if (turnId === null) return; + this.saveBinding({ + turnId, + runId, + historyId: checkpoint.historyId, + checkpoint: checkpoint.cursor, + capturedFrom: 'live', + }); + } + /** An adapter `error` while the run's turn is live; decides `failed` on a stop-less settle. */ noteError(sessionId: SessionId, runId: RunId): void { const entry = this.runningFor(sessionId, runId); @@ -361,9 +431,16 @@ export class ConversationTurnService { ); } + /** The first settle stands. Before the durable commit landed it is only recorded here: the + * commit's own `running` row must not land over the terminal state. */ private settle(sessionId: SessionId, runId: RunId, state: ConversationTurnState): void { const entry = this.runningFor(sessionId, runId); - if (!entry) return; + if (!entry || entry.settledAs !== undefined) return; + if (!entry.committed) { + entry.settledAs = state; + this.settledAt.set(sessionId, Date.now()); + return; + } this.running.delete(sessionId); this.persistTurnState(entry.turn, state); } @@ -376,14 +453,51 @@ export class ConversationTurnService { return entry.turn.runId === runId ? entry : undefined; } - private trackRunning(turn: ConversationTurn): void { + /** The commit landed. A turn already tracked off the adapter's `running` keeps its entry (and + * error flag); one that settled meanwhile gets its terminal state written now, behind the row. */ + private trackCommitted(turn: ConversationTurn): void { + const entry = this.running.get(turn.sessionId); + if (entry?.turn.turnId !== turn.turnId) { + this.track(turn, true); + return; + } + if (entry.settledAs === undefined) { + entry.committed = true; + return; + } + this.running.delete(turn.sessionId); + this.persistTurnState(turn, entry.settledAs); + } + + private track(turn: ConversationTurn, committed: boolean): void { const stale = this.running.get(turn.sessionId); // A new dispatch was admitted, so an unsettled predecessor demonstrably ended; close it out — // as failed when an adapter error was seen during its run, never a guessed 'completed'. if (stale && stale.turn.turnId !== turn.turnId) { - this.persistTurnState(stale.turn, stale.sawError ? 'failed' : 'completed'); + this.persistTurnState( + stale.turn, + stale.settledAs ?? (stale.sawError ? 'failed' : 'completed'), + ); + } + this.running.set(turn.sessionId, { turn, sawError: false, committed }); + if (this.dispatching.get(turn.sessionId)?.turn.turnId === turn.turnId) { + this.dispatching.delete(turn.sessionId); } - this.running.set(turn.sessionId, { turn, sawError: false }); + } + + /** Bindings are written off synchronous adapter callbacks, best-effort like turn settles. */ + private saveBinding(binding: ProviderTurnBinding): void { + this.runTask( + storeOperation('conversation.binding.save', () => this.store.saveBinding(binding)).pipe( + Effect.catch((error) => + Effect.logError( + error.publicMessage, + { operation: error.operation, turnId: binding.turnId }, + error.cause, + ), + ), + ), + ); } /** Settles run off synchronous adapter callbacks, so persistence is enqueued best-effort. */ @@ -404,6 +518,15 @@ export class ConversationTurnService { } } +/** How a command or shell turn reads as a user row. */ +export function turnInputText( + input: Extract, +): string { + return input.type === 'command' + ? `/${input.name}${input.arguments === undefined ? '' : ` ${input.arguments}`}` + : `$ ${input.command}`; +} + function storeOperation( operation: string, run: () => Promise, diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index f1f2e7ebc..20cfcf498 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -22,6 +22,7 @@ import { AutomationRequestHandler } from './automation/request-handler'; import { BrowserBrokerService } from './browser/broker'; import { BrowserReplHost } from './browser/repl-host'; import { BrowserRequestHandler } from './browser/request-handler'; +import { ConversationCheckpointService } from './conversation/checkpoint-service'; import { InMemoryConversationStore } from './conversation/conversation-store'; import { ConversationLiveJournals } from './conversation/live-journal'; import { ConversationProjectionService } from './conversation/projection-service'; @@ -211,6 +212,11 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( const artifacts = new ArtifactHostService(routes); const artifactRequests = new ArtifactRequestHandler(transport, artifacts, responder); const resourceRequests = new ResourceRequestHandler(transport, resources, responder); + const conversationCheckpoints = new ConversationCheckpointService( + conversationTurns, + records, + history, + ); const sessionLifecycle = new SessionLifecycleService( sessions, records, @@ -219,6 +225,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( workspaces, worktrees, conversationTurns, + conversationCheckpoints, ); const sessionRequests = new SessionRequestHandler( transport, @@ -235,7 +242,7 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( const conversationProjection = new ConversationProjectionService( conversationTurns, records, - history, + conversationCheckpoints, conversationJournals, (sessionId) => sessions.openInteractiveRequests(sessionId), ); diff --git a/packages/host/engine/src/session/history-service.ts b/packages/host/engine/src/session/history-service.ts index 84fa17764..27fd40f13 100644 --- a/packages/host/engine/src/session/history-service.ts +++ b/packages/host/engine/src/session/history-service.ts @@ -1,5 +1,5 @@ import type { AdapterFactory, AgentAdapter } from '@linkcode/agent-adapter'; -import { boundedLimit, cursorOffset } from '@linkcode/agent-adapter'; +import { boundedLimit, cursorOffset, HistoryCheckpointInvalidError } from '@linkcode/agent-adapter'; import type { AgentEvent, AgentHistoryBranchOptions, @@ -16,7 +16,6 @@ import type { import { Effect } from 'effect'; import { OperationError, RequestError } from '../failure'; import { RESOURCE_CONTEXT_SENTINEL } from '../resource/service'; -import { promptContentFingerprint } from './live-session'; export const HISTORY_CONVERSION_CACHE_VERSION = 5; @@ -24,6 +23,12 @@ export type HistoryListOptions = AgentHistoryListOptions & { forceRefresh?: boolean; }; +/** What `branch` forks at; `fallback` is the same turn's cut on another history, tried only when + * the provider no longer honours the cut itself — never for a refused capability. */ +export interface HistoryBranchCut extends AgentHistoryBranchOptions { + readonly fallback?: AgentHistoryBranchOptions; +} + export type HistoryReadOptions = AgentHistoryReadOptions & { forceRefresh?: boolean; /** Bypass a cache entry built at or before this timestamp — the caller knows the corpus moved @@ -194,9 +199,14 @@ export class HistoryService { ); } + /** Fork provider history right after the cut's checkpoint and start `adapter` on the child. + * Gated on the legacy `branch` capability — the turn-level `forkAfterTurn` gate is the submit + * saga's, at admit. A checkpoint the provider no longer honours (rewritten/deleted history, an + * unforkable rollout) moves on to the cut's `fallback`, else is a typed `unsupported` with a + * fixed message; the adapter's detail names provider ids only and stays in the daemon log. */ branch( adapter: AgentAdapter, - opts: AgentHistoryBranchOptions, + cut: HistoryBranchCut, startOpts: StartOptions, ): Effect.Effect { const branchHistory = adapter.branchHistory?.bind(adapter); @@ -208,66 +218,30 @@ export class HistoryService { }), ); } + const { fallback, ...opts } = cut; return agentHistoryOperation('history.branch', 'Failed to branch agent history', () => branchHistory(opts, startOpts), - ); - } - - resolveLiveBranchCursor( - kind: AgentKind, - historyId: AgentHistoryId, - cwd: string, - offsetFromEnd: number, - contentFingerprint: string, - ): Effect.Effect { - const adapter = this.factory(kind); - if (!adapter.historyCapabilities.read) { - return Effect.fail( - new RequestError({ - code: 'unsupported', - message: `${kind}: history read is not supported`, - }), - ); - } - return agentHistoryOperation('history.read', 'Failed to read agent history', async () => { - const branchablePrompts: Array<{ branchCursor: string; contentFingerprint: string }> = []; - const seenCursors = new Set(); - let cursor: string | undefined; - do { - const result = sanitizeHistoryResult( - // eslint-disable-next-line no-await-in-loop -- Provider cursors require serial pagination. - await adapter.readHistory({ historyId, cwd, limit: 1000, cursor }), + ).pipe( + Effect.catch((error): Effect.Effect => { + if (!(error.cause instanceof HistoryCheckpointInvalidError)) return Effect.fail(error); + return Effect.logWarning( + 'Provider refused the fork checkpoint', + { kind: adapter.kind, historyId: opts.historyId }, + error.cause, + ).pipe( + Effect.andThen( + fallback === undefined + ? Effect.fail( + new RequestError({ + code: 'unsupported', + message: 'The provider no longer honours this fork checkpoint', + }), + ) + : // Every adapter refuses before it spawns or starts anything, so the same instance retries. + this.branch(adapter, fallback, startOpts), + ), ); - for (let i = 0, len = result.events.length; i < len; i++) { - const entry = result.events[i]; - if (entry.event.type === 'user-message' && entry.event.branchCursor !== undefined) { - branchablePrompts.push({ - branchCursor: entry.event.branchCursor, - contentFingerprint: promptContentFingerprint(entry.event.content), - }); - } - } - cursor = result.cursor; - if (cursor !== undefined && seenCursors.has(cursor)) { - throw new Error(`${kind}: history read returned a repeated cursor`); - } - if (cursor !== undefined) seenCursors.add(cursor); - } while (cursor !== undefined); - const matchingPrompts = branchablePrompts.filter( - (prompt) => prompt.contentFingerprint === contentFingerprint, - ); - return matchingPrompts.at(-(offsetFromEnd + 1))?.branchCursor; - }).pipe( - Effect.flatMap((cursor) => - cursor === undefined - ? Effect.fail( - new RequestError({ - code: 'conflict', - message: 'The prompt does not match the latest provider history', - }), - ) - : Effect.succeed(cursor), - ), + }), ); } diff --git a/packages/host/engine/src/session/lifecycle-service.ts b/packages/host/engine/src/session/lifecycle-service.ts index c2a934499..1e4ad86b1 100644 --- a/packages/host/engine/src/session/lifecycle-service.ts +++ b/packages/host/engine/src/session/lifecycle-service.ts @@ -20,6 +20,8 @@ import type { import { Effect, Exit, Semaphore } from 'effect'; import { nullthrow } from 'foxts/guard'; import type { SessionDriver } from '../automation'; +import type { ConversationCheckpointService, ForkCut } from '../conversation/checkpoint-service'; +import { hasHiddenPrefix, pathToLeaf } from '../conversation/lineage-attribution'; import type { ConversationTurnService, PersistedTurnIntent, @@ -61,13 +63,18 @@ export interface TurnSubmitRequest { readonly operationId: OperationId; readonly input: TurnSubmitInput; /** Absent = plain send onto the active leaf; `null` = new root lineage; a turn id = tip-continue - * or (once checkpoints exist) fork. */ + * or a fork after that turn's checkpoint. */ readonly parentTurnId?: TurnId | null; readonly expectedGraphRevision?: number; } -/** Provider work a submit needs: none (live adapter continues), a cold resume, or a fresh session. */ -type TurnLaunch = 'continue' | 'resume' | 'fresh'; +/** Provider work a submit needs: none (live adapter continues), a resume (of the latest history, + * or an inactive lineage's own), a fresh session, or a fork at the parent's checkpoint. */ +type TurnLaunch = + | { readonly type: 'continue' } + | { readonly type: 'fresh' } + | { readonly type: 'resume'; readonly historyId?: AgentHistoryId } + | { readonly type: 'fork'; readonly cut: ForkCut }; function toAgentInput(input: TurnSubmitInput): AgentInput { if (input.type !== 'prompt') return input; @@ -95,6 +102,7 @@ export class SessionLifecycleService { private readonly workspaces: WorkspaceRegistry, private readonly worktrees: WorktreeService, private readonly turns: ConversationTurnService, + private readonly checkpoints: ConversationCheckpointService, ) { this.driver = { createSession: ({ signal, ...options }) => @@ -292,11 +300,8 @@ export class SessionLifecycleService { }), ); } - const sourceHistoryId = - liveCursor.type === 'live' - ? liveCursor.historyId - : this.records.historyId(sourceSessionId); - if (!sourceHistoryId) { + const sourceHistoryId = this.records.historyId(sourceSessionId); + if (!sourceHistoryId && liveCursor.type === 'provider') { return Effect.fail( new RequestError({ code: 'conflict', @@ -305,7 +310,7 @@ export class SessionLifecycleService { ); } - const { history, sessions, turns } = this; + const { checkpoints, history, sessions, turns } = this; const resolveForRecord = this.resolveForRecord.bind(this); const launchRun = this.launchRun.bind(this); return Effect.gen(function* () { @@ -319,49 +324,67 @@ export class SessionLifecycleService { } const resolved = yield* resolveForRecord(source); // The runtime rewrite stays destructive for old clients, but the tree records the - // replacement non-destructively. Live-echo message ids are never persisted, so - // `sourceMessageId` cannot name a graph turn; best-effort, the replacement lands as a - // sibling of the active leaf. Nothing is guessed destructively. - const runId = mintRunId(); + // replacement non-destructively. A live echo's cursor names its turn, so the replacement + // lands exactly under the edited turn's parent and the cut comes from that parent's + // checkpoint; a provider cursor (cold-read prompt) names no turn, so the replacement + // lands beside the active leaf and the cut is the cursor itself. Nothing is guessed. const existingTurns = yield* turns.listTurns(sourceSessionId); + const target = + liveCursor.type === 'live' + ? existingTurns.find((turn) => turn.turnId === liveCursor.turnId) + : undefined; + if (target === undefined && liveCursor.type === 'live') { + return yield* Effect.fail( + new RequestError({ + code: 'conflict', + message: 'The prompt does not belong to this session', + }), + ); + } const activeLeaf = existingTurns.find((turn) => turn.turnId === source.activeLeafTurnId); + const parentTurnId = target ? target.parentTurnId : (activeLeaf?.parentTurnId ?? null); + let startAdapter: (adapter: AgentAdapter) => Effect.Effect; + if (target === undefined) { + const historyId = nullthrow(sourceHistoryId, 'checked above for provider cursors'); + startAdapter = (adapter) => + history.branch(adapter, { historyId, cursor: branchCursor }, resolved.options); + } else if (target.parentTurnId === null && !hasHiddenPrefix(source, target)) { + // A root on a created session's first run: nothing precedes it in provider history, + // so its replacement starts a fresh provider session (the saga's root rule). Any other + // root — the first recorded prompt of a session older than its turn rows, or of an + // import — forks after the hidden history before it, or fails typed. + startAdapter = (adapter) => sessions.startAdapter(adapter, resolved.options); + } else { + // Resolved before the intent persists: a checkpoint-less prompt fails typed here + // instead of leaving a failed sibling behind. + const cut = yield* checkpoints.forkCutBefore(source, target.turnId); + if (cut === undefined) { + return yield* Effect.fail( + new RequestError({ + code: 'unsupported', + message: 'This prompt has no provider checkpoint to rewrite from', + }), + ); + } + startAdapter = (adapter) => history.branch(adapter, cut, resolved.options); + } + const runId = mintRunId(); const intent = yield* turns.persistIntent({ sessionId: sourceSessionId, operationId: mintOperationId(), runId, - parentTurnId: activeLeaf?.parentTurnId ?? null, + parentTurnId, input: { type: 'prompt', blocks: promptBlocksFromContent(content) }, }); yield* Effect.gen(function* () { yield* sessions.stopForReplacement(sourceSessionId); - const resolvedBranchCursor = - liveCursor.type === 'live' - ? yield* history.resolveLiveBranchCursor( - source.kind, - sourceHistoryId, - source.cwd, - liveCursor.offsetFromEnd, - liveCursor.contentFingerprint, - ) - : branchCursor; - yield* launchRun( - replyTo, - source, - resolved, - (adapter) => - history.branch( - adapter, - { historyId: sourceHistoryId, cursor: resolvedBranchCursor }, - resolved.options, - ), - { - initialInput: { type: 'prompt', content }, - preparedTurn: intent, - registerRecord: false, - rewindMessageId: sourceMessageId, - runId, - }, - ); + yield* launchRun(replyTo, source, resolved, startAdapter, { + initialInput: { type: 'prompt', content }, + preparedTurn: intent, + registerRecord: false, + rewindMessageId: sourceMessageId, + runId, + }); }).pipe( // The dispatcher does not resolve saga-prepared intents; every failure exit — stop, // branch, or dispatch failures, interrupts, defects — resolves here. @@ -389,8 +412,9 @@ export class SessionLifecycleService { submitTurn(request: TurnSubmitRequest): Effect.Effect { const { sessions, turns } = this; const admitSubmit = this.admitSubmit.bind(this); - const relaunchFresh = this.relaunchFresh.bind(this); + const relaunch = this.relaunch.bind(this); const resumeSession = this.resumeSession.bind(this); + const { history } = this; return Effect.gen(function* () { // Replay before any validation: a reply lost to a disconnect must not duplicate a sibling. const existing = yield* turns.getOperation(request.operationId); @@ -412,14 +436,30 @@ export class SessionLifecycleService { } const { intent, launch } = yield* admitSubmit(request); const dispatch = Effect.gen(function* () { - if (launch !== 'continue') { - const launchSession = - launch === 'fresh' - ? relaunchFresh(request.sessionId, intent.turn.runId) - : resumeSession(undefined, request.sessionId, { - runId: intent.turn.runId, - baseTurnId: intent.turn.parentTurnId ?? undefined, - }); + if (launch.type !== 'continue') { + const { runId } = intent.turn; + const baseTurnId = intent.turn.parentTurnId ?? undefined; + const { sessionId } = request; + let launchSession: Effect.Effect; + if (launch.type === 'fresh') { + launchSession = relaunch(sessionId, { runId }, (adapter, options) => + sessions.startAdapter(adapter, options), + ); + } else if (launch.type === 'fork') { + const { cut } = launch; + launchSession = relaunch(sessionId, { runId, baseTurnId }, (adapter, options) => + history.branch(adapter, cut, options), + ); + } else if (launch.historyId !== undefined) { + const { historyId } = launch; + launchSession = relaunch( + sessionId, + { runId, baseTurnId, historyId }, + (adapter, options) => history.resume(adapter, historyId, options), + ); + } else { + launchSession = resumeSession(undefined, sessionId, { runId, baseTurnId }); + } yield* launchSession.pipe( Effect.timeoutOrElse({ duration: LAUNCH_TIMEOUT_MS, @@ -532,7 +572,7 @@ export class SessionLifecycleService { ); } // Seam: the worktree co-leaseholder busy gate joins this critical section later. - const { sessions, turns } = this; + const { checkpoints, records, sessions, turns } = this; return Effect.gen(function* () { if (yield* turns.hasOpenOperation(request.sessionId)) { return yield* Effect.fail( @@ -547,7 +587,7 @@ export class SessionLifecycleService { if (request.parentTurnId === undefined) { // Plain send: no guards — targets the current active leaf under the busy rules alone. parentTurnId = record.activeLeafTurnId ?? null; - launch = 'continue'; + launch = { type: 'continue' }; } else if (request.parentTurnId === null) { if (request.expectedGraphRevision !== record.graphRevision) { return yield* Effect.fail( @@ -555,7 +595,37 @@ export class SessionLifecycleService { ); } parentTurnId = null; - launch = 'fresh'; + // Editing "the first prompt" starts fresh only when nothing can precede a root here; + // otherwise the active lineage's root names the hidden history the new root forks after. + const existingTurns = yield* turns.listTurns(request.sessionId); + const root = pathToLeaf( + new Map(existingTurns.map((turn) => [turn.turnId, turn])), + record.activeLeafTurnId, + ).at(0); + const nothingPrecedes = + root === undefined + ? records.historyId(request.sessionId) === undefined + : !hasHiddenPrefix(record, root); + if (nothingPrecedes) { + launch = { type: 'fresh' }; + } else { + const forkable = sessions.historyCapabilitiesOf(record.kind).forkAfterTurn === true; + const cut = + forkable && root !== undefined + ? yield* checkpoints.forkCutBefore(record, root.turnId) + : undefined; + if (cut === undefined) { + return yield* Effect.fail( + new RequestError({ + code: 'unsupported', + message: forkable + ? 'This turn has no provider checkpoint to fork from' + : `${record.kind}: forking from an earlier turn is not supported`, + }), + ); + } + launch = { type: 'fork', cut }; + } } else { const existingTurns = yield* turns.listTurns(request.sessionId); const parent = existingTurns.find((turn) => turn.turnId === request.parentTurnId); @@ -580,28 +650,55 @@ export class SessionLifecycleService { new RequestError({ code: 'conflict', message: 'The conversation graph has moved' }), ); } + parentTurnId = request.parentTurnId; if (request.parentTurnId === record.activeLeafTurnId) { // Tip-continue on the active lineage: the provider history head IS this leaf. - parentTurnId = request.parentTurnId; - launch = 'continue'; + launch = { type: 'continue' }; } else { - // Fork seam: per-turn provider checkpoints are not captured yet, so this read - // always finds none and every interior/edit fork is refused loudly. - const bindings = yield* turns.listBindings(request.parentTurnId); - return yield* Effect.fail( - new RequestError({ - code: 'unsupported', - message: - bindings.length === 0 + // A valid checkpoint forks — including at an inactive tip: pi's fork writes a new + // file, and the tip's own history may have grown outside LinkCode (CLI/TUI use), so + // a forking harness never continues a tip blind. Only a harness that cannot fork + // continues a tip by resuming the history its own run wrote to; an interior turn + // is fork-unavailable. + const forkable = sessions.historyCapabilitiesOf(record.kind).forkAfterTurn === true; + const cut = forkable + ? yield* checkpoints.forkCutAfter(record, parent.turnId) + : undefined; + if (cut !== undefined) { + launch = { type: 'fork', cut }; + } else if (existingTurns.some((turn) => turn.parentTurnId === parent.turnId)) { + return yield* Effect.fail( + new RequestError({ + code: 'unsupported', + message: forkable ? 'This turn has no provider checkpoint to fork from' - : 'Forking from an earlier turn is not supported yet', - }), - ); + : `${record.kind}: forking from an earlier turn is not supported`, + }), + ); + } else if (forkable) { + return yield* Effect.fail( + new RequestError({ + code: 'unsupported', + message: 'This turn has no provider checkpoint to continue from', + }), + ); + } else { + const historyId = record.runs.find((run) => run.runId === parent.runId)?.historyId; + if (historyId === undefined) { + return yield* Effect.fail( + new RequestError({ + code: 'unsupported', + message: 'This turn has no provider history to continue', + }), + ); + } + launch = { type: 'resume', historyId }; + } } } const liveRunId = - launch === 'continue' ? sessions.liveRunId(request.sessionId) : undefined; - if (launch === 'continue' && liveRunId === undefined) launch = 'resume'; + launch.type === 'continue' ? sessions.liveRunId(request.sessionId) : undefined; + if (liveRunId === undefined && launch.type === 'continue') launch = { type: 'resume' }; const intent = yield* turns.persistIntent({ sessionId: request.sessionId, operationId: request.operationId, @@ -615,9 +712,16 @@ export class SessionLifecycleService { ); } - /** Replace the session's adapter with a fresh provider session under the same LinkCode id — - * the `parentTurnId: null` (new root lineage) submit path. */ - private relaunchFresh(sessionId: SessionId, runId: RunId): Effect.Effect { + /** Replace the session's adapter under the same LinkCode id with one `start`ed on other provider + * history: a fresh session (new root lineage), a fork at a checkpoint, or an inactive lineage's + * own history resumed. The source adapter is stopped first — one live adapter per session, and + * a stopped source is the strongest quiesce a fork can get. `run` pre-mints the relaunch's run + * identity so the persisted turn references it. */ + private relaunch( + sessionId: SessionId, + run: { runId: RunId; baseTurnId?: TurnId; historyId?: AgentHistoryId }, + start: (adapter: AgentAdapter, options: StartOptions) => Effect.Effect, + ): Effect.Effect { return this.sessionSemaphore(sessionId).withPermit( Effect.suspend(() => { const record = this.records.get(sessionId); @@ -636,8 +740,8 @@ export class SessionLifecycleService { undefined, record, resolved, - (adapter) => sessions.startAdapter(adapter, resolved.options), - { registerRecord: false, runId }, + (adapter) => start(adapter, resolved.options), + { registerRecord: false, ...run }, ); }); }), diff --git a/packages/host/engine/src/session/live-session.ts b/packages/host/engine/src/session/live-session.ts index e448f5765..c64ec5010 100644 --- a/packages/host/engine/src/session/live-session.ts +++ b/packages/host/engine/src/session/live-session.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import type { AgentAdapter } from '@linkcode/agent-adapter'; +import type { AgentAdapter, HistoryCheckpoint } from '@linkcode/agent-adapter'; import { contentToText } from '@linkcode/agent-adapter'; import type { AgentCapabilities, @@ -14,7 +14,9 @@ import type { RunId, SessionId, SessionInfo, + TurnId, } from '@linkcode/schema'; +import { TurnIdSchema } from '@linkcode/schema'; import type { Unsubscribe } from '@linkcode/transport'; import type { Deferred, Scope } from 'effect'; import { Effect, Fiber } from 'effect'; @@ -27,16 +29,12 @@ const LIVE_BRANCH_CURSOR_TYPE = 'linkcode-live-branch'; export type LiveBranchCursorParseResult = | { readonly type: 'provider' } | { readonly type: 'invalid-live' } - | { - readonly type: 'live'; - readonly historyId: AgentHistoryId; - readonly offsetFromEnd: number; - readonly contentFingerprint: string; - }; + | { readonly type: 'live'; readonly historyId: AgentHistoryId; readonly turnId: TurnId }; interface LivePrompt { readonly messageId: MessageId; readonly content: ContentBlock[]; + readonly turnId: TurnId; } /** Mutable state derived from one live adapter's event stream. */ @@ -95,28 +93,37 @@ export class LiveSession { return true; } - listen(listener: (event: AgentEvent) => void): void { - this.unsubscribe = this.adapter.onEvent(listener); + listen( + listener: (event: AgentEvent) => void, + onCheckpoint: (checkpoint: HistoryCheckpoint) => void, + ): void { + const unsubscribeEvents = this.adapter.onEvent(listener); + const unsubscribeCheckpoints = this.adapter.onCheckpoint?.(onCheckpoint) ?? noop; + this.unsubscribe = () => { + unsubscribeEvents(); + unsubscribeCheckpoints(); + }; } stopListening(): void { this.unsubscribe(); } - trackPrompt(messageId: MessageId, content: ContentBlock[]): AgentEvent[] { - this.livePrompts.push({ messageId, content }); + /** Echo a live prompt; its branch cursor names the persisted turn, so `history.branch` resolves + * the cut through that turn's checkpoints. Without a history yet, the cursor rides the session-ref + * re-echo instead (≤v79 clients expect the cursor to arrive once the history is known). */ + trackPrompt(messageId: MessageId, content: ContentBlock[], turnId: TurnId): AgentEvent[] { + const prompt = { messageId, content, turnId }; + this.livePrompts.push(prompt); if (this.historyId === undefined) { return [{ type: 'user-message', messageId, content }]; } - return this.livePromptEvents(promptContentFingerprint(content)); + return [this.livePromptEvent(prompt, this.historyId)]; } - untrackPrompt(messageId: MessageId): AgentEvent[] { + untrackPrompt(messageId: MessageId): void { const index = this.livePrompts.findIndex((prompt) => prompt.messageId === messageId); - if (index < 0) return []; - const contentFingerprint = promptContentFingerprint(this.livePrompts[index].content); - this.livePrompts.splice(index, 1); - return this.historyId === undefined ? [] : this.livePromptEvents(contentFingerprint); + if (index >= 0) this.livePrompts.splice(index, 1); } /** Apply adapter-owned state before the original event is broadcast; returned resolutions must @@ -201,29 +208,19 @@ export class LiveSession { return [...resolutions, { type: 'status', status: 'stopped' }]; } - private livePromptEvents(onlyFingerprint?: string): AgentEvent[] { + private livePromptEvents(): AgentEvent[] { const historyId = this.historyId; if (historyId === undefined) return []; - const occurrenceByFingerprint = new Map(); - return this.livePrompts - .toReversed() - .map((prompt) => { - const contentFingerprint = promptContentFingerprint(prompt.content); - const offsetFromEnd = occurrenceByFingerprint.get(contentFingerprint) ?? 0; - occurrenceByFingerprint.set(contentFingerprint, offsetFromEnd + 1); - return { - type: 'user-message' as const, - messageId: prompt.messageId, - content: prompt.content, - branchCursor: encodeLiveBranchCursor(historyId, offsetFromEnd, contentFingerprint), - }; - }) - .reverse() - .filter( - (event) => - onlyFingerprint === undefined || - promptContentFingerprint(event.content) === onlyFingerprint, - ); + return this.livePrompts.map((prompt) => this.livePromptEvent(prompt, historyId)); + } + + private livePromptEvent(prompt: LivePrompt, historyId: AgentHistoryId): AgentEvent { + return { + type: 'user-message', + messageId: prompt.messageId, + content: prompt.content, + branchCursor: encodeLiveBranchCursor(historyId, prompt.turnId), + }; } } @@ -242,39 +239,18 @@ export function decodeLiveBranchCursor(cursor: string): LiveBranchCursorParseRes ) { return { type: 'provider' }; } - if ( - !('historyId' in parsed) || - typeof parsed.historyId !== 'string' || - !('offsetFromEnd' in parsed) || - typeof parsed.offsetFromEnd !== 'number' || - !Number.isSafeInteger(parsed.offsetFromEnd) || - parsed.offsetFromEnd < 0 || - !('contentFingerprint' in parsed) || - typeof parsed.contentFingerprint !== 'string' - ) { + if (!('historyId' in parsed) || typeof parsed.historyId !== 'string' || !('turnId' in parsed)) { return { type: 'invalid-live' }; } - return { - type: 'live', - historyId: parsed.historyId as AgentHistoryId, - offsetFromEnd: parsed.offsetFromEnd, - contentFingerprint: parsed.contentFingerprint, - }; + const turnId = TurnIdSchema.safeParse(parsed.turnId); + if (!turnId.success) return { type: 'invalid-live' }; + return { type: 'live', historyId: parsed.historyId as AgentHistoryId, turnId: turnId.data }; } export function promptContentFingerprint(content: ContentBlock[]): string { return createHash('sha256').update(contentToText(content)).digest('base64url'); } -function encodeLiveBranchCursor( - historyId: AgentHistoryId, - offsetFromEnd: number, - contentFingerprint: string, -): string { - return JSON.stringify({ - type: LIVE_BRANCH_CURSOR_TYPE, - historyId, - offsetFromEnd, - contentFingerprint, - }); +function encodeLiveBranchCursor(historyId: AgentHistoryId, turnId: TurnId): string { + return JSON.stringify({ type: LIVE_BRANCH_CURSOR_TYPE, historyId, turnId }); } diff --git a/packages/host/engine/src/session/orchestrator.ts b/packages/host/engine/src/session/orchestrator.ts index 1593975fd..90fcc4c54 100644 --- a/packages/host/engine/src/session/orchestrator.ts +++ b/packages/host/engine/src/session/orchestrator.ts @@ -4,6 +4,7 @@ import type { AgentEvent, AgentHistoryCapabilities, AgentInput, + AgentKind, ContentBlock, McpWarning, MessageId, @@ -105,6 +106,11 @@ export class SessionOrchestrator { return this.sessions.get(sessionId)?.adapter.historyCapabilities; } + /** The harness's static history capabilities, for gating work on a session with no live adapter. */ + historyCapabilitiesOf(kind: AgentKind): AgentHistoryCapabilities { + return this.factory(kind).historyCapabilities; + } + replay(sessionId: SessionId): void { const session = this.sessions.get(sessionId); if (session) this.events.broadcast(sessionId, session, session.replay()); @@ -286,6 +292,7 @@ export class SessionOrchestrator { scope: parentScope, sessions, transport, + turns, } = this; const { browserTools } = this; const discardFailedStart = (session: LiveSession): Effect.Effect => @@ -308,10 +315,14 @@ export class SessionOrchestrator { ); const startupEvents: AgentEvent[] = []; let bufferEvents = rewindMessageId !== undefined; - session.listen((event) => { - if (bufferEvents) startupEvents.push(event); - else events.handle(sessionId, session, event); - }); + session.listen( + (event) => { + if (bufferEvents) startupEvents.push(event); + else events.handle(sessionId, session, event); + }, + // Checkpoints never reach the wire, so the rewind buffer above does not apply. + (checkpoint) => turns.bindLiveCheckpoint(sessionId, session.runId, checkpoint), + ); if (sessions.has(sessionId)) { session.stopListening(); yield* Scope.close(scope, Exit.interrupt()); diff --git a/packages/host/engine/src/session/session-event-processor.ts b/packages/host/engine/src/session/session-event-processor.ts index bc23996fa..19d21892e 100644 --- a/packages/host/engine/src/session/session-event-processor.ts +++ b/packages/host/engine/src/session/session-event-processor.ts @@ -135,7 +135,11 @@ export class SessionEventProcessor { ) { return; } - // Captured before the settle below so a turn-ending event still carries its turn. + // `running` promotes the dispatching turn first so this frame already carries it; captured + // before the settle below so a turn-ending event still carries its turn. + if (event.type === 'status' && event.status === 'running') { + this.turns.noteRunning(sessionId, session.runId); + } const turnId = this.turns.runningTurnId(sessionId, session.runId); const derived = session.apply(event); for (let i = 0, len = derived.length; i < len; i++) { diff --git a/packages/host/engine/src/session/session-input-dispatcher.ts b/packages/host/engine/src/session/session-input-dispatcher.ts index ff79d2295..8dcf8ebd3 100644 --- a/packages/host/engine/src/session/session-input-dispatcher.ts +++ b/packages/host/engine/src/session/session-input-dispatcher.ts @@ -2,6 +2,7 @@ import { nextMessageId } from '@linkcode/agent-adapter'; import type { AgentInput, SessionId } from '@linkcode/schema'; import { agentCommandMatches } from '@linkcode/schema'; import { Cause, Effect, Exit } from 'effect'; +import { nullthrow } from 'foxts/guard'; import type { ConversationTurnService, PersistedTurnIntent } from '../conversation/turn-service'; import { mintOperationId, promptBlocksFromContent } from '../conversation/turn-service'; import { causeToRequestFailure, OperationError, RequestError } from '../failure'; @@ -120,7 +121,12 @@ export class SessionInputDispatcher { const dispatch = Effect.gen(function* () { // Echo before awaiting send: provider events can outrun the dispatch acknowledgement. if (promptMessageId !== undefined && input.type === 'prompt') { - events.broadcast(sessionId, session, session.trackPrompt(promptMessageId, input.content)); + const { turnId } = nullthrow(persisted, 'prompt dispatch without a persisted turn').turn; + events.broadcast( + sessionId, + session, + session.trackPrompt(promptMessageId, input.content, turnId), + ); records.setTitleFromContent(sessionId, input.content); } else if (input.type === 'command' || input.type === 'shell-command') { const text = @@ -171,9 +177,7 @@ export class SessionInputDispatcher { session.interactions.restoreResponse(responseInput.requestId, respondingAsk), ); } - if (promptMessageId !== undefined) { - events.broadcast(sessionId, session, session.untrackPrompt(promptMessageId)); - } + if (promptMessageId !== undefined) session.untrackPrompt(promptMessageId); if (startsTurn) events.rejectInput(sessionId, session, error.publicMessage); }), ),