mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
fix(native-chat): reserve the lease before the adopted Codex proof
Switching a Codex tab to structured chat failed from every desktop entry point with "The agent terminal could not verify its Codex session." `adoptStructuredAgentSessionTerminal` typed `/status` into the pane through `writeAgentSessionProof`, which `agentSessionPtyWriteGate.admitProof` admits only for a reservation that already names the session, `tui`, and the spawn token. Both the reservation and `bindPty` happened *after* the proof, so the gate saw no binding and no record and refused the first keystroke. Neither proof branch could ever run, which is why an agent mid-conversation failed the same way as a cold one. The reservation and the pane binding now come first, so the probe is authorized as `provingReservation` — the state the gate was written to accept. A proof that does not land hands the reservation back with the durable processless proof and releases the pane, so the next attempt is not refused by the record its predecessor left behind. `proveOwner` gets the reservation's own fence rather than a hardcoded 1, which is what makes that retry land. Launch-at-chat shared the defect: it routes through the same `setTabViewMode` → `setTerminalNativeChatMode` → `agentSession.adoptTerminal` path, now pinned by a test. The new adoption test drives the real write gate over a real record store and a real rollout proof; every earlier test mocked the PTY controller, so `admitProof` never ran and none of them could observe this.
This commit is contained in:
@@ -60,6 +60,13 @@ import {
|
||||
adoptStructuredTuiOwner,
|
||||
type StructuredTuiAdoptionRequest
|
||||
} from './structured-agent-session-tui-adoption'
|
||||
import {
|
||||
releaseStructuredTuiAdoptionReservation,
|
||||
reserveStructuredTuiAdoption,
|
||||
type StructuredTuiAdoptionReservationRelease,
|
||||
type StructuredTuiAdoptionReservationRequest,
|
||||
type StructuredTuiAdoptionReservationResult
|
||||
} from './structured-agent-session-tui-adoption-reservation'
|
||||
import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result'
|
||||
export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
|
||||
@@ -208,6 +215,20 @@ export class StructuredAgentSessionHost {
|
||||
return this.tasks.trackAttach(attaching)
|
||||
}
|
||||
|
||||
/** Reserve the lease an adopted TUI must hold before the write gate will admit its proof. */
|
||||
reserveAdoptedTuiOwner = (
|
||||
input: StructuredTuiAdoptionReservationRequest
|
||||
): Promise<StructuredTuiAdoptionReservationResult> =>
|
||||
this.serialize(input.sessionId, () =>
|
||||
reserveStructuredTuiAdoption({ ...input, deps: this.deps, now: this.now })
|
||||
)
|
||||
|
||||
/** Hand back a reservation whose proof never landed, so the next attempt is not refused by it. */
|
||||
releaseAdoptedTuiReservation = (input: StructuredTuiAdoptionReservationRelease): Promise<void> =>
|
||||
this.serialize(input.sessionId, () =>
|
||||
releaseStructuredTuiAdoptionReservation({ ...input, deps: this.deps, now: this.now })
|
||||
)
|
||||
|
||||
adoptTuiOwner = (
|
||||
input: StructuredTuiAdoptionRequest
|
||||
): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> =>
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* The reservation an adopted Codex TUI needs BEFORE it may talk to the provider.
|
||||
*
|
||||
* Adoption proves the running Codex by typing `/status` into its own pane, and
|
||||
* `agentSessionPtyWriteGate.admitProof` admits that probe only against a durable reservation that
|
||||
* already names this session, this runtime kind, and this spawn token. Reserving after the proof —
|
||||
* the shape this replaces — meant the probe was never admitted, so no adoption could ever succeed.
|
||||
*
|
||||
* The reservation is therefore created first and released again when the proof does not land, so a
|
||||
* failed attempt leaves nothing behind that would refuse the next one.
|
||||
*/
|
||||
|
||||
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
|
||||
import type {
|
||||
AgentSessionAccountHome,
|
||||
AgentSessionExecutionLocation,
|
||||
AgentSessionLaunchEnv,
|
||||
AgentSessionRecord
|
||||
} from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
|
||||
import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
|
||||
import { classifyStoreFailure } from './structured-agent-session-attach'
|
||||
import type {
|
||||
StructuredAgentSessionCaller,
|
||||
StructuredAgentSessionHostDeps
|
||||
} from './structured-agent-session-host-types'
|
||||
|
||||
export type StructuredTuiAdoptionReservationRequest = {
|
||||
caller: StructuredAgentSessionCaller
|
||||
sessionId: string
|
||||
clientOperationId: string
|
||||
/** The adoption call's own payload fingerprint; the durable operation row is keyed by it. */
|
||||
fingerprint: string
|
||||
location: AgentSessionExecutionLocation
|
||||
provider: AgentSessionHandleProvider
|
||||
accountHome: AgentSessionAccountHome
|
||||
spawnToken: string
|
||||
claimKeyId: string
|
||||
launchEnv?: AgentSessionLaunchEnv
|
||||
}
|
||||
|
||||
export type StructuredTuiAdoptionReservationResult =
|
||||
| {
|
||||
ok: true
|
||||
fence: number
|
||||
/** The token the proof must carry: a replayed call keeps the one already on the record. */
|
||||
spawnToken: string
|
||||
}
|
||||
| { ok: false; refusal: AgentSessionWireRefusal }
|
||||
|
||||
export type StructuredTuiAdoptionReservationRelease = {
|
||||
sessionId: string
|
||||
fence: number
|
||||
spawnToken: string
|
||||
}
|
||||
|
||||
/** The exact lease shape `admitProof` accepts as `provingReservation`. */
|
||||
export function isStructuredTuiAdoptionReservation(
|
||||
record: AgentSessionRecord,
|
||||
spawnToken: string
|
||||
): boolean {
|
||||
const { lease } = record
|
||||
return (
|
||||
lease.runtimeKind === 'tui' &&
|
||||
lease.claimStatus === 'reserved' &&
|
||||
lease.handoffStage === 'new-owner-proving' &&
|
||||
lease.ownerProcess === null &&
|
||||
lease.reservedSpawnToken === spawnToken &&
|
||||
!lease.unreconciled
|
||||
)
|
||||
}
|
||||
|
||||
export async function reserveStructuredTuiAdoption(
|
||||
input: StructuredTuiAdoptionReservationRequest & {
|
||||
deps: Pick<StructuredAgentSessionHostDeps, 'store' | 'probeOwner'>
|
||||
now: () => number
|
||||
}
|
||||
): Promise<StructuredTuiAdoptionReservationResult> {
|
||||
const { deps, sessionId } = input
|
||||
const existing = deps.store.getRecord(sessionId)
|
||||
try {
|
||||
const reserved = await deps.store.reserveOwner({
|
||||
sessionId,
|
||||
location: input.location,
|
||||
provider: input.provider,
|
||||
accountHome: input.accountHome,
|
||||
...(input.launchEnv ? { launchEnv: input.launchEnv } : {}),
|
||||
runtimeKind: 'tui',
|
||||
// An adoption never declares a fence, so the compare-and-swap runs against what the store
|
||||
// holds right now and the probe below is what refuses when that record still has an owner.
|
||||
expectedFence: existing?.lease.runtimeFence ?? null,
|
||||
spawnToken: input.spawnToken,
|
||||
claimKeyId: input.claimKeyId,
|
||||
// Why null: a retry mints a fresh operation id, and a non-null one here would make the
|
||||
// reservation left behind by the previous attempt refuse it as a different operation.
|
||||
handoffOperationId: null,
|
||||
probe: await previousOwnerProbe(input, existing),
|
||||
operation: {
|
||||
callerKey: input.caller.callerKey,
|
||||
operationId: input.clientOperationId,
|
||||
fingerprint: input.fingerprint
|
||||
},
|
||||
now: input.now()
|
||||
})
|
||||
const spawnToken = reserved.record.lease.reservedSpawnToken
|
||||
if (!spawnToken) {
|
||||
throw new Error('agent_session_ownership_unknown')
|
||||
}
|
||||
return { ok: true, fence: reserved.record.lease.runtimeFence, spawnToken }
|
||||
} catch (error) {
|
||||
const current = deps.store.getRecord(sessionId)
|
||||
return {
|
||||
ok: false,
|
||||
refusal: classifyStoreFailure(error, current?.lease.runtimeFence ?? null, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo a reservation whose proof never landed. `processlessAt` is the durable "nothing ever
|
||||
* spawned under this token" proof, which is what lets the next attempt supersede the record
|
||||
* instead of being refused by it forever — and what stops a restart from latching it into
|
||||
* recovery.
|
||||
*/
|
||||
export async function releaseStructuredTuiAdoptionReservation(
|
||||
input: StructuredTuiAdoptionReservationRelease & {
|
||||
deps: Pick<StructuredAgentSessionHostDeps, 'store'>
|
||||
now: () => number
|
||||
}
|
||||
): Promise<void> {
|
||||
const record = input.deps.store.getRecord(input.sessionId)
|
||||
if (
|
||||
!record ||
|
||||
record.lease.runtimeFence !== input.fence ||
|
||||
!isStructuredTuiAdoptionReservation(record, input.spawnToken)
|
||||
) {
|
||||
// Superseded, already proven, or gone: whatever holds the record now is not this attempt's.
|
||||
return
|
||||
}
|
||||
const now = input.now()
|
||||
await input.deps.store.setReservationProcesslessProof({
|
||||
sessionId: input.sessionId,
|
||||
fence: input.fence,
|
||||
spawnToken: input.spawnToken,
|
||||
processlessAt: now,
|
||||
now
|
||||
})
|
||||
}
|
||||
|
||||
function previousOwnerProbe(
|
||||
input: { deps: Pick<StructuredAgentSessionHostDeps, 'probeOwner'> },
|
||||
existing: AgentSessionRecord | null
|
||||
): Promise<AgentSessionOwnerProbe> | AgentSessionOwnerProbe {
|
||||
if (!existing) {
|
||||
return { outcome: 'reservation-unused' }
|
||||
}
|
||||
// Fails closed: without a prober the store must not be told the previous owner is gone.
|
||||
return (
|
||||
input.deps.probeOwner?.(existing) ?? {
|
||||
outcome: 'indeterminate',
|
||||
reason: 'this host cannot probe the recorded owner'
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type {
|
||||
AgentSessionAttachResult,
|
||||
AgentSessionMutationResult
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
StructuredAgentSessionHostSession
|
||||
} from './structured-agent-session-host-types'
|
||||
import type { StructuredTuiOwner } from './structured-agent-session-handoff-types'
|
||||
import { isStructuredTuiAdoptionReservation } from './structured-agent-session-tui-adoption-reservation'
|
||||
|
||||
export type StructuredTuiAdoptionRequest = {
|
||||
caller: StructuredAgentSessionCaller
|
||||
@@ -47,7 +49,13 @@ async function adopt(
|
||||
}
|
||||
let record = input.deps.store.getRecord(sessionId)
|
||||
let replayed = record !== null
|
||||
if (!record) {
|
||||
if (record && isStructuredTuiAdoptionReservation(record, owner.process.spawnToken)) {
|
||||
// This adoption's own pre-proof reservation: turn it into an owner rather than reserve again.
|
||||
replayed = false
|
||||
record = await proveAdoptedTuiOwner(input, record)
|
||||
} else if (record) {
|
||||
assertMatchingTuiOwner(record, owner)
|
||||
} else {
|
||||
const reserved = await input.deps.store.reserveOwner(
|
||||
reserveRequestFor({
|
||||
sessionId,
|
||||
@@ -65,21 +73,7 @@ async function adopt(
|
||||
})
|
||||
)
|
||||
replayed = reserved.disposition === 'replayed'
|
||||
record = reserved.record
|
||||
await input.deps.store.commitProcessIdentity({
|
||||
sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
process: owner.process,
|
||||
now: input.now()
|
||||
})
|
||||
record = await input.deps.store.proveOwner({
|
||||
sessionId,
|
||||
fence: record.lease.runtimeFence,
|
||||
link: owner.link,
|
||||
now: input.now()
|
||||
})
|
||||
} else {
|
||||
assertMatchingTuiOwner(record, owner)
|
||||
record = await proveAdoptedTuiOwner(input, reserved.record)
|
||||
}
|
||||
const attached = await attachJournal({
|
||||
record,
|
||||
@@ -116,6 +110,27 @@ async function adopt(
|
||||
}
|
||||
}
|
||||
|
||||
/** Steps 4 and 5 of acquisition against a reservation that already exists at this fence. */
|
||||
async function proveAdoptedTuiOwner(
|
||||
input: Parameters<typeof adoptStructuredTuiOwner>[0],
|
||||
record: AgentSessionRecord
|
||||
): Promise<AgentSessionRecord> {
|
||||
const { sessionId } = record
|
||||
const fence = record.lease.runtimeFence
|
||||
await input.deps.store.commitProcessIdentity({
|
||||
sessionId,
|
||||
fence,
|
||||
process: input.owner.process,
|
||||
now: input.now()
|
||||
})
|
||||
return input.deps.store.proveOwner({
|
||||
sessionId,
|
||||
fence,
|
||||
link: input.owner.link,
|
||||
now: input.now()
|
||||
})
|
||||
}
|
||||
|
||||
function assertMatchingTuiOwner(
|
||||
record: NonNullable<ReturnType<StructuredAgentSessionHostDeps['store']['getRecord']>>,
|
||||
owner: StructuredTuiOwner
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Adoption against the REAL `agentSessionPtyWriteGate`.
|
||||
*
|
||||
* Every other adoption test mocks the PTY controller, so `writeAgentSessionProof` answers whatever
|
||||
* the mock says and `admitProof` never runs — which is precisely why they all passed while no user
|
||||
* could ever switch a Codex tab to structured chat. Here the controller delegates to the real gate
|
||||
* over a real record store, so the probe is admitted only when adoption has actually reserved the
|
||||
* lease and bound the pane first.
|
||||
*/
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createStructuredAgentSessionOperationId } from '../../shared/structured-agent-session-mutation'
|
||||
import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate'
|
||||
import { createStructuredAgentSessionOwnerProbe } from './structured-agent-session-runtime'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const { readStructuredTuiProcessIdentity } = vi.hoisted(() => ({
|
||||
readStructuredTuiProcessIdentity: vi.fn()
|
||||
}))
|
||||
// The only OS-dependent step in the flow; the rollout proof and the write gate both stay real.
|
||||
vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity }))
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/tmp/structured-adoption'
|
||||
const PTY_ID = 'pty-adopt'
|
||||
const PANE_KEY = 'tab-adopt:leaf-adopt'
|
||||
const THREAD_ID = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
|
||||
const SESSION_ID = `codex_${THREAD_ID.replaceAll('-', '_')}`
|
||||
|
||||
type ProofRig = {
|
||||
runtime: OrcaRuntimeService
|
||||
store: AgentSessionRecordStore
|
||||
host: StructuredAgentSessionHost
|
||||
proofWrites: string[]
|
||||
refusedWrites: string[]
|
||||
codexHome: string
|
||||
}
|
||||
|
||||
let root: string
|
||||
let rig: ProofRig
|
||||
|
||||
async function writeRolloutFixture(codexHome: string, threadId: string): Promise<void> {
|
||||
const dir = join(codexHome, 'sessions', '2026', '08', '19')
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(
|
||||
join(dir, `rollout-2026-08-19T10-00-00-${threadId}.jsonl`),
|
||||
`${JSON.stringify({ type: 'session_meta', payload: { id: threadId } })}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A PTY that answers `/status` with a Codex session banner — but only for bytes the real gate
|
||||
* admitted. This mirrors `writeAgentSessionProof` in src/main/ipc/pty.ts verbatim.
|
||||
*/
|
||||
function installProofPty(
|
||||
rigState: ProofRig,
|
||||
threadId: string,
|
||||
ptyRecord: { lastOutputAt: number }
|
||||
) {
|
||||
return (ptyId: string, data: string, authority: { sessionId: string; spawnToken: string }) => {
|
||||
if (!agentSessionPtyWriteGate.admitProof(ptyId, authority)) {
|
||||
rigState.refusedWrites.push(data)
|
||||
return false
|
||||
}
|
||||
rigState.proofWrites.push(data)
|
||||
if (data === '\r') {
|
||||
const record = rigState.runtime as unknown as {
|
||||
ptysById: Map<string, { tailBuffer: string[]; lastOutputAt: number }>
|
||||
}
|
||||
const pty = record.ptysById.get(PTY_ID)!
|
||||
pty.tailBuffer = [`Session ID: ${threadId}`]
|
||||
pty.lastOutputAt = ptyRecord.lastOutputAt + 1_000
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function buildRig(): Promise<ProofRig> {
|
||||
const codexHome = join(root, 'codex-home')
|
||||
await writeRolloutFixture(codexHome, THREAD_ID)
|
||||
const store = await AgentSessionRecordStore.open({
|
||||
directory: join(root, 'agent-sessions'),
|
||||
hostId: 'local'
|
||||
})
|
||||
agentSessionPtyWriteGate.attachRecordLookup((sessionId) => store.getRecord(sessionId))
|
||||
const host = new StructuredAgentSessionHost({
|
||||
store,
|
||||
adapter: {
|
||||
acquire: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
cancelTurn: vi.fn(),
|
||||
answerPrompt: vi.fn(),
|
||||
setOption: vi.fn()
|
||||
},
|
||||
journalRoot: join(root, 'journals'),
|
||||
claimKeyId: 'key-1',
|
||||
probeOwner: createStructuredAgentSessionOwnerProbe(
|
||||
'local',
|
||||
async () => ({ outcome: 'identity-matched', matchedOn: ['process-start-time'] }),
|
||||
async () => []
|
||||
)
|
||||
})
|
||||
setStructuredAgentSessionHost(host)
|
||||
|
||||
const runtime = new OrcaRuntimeService({ getSettings: () => ({ agentDefaultEnv: {} }) } as never)
|
||||
const state: ProofRig = { runtime, store, host, proofWrites: [], refusedWrites: [], codexHome }
|
||||
const ptyRecord = {
|
||||
ptyId: PTY_ID,
|
||||
connected: true,
|
||||
connectionId: null,
|
||||
wslDistro: null,
|
||||
tabId: 'tab-adopt',
|
||||
paneKey: PANE_KEY,
|
||||
worktreeId: WORKTREE_ID,
|
||||
incarnationId: 'inc-adopt',
|
||||
tailBuffer: [] as string[],
|
||||
tailPartialLine: '',
|
||||
preview: '',
|
||||
lastOutputAt: 1_000
|
||||
}
|
||||
const internal = runtime as unknown as {
|
||||
ptysById: Map<string, unknown>
|
||||
resolveRuntimeFileTarget(): Promise<unknown>
|
||||
resolveStructuredAgentSessionCreateIntent(input: { envelope: unknown }): Promise<unknown>
|
||||
issueStructuredTuiPtyHandle(): string
|
||||
}
|
||||
internal.ptysById.set(PTY_ID, ptyRecord)
|
||||
internal.resolveRuntimeFileTarget = vi.fn(async () => ({
|
||||
connectionId: null,
|
||||
worktree: { id: WORKTREE_ID }
|
||||
}))
|
||||
internal.resolveStructuredAgentSessionCreateIntent = vi.fn(async ({ envelope }) => ({
|
||||
envelope,
|
||||
location: {
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: WORKTREE_ID,
|
||||
workspaceKind: 'git-worktree'
|
||||
},
|
||||
provider: 'codex',
|
||||
agent: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: codexHome },
|
||||
runtimeKind: 'native'
|
||||
}))
|
||||
internal.issueStructuredTuiPtyHandle = vi.fn(() => 'term-adopt')
|
||||
runtime.setPtyController({
|
||||
listProcesses: async () => [{ id: PTY_ID, incarnationId: 'inc-adopt', rootProcessId: 31337 }],
|
||||
writeAgentSessionProof: installProofPty(state, THREAD_ID, ptyRecord)
|
||||
} as never)
|
||||
return state
|
||||
}
|
||||
|
||||
function adoptInput(overrides: { threadId?: string } = {}) {
|
||||
return {
|
||||
envelope: {
|
||||
sessionId: SESSION_ID,
|
||||
clientOperationId: createStructuredAgentSessionOperationId(() => randomUUID()),
|
||||
expectedRuntimeFence: null as null,
|
||||
payloadFingerprint: 'a'.repeat(64)
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
tabId: 'tab-adopt',
|
||||
paneKey: PANE_KEY,
|
||||
ptyId: PTY_ID,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-adopt-gate-'))
|
||||
readStructuredTuiProcessIdentity.mockImplementation(
|
||||
async (input: { hostId: string; spawnToken: string }) => ({
|
||||
hostId: input.hostId,
|
||||
pid: 4242,
|
||||
processStartTimeMs: 1_700_000_000_000,
|
||||
spawnToken: input.spawnToken
|
||||
})
|
||||
)
|
||||
rig = await buildRig()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
agentSessionPtyWriteGate.unbindPty(PTY_ID)
|
||||
agentSessionPtyWriteGate.detachRecordLookup()
|
||||
setStructuredAgentSessionHost(null)
|
||||
await rig.host.flushAllStreamedEvents()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('structured Codex adoption against the real PTY write gate', () => {
|
||||
it('authorizes the provider probe, so switching a Codex tab to chat succeeds', async () => {
|
||||
const result = await rig.runtime.adoptStructuredAgentSessionTerminal(
|
||||
{ ...adoptInput(), threadId: THREAD_ID },
|
||||
{ callerKey: 'renderer-1' }
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
// The probe reached the pane: `/status`, submit, escape — none of them refused by the gate.
|
||||
expect(rig.refusedWrites).toEqual([])
|
||||
expect(rig.proofWrites).toHaveLength(3)
|
||||
expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBe(SESSION_ID)
|
||||
expect(rig.store.getRecord(SESSION_ID)).toMatchObject({
|
||||
lease: { runtimeKind: 'tui', claimStatus: 'live', handoffStage: null }
|
||||
})
|
||||
}, 20_000)
|
||||
|
||||
it('authorizes the probe when no thread id has been published yet', async () => {
|
||||
const result = await rig.runtime.adoptStructuredAgentSessionTerminal(adoptInput(), {
|
||||
callerKey: 'renderer-1'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: true })
|
||||
expect(rig.refusedWrites).toEqual([])
|
||||
expect(rig.store.getRecord(SESSION_ID)).toMatchObject({
|
||||
providerHandleChain: [{ origin: 'adopted', handle: { threadId: THREAD_ID } }]
|
||||
})
|
||||
}, 20_000)
|
||||
|
||||
it('leaves nothing latched when the proof fails, so the next attempt still succeeds', async () => {
|
||||
await rm(join(rig.codexHome, 'sessions'), { recursive: true, force: true })
|
||||
|
||||
await expect(
|
||||
rig.runtime.adoptStructuredAgentSessionTerminal(
|
||||
{ ...adoptInput(), threadId: THREAD_ID },
|
||||
{ callerKey: 'renderer-1' }
|
||||
)
|
||||
).rejects.toThrow('did not prove the expected Codex rollout')
|
||||
// The reservation is durably marked as never having spawned, and the pane is released.
|
||||
expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBeNull()
|
||||
expect(rig.store.getRecord(SESSION_ID)?.lease).toMatchObject({
|
||||
claimStatus: 'reserved',
|
||||
ownerProcess: null
|
||||
})
|
||||
expect(rig.store.getRecord(SESSION_ID)?.lease.processlessAt).toEqual(expect.any(Number))
|
||||
|
||||
await writeRolloutFixture(rig.codexHome, THREAD_ID)
|
||||
const retried = await rig.runtime.adoptStructuredAgentSessionTerminal(
|
||||
{ ...adoptInput(), threadId: THREAD_ID },
|
||||
{ callerKey: 'renderer-1' }
|
||||
)
|
||||
|
||||
expect(retried).toMatchObject({ ok: true })
|
||||
expect(rig.store.getRecord(SESSION_ID)).toMatchObject({
|
||||
lease: { claimStatus: 'live', runtimeKind: 'tui' }
|
||||
})
|
||||
}, 30_000)
|
||||
|
||||
it('refuses a pane another structured session already owns', async () => {
|
||||
agentSessionPtyWriteGate.bindPty(PTY_ID, 'someone-elses-session')
|
||||
|
||||
await expect(
|
||||
rig.runtime.adoptStructuredAgentSessionTerminal(
|
||||
{ ...adoptInput(), threadId: THREAD_ID },
|
||||
{ callerKey: 'renderer-1' }
|
||||
)
|
||||
).rejects.toThrow('already belongs to another structured Codex session')
|
||||
expect(agentSessionPtyWriteGate.boundSessionId(PTY_ID)).toBe('someone-elses-session')
|
||||
expect(rig.store.getRecord(SESSION_ID)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -77,6 +77,20 @@ function terminalAdoptionHarness() {
|
||||
return runtime
|
||||
}
|
||||
|
||||
/** The host half adoption now needs: the lease is reserved before the provider probe runs. */
|
||||
function structuredHostDouble(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
reserveAdoptedTuiOwner: vi.fn(async (input: { spawnToken: string }) => ({
|
||||
ok: true,
|
||||
fence: 1,
|
||||
spawnToken: input.spawnToken
|
||||
})),
|
||||
releaseAdoptedTuiReservation: vi.fn(async () => undefined),
|
||||
adoptTuiOwner: vi.fn(async () => ({ ok: true, replayed: false, fence: 1 })),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function notifier(revealTerminalSession: ReturnType<typeof vi.fn>) {
|
||||
return {
|
||||
worktreesChanged: vi.fn(),
|
||||
@@ -105,7 +119,7 @@ describe('structured TUI launch tab binding', () => {
|
||||
it('adopts the proven local Codex pane as a durable TUI owner', async () => {
|
||||
const runtime = terminalAdoptionHarness()
|
||||
const adoptTuiOwner = vi.fn(async () => ({ ok: true, replayed: false, fence: 1 }))
|
||||
setStructuredAgentSessionHost({ adoptTuiOwner } as never)
|
||||
setStructuredAgentSessionHost(structuredHostDouble({ adoptTuiOwner }) as never)
|
||||
proveCodexTuiRollout.mockResolvedValueOnce({
|
||||
transcriptPath: '/tmp/codex-home/rollout.jsonl'
|
||||
})
|
||||
@@ -162,6 +176,8 @@ describe('structured TUI launch tab binding', () => {
|
||||
|
||||
it('refuses adoption when the provider rollout cannot be proved', async () => {
|
||||
const runtime = terminalAdoptionHarness()
|
||||
const host = structuredHostDouble()
|
||||
setStructuredAgentSessionHost(host as never)
|
||||
proveCodexTuiRollout.mockRejectedValueOnce(
|
||||
new Error('The agent terminal did not prove the expected Codex rollout.')
|
||||
)
|
||||
@@ -172,29 +188,37 @@ describe('structured TUI launch tab binding', () => {
|
||||
spawnToken: 'spawn-adopt'
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.adoptStructuredAgentSessionTerminal(
|
||||
{
|
||||
envelope: {
|
||||
sessionId: 'session-adopt',
|
||||
clientOperationId: 'operation-adopt',
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: 'f'.repeat(64)
|
||||
try {
|
||||
await expect(
|
||||
runtime.adoptStructuredAgentSessionTerminal(
|
||||
{
|
||||
envelope: {
|
||||
sessionId: 'session-adopt',
|
||||
clientOperationId: 'operation-adopt',
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: 'f'.repeat(64)
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
tabId: 'tab-adopt',
|
||||
paneKey: 'tab-adopt:leaf-adopt',
|
||||
ptyId: 'pty-adopt',
|
||||
threadId: 'thread-adopt'
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
tabId: 'tab-adopt',
|
||||
paneKey: 'tab-adopt:leaf-adopt',
|
||||
ptyId: 'pty-adopt',
|
||||
threadId: 'thread-adopt'
|
||||
},
|
||||
{ callerKey: 'renderer-1' }
|
||||
)
|
||||
).rejects.toThrow('did not prove the expected Codex rollout')
|
||||
expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull()
|
||||
{ callerKey: 'renderer-1' }
|
||||
)
|
||||
).rejects.toThrow('did not prove the expected Codex rollout')
|
||||
expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull()
|
||||
// A proof that never lands hands the reservation back; nothing latches the next attempt.
|
||||
expect(host.releaseAdoptedTuiReservation).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
setStructuredAgentSessionHost(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a thread proof when the Codex process changes during it', async () => {
|
||||
const runtime = terminalAdoptionHarness()
|
||||
const host = structuredHostDouble()
|
||||
setStructuredAgentSessionHost(host as never)
|
||||
proveCodexTuiRollout.mockResolvedValueOnce({
|
||||
transcriptPath: '/tmp/codex-home/rollout.jsonl'
|
||||
})
|
||||
@@ -231,12 +255,14 @@ describe('structured TUI launch tab binding', () => {
|
||||
)
|
||||
).rejects.toThrow('process changed during conversation proof')
|
||||
expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull()
|
||||
expect(host.releaseAdoptedTuiReservation).toHaveBeenCalledTimes(1)
|
||||
setStructuredAgentSessionHost(null)
|
||||
})
|
||||
|
||||
it('discovers the provider thread when hook status has not published it yet', async () => {
|
||||
const runtime = terminalAdoptionHarness()
|
||||
const adoptTuiOwner = vi.fn(async () => ({ ok: true, replayed: false, fence: 1 }))
|
||||
setStructuredAgentSessionHost({ adoptTuiOwner } as never)
|
||||
setStructuredAgentSessionHost(structuredHostDouble({ adoptTuiOwner }) as never)
|
||||
resolveLiveCodexTuiRollout.mockResolvedValueOnce({
|
||||
threadId: 'thread-discovered',
|
||||
transcriptPath: '/tmp/codex-home/discovered.jsonl'
|
||||
|
||||
@@ -11437,7 +11437,82 @@ export class OrcaRuntimeService {
|
||||
throw new Error('Could not prove the Codex terminal process.')
|
||||
}
|
||||
const rootProcessId = listing.rootProcessId
|
||||
const spawnToken = randomUUID()
|
||||
const sessionId = input.envelope.sessionId
|
||||
const priorBoundSessionId = agentSessionPtyWriteGate.boundSessionId(input.ptyId)
|
||||
if (priorBoundSessionId !== null && priorBoundSessionId !== sessionId) {
|
||||
throw new Error('This terminal pane already belongs to another structured Codex session.')
|
||||
}
|
||||
const host = getStructuredAgentSessionHost()
|
||||
if (!host) {
|
||||
throw new Error('structured_agent_session_unsupported')
|
||||
}
|
||||
const launchEnv = resolveTuiAgentLaunchEnv(
|
||||
'codex',
|
||||
this.requireStore().getSettings().agentDefaultEnv
|
||||
)
|
||||
// Why the reservation comes before the proof: `admitProof` authorizes the `/status` probe only
|
||||
// for a reservation that already names this session, `tui`, and this spawn token. Reserving
|
||||
// afterwards left the probe unauthorized, so every adoption failed at its first keystroke.
|
||||
const reserved = await host.reserveAdoptedTuiOwner({
|
||||
caller,
|
||||
sessionId,
|
||||
clientOperationId: input.envelope.clientOperationId,
|
||||
fingerprint: input.envelope.payloadFingerprint,
|
||||
location: intent.location,
|
||||
provider: intent.provider,
|
||||
accountHome: intent.accountHome,
|
||||
spawnToken: randomUUID(),
|
||||
claimKeyId: this.agentSessionClaimSigner.keyId,
|
||||
launchEnv
|
||||
})
|
||||
if (!reserved.ok) {
|
||||
return reserved
|
||||
}
|
||||
const { fence, spawnToken } = reserved
|
||||
agentSessionPtyWriteGate.bindPty(input.ptyId, sessionId)
|
||||
let owned = false
|
||||
try {
|
||||
return await this.attachAdoptedStructuredTui({
|
||||
input,
|
||||
caller,
|
||||
host,
|
||||
intent,
|
||||
launchEnv,
|
||||
fence,
|
||||
spawnToken,
|
||||
rootProcessId,
|
||||
pty,
|
||||
onOwned: () => {
|
||||
owned = true
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
if (!owned) {
|
||||
// A failed proof must leave nothing behind: an unreleased reservation is exactly the
|
||||
// latched lease that makes the next attempt refuse itself.
|
||||
if (priorBoundSessionId === null) {
|
||||
agentSessionPtyWriteGate.unbindPty(input.ptyId)
|
||||
}
|
||||
await host
|
||||
.releaseAdoptedTuiReservation({ sessionId, fence, spawnToken })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async attachAdoptedStructuredTui(args: {
|
||||
input: Parameters<OrcaRuntimeService['adoptStructuredAgentSessionTerminal']>[0]
|
||||
caller: StructuredAgentSessionCaller
|
||||
host: NonNullable<ReturnType<typeof getStructuredAgentSessionHost>>
|
||||
intent: AgentSessionAttachParams
|
||||
launchEnv: Record<string, string>
|
||||
fence: number
|
||||
spawnToken: string
|
||||
rootProcessId: number
|
||||
pty: RuntimePtyWorktreeRecord
|
||||
onOwned: () => void
|
||||
}): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
|
||||
const { input, caller, host, intent, launchEnv, fence, spawnToken, rootProcessId, pty } = args
|
||||
const readPty = (): RuntimePtyWorktreeRecord => {
|
||||
const current = this.ptysById.get(input.ptyId)
|
||||
if (!current?.connected || current.paneKey !== input.paneKey) {
|
||||
@@ -11500,7 +11575,9 @@ export class OrcaRuntimeService {
|
||||
threadId,
|
||||
resumed: false,
|
||||
origin: 'adopted',
|
||||
fence: 1,
|
||||
// Why not 1: a retry supersedes the previous attempt's reservation, and `proveOwner`
|
||||
// refuses a handle link minted at any fence but the reservation's own.
|
||||
fence,
|
||||
observedAt: Date.now()
|
||||
}),
|
||||
transcriptPath,
|
||||
@@ -11528,14 +11605,6 @@ export class OrcaRuntimeService {
|
||||
runtimeKind: 'tui',
|
||||
providerHandle: { kind: 'codex', threadId }
|
||||
}
|
||||
const launchEnv = resolveTuiAgentLaunchEnv(
|
||||
'codex',
|
||||
this.requireStore().getSettings().agentDefaultEnv
|
||||
)
|
||||
const host = getStructuredAgentSessionHost()
|
||||
if (!host) {
|
||||
throw new Error('structured_agent_session_unsupported')
|
||||
}
|
||||
const adopted = await host.adoptTuiOwner({
|
||||
caller,
|
||||
params,
|
||||
@@ -11544,8 +11613,9 @@ export class OrcaRuntimeService {
|
||||
launchEnv
|
||||
})
|
||||
if (adopted.ok) {
|
||||
// The pane stays bound to the reservation this call already made; only the owner is new.
|
||||
this.adoptedStructuredTuiOwners.set(input.envelope.sessionId, owner)
|
||||
agentSessionPtyWriteGate.bindPty(input.ptyId, input.envelope.sessionId)
|
||||
args.onOwned()
|
||||
}
|
||||
return adopted
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Both ways into structured chat go through one door.
|
||||
*
|
||||
* Right-click → "Switch to chat" and launching a Codex tab with open-in-chat both end at
|
||||
* `setTabViewMode(tabId, 'chat')`, which hands a terminal tab to `setTerminalNativeChatMode` and
|
||||
* from there to `agentSession.adoptTerminal`. Pinning that here is what makes "launch-at-chat
|
||||
* shares the adoption defect" a checked claim rather than an inspection of the call graph.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import { createTestStore, makeUnifiedTab, makeTabGroup } from './store-test-helpers'
|
||||
import type * as StructuredNativeChatToggle from './structured-native-chat-toggle'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
const { mockSetTerminalNativeChatMode } = vi.hoisted(() => ({
|
||||
mockSetTerminalNativeChatMode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./structured-native-chat-toggle', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof StructuredNativeChatToggle>()),
|
||||
setTerminalNativeChatMode: mockSetTerminalNativeChatMode
|
||||
}))
|
||||
|
||||
const mockApi = {
|
||||
ui: { set: vi.fn().mockResolvedValue(undefined) },
|
||||
settings: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined) }
|
||||
}
|
||||
|
||||
// @ts-expect-error -- partial window stub is sufficient for these store-only tests
|
||||
globalThis.window = { api: mockApi }
|
||||
|
||||
const WT = 'repo1::/tmp/feature'
|
||||
|
||||
describe('structured chat route out of setTabViewMode', () => {
|
||||
let store: ReturnType<typeof createTestStore>
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetTerminalNativeChatMode.mockReset()
|
||||
mockSetTerminalNativeChatMode.mockResolvedValue('structured')
|
||||
store = createTestStore()
|
||||
store.setState({
|
||||
unifiedTabsByWorktree: {
|
||||
[WT]: [makeUnifiedTab({ id: 'codex-tab', worktreeId: WT, groupId: 'g-1' })]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[WT]: [
|
||||
makeTabGroup({
|
||||
id: 'g-1',
|
||||
worktreeId: WT,
|
||||
activeTabId: 'codex-tab',
|
||||
tabOrder: ['codex-tab']
|
||||
})
|
||||
]
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
})
|
||||
|
||||
it('hands an explicit chat mode to the structured adoption path', () => {
|
||||
store.getState().setTabViewMode('codex-tab', 'chat')
|
||||
|
||||
expect(mockSetTerminalNativeChatMode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabId: 'codex-tab', mode: 'chat' })
|
||||
)
|
||||
})
|
||||
|
||||
it('hands a toggled chat mode to the same path', () => {
|
||||
store.getState().toggleTabViewMode('codex-tab')
|
||||
|
||||
expect(mockSetTerminalNativeChatMode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabId: 'codex-tab', mode: 'chat' })
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user