Files
orca/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
6494f2a4f0 fix(native-chat): resume a structured chat from Agent Session History (#18933)
* fix(native-chat): resume a structured chat from Agent Session History

Clicking Resume on a chat-UI row could only reveal an already-open tab. If the
chat had been closed, or this process had never published it, the click re-read
an inventory that did not contain it and toasted "Retry in a moment" — advice
that could never come true, because nothing republishes an unpublished tab. The
legacy `claude --resume` fallback is deliberately refused for structured-owned
rows, so the row had no way back at all.

`close` already keeps the record and the journal on disk so a session can be
attached again, and the hold path already resurrects one in full. What was
missing was the tab: `restoreReadableSessions` is latched to run once, at
startup, so nothing could ask for a single session later.

Adds `agentSession.reveal`. The host looks up its own record, restores the
session readable, and republishes the tab through the same call
`agentSession.create` uses. Deliberately narrow:

- It takes no hold. A provider child exists because a surface asked, and the
  chat pane asks when it binds.
- A journal it cannot read is not a refusal. A chat whose journal predates the
  SQLite store restores to nothing here, but attach still recovers it, so the
  tab is published and the pane's hold finishes the job.
- Workspace and provider come from the record, never the client, so a session
  id alone cannot aim the publication at another workspace.

Claude and Codex both, by construction: eligibility is `adapterSupportsRecord`,
which the router answers from the record's own provider.

Gated on a new advertised capability rather than probing for method_not_found,
matching agent-session.structured.hold.v1 — absence is visible during
negotiation instead of by calling.

* fix(native-chat): negotiate reveal against the host that owns the workspace

The capability gate read the LOCAL runtime's advertised capabilities while the
call went to the host that owns the workspace, which for a paired workspace is
a different build. On desktop the renderer and its local host are always the
same build, so the gate passed unconditionally and proved nothing about the
host being called: an older paired host still received the unknown method and
its method_not_found was reported to the user as 'this chat is no longer on
this host'. The cache it read also starts empty and resets to empty when
status.get fails, so 'not fetched yet' and 'unsupported' were the same value.

Gate on the environment that will answer, the way agentSession.close already
does, and skip the round trip entirely for a local host. Reveal now reports
four outcomes instead of a boolean, so a host that is merely too old is not
reported as a chat that is gone, and a host we could not reach keeps the
retryable message.

Also syncs the localization catalog: the 'gone' key shipped without an en.json
entry, which reddens static analysis and verify while typecheck stays green.

* fix(native-chat): tell a refused reveal apart from a missing chat

The host raises two refusals here and they mean opposite things to a user: it
holds no such record, or it holds one no adapter of its own can open. The
client collapsed both into 'this chat is no longer on this host', which is a
eulogy for a chat still sitting on disk. Read the refusal code, and fold the
host-side case in with the too-old host under one honest message, since the
remedy for both is the same.

Adds the coverage the readiness pass found missing: the host's reveal answer
itself (workspace and provider from the record, both refusals, an unreadable
journal, a live session), and the activation branches for a host that cannot
open the chat and for one that never answered.

* fix(native-chat): read a host version block as the host's age, not a lost link

The capability probe reaches assertRuntimeStatusCompatible, which throws a
runtime_compat_block error. Treating that as unreachable told a user with an
out-of-date host to retry, which is the one thing that cannot help. Branch on
isRuntimeCompatBlockError the way remote-agent-session-launch already does for
the same probe.

Also adds the refusal-code case a previous commit claimed and did not deliver:
nothing drove a structured_agent_session_unsupported reply through the reveal
client, which is the branch that commit existed to add. Corrects a doc comment
that reveal made wrong: attach is no longer the only call that builds the host.

* fix(native-chat): let a dragged history row reach the same reveal as a click

Dropping an Agent Session History row onto a pane activated the tab by id and,
on a miss, raised the very toast this PR exists to remove — so the same row
answered a click and a drop differently, and the drop kept the advice that can
never come true. The structured branch never used the drop pane, so routing it
through the shared activation loses nothing and gains the reveal.

The helper only ever read one field, so its parameter narrows to that field and
the drag payload satisfies it directly. A source ratchet holds both entry points
to the reveal-capable path, since a mounted drag harness does not exist for this
layer and what regresses is a call site, not a rendering.

* fix(native-chat): stop an advisory refresh ending the click, and one click per row

Manual QA found the reveal never ran: the inventory refresh that precedes it
is an optimization, but its failure returned early with 'not available yet,
retry in a moment' — reinstating the dead end this PR removes, one step
earlier. A failed refresh now falls through to the reveal, which is the repair
and does not need the refresh to have worked.

The click can chain a refresh, a capability probe, a reveal and a second
refresh, each with its own timeout, while nothing on the row says it is
working. A per-session in-flight guard keeps an impatient second click from
running the whole sequence again and landing its own toast.

Also drops an unreachable owner scope: the snapshot apply discards any
worktree whose execution host is not local before it reads one, so naming a
remote scope there described a synchronisation that cannot happen.

* fix(native-chat): bound the capability probe and stop naming the wrong machine

The in-flight guard releases when the activation settles, so an await that
never settles holds the row for the life of the process. The capability probe
was the one call in the chain not raced against a deadline: on a cache hit it
awaits a promise an earlier probe created, which may carry no deadline of its
own. Race it like the two calls around it.

A version block can name either side — evaluateRuntimeCompat reports
client-too-old as well as host-too-old — so a message that blamed the host
pointed half of those at the wrong machine. Name the remedy instead of the
machine, which is true for every case that reaches it.

* chore: remove a scratch repro file committed by mistake

It was swept into the previous commit by a broad `git add` while a diagnostic
ran in this worktree. It asserts the current renderer-sync defect as expected
behaviour, so it would fail the moment that defect is fixed.

* fix(native-chat): stop a reveal's own inventory refresh discarding its republished tab

Manual QA: the host answered reveal with ok:true and republished the tab, and
the chat still did not reopen — only a renderer reload brought it back.

The renderer publishes under one epoch string for its whole lifetime, and a
frame recorded under a different lineage retires that epoch permanently with
nothing to un-retire it. The Resume click asks for an inventory first, and a
worktree the host holds no entry for answers with the none/v0 sentinel; the
structured path recorded it, retiring the renderer's own epoch, so the tab the
reveal published a moment later was dropped. A reload minted a new epoch,
which is why reloading appeared to fix it.

A frame that carries no publication is not a later publication to fence
against. Treat the sentinel and a removal frame as a cursor reset, the way the
mainstream session-tabs path already clears its tracking — its comment names
this exact hazard: recording that sentinel would retire the host epoch and
reject the next live frame.

Pre-existing, and it swallows an ordinary new-tab launch on an empty worktree
too; the reveal is what turned a silent invisibility into a visible failure.

* fix(native-chat): let a retraction prune its rows without retiring the epoch

Correcting the previous commit. Skipping a retraction frame outright stopped it
pruning the mirrored rows, so a worktree the host no longer publishes would
have kept a chat on screen with nothing behind it. Apply the frame as before
and clear its cursors instead of recording them, which is what the mainstream
session-tabs path does.

The unpublished sentinel keeps its cursor now too: it is skipped rather than
cleared, so a stale frame arriving late is still fenced. Adds the case the
earlier version would have broken.

* fix(native-chat): keep the retraction's fences, and fence the reveal's refresh

Correcting the retraction handling again. Clearing its cursors was more than the
bug needed and cost a guard: the host mints a fresh epoch when it rebuilds a
pruned entry, so a republication is never gated by the retained cursor, while
dropping it left an inventory response issued before the close free to land
afterwards and strand a chat row for a worktree the host no longer publishes.
Skip only the recording. The mainstream path keeps its epoch history for the
same reason, as a tombstone fence.

The test that justified the stronger clearing asserted a host behaviour that
does not exist — a rebuilt entry republishing under the renderer's epoch with a
restarted counter. It now uses what publishStructuredAgentSessionTab actually
mints for a pruned entry, and a new case covers the frame that would strand.

Also fences the reveal's inventory refresh on the sync generation, which every
other caller that applies an inventory already does: structured chat can be
switched off mid-flight, and the answer would otherwise re-seed a row into a
renderer that just discarded them.

* fix(native-chat): drop the retraction's epoch history, keep its version cursor

Third and final shape for this branch, and the only one of the three that holds.

Keeping both maps re-poisons the epoch one cycle later: the consumer here is
also the publisher, so the history's current is the renderer's own lifetime
epoch, and recording the reveal's fresh epoch retires it. The next chat the
renderer publishes is then dropped — this bug again, one close later. Deleting
both loses the guard that stops a frame issued before the close landing after
it and stranding a row nothing republishes.

So: clear the history, keep the cursor. The mainstream path keeps its history
as a tombstone because there the epochs belong to a remote publisher; that
reasoning does not carry to a path that publishes under its own.

Each of the three variants now fails a different test.

* fix(native-chat): a retraction forgets what is current, not the tombstones

The delete lost a fence the cursor cannot replace: the version cursor only
compares within a lineage, so a delayed frame from an already-superseded epoch
had nothing left to stop it putting a chat row back for a worktree the host no
longer publishes. Keeping the record intact had the opposite fault — the
renderer's own epoch is the history's current, so the next frame under any
other epoch retired it.

Clearing only current does neither: noteRetiredValue retires nothing when there
is nothing current, and the tombstones stay. Each of the four shapes now fails
a different test.

* fix(native-chat): narrow the retraction frame through its own type

Typecheck caught what the tests could not: `removed` is not on
RuntimeMobileSessionTabsResult. The repo already names the shape —
RuntimeMobileSessionTabsRemovedResult — so this reads it through a guard rather
than the inline cast the mainstream path uses.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Merge Sim <sim@local>
2026-09-06 00:50:20 -07:00

935 lines
36 KiB
TypeScript

// 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 receive a journal-backed RPC surface it cannot read, 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.
//
// The session-tabs projection may keep a metadata-only row for an incapable mobile client so the
// chat is not simply absent on the phone. Every `agentSession.*` method and destructive close stays
// refused, which is what the tests below pin; the row-level behaviour is pinned in
// src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts.
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 {
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
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
const CLIENT_CAPABILITY_UPDATE_METHOD = 'runtime.clientCapabilities.update'
const STATUS_FEED_METHOD = 'agentSession.subscribeStatus'
/** Every method the structured surface publishes: the host method it must reach,
* and the result it must hand back. A gate that hides one method and leaks
* another is the bug; so is a method that is registered and answers with an
* error, which is why `result` is declared per method rather than inferred from
* "did not say method_not_found". `result` is omitted only where the method
* legitimately answers with no reply at all. */
const STRUCTURED_CALLS: {
method: string
hostMethod: string | null
result?: Record<string, unknown>
}[] = [
{ method: 'agentSession.createSupport', hostMethod: null, result: { supported: true } },
{
method: 'agentSession.create',
hostMethod: 'attach',
result: { ok: true, replayed: false, value: { sessionId: SESSION } }
},
{
method: 'agentSession.ensure',
hostMethod: 'attach',
result: { ok: true, replayed: false, value: { sessionId: SESSION } }
},
{ method: 'agentSession.send', hostMethod: 'send', result: { ok: true, replayed: false } },
{ method: 'agentSession.cancel', hostMethod: 'cancel', result: { ok: true, replayed: false } },
{ method: 'agentSession.close', hostMethod: 'close', result: { ok: true } },
{
method: 'agentSession.respondToApproval',
hostMethod: 'respondToPrompt',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.respondToQuestion',
hostMethod: 'respondToPrompt',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.setOption',
hostMethod: 'setOption',
result: { ok: true, replayed: false }
},
{
method: 'agentSession.requestHandoff',
hostMethod: 'requestHandoff',
result: { status: { owner: 'native' } }
},
{
method: 'agentSession.handoffStatus',
hostMethod: 'handoffStatus',
result: { owner: 'native' }
},
{
method: 'agentSession.options',
hostMethod: 'readOptions',
result: { current: { model: 'gpt-live' } }
},
{
method: 'agentSession.reveal',
hostMethod: 'revealSession',
result: { ok: true, sessionId: SESSION, workspaceId: WORKSPACE, agent: 'codex', readable: true }
},
{ method: 'agentSession.hold', hostMethod: 'hold', result: { held: true } },
{ method: 'agentSession.release', hostMethod: 'release', result: { released: true } },
{
method: 'agentSession.history',
hostMethod: 'history',
result: { ok: true, page: { items: [] } }
},
// A subscription that opens with nothing to say answers with no reply at all,
// so reaching the host is the only signal that the gate opened.
{ method: 'agentSession.subscribe', hostMethod: 'subscribe' },
// The status feed opens with a snapshot of every session, so its first reply is the contract.
{
method: STATUS_FEED_METHOD,
hostMethod: 'subscribeStatus',
result: { type: 'snapshot', sessions: [] }
},
// 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, result: { unsubscribed: true } }
]
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 createIntentParams(): Record<string, unknown> {
const worktree = `id:${WORKSPACE}`
const fields = { worktree, agent: 'codex' }
return { envelope: envelope({ method: 'agentSession.create', fields, fence: null }), ...fields }
}
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.createSupport':
return { worktree: `id:${WORKSPACE}`, agent: 'codex' }
case 'agentSession.create':
return createIntentParams()
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.requestHandoff': {
const fields = {
direction: 'to-tui' as const,
mode: 'now' as const,
action: 'start' as const
}
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' }
case 'agentSession.hold':
case 'agentSession.release':
return { sessionId: SESSION, holderId: 'surface-1' }
default:
return { sessionId: SESSION }
}
}
function runtimeStub(): unknown {
const cleanups = new Map<string, () => void>()
return {
getRuntimeId: () => 'runtime-1',
ensureStructuredAgentSessionHost: async () => undefined,
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
resolveStructuredAgentSessionCreateIntent: async () => {
const {
envelope: _envelope,
providerHandle: _providerHandle,
...resolved
} = attachParams(null)
return resolved
},
publishStructuredAgentSessionTab: () => {},
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)
}
}
}
}
}
/**
* What a client too old to know the structured surface advertises: the baseline's
* own list, minus the capability. Derived rather than assumed to be the baseline's
* list as-is — the baseline is the newest release tag, so the day a release ships
* this capability the list would contain it and the gate below would stop being
* exercised at all, on a pull request that changed nothing.
*/
function legacyClientCapabilities(): string[] {
return baseline.capabilities.filter(
(capability) => capability !== STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
)
}
/** The structured methods the baseline release actually registers, read from it. */
function baselineStructuredMethods(): string[] {
return baseline.methodNames.filter((name) => name.startsWith('agentSession.'))
}
/** 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
}
/** The host every skew installs to drive the surface: enough of the real host's
* shape for each handler to run, and a spy per method so "which call reached the
* host" is answerable per call rather than per suite. */
function structuredHostStub(): Record<string, ReturnType<typeof vi.fn>> {
return {
attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId: SESSION } })),
// Attach-shaped entries take a client-supplied location, so the host is asked whether it
// supports creating there. A real host always answers; leaving it unstubbed made every
// `ensure` refuse for the harness's own reason rather than the location's.
supportsCreate: vi.fn(() => true),
send: vi.fn(async () => ({ ok: true, replayed: false })),
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
close: vi.fn(async () => undefined),
revealSession: vi.fn(async () => ({
sessionId: SESSION,
workspaceId: WORKSPACE,
agent: 'codex' as const,
readable: true
})),
hold: vi.fn(async () => undefined),
release: vi.fn(() => undefined),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
requestHandoff: vi.fn(async () => ({ status: { owner: 'native' } })),
handoffStatus: vi.fn(async () => ({ owner: 'native' })),
readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })),
history: vi.fn(() => ({ ok: true, page: { items: [] } })),
subscribe: vi.fn(() => () => undefined),
subscribeStatus: vi.fn((subscriber: { emit: (event: unknown) => void }) => {
subscriber.emit({ type: 'snapshot', sessions: [] })
return () => undefined
}),
unsubscribe: vi.fn()
}
}
/**
* The one thing this suite exists to guarantee, written once and applied per
* build: every method the manifest declares is not merely registered but reaches
* its host method on this call, answers, and answers with its declared result.
*
* Written as a helper rather than inline because a build passing it is the claim,
* and each skew that registers the surface owes the same claim — a check that
* covers one method leaves the rest registered-but-unusable behind a green suite.
*/
async function expectDeclaredSurfaceExecutes(
build: AgentSessionWireBuild,
hostCalls: Record<string, ReturnType<typeof vi.fn>>,
clientCapabilities: readonly string[]
): Promise<void> {
for (const { method, hostMethod, result } of STRUCTURED_CALLS) {
// Two methods share one host method, so "has been called" would already be
// true from the earlier one: only this call's own delta pins the pairing.
const before = hostMethod ? hostCalls[hostMethod].mock.calls.length : 0
const replies = await callBuild(build, method, paramsFor(method), {
clientKind: 'runtime',
clientCapabilities
})
if (hostMethod) {
expect(
hostCalls[hostMethod].mock.calls.length - before,
`${build.label}: ${method} did not reach the host`
).toBe(1)
}
for (const reply of replies) {
expect(
reply,
`${build.label}: ${method} was refused: ${JSON.stringify(reply)}`
).toMatchObject({ ok: true })
}
if (result) {
// The declared answer, not merely a non-refusal: a handler that is
// registered and returns an execution error, or hands back someone else's
// envelope, fails here rather than passing as "reached the host".
expect(replies, `${build.label}: ${method} must answer exactly once`).toHaveLength(1)
expect(replies[0], `${build.label}: ${method} answered off-contract`).toMatchObject({
ok: true,
result
})
}
}
}
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 = structuredHostStub()
setStructuredAgentSessionHost(hostCalls as unknown as StructuredAgentSessionHost)
})
afterEach(() => {
setStructuredAgentSessionHost(null)
})
it('is told the whole surface does not exist, and reaches no host method', async () => {
// Anti-vacuous: the old client still advertises a real list, so the refusal
// below is the capability gate answering, not an empty negotiation.
expect(legacyClientCapabilities().length).toBeGreaterThan(0)
for (const { method } of STRUCTURED_CALLS) {
const replies = await callBuild(current, method, paramsFor(method), {
clientKind: 'runtime',
clientCapabilities: legacyClientCapabilities()
})
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 () => {
await expectDeclaredSurfaceExecutes(current, hostCalls, [
...legacyClientCapabilities(),
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
])
})
})
describe('a new client against an old host', () => {
it('registers the whole surface on the new build', () => {
expect(current.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength(
STRUCTURED_CALLS.length
)
})
it('can detect the absence during negotiation instead of by calling', () => {
// The invariant that survives a release cut: each build's advertised list and
// its registered methods agree. "The old build has neither" is only true
// until a release ships the surface, and pinning it turns this red on the cut
// rather than on a change.
expect(baseline.capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)).toBe(
baselineStructuredMethods().length > 0
)
// The status feed is additive to a surface that already shipped, so it carries its own
// capability or a client cannot tell "host too old" from "the call failed" — and it
// would relay-retry a method_not_found forever instead of degrading once.
for (const build of [current, baseline]) {
expect(build.capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY)).toBe(
build.methodNames.includes(STATUS_FEED_METHOD)
)
}
// 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 answer from the old dispatcher rather than silence', async () => {
const registered = new Set(baselineStructuredMethods())
for (const { method } of STRUCTURED_CALLS) {
const replies = await callBuild(baseline, method, paramsFor(method), {
clientKind: 'runtime',
clientCapabilities: current.capabilities
})
// Silence is the failure mode a new client cannot recover from, whatever
// the old build knows; the refusal code is only asserted for the methods
// that release genuinely does not have.
expect(replies, `${method} must answer exactly once`).toHaveLength(1)
if (!registered.has(method)) {
expect(replies[0], `${method} on the old host`).toMatchObject({
ok: false,
error: { code: 'method_not_found' }
})
} else {
expect(replies[0], `${method} is registered on the old host`).not.toMatchObject({
ok: false,
error: { code: 'method_not_found' }
})
}
}
})
it(
'executes every method a release-shaped checkout registers',
async () => {
// The stand-in for the release that ships this surface: the same source,
// read the way a release checkout reads it rather than through the test
// runner's module graph. It is the only place the "registered means
// usable" claim is executable today, because the baseline registers none
// of these methods — so it has to carry the whole manifest, not a sample.
const releasedCurrent = await loadAgentSessionWireBuild('HEAD')
expect(releasedCurrent.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
expect(
releasedCurrent.methodNames.filter((name) => name.startsWith('agentSession.'))
).toHaveLength(STRUCTURED_CALLS.length)
// Each build owns its own host slot, so the one the suite installed in
// current source is not this dispatcher's. Installing here is also the
// anti-vacuous guard: without it every host-backed method answers
// `structured_agent_session_unsupported`, the same words the capability
// gate uses, and the run would read as a refusal rather than a miss.
const hostCalls = structuredHostStub()
await releasedCurrent.installStructuredHost(hostCalls)
try {
await expectDeclaredSurfaceExecutes(
releasedCurrent,
hostCalls,
releasedCurrent.capabilities
)
} finally {
await releasedCurrent.installStructuredHost(null)
}
},
SUITE_TIMEOUT_MS
)
})
describe('post-auth mobile capability negotiation', () => {
it('is an additive method that lets the current host record mobile capabilities', async () => {
const updates: string[][] = []
const replies = await callBuild(
current,
CLIENT_CAPABILITY_UPDATE_METHOD,
{ clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] },
{
clientKind: 'mobile',
clientCapabilities: [],
updateClientCapabilities: (capabilities) => updates.push([...capabilities])
}
)
expect(current.methodNames).toContain(CLIENT_CAPABILITY_UPDATE_METHOD)
expect(current.protocolVersion).toBe(baseline.protocolVersion)
expect(replies).toHaveLength(1)
expect(replies[0]).toMatchObject({
ok: true,
result: { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] }
})
expect(updates).toEqual([[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]])
})
it('gets a normal answer from an old host instead of changing the auth shape', async () => {
const replies = await callBuild(
baseline,
CLIENT_CAPABILITY_UPDATE_METHOD,
{ clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] },
{ clientKind: 'mobile', clientCapabilities: [] }
)
expect(replies).toHaveLength(1)
if (!baseline.methodNames.includes(CLIENT_CAPABILITY_UPDATE_METHOD)) {
expect(replies[0]).toMatchObject({
ok: false,
error: { code: 'method_not_found' }
})
}
})
})
describe('an old client against a structured-owned AI Vault row', () => {
let root: string
let store: AgentSessionRecordStore
let runtime: Record<string, unknown>
let createMobileSessionTerminal: ReturnType<typeof vi.fn>
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-'))
store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
const host = new StructuredAgentSessionHost({
store,
adapter: {
acquire: async ({ fence }) => ({
process: {
hostId: 'local',
pid: 4242,
processStartTimeMs: NOW,
spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-vault'
},
link: {
linkId: `link-${fence}`,
handle: { provider: 'codex', threadId: THREAD },
origin: 'created',
mintedAtFence: fence,
observedAt: NOW
}
}),
dispatch: async () => ({ state: 'accepted' }),
cancelTurn: async () => ({ cancelled: true }),
answerPrompt: async () => undefined,
setOption: async () => undefined
},
journalRoot: root,
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-vault',
now: () => NOW
})
setStructuredAgentSessionHost(host)
const attached = await host.attach({ callerKey: 'test' }, attachParams(null) as never)
expect(attached.ok).toBe(true)
createMobileSessionTerminal = vi.fn()
runtime = {
...(runtimeStub() as Record<string, unknown>),
listAiVaultSessions: vi.fn(async () => ({
sessions: [
{
id: `local:codex:${THREAD}:/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`,
executionHostId: 'local',
agent: 'codex',
sessionId: THREAD,
title: 'Owned thread',
cwd: '/repo',
branch: null,
model: null,
filePath: `/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`,
codexHome: '/home/dev/.codex',
createdAt: null,
updatedAt: null,
modifiedAt: '2026-08-11T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume '${THREAD}'`,
subagent: null
}
],
issues: [],
scannedAt: '2026-08-11T00:00:00.000Z'
})),
prepareAiVaultSessionResume: vi.fn(),
createMobileSessionTerminal
}
})
afterEach(async () => {
setStructuredAgentSessionHost(null)
await rm(root, { recursive: true, force: true })
})
it('hides the row from the old client and annotates it for a capable client', async () => {
const oldReply = (
await callBuild(
current,
'aiVault.listSessions',
{},
{
clientKind: 'runtime',
clientCapabilities: legacyClientCapabilities()
},
runtime
)
)[0]
expect(oldReply).toMatchObject({ ok: true, result: { sessions: [] } })
const capableReply = (
await callBuild(
current,
'aiVault.listSessions',
{},
{
clientKind: 'runtime',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
},
runtime
)
)[0]
expect(capableReply).toMatchObject({
ok: true,
result: {
sessions: [
{
structuredSession: { sessionId: SESSION, workspaceId: WORKSPACE }
}
]
}
})
})
it('refuses cached prepare and both legacy launch deliveries before a second writer starts', async () => {
const params = {
agent: 'codex',
filePath: `/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`,
codexHome: '/home/dev/.codex'
}
expect(
(
await callBuild(
current,
'aiVault.prepareSessionResume',
params,
{
clientKind: 'runtime',
clientCapabilities: legacyClientCapabilities()
},
runtime
)
)[0]
).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } })
expect(
(
await callBuild(
current,
'session.tabs.createTerminal',
{ worktree: `id:${WORKSPACE}`, command: `codex resume '${THREAD}'` },
{ clientKind: 'runtime', clientCapabilities: legacyClientCapabilities() },
runtime
)
)[0]
).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } })
expect(
(
await callBuild(
current,
'terminal.send',
{ terminal: 'terminal-1', text: `codex resume '${THREAD}'`, enter: true },
{ clientKind: 'runtime', clientCapabilities: legacyClientCapabilities() },
runtime
)
)[0]
).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } })
expect(createMobileSessionTerminal).not.toHaveBeenCalled()
// The positive control for the three refusals above: the same client, the
// same method, a command that is not this thread's resume, and it lands.
// Without it, a stub whose shape drifted from the runtime would satisfy
// "was never called" by never being reachable at all.
expect(
(
await callBuild(
current,
'session.tabs.createTerminal',
{ worktree: `id:${WORKSPACE}`, command: 'echo unrelated' },
{ clientKind: 'runtime', clientCapabilities: legacyClientCapabilities() },
runtime
)
)[0]
).toMatchObject({ ok: true })
expect(createMobileSessionTerminal).toHaveBeenCalledTimes(1)
})
})
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 {
// Every real adapter answers this; without it adapterSupportsCreate falls through to
// `supportsLocation`, which this fake also lacks, so the client-supplied-location gate
// refused for the fake's silence rather than for the location.
supportsCreate: () => true,
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<StructuredAgentSessionHost> {
store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
const host = 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
})
setStructuredAgentSessionHost(host)
return host
}
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: 'runtime',
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', createIntentParams())
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
const restarted = await bootHost('b')
await restarted.restoreReadableSessions()
// Restart restores the session for READING. The chat the client still has open takes its
// hold, and that is what gives the session a provider child again.
await answer('agentSession.hold', { sessionId: SESSION, holderId: 'surface-1' })
const resumedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0
expect(resumedFence).toBeGreaterThan(created.fence)
const second = await answer('agentSession.send', sendParams('after restart', resumedFence))
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', createIntentParams())
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' }
})
})
})
})