Pair the agent-session wire across versions

Extends the cross-version harness, which covered the terminal stream only, with
the structured surface. The old side's advertised capabilities and registered
method names are read from the extracted release checkout rather than written by
hand: a build cannot advertise a string its own source never names, so "the old
side does not have it" stays a fact about a real release.
This commit is contained in:
Brennan Benson
2026-08-09 13:35:30 -07:00
parent 7924a339e0
commit 775fee768e
3 changed files with 633 additions and 4 deletions
+23 -4
View File
@@ -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.
@@ -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<string, unknown>
fence: number | null
}): Record<string, unknown> {
return {
sessionId: SESSION,
clientOperationId: operationId(),
expectedRuntimeFence: args.fence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: args.method,
sessionId: SESSION,
fields: args.fields
})
}
}
function attachParams(fence: number | null): Record<string, unknown> {
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<string, unknown> {
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<string, () => 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<RpcReply[]> {
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<string, ReturnType<typeof vi.fn>>
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<void> {
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<HostAnswer> {
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<RpcReply[]> {
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<HostAnswer> {
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' }
})
})
})
})
@@ -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<void>
}
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<string>()
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<string, unknown>): 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<AgentSessionWireBuild> {
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<string, unknown>),
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<Record<string, unknown>> {
return import(/* @vite-ignore */ specifier) as Promise<Record<string, unknown>>
}
async function loadReleaseBuild(checkout: ReleaseCheckout): Promise<AgentSessionWireBuild> {
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<AgentSessionWireBuild> {
if (ref === WORKING_TREE) {
return loadWorkingTreeBuild()
}
return loadReleaseBuild(materializeReleaseCheckout(ref))
}