diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 6ca32f49c48..544a5d45842 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -95,7 +95,26 @@ negotiated capabilities differ from the contract. Adding an optional field keeps green (Rule 1); making a client depend on that field turns the new-client/old-host pairing red. -The harness covers the terminal stream only. It does **not** cover the session-tab -sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or -the relay transport. A change on those paths still needs its own reasoning against -the three rules above. +`tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts` pairs the +same two builds over the structured `agentSession.*` surface. Because a released build +cannot name a capability string its own source never contains, the old side's advertised +list and registered method names are read from the extracted checkout rather than +hand-written. It covers the three skews that surface can fail on: + +- an old client — advertising only what the baseline build defines — is told the whole + surface does not exist and reaches no host method; +- a new client against the old dispatcher gets `method_not_found` on every method, and + can see the absence during negotiation instead of by calling; +- a cursor survives a host restart: the client's fence is refused as stale with the live + one attached, and resuming from the held cursor replays only what it missed. + +Run it with: + +```bash +pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +``` + +The harness covers the terminal stream and the structured agent-session surface. It does +**not** cover the session-tab sync channel, legacy agent-session publications, file or Git +RPCs, mobile/E2EE framing, or the relay transport. A change on those paths still needs its +own reasoning against the three rules above. diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts new file mode 100644 index 00000000000..b8950517c20 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -0,0 +1,246 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import { projectJournalBatch } from './agent-session-journal-batch' +import { readAgentSessionHistory, resolveHistoryLimit } from './agent-session-history-page' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string +let clock = 1_000 +let epochs = 0 +let journal: AgentSessionJournal + +function tick(): number { + clock += 1 + return clock +} + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function body(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +async function appendItems(count: number): Promise { + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { fence: 1 }) + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-history-')) + clock = 1_000 + epochs = 0 + journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => { + epochs += 1 + return `epoch-${epochs}` + } + }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('resolveHistoryLimit', () => { + it('clamps rather than rejecting so a mid-scroll client keeps paging', () => { + expect(resolveHistoryLimit(undefined)).toBe(40) + expect(resolveHistoryLimit(0)).toBe(1) + expect(resolveHistoryLimit(-5)).toBe(1) + expect(resolveHistoryLimit(10_000)).toBe(AGENT_SESSION_HISTORY_MAX_LIMIT) + expect(resolveHistoryLimit(Number.NaN)).toBe(40) + }) +}) + +describe('readAgentSessionHistory', () => { + it('serves the newest page on tail and pages backward from it', async () => { + await appendItems(5) + const tail = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'tail', + limit: 2 + }) + if (!tail.ok) { + throw new Error(`expected a page, got reset ${tail.reset}`) + } + expect(tail.page.items.map((entry) => entry.body)).toEqual([body('item-4'), body('item-5')]) + expect(tail.page.hasOlder).toBe(true) + expect(tail.page.hasNewer).toBe(false) + + const older = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: tail.page.window.nextCursor, + limit: 2 + }) + if (!older.ok) { + throw new Error(`expected a page, got reset ${older.reset}`) + } + expect(older.page.items.map((entry) => entry.body)).toEqual([body('item-2'), body('item-3')]) + expect(older.page.hasNewer).toBe(true) + }) + + it('catches a live reader up from its cursor and stops at the limit', async () => { + await appendItems(2) + const cursor = journal.cursor() + await appendItems(5) + const page = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor, + limit: 2 + }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items).toHaveLength(2) + expect(page.page.hasNewer).toBe(true) + expect(page.page.window.nextCursor.sequence).toBeGreaterThan(cursor.sequence) + }) + + it('carries tombstones in a forward catch-up page', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendTombstone(item(1), { fence: 1 }) + + const page = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor + }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items).toHaveLength(0) + expect(page.page.removedItemIds).toEqual(['codex:thread-1:turn-1:1']) + }) + + it('reports a forward read with no cursor as cursor_ahead rather than serving the tail', async () => { + await appendItems(1) + expect( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'after' }) + ).toMatchObject({ ok: false, reset: 'cursor_ahead' }) + }) + + it('resets a cursor from a previous epoch', async () => { + await appendItems(1) + const stale = journal.cursor() + await journal.rollEpoch('legacy_import', 2) + for (const direction of ['before', 'after'] as const) { + expect( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction, cursor: stale }) + ).toMatchObject({ ok: false, reset: 'epoch_changed' }) + } + }) + + it('resets a cursor ahead of the journal', async () => { + await appendItems(1) + const ahead = { epoch: journal.epoch, sequence: journal.cursor().sequence + 10 } + expect( + readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: ahead + }) + ).toMatchObject({ ok: false, reset: 'cursor_ahead' }) + }) + + it('carries the submission for a message on the page', async () => { + await journal.appendSubmission({ + clientMessageId: 'msg-1', + payloadFingerprint: 'a'.repeat(64), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + const page = readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail' }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items[0]?.itemId).toBe(agentJournalSubmissionKey('msg-1')) + expect(page.page.submissions).toHaveLength(1) + expect(page.page.submissions[0]).toMatchObject({ + clientMessageId: 'msg-1', + dispatchState: 'pending' + }) + }) +}) + +describe('projectJournalBatch', () => { + it('reports a hole in the row sequence as journal_gap', async () => { + await appendItems(3) + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const withHole = since.rows.filter((row) => row.seq !== since.rows[1]?.seq) + expect( + projectJournalBatch({ rows: withHole, snapshot: journal.snapshot(), afterSequence: 0 }) + ).toEqual({ ok: false, reset: 'journal_gap' }) + }) + + it('publishes touched items at their current reduced state, not as a delta', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendItem(item(1), body('revised'), { fence: 1 }) + const since = journal.readSince(cursor) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const projected = projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: cursor.sequence + }) + if (!projected.ok) { + throw new Error(`expected a batch, got reset ${projected.reset}`) + } + expect(projected.batch.items).toHaveLength(1) + expect(projected.batch.items[0]).toMatchObject({ body: body('revised'), revision: 2 }) + expect(projected.batch.cursor).toEqual(journal.cursor()) + }) + + it('lists a tombstoned item as removed', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendTombstone(item(1), { fence: 1 }) + const since = journal.readSince(cursor) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const projected = projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: cursor.sequence + }) + if (!projected.ok) { + throw new Error(`expected a batch, got reset ${projected.reset}`) + } + expect(projected.batch.removedItemIds).toHaveLength(1) + expect(projected.batch.items).toHaveLength(0) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts new file mode 100644 index 00000000000..2f7a398e5c5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -0,0 +1,146 @@ +// Paged history over one journal. +// +// `tail` and `before` read the REDUCED timeline, so backward paging keeps +// working after compaction — the folded snapshot still holds every live item. +// `after` is the catch-up direction and must read rows instead: an item created +// early and revised late orders by its creation sequence, so an item-window +// read would silently skip that revision. Rows carry the revision, which is why +// `after` is the only direction that can answer `cursor_compacted`. + +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalCursor, + AgentJournalRenderItem, + AgentJournalSnapshot +} from '../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_HISTORY_DEFAULT_LIMIT, + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryDirection, + type AgentSessionHistoryPage, + type AgentSessionHistoryRequest, + type AgentSessionHistoryResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { projectJournalBatch } from './agent-session-journal-batch' + +/** Clamped, never rejected: a client asking for more than the host will serve + * should get a smaller page and keep paging, not an error mid-scroll. */ +export function resolveHistoryLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit)) { + return AGENT_SESSION_HISTORY_DEFAULT_LIMIT + } + return Math.min(AGENT_SESSION_HISTORY_MAX_LIMIT, Math.max(1, Math.floor(limit))) +} + +export function readAgentSessionHistory( + journal: AgentSessionJournal, + request: AgentSessionHistoryRequest +): AgentSessionHistoryResult { + const snapshot = journal.snapshot() + if (journal.isReadOnly) { + return { ok: false, reset: 'schema_unreadable', snapshot } + } + const limit = resolveHistoryLimit(request.limit) + if (request.direction === 'after') { + return readForward(journal, snapshot, request.cursor, limit) + } + const cursor = request.direction === 'before' ? request.cursor : undefined + if (cursor) { + if (cursor.epoch !== snapshot.cursor.epoch) { + return { ok: false, reset: 'epoch_changed', snapshot } + } + if (cursor.sequence > snapshot.cursor.sequence) { + return { ok: false, reset: 'cursor_ahead', snapshot } + } + } + const older = cursor + ? snapshot.items.filter((item) => item.sequence < cursor.sequence) + : snapshot.items + const items = older.slice(Math.max(0, older.length - limit)) + return { + ok: true, + page: buildPage({ + snapshot, + direction: request.direction, + items, + hasOlder: older.length > items.length, + hasNewer: older.length < snapshot.items.length, + fallbackCursor: cursor ?? { epoch: snapshot.cursor.epoch, sequence: 0 }, + nextCursor: items[0] + ? { epoch: snapshot.cursor.epoch, sequence: items[0].sequence } + : undefined + }) + } +} + +function readForward( + journal: AgentSessionJournal, + snapshot: AgentJournalSnapshot, + cursor: AgentJournalCursor | undefined, + limit: number +): AgentSessionHistoryResult { + if (!cursor) { + // Why: forward paging replays rows after a position; without one there is + // nothing to be after, and silently serving the tail would hand the client + // a page it cannot place. + return { ok: false, reset: 'cursor_ahead', snapshot } + } + const since = journal.readSince(cursor) + if (!since.ok) { + return { ok: false, reset: since.reset, snapshot } + } + const rows = since.rows.slice(0, limit) + const projected = projectJournalBatch({ rows, snapshot, afterSequence: cursor.sequence }) + if (!projected.ok) { + return { ok: false, reset: projected.reset, snapshot } + } + const lastSequence = rows.at(-1)?.seq ?? cursor.sequence + return { + ok: true, + page: buildPage({ + snapshot, + direction: 'after', + items: projected.batch.items, + removedItemIds: projected.batch.removedItemIds, + // Reading after a position means there is something before it. + hasOlder: cursor.sequence > 0, + hasNewer: since.rows.length > rows.length, + fallbackCursor: cursor, + nextCursor: { epoch: cursor.epoch, sequence: lastSequence } + }) + } +} + +function buildPage(input: { + snapshot: AgentJournalSnapshot + direction: AgentSessionHistoryDirection + items: AgentJournalRenderItem[] + removedItemIds?: string[] + hasOlder: boolean + hasNewer: boolean + fallbackCursor: AgentJournalCursor + nextCursor: AgentJournalCursor | undefined +}): AgentSessionHistoryPage { + const epoch = input.snapshot.cursor.epoch + const pageItemIds = new Set(input.items.map((item) => item.itemId)) + const oldest = input.items[0] + const newest = input.items.at(-1) + return { + sessionId: input.snapshot.sessionId, + epoch, + direction: input.direction, + items: input.items, + removedItemIds: input.removedItemIds ?? [], + submissions: input.snapshot.submissions.filter((submission) => + pageItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) + ), + window: { + oldest: oldest ? { epoch, sequence: oldest.sequence } : null, + newest: newest ? { epoch, sequence: newest.sequence } : null, + nextCursor: input.nextCursor ?? input.fallbackCursor + }, + hasOlder: input.hasOlder, + hasNewer: input.hasNewer + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts new file mode 100644 index 00000000000..96a3c262c8b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -0,0 +1,79 @@ +// Rows appended since a cursor, projected into what a subscriber must apply. +// +// The batch carries each touched item at its CURRENT reduced state rather than +// the raw rows: a client that applies the same batch twice converges, and a +// provider echo that was adopted into a submission slot arrives under the slot +// key instead of appearing as a second copy of the user's own message. + +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' +import { findSequenceGap } from '../agent-session-journal/journal-cursor' +import type { JournalRow } from '../agent-session-journal/journal-row-schema' + +export type JournalBatchProjection = + | { ok: true; batch: AgentSessionJournalBatch } + /** A missing sequence means the tail lost a row; the subscriber reloads + * rather than rendering a timeline with a hole in it. */ + | { ok: false; reset: 'journal_gap' } + +export function projectJournalBatch(input: { + rows: readonly JournalRow[] + snapshot: AgentJournalSnapshot + /** Sequence the subscriber has already applied. */ + afterSequence: number +}): JournalBatchProjection { + const gap = findSequenceGap( + input.rows.map((row) => row.seq), + input.afterSequence + 1 + ) + if (gap) { + return { ok: false, reset: 'journal_gap' } + } + const aliases = submissionAliases(input.snapshot) + const touchedItemIds = new Set() + const touchedClientMessageIds = new Set() + for (const row of input.rows) { + if (row.kind === 'item' || row.kind === 'tombstone') { + touchedItemIds.add(aliases.get(row.itemId) ?? row.itemId) + continue + } + if (row.kind === 'submission' || row.kind === 'dispatch') { + touchedClientMessageIds.add(row.clientMessageId) + touchedItemIds.add(agentJournalSubmissionKey(row.clientMessageId)) + } + } + + const live = new Map(input.snapshot.items.map((item) => [item.itemId, item])) + const items = [...touchedItemIds] + .map((itemId) => live.get(itemId)) + .filter((item) => item !== undefined) + .sort((a, b) => a.sequence - b.sequence) + return { + ok: true, + batch: { + cursor: input.snapshot.cursor, + items, + removedItemIds: [...touchedItemIds].filter((itemId) => !live.has(itemId)), + submissions: input.snapshot.submissions.filter((submission) => + touchedClientMessageIds.has(submission.clientMessageId) + ) + } + } +} + +/** + * Provider item id → the submission slot that adopted it, rebuilt from the + * snapshot's own accepted submissions. This mirrors the alias the reducer + * writes on an accepted dispatch; deriving it here keeps the projection a pure + * function of published state instead of reaching into reducer internals. + */ +function submissionAliases(snapshot: AgentJournalSnapshot): Map { + const aliases = new Map() + for (const submission of snapshot.submissions) { + if (submission.dispatchState === 'accepted' && submission.providerItemId) { + aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) + } + } + return aliases +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts new file mode 100644 index 00000000000..24a985719e1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -0,0 +1,175 @@ +// Recovery drives the real journal loader against real on-disk damage: a hole +// punched in the log, and a row stamped with a schema this host cannot read. + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import { + openAgentSessionJournalWithRecovery, + providerHistoryId, + recoveryJournalDir +} from './agent-session-journal-recovery' + +const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: CODEX_SESSION, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: CODEX_SESSION } +} + +const CODEX_LINES = [ + { + type: 'session_meta', + timestamp: '2026-08-05T10:00:00.000Z', + payload: { + id: CODEX_SESSION, + session_id: CODEX_SESSION, + cwd: '/Users/dev/project', + originator: 'codex_cli_rs', + cli_version: '0.146.1' + } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:02.000Z', + payload: { type: 'user_message', message: 'add a retry', kind: 'plain' } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:05.000Z', + payload: { type: 'agent_message', message: 'On it.' } + } +] + +let root: string +let journalDir: string +let historyFilePath: string + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal } +} + +/** Fills a journal with `count` items and hands back the raw log lines. */ +async function seedJournal(count: number): Promise { + const journal = await openAgentSessionJournal({ identity: IDENTITY, journalDir }) + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await journal.appendItem( + item(ordinal), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `item-${ordinal}` }] }, + { fence: 1 } + ) + } + const raw = await readFile(join(journalDir, 'log.jsonl'), 'utf-8') + return raw.split('\n').filter((line) => line.trim().length > 0) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-recovery-')) + journalDir = join(root, 'journal') + historyFilePath = join(root, 'rollout.jsonl') + await writeFile( + historyFilePath, + `${CODEX_LINES.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf-8' + ) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('providerHistoryId', () => { + it('uses the provider handle, never the Orca session id', () => { + expect(providerHistoryId({ kind: 'codex', threadId: 'thread-9' })).toBe('thread-9') + expect(providerHistoryId({ kind: 'claude', sessionId: 'sess-9', leafUuid: null })).toBe( + 'sess-9' + ) + }) +}) + +describe('openAgentSessionJournalWithRecovery', () => { + it('opens a healthy journal untouched', async () => { + await seedJournal(2) + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toBeNull() + expect(opened.journal.snapshot().items).toHaveLength(2) + }) + + it('rebuilds a holed journal in place on a fresh epoch', async () => { + const lines = await seedJournal(3) + const holed = lines.filter((_line, index) => index !== 1) + await writeFile(join(journalDir, 'log.jsonl'), `${holed.join('\n')}\n`, 'utf-8') + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' }) + expect(opened.recovery?.imported).toBeGreaterThan(0) + expect(opened.journal.isReadOnly).toBe(false) + // The rebuilt timeline is the only content of its epoch — nothing from the + // damaged prefix survives into it. + const texts = opened.journal.snapshot().items.map((entry) => JSON.stringify(entry.body)) + expect(texts.some((text) => text.includes('item-1'))).toBe(false) + expect(texts.some((text) => text.includes('add a retry'))).toBe(true) + }) + + it('reconstructs a future-schema journal into a schema-scoped sibling, never in place', async () => { + const lines = await seedJournal(1) + await writeFile( + join(journalDir, 'log.jsonl'), + `${lines.join('\n')}\n${JSON.stringify({ v: 99, seq: 2, epoch: 'e', kind: 'item' })}\n`, + 'utf-8' + ) + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toMatchObject({ + trigger: 'schema_unreadable', + reset: 'schema_unreadable' + }) + expect(opened.recovery?.imported).toBeGreaterThan(0) + + // The unreadable journal is left exactly as found; a newer host still owns it. + const untouched = await readFile(join(journalDir, 'log.jsonl'), 'utf-8') + expect(untouched).toContain('"v":99') + const sibling = await readFile(join(recoveryJournalDir(journalDir), 'log.jsonl'), 'utf-8') + expect(sibling).toContain('add a retry') + }) + + it('still opens the session when provider history cannot be read', async () => { + const lines = await seedJournal(3) + const holed = lines.filter((_line, index) => index !== 1) + await writeFile(join(journalDir, 'log.jsonl'), `${holed.join('\n')}\n`, 'utf-8') + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath: join(root, 'missing.jsonl') + }) + expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) + expect(opened.recovery?.error).toBeTruthy() + expect(opened.journal.snapshot().items).toHaveLength(0) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts new file mode 100644 index 00000000000..14d09730005 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -0,0 +1,116 @@ +// Journal recovery: rehydrate the timeline from provider history. +// +// Two triggers, and they need different destinations. A journal whose prefix is +// unusable is writable, so it is rebuilt in place on a fresh epoch. A journal +// written by a NEWER schema is not writable by this host at all — rebuilding it +// in place would fork the sequence space a newer host still owns — so the +// reconstruction goes to a schema-scoped sibling directory that is only ever +// written by hosts at this version and is never merged back. + +import type { AgentType } from '../../../shared/agent-status-types' +import { + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalResetReason, + type AgentSessionJournalIdentity, + type AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' +import { loadJournal } from '../agent-session-journal/journal-open' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' + +export type AgentSessionJournalRecovery = { + trigger: 'journal_corrupt' | 'schema_unreadable' + /** What subscribers are told; both force a clean snapshot reload. */ + reset: AgentJournalResetReason + epoch: string + imported: number + /** Set when provider history could not be read. The session still opens, on + * an empty fresh epoch — an unreadable transcript is not a reason to refuse + * the user their session. */ + error?: string +} + +export type AgentSessionJournalOpened = { + journal: AgentSessionJournal + recovery: AgentSessionJournalRecovery | null +} + +/** Where a reconstruction lands when the real journal cannot be written. */ +export function recoveryJournalDir(journalDir: string): string { + return `${journalDir}-recovered-v${AGENT_SESSION_JOURNAL_SCHEMA_VERSION}` +} + +/** The provider's own session id, which is what the transcript readers index + * by — never the Orca session id. */ +export function providerHistoryId(handle: AgentSessionProviderHandle): string { + if (handle.kind === 'codex') { + return handle.threadId + } + return handle.kind === 'claude' ? handle.sessionId : handle.value +} + +export async function openAgentSessionJournalWithRecovery(input: { + identity: AgentSessionJournalIdentity + journalDir: string + fence: number + /** Resolve directly to a transcript instead of discovering it by session id. */ + historyFilePath?: string | null +}): Promise { + const probe = await loadJournal(input.journalDir, input.identity.sessionId) + if (probe?.readOnly) { + const journal = await openAgentSessionJournal({ + identity: input.identity, + journalDir: recoveryJournalDir(input.journalDir) + }) + return { + journal, + recovery: await rehydrate({ ...input, journal, trigger: 'schema_unreadable' }) + } + } + const journal = await openAgentSessionJournal({ + identity: input.identity, + journalDir: input.journalDir + }) + if (!probe?.corrupt) { + return { journal, recovery: null } + } + // `open()` already rolled the epoch off the unusable prefix; the import rolls + // once more so the rebuilt timeline is the only content of its epoch. + return { journal, recovery: await rehydrate({ ...input, journal, trigger: 'journal_corrupt' }) } +} + +async function rehydrate(input: { + identity: AgentSessionJournalIdentity + journal: AgentSessionJournal + fence: number + historyFilePath?: string | null + trigger: AgentSessionJournalRecovery['trigger'] +}): Promise { + const reset: AgentJournalResetReason = + input.trigger === 'schema_unreadable' ? 'schema_unreadable' : 'epoch_changed' + const result = await importLegacyTranscriptIntoJournal({ + journal: input.journal, + agent: input.identity.agent satisfies AgentType, + sessionId: providerHistoryId(input.identity.providerHandle), + fence: input.fence, + ...(input.historyFilePath ? { options: { filePath: input.historyFilePath } } : {}) + }) + if (!result.ok) { + return { + trigger: input.trigger, + reset, + epoch: input.journal.epoch, + imported: 0, + error: result.error + } + } + return { + trigger: input.trigger, + reset, + epoch: result.epoch, + imported: result.imported + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts new file mode 100644 index 00000000000..9b5c9dcc40b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -0,0 +1,66 @@ +// What the wire needs from a provider adapter. +// +// Phase 2 implements this over the Codex app-server and the Claude Agent SDK; +// nothing here starts, resumes, or talks to a process. The wire owns the +// journal and the lease, so an adapter only has to answer "did the provider +// take this?" — and it answers `unknown` rather than guessing, because the +// journal renders that as delivery unconfirmed instead of as failure. + +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionProcessIdentity } from '../../../shared/agent-session-record' + +/** What a reservation turns into once something is actually running under it: + * the process the host can probe, and the provider handle it was minted with. */ +export type AgentSessionAcquisition = { + process: AgentSessionProcessIdentity + link: AgentSessionProviderHandleLink +} + +export type AgentSessionDispatchOutcome = + /** The provider owns the turn now, under this identity. */ + | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } + | { state: 'rejected'; reason: string } + /** The call did not settle. Never re-send on the user's behalf. */ + | { state: 'unknown'; reason: string } + +export type StructuredAgentSessionAdapter = { + /** Makes the reservation real. Called once per reservation, with the spawn + * token the lease was reserved under and the fence the handle must be minted + * at — the store rejects a link minted at any other fence. */ + acquire(input: { + identity: AgentSessionJournalIdentity + fence: number + spawnToken: string + }): Promise + dispatch(input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + fence: number + }): Promise + /** Cancels one turn, not the session: a session-wide interrupt would also kill + * a turn the client never asked to stop. */ + cancelTurn(input: { + sessionId: string + turnId: string + fence: number + }): Promise<{ cancelled: boolean }> + /** Fires the provider callback for an approval or a question. The wire calls + * this only after the durable compare-and-set won, so it runs exactly once. */ + answerPrompt(input: { + sessionId: string + itemId: string + kind: 'approval' | 'question' + optionId: string + fence: number + }): Promise + setOption(input: { sessionId: string; key: string; value: string; fence: number }): Promise + /** Transcript path for journal recovery. Omit to let the existing session-file + * resolver discover it from the provider session id. */ + historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts new file mode 100644 index 00000000000..4ce563ec9ce --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -0,0 +1,138 @@ +// The attach transition end to end: reserve the lease, make the reservation +// real, open the journal. +// +// Split out of the host so the sequence reads in one place. The host still owns +// the decisions that must not be client-supplied — the spawn token, the claim +// key, the owner probe — and passes them in. + +import { isDeepStrictEqual } from 'node:util' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + admitAttachOrRefuse, + attachJournal, + classifyStoreFailure, + journalIdentityFor, + reserveRequestFor, + type AgentSessionAttachAuthority, + type AgentSessionAttachParams, + type AttachedJournal +} from './structured-agent-session-attach' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +export type AttachFlowInput = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + journalRoot: string + authority: AgentSessionAttachAuthority + callerKey: string + params: AgentSessionAttachParams + now: () => number + /** Registers the opened journal and fans out to subscribers before the caller + * sees the result, so no client can send against a session the host has not + * finished publishing. */ + onAttached: (attached: AttachedJournal) => void +} + +export async function performAttach( + input: AttachFlowInput +): Promise> { + const { params, store } = input + const sessionId = params.envelope.sessionId + const admitted = admitAttachOrRefuse(params) + if (!admitted.ok) { + return admitted + } + + let record: AgentSessionRecord + let replayed = false + try { + const reserved = await store.reserveOwner( + reserveRequestFor({ + sessionId, + params, + authority: input.authority, + callerKey: input.callerKey, + fingerprint: admitted.fingerprint, + now: input.now() + }) + ) + record = reserved.record + replayed = reserved.disposition === 'replayed' + if (!agentSessionLeaseAdmitsWriter(record.lease)) { + record = await acquireOwner(input, record) + } + } catch (error) { + return { + ok: false, + refusal: classifyStoreFailure(error, store.getRecord(sessionId)?.lease.runtimeFence ?? null) + } + } + + const attached = await attachJournal({ + record, + params, + journalRoot: input.journalRoot, + adapter: input.adapter + }) + input.onAttached(attached) + await store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + + const fence = record.lease.runtimeFence + return { + ok: true, + replayed, + fence, + cursor: attached.journal.cursor(), + value: { + sessionId, + fence, + snapshot: attached.journal.snapshot(), + unconfirmedClientMessageIds: attached.unconfirmedClientMessageIds + } + } +} + +/** A reservation with no process behind it is only a promise to spawn; the + * adapter makes it real and the store then grants the writer. */ +async function acquireOwner( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise { + const fence = record.lease.runtimeFence + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + throw new Error('agent_session_ownership_unknown') + } + const acquired = await input.adapter.acquire({ + identity: journalIdentityFor(record.sessionId, input.params), + fence, + // Retries must recover the original reservation, not mint a second child. + spawnToken + }) + if (record.lease.ownerProcess === null) { + await input.store.commitProcessIdentity({ + sessionId: record.sessionId, + fence, + process: acquired.process, + now: input.now() + }) + } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { + throw new Error('agent_session_ownership_unknown') + } + return input.store.proveOwner({ + sessionId: record.sessionId, + fence, + link: acquired.link, + now: input.now() + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts new file mode 100644 index 00000000000..facca4d142c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -0,0 +1,197 @@ +// Attach: reserve the session record, then open its journal. +// +// `create` and `ensure` are the same transition with a different starting +// point — a null expected fence means "no session exists yet". Both go through +// the record store's compare-and-swap, which also owns the idempotency row, so +// a retried attach replays instead of reserving a second owner. + +import type { AgentType } from '../../../shared/agent-status-types' +import type { + AgentSessionJournalIdentity, + AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionAccountHome, + AgentSessionExecutionLocation, + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + type AgentSessionMutationEnvelope, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + openAgentSessionJournalWithRecovery, + type AgentSessionJournalRecovery +} from './agent-session-journal-recovery' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +/** + * Everything a client may declare about the session it wants. Deliberately no + * spawn token, claim key, or owner probe: those are host observations, and a + * client that could assert "the previous owner is dead" could steal a live + * session. The host fills them in. + */ +export type AgentSessionAttachParams = { + envelope: AgentSessionMutationEnvelope + location: AgentSessionExecutionLocation + provider: AgentSessionHandleProvider + agent: AgentType + accountHome: AgentSessionAccountHome + runtimeKind: AgentSessionOwnerRuntimeKind + providerHandle: Exclude +} + +/** Host-supplied half of the reservation. */ +export type AgentSessionAttachAuthority = { + spawnToken: string + claimKeyId: string + handoffOperationId: string | null + probe: AgentSessionOwnerProbe +} + +/** The fields that define WHICH session this call would attach to. Deliberately + * excludes the spawn token and the probe: those differ between a first attempt + * and its retry, and a retry must replay rather than conflict. */ +export function attachFingerprintFields(params: AgentSessionAttachParams): Record { + return { + location: params.location, + provider: params.provider, + agent: params.agent, + accountHome: params.accountHome, + runtimeKind: params.runtimeKind, + providerHandle: params.providerHandle, + expectedRuntimeFence: params.envelope.expectedRuntimeFence + } +} + +/** Recomputes the fingerprint the client declared and refuses a mismatch before + * anything reaches the store. */ +export function admitAttachOrRefuse( + params: AgentSessionAttachParams +): { ok: true; fingerprint: string } | { ok: false; refusal: AgentSessionWireRefusal } { + if (params.providerHandle.kind !== params.provider) { + return { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: `A ${params.provider} session requires a ${params.provider} provider handle.` + } + } + } + const fingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: attachFingerprintFields(params) + }) + const conflict = agentSessionFingerprintConflict(params.envelope, fingerprint) + return conflict ? { ok: false, refusal: conflict } : { ok: true, fingerprint } +} + +export function journalIdentityFor( + sessionId: string, + params: AgentSessionAttachParams +): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: params.location.workspaceId, + hostId: params.location.executionHostId, + agent: params.agent, + providerHandle: params.providerHandle + } +} + +export type AttachedJournal = { + journal: AgentSessionJournal + recovery: AgentSessionJournalRecovery | null + /** Submissions the crash boundary settled as `unknown` on this open. */ + unconfirmedClientMessageIds: string[] +} + +/** + * Open the session's journal, recovering it when the stored one is unusable, + * and settle every submission left in flight by a previous process. Orca never + * re-sends those; they surface as delivery unconfirmed. + */ +export async function attachJournal(input: { + record: AgentSessionRecord + params: AgentSessionAttachParams + journalRoot: string + adapter: StructuredAgentSessionAdapter +}): Promise { + const identity = journalIdentityFor(input.record.sessionId, input.params) + const fence = input.record.lease.runtimeFence + const historyFilePath = input.adapter.historyFilePath + ? await input.adapter.historyFilePath({ identity }) + : null + const opened = await openAgentSessionJournalWithRecovery({ + identity, + journalDir: journalDirectoryFor(input.journalRoot, { + workspaceId: identity.workspaceId, + sessionId: identity.sessionId + }), + fence, + historyFilePath + }) + return { + ...opened, + unconfirmedClientMessageIds: await opened.journal.markPendingSubmissionsUnknown(fence) + } +} + +export function reserveRequestFor(input: { + sessionId: string + params: AgentSessionAttachParams + authority: AgentSessionAttachAuthority + callerKey: string + fingerprint: string + now: number +}): Parameters[0] { + const { params, authority } = input + return { + sessionId: input.sessionId, + location: params.location, + provider: params.provider, + accountHome: params.accountHome, + runtimeKind: params.runtimeKind, + expectedFence: params.envelope.expectedRuntimeFence, + spawnToken: authority.spawnToken, + claimKeyId: authority.claimKeyId, + handoffOperationId: authority.handoffOperationId, + probe: authority.probe, + operation: { + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + fingerprint: input.fingerprint + }, + now: input.now + } +} + +/** The store signals refusals by throwing the refusal code. Anything not in the + * known set is a defect, not a client error, and is rethrown. */ +export function classifyStoreFailure( + error: unknown, + currentFence: number | null +): AgentSessionWireRefusal { + const code = error instanceof Error ? error.message : String(error) + if (!(AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(code)) { + throw error + } + return { + code: code as AgentSessionWireRefusalCode, + message: `The session store refused this call: ${code}.`, + ...(code === 'agent_session_checkpoint_stale' && currentFence !== null ? { currentFence } : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts new file mode 100644 index 00000000000..68ec4bcbce0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -0,0 +1,805 @@ +// The host against a real record store and a real journal, with only the +// provider adapter stubbed — admission, idempotency, and the journal writes are +// exactly the ones that ship. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { + AgentSessionMutationEnvelope, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { attachFingerprintFields } from './structured-agent-session-attach' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { StructuredAgentSessionHost } from './structured-agent-session-host' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +const LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +const CALLER = { callerKey: 'client-1' } + +let operations = 0 + +/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. */ +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function envelope( + method: string, + fields: Record, + overrides: Partial = {} +): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: operationId(), + // A mutation names the fence it believes it is writing under; only attach + // may leave it null. + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }), + ...overrides + } +} + +function attachParams(overrides: Partial = {}): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: null, + payloadFingerprint: '0'.repeat(64) + }, + location: LOCATION, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: THREAD }, + ...overrides + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: attachFingerprintFields(params) + }) + } + } +} + +/** `ensure`: the same transition as create, from a fence the client already holds. */ +function ensureParams(fence: number): AgentSessionAttachParams { + return attachParams({ + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '0'.repeat(64) + } + }) +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let cancelTurn: Mock +let answerPrompt: Mock +let setOption: Mock +let ordinal = 0 + +function accepted(): AgentSessionDispatchOutcome { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } + } +} + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // A restarted host re-proves the thread it inherited; only the first + // owner of a session may claim to have created it. + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch, + cancelTurn, + answerPrompt, + setOption + } +} + +async function attach(): Promise { + const result = await host.attach(CALLER, attachParams()) + expect(result.ok).toBe(true) + return store.getRecord(SESSION) +} + +/** Puts a pending approval in the journal BEFORE attach, which is the only way + * 1d can stage one: the adapter that would emit it is phase 2's. */ +async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> { + const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } + const journalDir = journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir + }) + const appended = await journal.appendItem( + identity, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1 } + ) + return { itemId: appended.itemId, revision: appended.revision } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-host-')) + operations = 0 + ordinal = 0 + dispatch = vi.fn(async () => accepted()) + cancelTurn = vi.fn(async () => ({ cancelled: true })) + answerPrompt = vi.fn(async () => undefined) + setOption = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('attach', () => { + it('reserves the lease, spawns through the adapter, and opens the journal', async () => { + const result = await host.attach(CALLER, attachParams()) + expect(result).toMatchObject({ ok: true, replayed: false }) + expect(host.hasSession(SESSION)).toBe(true) + const record = store.getRecord(SESSION) + expect(record?.lease.ownerProcess?.pid).toBe(4242) + expect(record?.lease.handoffStage).toBeNull() + }) + + it('refuses a payload the client fingerprinted wrong', async () => { + const params = attachParams() + const result = await host.attach(CALLER, { + ...params, + envelope: { ...params.envelope, payloadFingerprint: 'a'.repeat(64) } + }) + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + }) + + it('refuses a provider handle that belongs to a different provider', async () => { + const params = attachParams({ + providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + }) + + expect(await host.attach(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(store.getRecord(SESSION)).toBeNull() + }) + + it('refuses a second create against a live session', async () => { + await attach() + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: false }) + }) + + it('replays a retried attach instead of reserving a second owner', async () => { + const params = attachParams() + await host.attach(CALLER, params) + const retry = await host.attach(CALLER, params) + expect(retry).toMatchObject({ ok: true, replayed: true }) + }) + + it('reuses the persisted spawn token when acquisition is retried', async () => { + const spawnTokens: string[] = [] + const acquire = vi + .fn() + .mockImplementationOnce(async ({ spawnToken }) => { + spawnTokens.push(spawnToken) + throw new Error('reply lost') + }) + .mockImplementation(async ({ fence, spawnToken }) => { + spawnTokens.push(spawnToken) + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + let token = 0 + host = new StructuredAgentSessionHost({ + store, + adapter: { ...adapter(), acquire }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${++token}`, + now: () => NOW + }) + const params = attachParams() + + await expect(host.attach(CALLER, params)).rejects.toThrow('reply lost') + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(spawnTokens).toEqual(['spawn-1', 'spawn-1']) + }) + + it('finishes proof when a retry finds the process identity already committed', async () => { + const acquire = vi + .fn() + .mockImplementationOnce(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: 'stale-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence + 1, + observedAt: NOW + } + })) + .mockImplementation(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + host = new StructuredAgentSessionHost({ + store, + adapter: { ...adapter(), acquire }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const params = attachParams() + + await expect(host.attach(CALLER, params)).rejects.toThrow( + 'agent_session_provider_handle_stale_fence' + ) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + }) +}) + +describe('send', () => { + it('writes the submission before dispatching and resolves it accepted', async () => { + await attach() + const body = message('add a retry') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + if (!result.ok) { + throw new Error(`expected a send, got ${result.refusal.code}`) + } + expect(result.value.submission.dispatchState).toBe('accepted') + expect(dispatch).toHaveBeenCalledTimes(1) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items).toHaveLength(1) + }) + + it('settles a thrown dispatch as unknown, never as a rejection', async () => { + await attach() + dispatch.mockRejectedValueOnce(new Error('socket closed')) + const body = message('add a retry') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + }) + + it('replays a retried send from the journal without dispatching twice', async () => { + await attach() + const body = message('add a retry') + const params = { envelope: envelope('agentSession.send', { body }), body } + await host.send(CALLER, params) + const retry = await host.send(CALLER, params) + expect(retry).toMatchObject({ ok: true, replayed: true }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('refuses a stale fence and hands back the current one', async () => { + const record = await attach() + const body = message('add a retry') + const result = await host.send(CALLER, { + envelope: envelope( + 'agentSession.send', + { body }, + { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } + ), + body + }) + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale', currentFence: record?.lease.runtimeFence } + }) + }) + + it('does not let a refused call leave a ledger row that replays past the fence', async () => { + const record = await attach() + const body = message('add a retry') + const params = { + envelope: envelope( + 'agentSession.send', + { body }, + { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } + ), + body + } + await host.send(CALLER, params) + expect(await host.send(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('refuses any mutation against a session this host has not attached', async () => { + const body = message('add a retry') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) + }) +}) + +describe('cancel', () => { + it('records the outcome as a status item keyed by the operation id', async () => { + await attach() + const result = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items[0]?.body).toMatchObject({ kind: 'status' }) + }) + + it('reports an unconfirmed cancellation rather than failing the call', async () => { + await attach() + cancelTurn.mockRejectedValueOnce(new Error('no answer')) + const result = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + expect(result).toMatchObject({ ok: true, value: { cancelled: false } }) + }) + + it('never interrupts twice on a replay', async () => { + await attach() + const params = { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + } + await host.cancel(CALLER, params) + expect(await host.cancel(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + value: { cancelled: false } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + }) +}) + +describe('respondToPrompt', () => { + it('commits the answer before the provider callback', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + expect(result).toMatchObject({ + ok: true, + value: { resolution: { state: 'resolved', selectedOptionId: 'allow' } } + }) + expect(answerPrompt).toHaveBeenCalledTimes(1) + }) + + it('refuses a second answer to one prompt and says which answer won', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + const loser = await host.respondToPrompt( + { callerKey: 'client-2' }, + { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + } + ) + expect(loser).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_item_revision_stale', + resolution: { selectedOptionId: 'allow' } + } + }) + expect(answerPrompt).toHaveBeenCalledTimes(1) + }) + + it('refuses an option the prompt does not offer', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'deny' } + expect( + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } }) + expect(answerPrompt).not.toHaveBeenCalled() + }) + + it("does not turn a recorded refusal into another client's successful answer", async () => { + const prompt = await seedApproval() + await attach() + const rejectedFields = { + itemId: prompt.itemId, + expectedRevision: prompt.revision, + optionId: 'deny' + } + const rejected = { + envelope: envelope('agentSession.respondTo:approval', rejectedFields), + kind: 'approval' as const, + ...rejectedFields + } + await host.respondToPrompt(CALLER, rejected) + + const acceptedFields = { ...rejectedFields, optionId: 'allow' } + await host.respondToPrompt( + { callerKey: 'client-2' }, + { + envelope: envelope('agentSession.respondTo:approval', acceptedFields), + kind: 'approval', + ...acceptedFields + } + ) + + expect(await host.respondToPrompt(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + }) + + it('keeps the answer and reports it undelivered when the provider callback throws', async () => { + const prompt = await seedApproval() + await attach() + answerPrompt.mockRejectedValueOnce(new Error('pipe closed')) + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + expect(result.ok).toBe(true) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const statusId = agentJournalItemKey({ + provider: 'orca', + clientMessageId: `${prompt.itemId}#delivery` + }) + expect(page.ok && page.page.items.some((entry) => entry.itemId === statusId)).toBe(true) + }) +}) + +describe('setOption', () => { + it('goes to the provider and writes nothing to the journal', async () => { + await attach() + const fields = { key: 'model', value: 'gpt-5' } + const result = await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + expect(result).toMatchObject({ ok: true, value: fields }) + expect(setOption).toHaveBeenCalledTimes(1) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items).toHaveLength(0) + }) + + it('does not turn an unknown provider outcome into a successful replay', async () => { + await attach() + setOption.mockRejectedValueOnce(new Error('reply lost')) + const fields = { key: 'model', value: 'gpt-5' } + const params = { + envelope: envelope('agentSession.setOption', fields), + ...fields + } + + await expect(host.setOption(CALLER, params)).rejects.toThrow('reply lost') + expect(await host.setOption(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(setOption).toHaveBeenCalledTimes(1) + }) +}) + +describe('restart', () => { + /** A restarted process: the same directories, a new store and a new host over + * them. Every lease loads unreconciled, so this is the state that decides + * whether a persisted session is reachable at all. */ + async function reboot( + probeOwner: (record: AgentSessionRecord) => Promise + ) { + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-b', + probeOwner, + now: () => NOW + }) + } + + /** The refusal a restarted host owes a client holding the dead generation's + * fence: stale, with the live fence attached so the retry can succeed. */ + async function staleFenceFrom(held: number): Promise { + const refused = await host.attach(CALLER, ensureParams(held)) + if (refused.ok) { + throw new Error('a fence from the previous host generation was accepted') + } + expect(refused.refusal.code).toBe('agent_session_checkpoint_stale') + const current = refused.refusal.currentFence + expect(current).toBeGreaterThan(held) + return current ?? 0 + } + + it('adjudicates the leases it loaded before deciding who may write', async () => { + const before = await attach() + const held = before?.lease.runtimeFence ?? 0 + await reboot(async () => ({ outcome: 'pid-absent' })) + + const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) + expect(reattached).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) + expect(store.getRecord(SESSION)?.lease.ownerProcess?.pid).toBe(4242) + }) + + it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => { + await attach() + const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + await reboot(async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' })) + + // Nothing proved the previous process dead, so the lease routes to manual + // recovery rather than letting a second writer in behind it. + expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_ownership_unknown' } + }) + }) + + it('does not remember a failed adjudication as done', async () => { + const before = await attach() + const held = before?.lease.runtimeFence ?? 0 + const probe = vi + .fn<(record: AgentSessionRecord) => Promise>() + .mockRejectedValueOnce(new Error('probe exploded')) + .mockResolvedValue({ outcome: 'pid-absent' }) + await reboot(probe) + + await expect(host.attach(CALLER, ensureParams(held))).rejects.toThrow('probe exploded') + // One unlucky startup must not strand the session for this host's lifetime. + const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) + expect(reattached).toMatchObject({ ok: true }) + expect(probe).toHaveBeenCalledTimes(2) + }) +}) + +describe('subscribe', () => { + it('opens with a snapshot and then streams cursor-qualified batches', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + const dispose = host.subscribe({ + id: 'sub-1', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const body = message('add a retry') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + expect(events[0]?.type).toBe('snapshot') + const batches = events.filter((event) => event.type === 'batch') + expect(batches.length).toBeGreaterThan(0) + const last = batches.at(-1) + expect(last?.type === 'batch' && last.batch.cursor.sequence).toBeGreaterThan(0) + + dispose() + expect(events.at(-1)?.type).toBe('end') + }) + + it('resumes from a client cursor with only the rows it missed', async () => { + await attach() + const body = message('add a retry') + const first = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + if (!first.ok) { + throw new Error(`expected a send, got ${first.refusal.code}`) + } + + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-2', + sessionId: SESSION, + emit: (event) => events.push(event), + cursor: first.cursor + }) + // Nothing new since that cursor: a resume is silent, not a redundant replay. + expect(events).toHaveLength(0) + + const second = message('and a timeout') + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: second }), + body: second + }) + expect(events.some((event) => event.type === 'batch')).toBe(true) + expect(events.some((event) => event.type === 'snapshot')).toBe(false) + }) + + it('drops a failed transport without aborting the mutation or other subscribers', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'dead-sub', + sessionId: SESSION, + emit: () => { + throw new Error('socket closed') + } + }) + host.subscribe({ + id: 'live-sub', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const body = message('survive subscriber failure') + + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + + expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(events.some((event) => event.type === 'batch')).toBe(true) + }) + + it('resets a subscriber whose epoch is gone', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-3', + sessionId: SESSION, + emit: (event) => events.push(event), + cursor: { epoch: 'epoch-from-a-previous-life', sequence: 3 } + }) + expect(events[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed', fence: 1 }) + }) + + it('publishes the replacement fence when the owner generation changes', async () => { + const record = await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-4', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const released = await store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: record?.lease.runtimeFence ?? 1, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + + const replacement = await host.attach(CALLER, ensureParams(released.lease.runtimeFence)) + if (!replacement.ok) { + throw new Error(`expected replacement owner, got ${replacement.refusal.code}`) + } + expect(events.at(-1)).toMatchObject({ type: 'snapshot', fence: replacement.fence }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts new file mode 100644 index 00000000000..4bf5940ecea --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -0,0 +1,357 @@ +// The structured agent-session host: one place where the lease, the journal, +// and the provider adapter meet. +// +// Every mutating call takes the same route — recompute the fingerprint, admit +// through the durable operation ledger, check the lease, then act — so no +// method can grow its own admission rules. Calls against one session are +// serialized; the journal's own queue orders writes, but admission has to be +// single-file too or two clients could both read one prompt as pending. + +import { randomUUID } from 'node:crypto' +import type { + AgentJournalCursor, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + admitAgentSessionMutation, + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionAttachResult, + AgentSessionCancelResult, + AgentSessionHistoryRequest, + AgentSessionHistoryResult, + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionOptionResult, + AgentSessionPromptResult, + AgentSessionSendResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { readAgentSessionHistory } from './agent-session-history-page' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' +import { createRestartReconciler } from './structured-agent-session-restart-reconcile' +import { + cancelPlan, + promptPlan, + sendPlan, + setOptionPlan, + type MutationPlan +} from './structured-agent-session-mutation-plans' +import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import { + AgentSessionSubscribers, + type AgentSessionSubscriberEmit +} from './structured-agent-session-subscribers' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +export type StructuredAgentSessionCaller = { + /** Stable per-client identity; scopes the operation ledger and records who + * answered a prompt. */ + callerKey: string +} + +export type StructuredAgentSessionHostDeps = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + journalRoot: string + /** Key id the host's claims are minted under. */ + claimKeyId: string + probeOwner?: (record: AgentSessionRecord) => Promise + mintSpawnToken?: () => string + now?: () => number +} + +type SessionState = { + journal: AgentSessionJournal + params: AgentSessionAttachParams + fence: number +} + +function refuse(refusal: AgentSessionWireRefusal): { ok: false; refusal: AgentSessionWireRefusal } { + return { ok: false, refusal } +} + +const NO_SESSION: AgentSessionWireRefusal = { + code: 'agent_session_ownership_unknown', + message: 'This host holds no attached session by that id.' +} + +export class StructuredAgentSessionHost { + private readonly sessions = new Map() + private readonly subscribers = new AgentSessionSubscribers() + private readonly chains = new Map>() + private readonly reconcileLeases: (sessionId: string) => Promise + + constructor(private readonly deps: StructuredAgentSessionHostDeps) { + this.reconcileLeases = createRestartReconciler({ + store: deps.store, + probe: (record) => this.probeRecord(record), + now: () => this.now() + }) + } + + private now = (): number => this.deps.now?.() ?? Date.now() + + hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId) + } + + /** Per-session single file. A rejected task must not poison the chain. */ + private serialize(sessionId: string, task: () => Promise): Promise { + const prior = this.chains.get(sessionId) ?? Promise.resolve() + const next = prior.then(task, task) + this.chains.set( + sessionId, + next.catch(() => undefined) + ) + return next + } + + /** + * `create` and `ensure` in one: a null expected fence means the session must + * not exist yet, any other value is a compare-and-swap against the lease. + */ + attach( + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams + ): Promise> { + return this.serialize(params.envelope.sessionId, async () => { + const sessionId = params.envelope.sessionId + const unreconciled = await this.reconcileLeases(sessionId) + if (unreconciled) { + return refuse(unreconciled) + } + return performAttach({ + store: this.deps.store, + adapter: this.deps.adapter, + journalRoot: this.deps.journalRoot, + authority: { + spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(), + claimKeyId: this.deps.claimKeyId, + handoffOperationId: params.envelope.clientOperationId, + probe: await this.probeOwner(sessionId) + }, + callerKey: caller.callerKey, + params, + now: () => this.now(), + onAttached: (attached) => { + const fence = this.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 + const previousFence = this.sessions.get(sessionId)?.fence + this.sessions.set(sessionId, { journal: attached.journal, params, fence }) + if (attached.recovery) { + this.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) + } else if (previousFence !== undefined && previousFence !== fence) { + this.subscribers.snapshot(sessionId, attached.journal, fence) + } else { + this.subscribers.publish(sessionId, attached.journal) + } + } + }) + }) + } + + /** + * Liveness of whatever currently owns the session. Never client-asserted: a + * client that could claim the owner is gone could evict a live one. Without a + * host prober the answer is `indeterminate`, which routes to manual recovery + * rather than a silent takeover. + */ + private probeOwner(sessionId: string): Promise { + const record = this.deps.store.getRecord(sessionId) + if (!record || record.lease.ownerProcess === null) { + return Promise.resolve({ outcome: 'reservation-unused' }) + } + return this.probeRecord(record) + } + + private probeRecord(record: AgentSessionRecord): Promise { + return ( + this.deps.probeOwner?.(record) ?? + Promise.resolve({ + outcome: 'indeterminate', + reason: 'This host cannot probe structured session owners.' + }) + ) + } + + send( + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } + ): Promise> { + return this.mutate(caller, params.envelope, sendPlan(params)) + } + + cancel( + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; turnId: string } + ): Promise> { + return this.mutate(caller, params.envelope, cancelPlan(params)) + } + + respondToPrompt( + caller: StructuredAgentSessionCaller, + params: { + envelope: AgentSessionMutationEnvelope + kind: 'approval' | 'question' + itemId: string + expectedRevision: number + optionId: string + } + ): Promise> { + return this.mutate(caller, params.envelope, promptPlan(params)) + } + + setOption( + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } + ): Promise> { + return this.mutate(caller, params.envelope, setOptionPlan(params)) + } + + private mutate( + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan + ): Promise> { + return this.serialize(envelope.sessionId, () => this.runMutation(caller, envelope, plan)) + } + + private async runMutation( + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan + ): Promise> { + const session = this.sessions.get(envelope.sessionId) + const record = this.deps.store.getRecord(envelope.sessionId) + if (!session || !record) { + return refuse(NO_SESSION) + } + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const conflict = agentSessionFingerprintConflict(envelope, hostFingerprint) + if (conflict) { + return refuse(conflict) + } + const admission = admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: await this.deps.store.admitOperation({ + callerKey: caller.callerKey, + operationId: envelope.clientOperationId, + fingerprint: hostFingerprint, + now: this.now() + }), + lease: record.lease + }) + if (admission.decision === 'refused') { + return refuse(admission.refusal) + } + + const fence = record.lease.runtimeFence + const ctx = this.contextFor(caller, session, envelope.sessionId, fence) + if (admission.decision === 'replay') { + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: admission.row.outcome, + reconstruct: () => plan.replay(ctx, admission.row.outcome) + }) + if (replay.decision === 'refuse') { + return refuse(replay.refusal) + } + if (replay.decision === 'replay') { + return { + ok: true, + replayed: true, + fence, + cursor: ctx.journal.cursor(), + value: replay.value + } + } + // Nothing durable landed, so this id is about to run for the first time. + // A refused call leaves its ledger row behind, and replaying past the + // lease and the fence would let a resend act under an owner that has since + // changed — so a first run pays the full admission price either way. + const rerun = admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: { decision: 'admit', row: admission.row }, + lease: record.lease + }) + if (rerun.decision === 'refused') { + return refuse(rerun.refusal) + } + } + + const outcome = await runSettledAgentSessionMutation({ + store: this.deps.store, + callerKey: caller.callerKey, + envelope, + plan, + context: ctx + }) + return outcome.ok + ? { ok: true, replayed: false, fence, cursor: ctx.journal.cursor(), value: outcome.value } + : refuse(outcome.refusal) + } + + private contextFor( + caller: StructuredAgentSessionCaller, + session: SessionState, + sessionId: string, + fence: number + ): AgentSessionTurnContext { + return { + sessionId, + journal: session.journal, + fence, + adapter: this.deps.adapter, + resolvedBy: caller.callerKey, + publish: () => this.subscribers.publish(sessionId, session.journal), + now: () => this.now() + } + } + + /** + * Paged read. Throws `agent_session_ownership_unknown` for a session this host + * has not attached — a reset would tell the client to reload something that + * does not exist here. + */ + history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { + return readAgentSessionHistory(this.requireSession(request.sessionId).journal, request) + } + + subscribe(input: { + id: string + sessionId: string + emit: AgentSessionSubscriberEmit + cursor?: AgentJournalCursor + }): () => void { + const session = this.requireSession(input.sessionId) + const fence = this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0 + return this.subscribers.open({ ...input, journal: session.journal, fence }) + } + + unsubscribe(sessionId: string, id: string): void { + this.subscribers.close(sessionId, id) + } + + private requireSession(sessionId: string): SessionState { + const session = this.sessions.get(sessionId) + if (!session) { + throw new Error(NO_SESSION.code) + } + return session + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts new file mode 100644 index 00000000000..0d8bad1d19e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -0,0 +1,117 @@ +// One plan per mutating method: what it fingerprints, what it does, and how its +// answer is rebuilt on a replay. +// +// The replay half matters more than it looks. The ledger records only that an +// operation happened, so the durable answer has to come back out of the journal. +// A plan that cannot find its effect returns null, and the call runs for real — +// which is exactly right when the crash landed before the journal write. + +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionCancelResult, + AgentSessionMutationEnvelope, + AgentSessionOptionResult, + AgentSessionPromptResult, + AgentSessionSendResult +} from '../../../shared/agent-session-wire' +import { + performCancel, + performPrompt, + performSend, + performSetOption, + type AgentSessionTurnContext, + type TurnOutcome +} from './structured-agent-session-turns' + +export type MutationPlan = { + method: string + fields: Record + run: (ctx: AgentSessionTurnContext) => Promise> + replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null +} + +export function sendPlan(params: { + envelope: AgentSessionMutationEnvelope + body: AgentJournalMessageItem +}): MutationPlan { + // The operation id IS the client message id: one send, one durable row, one + // key the client reconciles its optimistic bubble against. + const clientMessageId = params.envelope.clientOperationId + return { + method: 'agentSession.send', + fields: { body: params.body }, + run: (ctx) => + performSend(ctx, { + clientMessageId, + payloadFingerprint: params.envelope.payloadFingerprint, + body: params.body + }), + replay: (ctx) => { + const submission = ctx.journal + .submissions() + .find((entry) => entry.clientMessageId === clientMessageId) + return submission ? { clientMessageId, submission } : null + } + } +} + +export function cancelPlan(params: { + envelope: AgentSessionMutationEnvelope + turnId: string +}): MutationPlan { + return { + method: 'agentSession.cancel', + fields: { turnId: params.turnId }, + run: (ctx) => + performCancel(ctx, { + clientOperationId: params.envelope.clientOperationId, + turnId: params.turnId + }), + // Interrupting twice would kill a turn the client never asked to stop, so a + // replay reports the turn as already handled instead. + replay: () => ({ turnId: params.turnId, cancelled: false }) + } +} + +export function promptPlan(params: { + kind: 'approval' | 'question' + itemId: string + expectedRevision: number + optionId: string +}): MutationPlan { + return { + method: `agentSession.respondTo:${params.kind}`, + fields: { + itemId: params.itemId, + expectedRevision: params.expectedRevision, + optionId: params.optionId + }, + run: (ctx) => performPrompt(ctx, params), + replay: (ctx) => { + const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === params.itemId) + const body = item?.body + if (!item || !body || (body.kind !== 'approval' && body.kind !== 'question')) { + return null + } + return body.resolution.state === 'pending' + ? null + : { itemId: item.itemId, revision: item.revision, resolution: body.resolution } + } + } +} + +export function setOptionPlan(params: { + key: string + value: string +}): MutationPlan { + return { + method: 'agentSession.setOption', + fields: { key: params.key, value: params.value }, + run: (ctx) => performSetOption(ctx, params), + // A pending row may have crashed before the adapter call. Reapplying the + // same assignment is safe; only a settled success can be answered directly. + replay: (_ctx, outcome) => + outcome.status === 'succeeded' ? { key: params.key, value: params.value } : null + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts new file mode 100644 index 00000000000..4cb24518c69 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -0,0 +1,33 @@ +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +export async function runSettledAgentSessionMutation(input: { + store: AgentSessionRecordStore + callerKey: string + envelope: AgentSessionMutationEnvelope + plan: MutationPlan + context: AgentSessionTurnContext +}): Promise> { + const settle = ( + outcome: Parameters[0]['outcome'] + ) => + input.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.envelope.clientOperationId, + outcome + }) + try { + const outcome = await input.plan.run(input.context) + await settle( + outcome.ok + ? { status: 'succeeded', sessionId: input.envelope.sessionId } + : { status: 'failed', code: outcome.refusal.code } + ) + return outcome + } catch (error) { + await settle({ status: 'unknown' }) + throw error + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts new file mode 100644 index 00000000000..b7a26bb8e3e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts @@ -0,0 +1,18 @@ +// Where the RPC layer finds the host. +// +// The runtime service is already far past its size budget, so structured +// sessions hang off a module-level slot instead of another field on it — the +// same shape the native-chat RPC methods use to reach their own collaborators. +// Tests install a host with a stub adapter and clear it on teardown. + +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +let host: StructuredAgentSessionHost | null = null + +export function setStructuredAgentSessionHost(next: StructuredAgentSessionHost | null): void { + host = next +} + +export function getStructuredAgentSessionHost(): StructuredAgentSessionHost | null { + return host +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts new file mode 100644 index 00000000000..1121dbc8124 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -0,0 +1,53 @@ +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' + +export type AgentSessionReplayOutcomeDecision = + | { decision: 'replay'; value: TValue } + | { decision: 'rerun' } + | { decision: 'refuse'; refusal: AgentSessionWireRefusal } + +export function resolveAgentSessionReplayOutcome(input: { + operationId: string + outcome: AgentSessionOperationOutcome + reconstruct: () => TValue | null +}): AgentSessionReplayOutcomeDecision { + const { operationId, outcome } = input + if (outcome.status === 'failed') { + const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code) + ? (outcome.code as AgentSessionWireRefusalCode) + : 'agent_session_operation_invalid' + return { + decision: 'refuse', + refusal: { + code, + message: `Operation ${operationId} was already refused: ${outcome.code}.` + } + } + } + if (outcome.status === 'unknown') { + return { + decision: 'refuse', + refusal: { + code: 'agent_session_operation_unknown', + message: `The outcome of operation ${operationId} is unknown; it was not run again.` + } + } + } + const recorded = input.reconstruct() + if (recorded) { + return { decision: 'replay', value: recorded } + } + return outcome.status === 'succeeded' + ? { + decision: 'refuse', + refusal: { + code: 'agent_session_operation_unknown', + message: `Operation ${operationId} succeeded, but its result is no longer reconstructable.` + } + } + : { decision: 'rerun' } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts new file mode 100644 index 00000000000..ac0ae832c0b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { createRestartReconciler } from './structured-agent-session-restart-reconcile' + +describe('createRestartReconciler', () => { + it('reruns after an external store refresh introduces unreconciled leases', async () => { + let record = { sessionId: 'session-1', lease: { unreconciled: true } } as AgentSessionRecord + const reconcileOnRestart = vi.fn(async () => { + record = { ...record, lease: { ...record.lease, unreconciled: false } } + return new Map() + }) + const store = { + listRecords: () => [record], + getRecord: () => record, + reconcileOnRestart + } as unknown as AgentSessionRecordStore + const reconcile = createRestartReconciler({ + store, + probe: async () => ({ outcome: 'pid-absent' }), + now: () => 1 + }) + + expect(await reconcile('session-1')).toBeNull() + record = { ...record, lease: { ...record.lease, unreconciled: true } } + expect(await reconcile('session-1')).toBeNull() + expect(reconcileOnRestart).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts new file mode 100644 index 00000000000..895381f4dbf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -0,0 +1,42 @@ +// Every lease loads from disk unreconciled: the process that wrote it may still +// be alive, so nothing the store persisted grants a writer until this host has +// adjudicated it. Without this an attach after a restart is refused forever with +// `execution_owner_reconciling`, and the session becomes unreachable. + +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { classifyStoreFailure } from './structured-agent-session-attach' + +/** Adjudicates leases loaded by this process or refreshed from another writer. + * Answers with the refusal attach owes its caller, or null once settled. */ +export function createRestartReconciler(deps: { + store: AgentSessionRecordStore + probe: (record: AgentSessionRecord) => Promise + now: () => number +}): (sessionId: string) => Promise { + let pending: Promise | null = null + return async (sessionId) => { + if (!deps.store.listRecords().some((record) => record.lease.unreconciled)) { + return null + } + if (!pending) { + const run = deps.store + .reconcileOnRestart({ probe: deps.probe, now: deps.now() }) + .then(() => undefined) + pending = run.finally(() => { + pending = null + }) + } + try { + await pending + return null + } catch (error) { + return classifyStoreFailure( + error, + deps.store.getRecord(sessionId)?.lease.runtimeFence ?? null + ) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts new file mode 100644 index 00000000000..a3b4e3484ee --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -0,0 +1,172 @@ +// Per-subscriber cursors over one session's journal. +// +// Each subscriber advances independently: a client that connected two epochs +// ago gets a reset while a caught-up one gets a batch from the same publish. +// Nothing raw reaches a subscriber — every event carries reducer output. + +import type { + AgentJournalCursor, + AgentJournalResetReason +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { projectJournalBatch } from './agent-session-journal-batch' + +export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void + +type Subscriber = { + id: string + sessionId: string + emit: AgentSessionSubscriberEmit + cursor: AgentJournalCursor + fence: number +} + +export class AgentSessionSubscribers { + private readonly bySession = new Map>() + + /** Opens the stream with a snapshot or, when the client's cursor still + * resolves, with the rows it missed. Returns the disposer. */ + open(input: { + id: string + sessionId: string + journal: AgentSessionJournal + fence: number + emit: AgentSessionSubscriberEmit + cursor?: AgentJournalCursor + }): () => void { + const snapshot = input.journal.snapshot() + const subscriber: Subscriber = { + id: input.id, + sessionId: input.sessionId, + emit: input.emit, + cursor: input.cursor ?? { epoch: snapshot.cursor.epoch, sequence: 0 }, + fence: input.fence + } + const session = this.bySession.get(input.sessionId) ?? new Map() + session.set(input.id, subscriber) + this.bySession.set(input.sessionId, session) + + if (input.cursor) { + this.deliver(subscriber, input.journal) + } else { + this.emit(subscriber, { + type: 'snapshot', + sessionId: input.sessionId, + snapshot, + fence: input.fence + }) + subscriber.cursor = snapshot.cursor + } + return () => this.close(input.sessionId, input.id) + } + + close(sessionId: string, id: string): void { + const session = this.bySession.get(sessionId) + const subscriber = session?.get(id) + if (!session || !subscriber) { + return + } + this.drop(subscriber) + try { + subscriber.emit({ type: 'end' }) + } catch { + // The transport is already gone; teardown must remain idempotent. + } + } + + /** Fan out whatever each subscriber has not yet seen. */ + publish(sessionId: string, journal: AgentSessionJournal): void { + for (const subscriber of this.subscribers(sessionId)) { + this.deliver(subscriber, journal) + } + } + + /** Force every subscriber back to a clean snapshot — recovery, epoch + * rollover, an unreadable schema. */ + reset( + sessionId: string, + journal: AgentSessionJournal, + reason: AgentJournalResetReason, + fence: number + ): void { + const snapshot = journal.snapshot() + for (const subscriber of this.subscribers(sessionId)) { + this.emit(subscriber, { type: 'reset', sessionId, reset: reason, snapshot, fence }) + subscriber.cursor = snapshot.cursor + subscriber.fence = fence + } + } + + snapshot(sessionId: string, journal: AgentSessionJournal, fence: number): void { + const snapshot = journal.snapshot() + for (const subscriber of this.subscribers(sessionId)) { + this.emit(subscriber, { type: 'snapshot', sessionId, snapshot, fence }) + subscriber.cursor = snapshot.cursor + subscriber.fence = fence + } + } + + private subscribers(sessionId: string): Subscriber[] { + return [...(this.bySession.get(sessionId)?.values() ?? [])] + } + + private deliver(subscriber: Subscriber, journal: AgentSessionJournal): void { + const since = journal.readSince(subscriber.cursor) + const snapshot = journal.snapshot() + if (!since.ok) { + this.emit(subscriber, { + type: 'reset', + sessionId: subscriber.sessionId, + reset: since.reset, + snapshot, + fence: subscriber.fence + }) + subscriber.cursor = snapshot.cursor + return + } + if (since.rows.length === 0) { + return + } + const projected = projectJournalBatch({ + rows: since.rows, + snapshot, + afterSequence: subscriber.cursor.sequence + }) + if (!projected.ok) { + this.emit(subscriber, { + type: 'reset', + sessionId: subscriber.sessionId, + reset: projected.reset, + snapshot, + fence: subscriber.fence + }) + subscriber.cursor = snapshot.cursor + return + } + this.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: projected.batch + }) + subscriber.cursor = projected.batch.cursor + } + + /** A dead transport cannot be allowed to turn a durable mutation into an + * unknown outcome or poison every later publication. */ + private emit(subscriber: Subscriber, event: AgentSessionSubscribeEvent): void { + try { + subscriber.emit(event) + } catch { + this.drop(subscriber) + } + } + + private drop(subscriber: Subscriber): void { + const session = this.bySession.get(subscriber.sessionId) + session?.delete(subscriber.id) + if (session?.size === 0) { + this.bySession.delete(subscriber.sessionId) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts new file mode 100644 index 00000000000..c4515e104f7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -0,0 +1,246 @@ +// The effects behind send / cancel / respond / setOption. +// +// Admission (lease, fence, idempotency) has already passed by the time anything +// here runs; these functions own only the journal writes and the adapter call, +// in that order. Journal first is deliberate: a crash between the two leaves a +// row the next attach settles as `unknown`, whereas the reverse would lose a +// turn the provider already accepted. + +import type { + AgentJournalItemBody, + AgentJournalMessageItem, + AgentJournalResolution +} from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentSessionCancelResult, + AgentSessionOptionResult, + AgentSessionPromptResult, + AgentSessionSendResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' + +export type AgentSessionTurnContext = { + sessionId: string + journal: AgentSessionJournal + fence: number + adapter: StructuredAgentSessionAdapter + /** Opaque client identity recorded as the resolver of a prompt. */ + resolvedBy: string + publish: () => void + now: () => number +} + +export type TurnOutcome = + | { ok: true; value: TValue } + | { ok: false; refusal: AgentSessionWireRefusal } + +function invalid(message: string): { ok: false; refusal: AgentSessionWireRefusal } { + return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +} + +/** A thrown adapter error is indistinguishable from a lost reply, so it settles + * as `unknown` rather than as a rejection. */ +async function dispatchSafely( + ctx: AgentSessionTurnContext, + clientMessageId: string, + body: AgentJournalMessageItem +): Promise { + try { + return await ctx.adapter.dispatch({ + sessionId: ctx.sessionId, + clientMessageId, + body, + fence: ctx.fence + }) + } catch (error) { + return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } + } +} + +async function appendStatus( + ctx: AgentSessionTurnContext, + clientMessageId: string, + text: string +): Promise { + await ctx.journal.appendItem( + { provider: 'orca', clientMessageId }, + { kind: 'status', text }, + { fence: ctx.fence } + ) + ctx.publish() +} + +export async function performSend( + ctx: AgentSessionTurnContext, + input: { clientMessageId: string; payloadFingerprint: string; body: AgentJournalMessageItem } +): Promise> { + await ctx.journal.appendSubmission({ ...input, fence: ctx.fence }) + ctx.publish() + + const outcome = await dispatchSafely(ctx, input.clientMessageId, input.body) + await ctx.journal.resolveDispatch( + outcome.state === 'accepted' + ? { + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: outcome.providerIdentity, + fence: ctx.fence + } + : { + clientMessageId: input.clientMessageId, + state: outcome.state, + reason: outcome.reason, + fence: ctx.fence + } + ) + ctx.publish() + + const submission = ctx.journal + .submissions() + .find((entry) => entry.clientMessageId === input.clientMessageId) + if (!submission) { + throw new Error('agent_session_submission_lost') + } + return { ok: true, value: { clientMessageId: input.clientMessageId, submission } } +} + +export async function performCancel( + ctx: AgentSessionTurnContext, + input: { clientOperationId: string; turnId: string } +): Promise> { + let cancelled = false + let note = `Cancelled turn ${input.turnId}.` + try { + cancelled = ( + await ctx.adapter.cancelTurn({ + sessionId: ctx.sessionId, + turnId: input.turnId, + fence: ctx.fence + }) + ).cancelled + if (!cancelled) { + note = `The provider had already finished turn ${input.turnId}.` + } + } catch (error) { + note = `Cancellation of turn ${input.turnId} was not confirmed: ${ + error instanceof Error ? error.message : String(error) + }` + } + // Keyed by the operation id so a replayed cancel upserts one item, not two. + await appendStatus(ctx, input.clientOperationId, note) + return { ok: true, value: { turnId: input.turnId, cancelled } } +} + +function promptBodyOf( + body: AgentJournalItemBody +): { options: readonly { id: string }[]; resolution: AgentJournalResolution } | null { + return body.kind === 'approval' || body.kind === 'question' ? body : null +} + +/** + * Durable compare-and-set on (itemId, revision) plus the pending state. The + * journal write commits before the provider callback fires, so two clients + * answering one prompt produce exactly one callback and the loser is told which + * answer won. + */ +export async function performPrompt( + ctx: AgentSessionTurnContext, + input: { + itemId: string + expectedRevision: number + optionId: string + kind: 'approval' | 'question' + } +): Promise> { + const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) + if (!item) { + return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) + } + const prompt = promptBodyOf(item.body) + if (!prompt || item.body.kind !== input.kind) { + return invalid(`Item ${input.itemId} is not a pending ${input.kind}.`) + } + if (item.revision !== input.expectedRevision) { + return { + ok: false, + refusal: { + code: 'agent_session_item_revision_stale', + message: `Item ${input.itemId} has moved on.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + if (prompt.resolution.state !== 'pending') { + return { + ok: false, + refusal: { + code: 'agent_session_already_resolved', + message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + if (!prompt.options.some((option) => option.id === input.optionId)) { + return invalid(`Option ${input.optionId} is not offered by item ${input.itemId}.`) + } + const identity = parseAgentJournalItemKey(input.itemId) + if (!identity) { + return invalid(`Item id ${input.itemId} is not a well-formed item key.`) + } + + const resolution: AgentJournalResolution = { + state: 'resolved', + selectedOptionId: input.optionId, + resolvedBy: ctx.resolvedBy, + resolvedAt: ctx.now() + } + const appended = await ctx.journal.appendItem( + identity, + { ...item.body, resolution }, + { + fence: ctx.fence + } + ) + ctx.publish() + + try { + await ctx.adapter.answerPrompt({ + sessionId: ctx.sessionId, + itemId: input.itemId, + kind: input.kind, + optionId: input.optionId, + fence: ctx.fence + }) + } catch (error) { + // The answer is committed and will not be offered again; say so rather than + // reopening the prompt and risking a second callback. + await appendStatus( + ctx, + `${input.itemId}#delivery`, + `Your answer was recorded but the agent did not confirm it: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + return { + ok: true, + value: { itemId: appended.itemId, revision: appended.revision, resolution } + } +} + +/** Options live on the provider, not in the journal, so this writes nothing. */ +export async function performSetOption( + ctx: AgentSessionTurnContext, + input: { key: string; value: string } +): Promise> { + await ctx.adapter.setOption({ sessionId: ctx.sessionId, ...input, fence: ctx.fence }) + return { ok: true, value: input } +} diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts new file mode 100644 index 00000000000..520e75a03c3 --- /dev/null +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -0,0 +1,33 @@ +// Ledger admission for mutations that are not reservations — send, cancel, an +// approval answer. Split from the store so the store keeps only the transaction. + +import { + agentSessionOperationKey, + evaluateAgentSessionOperation, + pruneAgentSessionOperationRows, + type AgentSessionOperationDecision, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' + +export type AgentSessionOperationAdmission = { + callerKey: string + operationId: string + fingerprint: string + now: number +} + +type OperationRows = Map + +/** Prune, evaluate, and (on admit) place the row. The caller runs this inside one + * transaction, so two concurrent copies of an operation id cannot both admit. */ +export function admitAgentSessionOperationRow( + rows: OperationRows, + args: AgentSessionOperationAdmission +): { rows: OperationRows; decision: AgentSessionOperationDecision } { + const pruned = pruneAgentSessionOperationRows(rows, args.now) + const decision = evaluateAgentSessionOperation({ rows: pruned, ...args }) + if (decision.decision === 'admit') { + pruned.set(agentSessionOperationKey(args.callerKey, args.operationId), decision.row) + } + return { rows: pruned, decision } +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 3f70fa44a73..15fcef3c85e 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -12,9 +12,14 @@ import { pruneAgentSessionOperationRows, agentSessionOperationKey, + type AgentSessionOperationDecision, type AgentSessionOperationOutcome, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import { + admitAgentSessionOperationRow, + type AgentSessionOperationAdmission +} from './agent-session-operation-admission' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' @@ -276,6 +281,17 @@ export class AgentSessionRecordStore { }) } + /** Admits one non-reservation mutation through the durable ledger. */ + async admitOperation( + args: AgentSessionOperationAdmission + ): Promise { + return this.transact(() => { + const admitted = admitAgentSessionOperationRow(this.state.operations, args) + this.state.operations = admitted.rows + return admitted.decision + }) + } + async recordOperationOutcome(args: { callerKey: string operationId: string diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 26c9a5a5ed6..98e6c73432b 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -39,6 +39,7 @@ import { EMULATOR_METHODS } from './emulator' import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { ARTIFACT_METHODS } from './artifacts' // Why: a flat manifest keeps registration order explicit and provides one @@ -52,6 +53,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...REPO_METHODS, ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, + ...STRUCTURED_AGENT_SESSION_METHODS, ...TERMINAL_METHODS, ...TERMINAL_ORPHAN_METHODS, ...BROWSER_CORE_METHODS, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts new file mode 100644 index 00000000000..d766399202a --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -0,0 +1,173 @@ +// Wire validation for `agentSession.*`. +// +// Strict objects throughout: zod drops unknown keys, and a silently dropped key +// is how a newer client's field becomes a different effect on an older host. + +import { z } from 'zod' +import { isAgentSessionId } from '../../../../shared/agent-session-record' +import { + AGENT_SESSION_HISTORY_DIRECTIONS, + AGENT_SESSION_HISTORY_MAX_LIMIT +} from '../../../../shared/agent-session-wire' +import { normalizeExecutionHostId } from '../../../../shared/execution-host' + +const MAX_ID_LENGTH = 512 +const MAX_PROMPT_BYTES = 256 * 1024 +const MAX_BLOCKS = 64 +const MAX_OPTION_LABEL = 512 + +export const SessionId = z + .string() + .max(MAX_ID_LENGTH) + .refine(isAgentSessionId, 'Invalid agent session id') + +const Identifier = (message: string) => + z + .string() + .min(1, message) + .max(MAX_ID_LENGTH, message) + .refine((value) => value === value.trim(), message) + +export const JournalCursor = z + .object({ + epoch: Identifier('Invalid journal epoch'), + sequence: z.number().int().nonnegative() + }) + .strict() + +export const MutationEnvelope = z + .object({ + sessionId: SessionId, + clientOperationId: Identifier('Invalid client operation id'), + /** Null is the "must not exist yet" case; every other call fences. */ + expectedRuntimeFence: z.number().int().positive().nullable(), + payloadFingerprint: z + .string() + .regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest') + }) + .strict() + +const ProviderHandle = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('codex'), threadId: Identifier('Invalid thread id') }).strict(), + z + .object({ + kind: z.literal('claude'), + sessionId: Identifier('Invalid provider session id'), + leafUuid: Identifier('Invalid leaf uuid').nullable() + }) + .strict() +]) + +const ExecutionHostId = z + .string() + .max(MAX_ID_LENGTH) + .transform((value) => normalizeExecutionHostId(value)) + .refine((value): value is NonNullable => value !== null, { + message: 'Invalid execution host id' + }) + +const ExecutionLocation = z + .object({ + executionHostId: ExecutionHostId, + wslDistro: Identifier('Invalid WSL distro').nullable(), + workspaceId: Identifier('Invalid workspace id'), + workspaceKind: z.enum(['git-worktree', 'folder']) + }) + .strict() + +const AccountHome = z + .object({ + variable: z.enum(['CLAUDE_CONFIG_DIR', 'CODEX_HOME']), + path: z.string().min(1).max(4096) + }) + .strict() + +export const AttachParams = z + .object({ + envelope: MutationEnvelope, + location: ExecutionLocation, + provider: z.enum(['codex', 'claude']), + agent: Identifier('Invalid agent'), + accountHome: AccountHome, + runtimeKind: z.enum(['native', 'tui']), + providerHandle: ProviderHandle + }) + .strict() + +/** Clients may only author user turns. Accepting an assistant or tool role here + * would let one client write words into the agent's mouth in another's + * timeline, and the provider — not the client — owns those. */ +const SendBlock = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }).strict(), + z + .object({ + type: z.literal('image-ref'), + path: z.string().min(1).max(4096).optional(), + url: z.string().min(1).max(4096).optional(), + alt: z.string().max(MAX_OPTION_LABEL).optional() + }) + .strict() + .refine( + (value) => Boolean(value.path) !== Boolean(value.url), + 'Provide exactly one of path/url' + ) +]) + +export const SendParams = z + .object({ + envelope: MutationEnvelope, + body: z + .object({ + kind: z.literal('message'), + role: z.literal('user'), + blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) + }) + .strict() + .refine( + (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, + 'Message is too large' + ) + }) + .strict() + +export const CancelParams = z + .object({ envelope: MutationEnvelope, turnId: Identifier('Invalid turn id') }) + .strict() + +export const RespondParams = z + .object({ + envelope: MutationEnvelope, + itemId: Identifier('Invalid item id'), + /** Compare-and-set: the revision the client had on screen. */ + expectedRevision: z.number().int().positive(), + optionId: Identifier('Invalid option id') + }) + .strict() + +export const SetOptionParams = z + .object({ + envelope: MutationEnvelope, + key: Identifier('Invalid option key'), + value: z.string().max(MAX_OPTION_LABEL) + }) + .strict() + +export const HistoryParams = z + .object({ + sessionId: SessionId, + direction: z.enum(AGENT_SESSION_HISTORY_DIRECTIONS), + cursor: JournalCursor.optional(), + limit: z.number().int().positive().max(AGENT_SESSION_HISTORY_MAX_LIMIT).optional() + }) + .strict() + +export const SubscribeParams = z + .object({ sessionId: SessionId, cursor: JournalCursor.optional() }) + .strict() + +export const UnsubscribeParams = z + .object({ + sessionId: SessionId, + subscriptionId: Identifier('Invalid subscription id').optional() + }) + .strict() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts new file mode 100644 index 00000000000..85460a63448 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -0,0 +1,292 @@ +// The wire boundary: who may see `agentSession.*` at all, and what shapes it +// accepts once they can. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + RUNTIME_CAPABILITIES, + RUNTIME_PROTOCOL_VERSION, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest, RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { ALL_RPC_METHODS } from './index' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +const SESSION = 'session-alpha' +const FINGERPRINT = 'f'.repeat(64) +const OPERATION = '1800000000000-00000000000000000000000000000001' + +function envelope(overrides: Record = {}) { + return { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: 1, + payloadFingerprint: FINGERPRINT, + ...overrides + } +} + +function sendParams(overrides: Record = {}) { + return { + envelope: envelope(), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...overrides + } +} + +function attachParams(overrides: Record = {}) { + return { + envelope: envelope({ expectedRuntimeFence: null }), + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + ...overrides + } +} + +function request(method: string, params: unknown): RpcRequest { + return { id: 'request-1', authToken: 'token', method, params } +} + +let hostCalls: Record> + +function hostStub(): StructuredAgentSessionHost { + hostCalls = { + attach: vi.fn(async () => ({ ok: true, replayed: false })), + send: vi.fn(async () => ({ ok: true, replayed: false })), + cancel: vi.fn(async () => ({ ok: true, replayed: false })), + respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), + setOption: vi.fn(async () => ({ ok: true, replayed: false })), + history: vi.fn(() => ({ ok: true, page: { items: [] } })), + subscribe: vi.fn(() => () => undefined), + unsubscribe: vi.fn() + } + return hostCalls as unknown as StructuredAgentSessionHost +} + +function dispatcher(): RpcDispatcher { + const runtime = { + getRuntimeId: () => 'runtime-1', + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn(), + cleanupSubscriptionsByPrefix: vi.fn() + } + return new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) +} + +/** The reply path is the only one that carries a client's negotiated identity, + * which is exactly what the capability gate reads. */ +async function call( + method: string, + params: unknown, + client?: { clientKind?: 'mobile' | 'runtime'; clientCapabilities?: string[] } +): Promise { + const replies: RpcResponse[] = [] + await dispatcher().dispatchStreaming( + request(method, params), + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + client + ) + const first = replies[0] + if (!first) { + throw new Error(`no reply for ${method}`) + } + return first +} + +const STRUCTURED_CLIENT = { + clientKind: 'mobile' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +beforeEach(() => { + setStructuredAgentSessionHost(hostStub()) +}) + +afterEach(() => { + setStructuredAgentSessionHost(null) +}) + +describe('capability gating', () => { + it('advertises the capability without bumping the protocol version', () => { + expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + // Additive methods do not break an old client; bumping would strand every + // paired device that has not updated. + expect(RUNTIME_PROTOCOL_VERSION).toBe(3) + }) + + it('registers every structured method on the runtime manifest', () => { + const names = new Set(ALL_RPC_METHODS.map((method) => method.name)) + for (const method of STRUCTURED_AGENT_SESSION_METHODS) { + expect(names).toContain(method.name) + } + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(10) + }) + + it('hides the surface from a declared client that did not advertise it', async () => { + const response = await call('agentSession.send', sendParams(), { + clientKind: 'mobile', + clientCapabilities: ['terminal.stream.v1'] + }) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(hostCalls.send).not.toHaveBeenCalled() + }) + + it('serves a client that advertised it', async () => { + const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.send).toHaveBeenCalledTimes(1) + }) + + it('serves an in-process caller, which negotiates no capabilities at all', async () => { + const response = await call('agentSession.send', sendParams()) + expect(response).toMatchObject({ ok: true }) + }) + + it('reports the surface as absent when no host is installed', async () => { + setStructuredAgentSessionHost(null) + const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: false }) + }) +}) + +describe('method routing', () => { + it('separates create from ensure by the fence the client may declare', async () => { + const created = await call('agentSession.create', attachParams(), STRUCTURED_CLIENT) + expect(created).toMatchObject({ ok: true }) + + const fenced = await call( + 'agentSession.create', + attachParams({ envelope: envelope() }), + STRUCTURED_CLIENT + ) + expect(fenced).toMatchObject({ ok: false }) + + const ensured = await call( + 'agentSession.ensure', + attachParams({ envelope: envelope() }), + STRUCTURED_CLIENT + ) + expect(ensured).toMatchObject({ ok: true }) + }) + + it('tags the prompt kind from the method name, not from the client', async () => { + const params = { + envelope: envelope(), + itemId: 'item-1', + expectedRevision: 1, + optionId: 'allow' + } + await call('agentSession.respondToApproval', params, STRUCTURED_CLIENT) + await call('agentSession.respondToQuestion', params, STRUCTURED_CLIENT) + expect(hostCalls.respondToPrompt.mock.calls.map((invocation) => invocation[1].kind)).toEqual([ + 'approval', + 'question' + ]) + }) +}) + +describe('parameter validation', () => { + const rejects = async (method: string, params: unknown): Promise => { + const response = await call(method, params, STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + } + + it('rejects an unknown key rather than dropping it', async () => { + await rejects('agentSession.send', { ...sendParams(), replyToItemId: 'item-1' }) + await rejects('agentSession.send', { + ...sendParams(), + envelope: { ...envelope(), priority: 'high' } + }) + }) + + it('refuses to let a client author anything but a user turn', async () => { + await rejects( + 'agentSession.send', + sendParams({ + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] } + }) + ) + await rejects( + 'agentSession.send', + sendParams({ + body: { kind: 'message', role: 'user', blocks: [{ type: 'tool-call', name: 'Bash' }] } + }) + ) + }) + + it('rejects a journal-only opaque provider handle', async () => { + await rejects( + 'agentSession.create', + attachParams({ providerHandle: { kind: 'opaque', agent: 'codex', value: 'thread-1' } }) + ) + }) + + it('requires a sha256 fingerprint and a positive fence', async () => { + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ payloadFingerprint: 'f' }) }) + ) + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ payloadFingerprint: 'F'.repeat(64) }) }) + ) + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ expectedRuntimeFence: 0 }) }) + ) + }) + + it('requires the item revision on a prompt answer', async () => { + await rejects('agentSession.respondToApproval', { + envelope: envelope(), + itemId: 'item-1', + optionId: 'allow' + }) + }) + + it('bounds a history page and validates its cursor', async () => { + await rejects('agentSession.history', { + sessionId: SESSION, + direction: 'tail', + limit: 100_000 + }) + await rejects('agentSession.history', { sessionId: SESSION, direction: 'sideways' }) + await rejects('agentSession.history', { + sessionId: SESSION, + direction: 'after', + cursor: { epoch: 'epoch-1', sequence: -1 } + }) + }) + + it('accepts a well-formed history request', async () => { + const response = await call( + 'agentSession.history', + { + sessionId: SESSION, + direction: 'after', + cursor: { epoch: 'epoch-1', sequence: 4 }, + limit: 40 + }, + STRUCTURED_CLIENT + ) + expect(response).toMatchObject({ ok: true }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts new file mode 100644 index 00000000000..e608e0aba9e --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -0,0 +1,159 @@ +// `agentSession.*` — the structured session RPC surface. +// +// Every method here is gated on the client advertising +// `agent-session.structured.v1`. A client that does not is told the surface does +// not exist rather than being handed a session it cannot render or drive; that +// is the whole visibility rule, because nothing else on the runtime publishes a +// structured session. + +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHost +} from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { + AttachParams, + CancelParams, + HistoryParams, + RespondParams, + SendParams, + SetOptionParams, + SubscribeParams, + UnsubscribeParams +} from './structured-agent-session-schemas' + +const SUBSCRIPTION_PREFIX = 'agentSession' + +/** + * In-process callers are the same build as the host, so they carry no negotiated + * capability list; every remote client must say it can read structured sessions. + */ +function supportsStructuredSessions(ctx: RpcContext): boolean { + return ( + ctx.clientKind === undefined || + (ctx.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false) + ) +} + +function requireHost(ctx: RpcContext): StructuredAgentSessionHost { + const host = supportsStructuredSessions(ctx) ? getStructuredAgentSessionHost() : null + if (!host) { + throw new Error('structured_agent_session_unsupported') + } + return host +} + +/** Mirrors the existing agent-session host-authority derivation so one client + * gets one operation namespace across both surfaces. */ +function callerFor(ctx: RpcContext): StructuredAgentSessionCaller { + return { + callerKey: ctx.clientId?.trim() || `trusted-local:${ctx.clientKind ?? 'runtime'}` + } +} + +function subscriptionIdFor(ctx: RpcContext, sessionId: string): string { + const base = `${SUBSCRIPTION_PREFIX}:${ctx.connectionId ?? 'local'}:${sessionId}` + // Shared control multiplexes several streams over one socket; the frame id + // keeps one subscriber from evicting another on the same session. + return ctx.requestId ? `${base}:${ctx.requestId}` : base +} + +export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'agentSession.create', + params: AttachParams, + handler: async (params, ctx) => { + if (params.envelope.expectedRuntimeFence !== null) { + throw new Error('agent_session_operation_invalid') + } + return requireHost(ctx).attach(callerFor(ctx), params) + } + }), + defineMethod({ + name: 'agentSession.ensure', + params: AttachParams, + handler: async (params, ctx) => requireHost(ctx).attach(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.send', + params: SendParams, + handler: async (params, ctx) => requireHost(ctx).send(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.cancel', + params: CancelParams, + handler: async (params, ctx) => requireHost(ctx).cancel(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.respondToApproval', + params: RespondParams, + handler: async (params, ctx) => + requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'approval' }) + }), + defineMethod({ + name: 'agentSession.respondToQuestion', + params: RespondParams, + handler: async (params, ctx) => + requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'question' }) + }), + defineMethod({ + name: 'agentSession.setOption', + params: SetOptionParams, + handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.history', + params: HistoryParams, + handler: async (params, ctx) => requireHost(ctx).history(params) + }), + defineStreamingMethod({ + name: 'agentSession.subscribe', + params: SubscribeParams, + handler: async (params, ctx, emit) => { + const host = requireHost(ctx) + const subscriptionId = subscriptionIdFor(ctx, params.sessionId) + let closed = false + let dispose = (): void => {} + ctx.runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + dispose() + }, + ctx.connectionId + ) + if (closed) { + return + } + // The host emits the opening snapshot (or the missed batch) synchronously + // inside open(), so nothing between here and there can interleave. + dispose = host.subscribe({ + id: subscriptionId, + sessionId: params.sessionId, + emit, + ...(params.cursor ? { cursor: params.cursor } : {}) + }) + if (closed) { + dispose() + } + } + }), + defineMethod({ + name: 'agentSession.unsubscribe', + params: UnsubscribeParams, + handler: async (params, ctx) => { + requireHost(ctx) + const connection = ctx.connectionId ?? 'local' + const base = `${SUBSCRIPTION_PREFIX}:${connection}:${params.sessionId}` + if (params.subscriptionId) { + ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`) + return { unsubscribed: true } + } + ctx.runtime.cleanupSubscription(base) + ctx.runtime.cleanupSubscriptionsByPrefix(`${base}:`) + return { unsubscribed: true } + } + }) +] diff --git a/src/shared/agent-session-journal-item-key.ts b/src/shared/agent-session-journal-item-key.ts index 45931ddc4ed..9548bc4de63 100644 --- a/src/shared/agent-session-journal-item-key.ts +++ b/src/shared/agent-session-journal-item-key.ts @@ -49,3 +49,35 @@ export function agentJournalItemKey(identity: AgentJournalItemIdentity): string export function agentJournalSubmissionKey(clientMessageId: string): string { return agentJournalItemKey({ provider: 'orca', clientMessageId }) } + +/** + * Inverse of {@link agentJournalItemKey}. Clients hold item KEYS, but an upsert + * needs the identity behind one — answering an approval re-appends the same + * item at the next revision. Every component is percent-encoded, and + * `encodeURIComponent` escapes the delimiter, so the split is unambiguous. + */ +export function parseAgentJournalItemKey(key: string): AgentJournalItemIdentity | null { + const parts = key.split(KEY_DELIMITER).map((part) => decodeURIComponent(part)) + const [provider, ...rest] = parts + if (provider === 'codex' && rest.length === 3) { + const ordinal = Number(rest[2]) + return Number.isSafeInteger(ordinal) && ordinal >= 0 + ? { provider, threadId: rest[0] as string, turnId: rest[1] as string, ordinal } + : null + } + if (provider === 'claude' && rest.length === 2) { + return { provider, sessionId: rest[0] as string, uuid: rest[1] as string } + } + if (provider === 'orca' && rest.length === 1) { + return { provider, clientMessageId: rest[0] as string } + } + if (provider === 'legacy' && rest.length === 3) { + return { + provider, + agent: rest[0] as string, + sessionId: rest[1] as string, + recordId: rest[2] as string + } + } + return null +} diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts new file mode 100644 index 00000000000..f488e14e31e --- /dev/null +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionOperationDecision } from './agent-session-operation-ledger' +import { agentSessionLeaseFixture } from './agent-session-record.test-fixture' +import { + admitAgentSessionMutation, + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from './agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from './agent-session-wire' + +const LEASE = agentSessionLeaseFixture({ + sessionId: 'session-1', + runtimeKind: 'native', + runtimeFence: 4 +}) + +function envelope(overrides: Partial = {}) { + return { + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 4, + payloadFingerprint: 'f'.repeat(64), + ...overrides + } +} + +function row(fingerprint: string) { + return { + callerKey: 'caller-1', + operationId: 'op-1', + fingerprint, + operationTimestamp: 1_000, + recordedAt: 1_000, + expiresAt: 100_000, + outcome: { status: 'pending' as const } + } +} + +const ADMIT = (fingerprint: string): AgentSessionOperationDecision => ({ + decision: 'admit', + row: row(fingerprint) +}) + +describe('computeAgentSessionPayloadFingerprint', () => { + it('is stable across key order at every depth', () => { + const a = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: { kind: 'message', blocks: [{ type: 'text', text: 'hi' }] }, extra: 1 } + }) + const b = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { extra: 1, body: { blocks: [{ text: 'hi', type: 'text' }], kind: 'message' } } + }) + expect(a).toBe(b) + expect(a).toMatch(/^[0-9a-f]{64}$/) + }) + + it('separates one payload from another and one method from another', () => { + const send = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { text: 'hi' } + }) + expect( + computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { text: 'hi there' } + }) + ).not.toBe(send) + expect( + computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: 'session-1', + fields: { text: 'hi' } + }) + ).not.toBe(send) + }) +}) + +describe('agentSessionFingerprintConflict', () => { + it('refuses a payload that does not match what the client declared', () => { + const conflict = agentSessionFingerprintConflict(envelope(), 'a'.repeat(64)) + expect(conflict?.code).toBe('agent_session_operation_conflict') + }) + + it('passes a matching declaration', () => { + expect(agentSessionFingerprintConflict(envelope(), 'f'.repeat(64))).toBeNull() + }) +}) + +describe('admitAgentSessionMutation', () => { + const base = { envelope: envelope(), hostFingerprint: 'f'.repeat(64), lease: LEASE } + + it('admits a first-time operation under a live lease at the expected fence', () => { + expect(admitAgentSessionMutation({ ...base, ledger: ADMIT('f'.repeat(64)) }).decision).toBe( + 'admit' + ) + }) + + it('replays a recorded operation without re-checking the fence', () => { + const admission = admitAgentSessionMutation({ + ...base, + envelope: envelope({ expectedRuntimeFence: 1 }), + ledger: { decision: 'replay', row: row('f'.repeat(64)) } + }) + expect(admission.decision).toBe('replay') + }) + + it('refuses a stale fence and hands back the current one', () => { + const admission = admitAgentSessionMutation({ + ...base, + envelope: envelope({ expectedRuntimeFence: 3 }), + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_checkpoint_stale', currentFence: 4 } + }) + }) + + it('refuses a writer while the lease is unreconciled', () => { + const admission = admitAgentSessionMutation({ + ...base, + lease: { ...LEASE, unreconciled: true }, + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'execution_owner_reconciling' } + }) + }) + + it('refuses a writer mid-handoff', () => { + const admission = admitAgentSessionMutation({ + ...base, + lease: { ...LEASE, handoffStage: 'new-owner-proving' }, + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_conflict' } + }) + }) + + it('surfaces a ledger refusal verbatim', () => { + const admission = admitAgentSessionMutation({ + ...base, + ledger: { decision: 'refused', code: 'agent_session_operation_expired' } + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_operation_expired' } + }) + }) +}) diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts new file mode 100644 index 00000000000..40a3ec5fbf6 --- /dev/null +++ b/src/shared/agent-session-mutation-envelope.ts @@ -0,0 +1,151 @@ +// Admission for one mutating `agentSession.*` call. +// +// The rules themselves live in the durable ledger and the lease adjudicator; +// this is only the fixed order they are applied in, plus the payload +// fingerprint both peers derive from the same request fields. Nothing here +// re-derives who may write — that answer comes from +// `agentSessionLeaseAdmitsWriter` alone. + +import { createHash } from 'node:crypto' +import type { + AgentSessionOperationDecision, + AgentSessionOperationRow +} from './agent-session-operation-ledger' +import { + agentSessionLeaseAdmitsWriter, + isAgentSessionFenceCurrent +} from './agent-session-lease-adjudication' +import type { AgentSessionLease } from './agent-session-record' +import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire' + +/** + * Stable digest over the fields that define what this call DOES. Keys are + * emitted in sorted order at every depth so two peers serializing the same + * request in different property order agree, and an undefined field is dropped + * rather than hashed as present-but-empty. + */ +export function computeAgentSessionPayloadFingerprint(input: { + method: string + sessionId: string + fields: Record +}): string { + const canonical = canonicalize({ + method: input.method, + sessionId: input.sessionId, + fields: input.fields + }) + return createHash('sha256').update(canonical).digest('hex') +} + +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value ?? null) + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]` + } + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`).join(',')}}` +} + +/** + * A retry whose payload changed is a different call wearing the same id. + * Checked BEFORE the ledger is consulted, so a refused call never leaves an + * admitted row that a later honest retry would replay as already-done. + */ +export function agentSessionFingerprintConflict( + envelope: AgentSessionMutationEnvelope, + hostFingerprint: string +): AgentSessionWireRefusal | null { + return envelope.payloadFingerprint === hostFingerprint + ? null + : { + code: 'agent_session_operation_conflict', + message: + 'The payload does not match the fingerprint the client declared for this operation.' + } +} + +export type AgentSessionMutationAdmission = + | { decision: 'admit'; row: AgentSessionOperationRow } + /** The recorded outcome answers this call; do not run the effect again. */ + | { decision: 'replay'; row: AgentSessionOperationRow } + | { decision: 'refused'; refusal: AgentSessionWireRefusal } + +/** + * Fixed order: fingerprint agreement, then the ledger (so a retry replays + * before anything else can refuse it), then the lease, then the fence. Putting + * the ledger ahead of the fence is deliberate — a retry that crossed an owner + * change must still return its recorded answer instead of a stale-checkpoint + * refusal the client would then resend as a second effect. + */ +export function admitAgentSessionMutation(input: { + envelope: AgentSessionMutationEnvelope + /** Fingerprint the host computed from the request it actually received. */ + hostFingerprint: string + /** Decision from the durable ledger, evaluated under `hostFingerprint`. */ + ledger: AgentSessionOperationDecision + lease: AgentSessionLease +}): AgentSessionMutationAdmission { + const { envelope, lease, ledger } = input + const mismatch = agentSessionFingerprintConflict(envelope, input.hostFingerprint) + if (mismatch) { + return { decision: 'refused', refusal: mismatch } + } + if (ledger.decision === 'refused') { + return { + decision: 'refused', + refusal: { + code: ledger.code, + message: `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` + } + } + } + if (ledger.decision === 'replay') { + return { decision: 'replay', row: ledger.row } + } + const leaseRefusal = refuseUnlessWriterAdmitted(lease) + if (leaseRefusal) { + return { decision: 'refused', refusal: leaseRefusal } + } + if ( + envelope.expectedRuntimeFence === null || + !isAgentSessionFenceCurrent(lease, envelope.expectedRuntimeFence) + ) { + return { + decision: 'refused', + refusal: { + code: 'agent_session_checkpoint_stale', + message: `Expected runtime fence ${envelope.expectedRuntimeFence ?? 'none'}; the session is at ${lease.runtimeFence}.`, + currentFence: lease.runtimeFence + } + } + } + return { decision: 'admit', row: ledger.row } +} + +/** Why the single admission oracle said no, mapped to what the client can do + * about it. The predicate itself is never re-implemented here. */ +function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null { + if (agentSessionLeaseAdmitsWriter(lease)) { + return null + } + if (lease.unreconciled) { + return { + code: 'execution_owner_reconciling', + message: 'This host has not yet adjudicated the session lease.' + } + } + if (lease.handoffStage !== null) { + return { + code: 'agent_session_conflict', + message: `The session is mid-handoff (${lease.handoffStage}).` + } + } + return { + code: 'agent_session_ownership_unknown', + message: 'The session has no live owner to accept writes.' + } +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts new file mode 100644 index 00000000000..4dcfe039525 --- /dev/null +++ b/src/shared/agent-session-wire.ts @@ -0,0 +1,170 @@ +// ─── Structured agent-session wire contract ───────────────────────────────── +// The shapes `agentSession.*` accepts and publishes. Phase 2 builds provider +// adapters and the mobile client against exactly these types, so everything +// here must be plain JSON and every new field must be optional to old readers +// (docs/reference/remote-wire-compatibility.md). + +import type { + AgentJournalCursor, + AgentJournalRenderItem, + AgentJournalResetReason, + AgentJournalResolution, + AgentJournalSnapshot, + AgentJournalSubmission +} from './agent-session-journal-types' + +/** Backward paging is the client's normal read; 40 matches the page size the + * mobile list renders without a visible fill-in. */ +export const AGENT_SESSION_HISTORY_DEFAULT_LIMIT = 40 +export const AGENT_SESSION_HISTORY_MAX_LIMIT = 200 + +export const AGENT_SESSION_HISTORY_DIRECTIONS = ['tail', 'before', 'after'] as const +/** `tail` is the newest page, `before` pages backward, `after` catches a live + * reader up. Only `after` needs replayable rows; the other two read the + * reduced timeline and so survive compaction. */ +export type AgentSessionHistoryDirection = (typeof AGENT_SESSION_HISTORY_DIRECTIONS)[number] + +export type AgentSessionHistoryRequest = { + sessionId: string + direction: AgentSessionHistoryDirection + /** Required for `before` and `after`; ignored for `tail`. */ + cursor?: AgentJournalCursor + limit?: number +} + +export type AgentSessionHistoryPage = { + sessionId: string + epoch: string + direction: AgentSessionHistoryDirection + items: AgentJournalRenderItem[] + /** Populated by `after` reads so a disconnected client can apply tombstones. */ + removedItemIds: string[] + /** Submissions overlapping this page, so an unconfirmed bubble renders with + * its dispatch state instead of as a plain message. */ + submissions: AgentJournalSubmission[] + /** Page edges. `nextCursor` is what the client sends back for the same + * direction; it equals the request cursor when the page is empty. */ + window: { + oldest: AgentJournalCursor | null + newest: AgentJournalCursor | null + nextCursor: AgentJournalCursor + } + hasOlder: boolean + hasNewer: boolean +} + +export type AgentSessionHistoryResult = + | { ok: true; page: AgentSessionHistoryPage } + /** Every reset forces a clean reload; the snapshot is inlined so the client + * never has to make a second call to recover. */ + | { ok: false; reset: AgentJournalResetReason; snapshot: AgentJournalSnapshot } + +/** Cursor-qualified incremental publication. Items and submissions carry their + * CURRENT reduced state rather than a delta, so applying a batch twice + * converges instead of double-appending. */ +export type AgentSessionJournalBatch = { + cursor: AgentJournalCursor + items: AgentJournalRenderItem[] + removedItemIds: string[] + submissions: AgentJournalSubmission[] +} + +export type AgentSessionSubscribeEvent = + | { type: 'snapshot'; sessionId: string; snapshot: AgentJournalSnapshot; fence: number } + | { type: 'batch'; sessionId: string; batch: AgentSessionJournalBatch } + | { + type: 'reset' + sessionId: string + reset: AgentJournalResetReason + snapshot: AgentJournalSnapshot + fence: number + } + | { type: 'end' } + +// ─── Mutation envelope ────────────────────────────────────────────────────── + +/** + * The four fields every mutating call carries. Same operation id and same + * fingerprint replays the recorded outcome; a different fingerprint under one + * operation id is a conflict, never a second effect. + */ +export type AgentSessionMutationEnvelope = { + sessionId: string + clientOperationId: string + /** Null only on a create for a session that does not exist yet. */ + expectedRuntimeFence: number | null + /** Client-declared; the host recomputes it and compares. */ + payloadFingerprint: string +} + +export const AGENT_SESSION_WIRE_REFUSAL_CODES = [ + 'structured_agent_session_unsupported', + 'agent_session_checkpoint_stale', + 'agent_session_conflict', + 'agent_session_ownership_unknown', + 'agent_session_operation_conflict', + 'agent_session_operation_expired', + 'agent_session_operation_capacity', + 'agent_session_operation_invalid', + 'agent_session_operation_unknown', + 'agent_session_item_revision_stale', + 'agent_session_already_resolved', + 'agent_session_identity_required', + 'agent_session_journal_unreadable', + 'execution_owner_reconciling' +] as const +export type AgentSessionWireRefusalCode = (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number] + +export type AgentSessionWireRefusal = { + code: AgentSessionWireRefusalCode + message: string + /** On a stale fence, so the client can retry without another round trip. */ + currentFence?: number + /** On a lost compare-and-set: the winning answer and who gave it. */ + resolution?: AgentJournalResolution + /** On a lost compare-and-set: the revision the host actually holds. */ + currentRevision?: number +} + +export type AgentSessionMutationResult = + | { + ok: true + /** True when the recorded outcome was returned instead of a new effect. */ + replayed: boolean + fence: number + cursor: AgentJournalCursor + value: TValue + } + | { ok: false; refusal: AgentSessionWireRefusal } + +// ─── Per-method payloads ──────────────────────────────────────────────────── + +export type AgentSessionAttachResult = { + sessionId: string + fence: number + snapshot: AgentJournalSnapshot + /** Submissions the crash boundary settled as `unknown` while attaching. */ + unconfirmedClientMessageIds: string[] +} + +export type AgentSessionSendResult = { + clientMessageId: string + submission: AgentJournalSubmission +} + +export type AgentSessionCancelResult = { + /** The turn the client named, echoed so a late reply can be matched. */ + turnId: string + cancelled: boolean +} + +export type AgentSessionPromptResult = { + itemId: string + revision: number + resolution: AgentJournalResolution +} + +export type AgentSessionOptionResult = { + key: string + value: string +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index c967c66822b..53aff48de44 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -79,6 +79,11 @@ export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = 'agent-session.omp-resume-path.v1' as const +// Why: structured sessions are journal-backed, not PTY-backed, so a client that +// cannot read them must not see them at all — it would render an agent tab it +// can neither display nor drive. The host also refuses every agentSession.* +// method from a connection that does not advertise this. +export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const // Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = @@ -114,6 +119,7 @@ export const RUNTIME_CAPABILITIES = [ REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts new file mode 100644 index 00000000000..e9cc27678c0 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -0,0 +1,461 @@ +// Cross-version coverage for the structured agent-session surface, paired the same +// way the terminal wire harness is: current code against a real published release. +// +// Three skews matter here, and none can be checked from one build alone — an old +// client must not be shown a session it cannot render, a new client must find an +// old host's missing surface cleanly, and a client's cursor must survive the host +// process that minted it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' +import { attachFingerprintFields } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' +import type { AgentSessionAttachParams } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' +import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { resolveBaselineReleaseRef } from './release-checkout' +import { + loadAgentSessionWireBuild, + WORKING_TREE, + type AgentSessionWireBuild, + type RpcClientIdentity, + type RpcReply +} from './versioned-agent-session-wire' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +const SESSION = 'session-alpha' +const WORKSPACE = 'workspace-1' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const NOW = 1_800_000_000_000 + +/** Every method the structured surface publishes, paired with the host method it + * must reach — a gate that hides one method and leaks another is the bug. */ +const STRUCTURED_CALLS: { method: string; hostMethod: string | null }[] = [ + { method: 'agentSession.create', hostMethod: 'attach' }, + { method: 'agentSession.ensure', hostMethod: 'attach' }, + { method: 'agentSession.send', hostMethod: 'send' }, + { method: 'agentSession.cancel', hostMethod: 'cancel' }, + { method: 'agentSession.respondToApproval', hostMethod: 'respondToPrompt' }, + { method: 'agentSession.respondToQuestion', hostMethod: 'respondToPrompt' }, + { method: 'agentSession.setOption', hostMethod: 'setOption' }, + { method: 'agentSession.history', hostMethod: 'history' }, + { method: 'agentSession.subscribe', hostMethod: 'subscribe' }, + // Teardown runs through the runtime's subscription registry rather than the + // host, so its reply is the only signal that the gate opened. + { method: 'agentSession.unsubscribe', hostMethod: null } +] + +let baselineRef: string +let current: AgentSessionWireBuild +let baseline: AgentSessionWireBuild +let operations = 0 + +beforeAll(async () => { + baselineRef = resolveBaselineReleaseRef() + current = await loadAgentSessionWireBuild(WORKING_TREE) + baseline = await loadAgentSessionWireBuild(baselineRef) +}, SUITE_TIMEOUT_MS) + +/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. */ +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +function envelope(args: { + method: string + fields: Record + fence: number | null +}): Record { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: args.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: args.method, + sessionId: SESSION, + fields: args.fields + }) + } +} + +function attachParams(fence: number | null): Record { + const params = { + envelope: { sessionId: SESSION, clientOperationId: operationId(), expectedRuntimeFence: fence }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: THREAD } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params as unknown as AgentSessionAttachParams) + }) + } + } +} + +function sendParams(text: string, fence: number): Record { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + return { envelope: envelope({ method: 'agentSession.send', fields: { body }, fence }), body } +} + +/** Schema-valid params per method; values only need to survive validation. */ +function paramsFor(method: string): unknown { + const fence = 1 + switch (method) { + case 'agentSession.create': + return attachParams(null) + case 'agentSession.ensure': + return attachParams(fence) + case 'agentSession.send': + return sendParams('hi', fence) + case 'agentSession.cancel': + return { + envelope: envelope({ method: 'agentSession.cancel', fields: { turnId: 'turn-1' }, fence }), + turnId: 'turn-1' + } + case 'agentSession.respondToApproval': + case 'agentSession.respondToQuestion': { + const fields = { itemId: 'item-1', expectedRevision: 1, optionId: 'allow' } + return { envelope: envelope({ method, fields, fence }), ...fields } + } + case 'agentSession.setOption': { + const fields = { key: 'model', value: 'gpt-5' } + return { envelope: envelope({ method, fields, fence }), ...fields } + } + case 'agentSession.history': + return { sessionId: SESSION, direction: 'tail' } + default: + return { sessionId: SESSION } + } +} + +function runtimeStub(): unknown { + const cleanups = new Map void>() + return { + getRuntimeId: () => 'runtime-1', + registerSubscriptionCleanup: (id: string, cleanup: () => void) => cleanups.set(id, cleanup), + cleanupSubscription: (id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }, + cleanupSubscriptionsByPrefix: (prefix: string) => { + for (const [id, cleanup] of cleanups) { + if (id.startsWith(prefix)) { + cleanup() + cleanups.delete(id) + } + } + } + } +} + +/** Every reply one call produced. Streaming methods answer more than once, and a + * refusal has to arrive as a reply rather than as silence. */ +async function callBuild( + build: AgentSessionWireBuild, + method: string, + params: unknown, + client: RpcClientIdentity, + runtime: unknown = runtimeStub() +): Promise { + const replies: RpcReply[] = [] + await build + .createDispatcher(runtime) + .dispatchStreaming( + { id: `request-${method}`, authToken: 'cross-version-token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcReply), + client + ) + return replies +} + +describe('cross-version structured agent sessions', () => { + it( + 'skews current code against a real published release', + () => { + expect(baselineRef).toMatch(/^v?\d/) + expect(baseline.revision).toMatch(/^[0-9a-f]{40}$/) + expect(baseline.revision).not.toBe(current.revision) + // The anti-vacuous oracle for the source scan: a scan that found nothing + // would make every "no structured method here" claim below meaningless. + expect(baseline.methodNames).toContain('terminal.create') + expect(current.methodNames).toContain('terminal.create') + }, + SUITE_TIMEOUT_MS + ) + + describe('a client that never asked for structured sessions', () => { + let hostCalls: Record> + + beforeEach(() => { + operations = 0 + hostCalls = { + attach: vi.fn(async () => ({ ok: true, replayed: false })), + send: vi.fn(async () => ({ ok: true, replayed: false })), + cancel: vi.fn(async () => ({ ok: true, replayed: false })), + respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), + setOption: vi.fn(async () => ({ ok: true, replayed: false })), + history: vi.fn(() => ({ ok: true, page: { items: [] } })), + subscribe: vi.fn(() => () => undefined), + unsubscribe: vi.fn() + } + setStructuredAgentSessionHost(hostCalls as unknown as StructuredAgentSessionHost) + }) + + afterEach(() => { + setStructuredAgentSessionHost(null) + }) + + it('is told the whole surface does not exist, and reaches no host method', async () => { + // The old build cannot name the capability, so its clients never send it. + expect(baseline.capabilities).not.toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + for (const { method } of STRUCTURED_CALLS) { + const replies = await callBuild(current, method, paramsFor(method), { + clientKind: 'mobile', + clientCapabilities: baseline.capabilities + }) + expect(replies, `${method} must answer exactly once`).toHaveLength(1) + expect(replies[0]).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + } + for (const [name, spy] of Object.entries(hostCalls)) { + expect(spy, `${name} ran for a client without the capability`).not.toHaveBeenCalled() + } + }) + + it('is served the same calls once it advertises the capability', async () => { + for (const { method, hostMethod } of STRUCTURED_CALLS) { + const replies = await callBuild(current, method, paramsFor(method), { + clientKind: 'mobile', + clientCapabilities: [ + ...baseline.capabilities, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + }) + // A subscription that opens with nothing to say answers with no reply at + // all, so reaching the host is the signal that the gate opened. + if (hostMethod) { + expect(hostCalls[hostMethod], `${method} did not reach the host`).toHaveBeenCalled() + } else { + expect(replies[0], `${method} was refused`).toMatchObject({ ok: true }) + } + for (const reply of replies) { + expect(reply, `${method} was refused`).toMatchObject({ ok: true }) + } + } + }) + }) + + describe('a new client against an old host', () => { + it('finds no structured method registered on the old build', () => { + expect(baseline.methodNames.filter((name) => name.startsWith('agentSession.'))).toEqual([]) + expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength( + 10 + ) + }) + + it('can detect the absence during negotiation instead of by calling', () => { + expect(current.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(baseline.capabilities).not.toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + // Additive surface: bumping the protocol number would strand every paired + // device on this release rather than degrade one feature. + expect(current.protocolVersion).toBe(baseline.protocolVersion) + }) + + it('gets a clean method_not_found from the old dispatcher rather than silence', async () => { + for (const { method } of STRUCTURED_CALLS) { + const replies = await callBuild(baseline, method, paramsFor(method), { + clientKind: 'mobile', + clientCapabilities: current.capabilities + }) + expect(replies, `${method} must answer exactly once`).toHaveLength(1) + expect(replies[0], `${method} on the old host`).toMatchObject({ + ok: false, + error: { code: 'method_not_found' } + }) + } + }) + }) + + describe('a cursor across a host restart', () => { + let root: string + let store: AgentSessionRecordStore + let runtime: unknown + + /** Phase 2 owns provider processes; the adapter is the only stub here. */ + function adapter(): StructuredAgentSessionAdapter { + return { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // A restarted host re-proves the thread it inherited; only the first + // owner of a session may claim to have created it. + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } + } + + /** Reopens the store from disk and installs a fresh host over the same journal + * root — what a process restart actually leaves behind. */ + async function bootHost(generation: string): Promise { + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + setStructuredAgentSessionHost( + new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${generation}`, + // The provider died with the host that spawned it, which is what makes + // the restarted host the legitimate next writer. + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + }) + ) + } + + type HostAnswer = { + ok: boolean + fence: number + cursor: { epoch: string; sequence: number } + refusal?: { code: string; currentFence?: number } + } + + /** Reattaching after a restart: the client's fence died with the previous + * host, and the refusal that says so is what hands it the live one. */ + async function reattach(staleFence: number): Promise { + const refused = await answer('agentSession.ensure', attachParams(staleFence)) + expect(refused).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + const currentFence = refused.refusal?.currentFence + expect(currentFence).toBeGreaterThan(staleFence) + const reattached = await answer('agentSession.ensure', attachParams(currentFence ?? 0)) + expect(reattached).toMatchObject({ ok: true }) + return reattached + } + + async function call(method: string, params: unknown): Promise { + return callBuild( + current, + method, + params, + { + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + clientId: 'paired-device-1', + connectionId: 'connection-1' + }, + runtime + ) + } + + /** The host's own answer, which carries its refusals inside a successful RPC. */ + async function answer(method: string, params: unknown): Promise { + const reply = (await call(method, params))[0] + if (!reply?.ok) { + throw new Error(`${method} failed at the wire: ${JSON.stringify(reply?.error ?? reply)}`) + } + return reply.result as HostAnswer + } + + beforeEach(async () => { + operations = 0 + root = await mkdtemp(join(tmpdir(), 'orca-cross-version-agent-session-')) + runtime = runtimeStub() + await bootHost('a') + }) + + afterEach(async () => { + setStructuredAgentSessionHost(null) + await rm(root, { recursive: true, force: true }) + }) + + it('resumes from the cursor the client held, with no snapshot and no replay', async () => { + const created = await answer('agentSession.create', attachParams(null)) + expect(created.ok).toBe(true) + const first = await answer('agentSession.send', sendParams('before restart', created.fence)) + expect(first.ok).toBe(true) + const held = first.cursor + + await bootHost('b') + // A restart is a new writer generation; the old fence must not survive it. + const reattached = await reattach(created.fence) + expect(reattached.fence).toBeGreaterThan(created.fence) + const second = await answer( + 'agentSession.send', + sendParams('after restart', reattached.fence) + ) + expect(second.ok).toBe(true) + + const events = ( + await call('agentSession.subscribe', { sessionId: SESSION, cursor: held }) + ).map((reply) => reply.result as AgentSessionSubscribeEvent) + expect(events.map((event) => event.type)).toEqual(['batch']) + const batch = events[0]?.type === 'batch' ? events[0].batch : null + const rendered = JSON.stringify(batch?.items ?? []) + expect(rendered).toContain('after restart') + // Everything the client already had stays out of the resume. + expect(rendered).not.toContain('before restart') + expect(batch?.cursor.epoch).toBe(held.epoch) + expect(batch?.cursor.sequence).toBeGreaterThan(held.sequence) + }) + + it('refuses a write still fenced to the host generation that died', async () => { + const created = await answer('agentSession.create', attachParams(null)) + await bootHost('b') + const reattached = await reattach(created.fence) + expect(reattached.fence).toBeGreaterThan(created.fence) + + expect(await answer('agentSession.send', sendParams('stale', created.fence))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + }) + }) +}) diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts new file mode 100644 index 00000000000..1b2eb3dae97 --- /dev/null +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -0,0 +1,149 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { materializeReleaseCheckout, REPO_ROOT, type ReleaseCheckout } from './release-checkout' + +/** + * The two things that decide whether a structured agent session exists for a given + * pairing: the capability strings a build can name, and the RPC methods it + * registers. Both are read per build, so "the old side does not have it" is a fact + * about a real release rather than a hand-written list. + */ + +export const WORKING_TREE = 'working-tree' as const + +export type RpcReply = { + id: string + ok: boolean + streaming?: true + result?: unknown + error?: { code: string; message: string } +} + +export type RpcClientIdentity = { + clientKind?: 'mobile' | 'runtime' + clientCapabilities?: readonly string[] + connectionId?: string + clientId?: string +} + +export type AgentSessionDispatcher = { + dispatchStreaming: ( + request: { id: string; authToken: string; method: string; params?: unknown }, + reply: (message: string) => void, + options?: RpcClientIdentity + ) => Promise +} + +export type AgentSessionWireBuild = { + /** Human label used in test names and failure messages. */ + label: string + /** `working-tree` for current code, otherwise the resolved release commit. */ + revision: string + /** Capability strings this build defines. A peer cannot advertise — nor a client + * ask for — a string its own source never names. */ + capabilities: readonly string[] + protocolVersion: number + /** RPC method names the build registers, read from source. */ + methodNames: readonly string[] + /** A dispatcher carrying a method set this build really ships, so an + * unknown-method answer is about the method and not an empty registry. */ + createDispatcher: (runtime: unknown) => AgentSessionDispatcher +} + +type DispatcherModule = { + RpcDispatcher: new (options: { runtime: unknown; methods: unknown[] }) => AgentSessionDispatcher +} + +// A dotted literal in a `name:` position. Deliberately loose: over-matching only +// makes "this build registers no agentSession method" a stronger claim. +const METHOD_NAME = /\bname:\s*'([A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)+)'/g + +/** + * Method names declared under `runtime/rpc/methods`, scanned rather than imported: + * a released build's method manifest reaches Electron, which cannot load here. + */ +function scanMethodNames(root: string): string[] { + const names = new Set() + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = join(directory, entry.name) + if (entry.isDirectory()) { + walk(full) + } else if (entry.isFile() && entry.name.endsWith('.ts')) { + for (const match of readFileSync(full, 'utf8').matchAll(METHOD_NAME)) { + names.add(match[1]!) + } + } + } + } + walk(join(root, 'src', 'main', 'runtime', 'rpc', 'methods')) + return [...names].sort() +} + +function capabilityStrings(module: Record): readonly string[] { + const declared = module.RUNTIME_CAPABILITIES + if (!Array.isArray(declared) || declared.length === 0) { + throw new Error('Cross-version harness found no RUNTIME_CAPABILITIES to compare') + } + return declared as readonly string[] +} + +async function loadWorkingTreeBuild(): Promise { + const [protocol, dispatcher, structured] = await Promise.all([ + import('../../../src/shared/protocol-version'), + import('../../../src/main/runtime/rpc/dispatcher'), + import('../../../src/main/runtime/rpc/methods/structured-agent-session') + ]) + const module = dispatcher as unknown as DispatcherModule + return { + label: WORKING_TREE, + revision: WORKING_TREE, + capabilities: capabilityStrings(protocol as unknown as Record), + protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION, + methodNames: scanMethodNames(REPO_ROOT), + createDispatcher: (runtime) => + new module.RpcDispatcher({ + runtime, + methods: structured.STRUCTURED_AGENT_SESSION_METHODS as unknown[] + }) + } +} + +// Why @vite-ignore: the checkout is created at run time, so Vite cannot glob it at +// transform time. Vite-node still resolves and transforms the target on demand. +function importFromCheckout(specifier: string): Promise> { + return import(/* @vite-ignore */ specifier) as Promise> +} + +async function loadReleaseBuild(checkout: ReleaseCheckout): Promise { + const base = `${checkout.root}/src` + const [protocol, dispatcher, terminalMethods] = await Promise.all([ + importFromCheckout(`${base}/shared/protocol-version.ts`), + importFromCheckout(`${base}/main/runtime/rpc/dispatcher.ts`), + importFromCheckout(`${base}/main/runtime/rpc/methods/terminal.ts`) + ]) + const module = dispatcher as unknown as DispatcherModule + return { + label: checkout.ref, + revision: checkout.commit, + capabilities: capabilityStrings(protocol), + protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION as number, + methodNames: scanMethodNames(checkout.root), + createDispatcher: (runtime) => + new module.RpcDispatcher({ + runtime, + methods: terminalMethods.TERMINAL_METHODS as unknown[] + }) + } +} + +/** + * Load the structured-session wire surface for one build. `WORKING_TREE` imports + * current source; any other value is a git ref extracted into a cached checkout. + */ +export async function loadAgentSessionWireBuild(ref: string): Promise { + if (ref === WORKING_TREE) { + return loadWorkingTreeBuild() + } + return loadReleaseBuild(materializeReleaseCheckout(ref)) +}