Merge commit 'e26257faf49370dc30c368a7cd8dcb5e5895e113' into HEAD

This commit is contained in:
Brennan Benson
2026-08-28 23:56:05 -07:00
731 changed files with 71010 additions and 2535 deletions
+2
View File
@@ -539,8 +539,10 @@ jobs:
- name: Old/new client and server compatibility journeys
run: >-
pnpm exec vitest run --config config/vitest.config.ts
tests/e2e/cross-version-wire/release-checkout.unit.test.ts
tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
managed_hook_node18:
name: managed hooks on Node 18
+3
View File
@@ -140,6 +140,9 @@ module.exports = {
// it is gitignored, but exclude it defensively so a stray local capture at
// package time never bloats app.asar.
'!pr-evidence{,/**/*}',
// Why: local agent/tooling directories may contain worktree symlink loops;
// they are never runtime inputs and must not be traversed by electron-builder.
'!{.claude,.grok,.agents,.codex}{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
'!out/**/*.test.js',
+1
View File
@@ -22,6 +22,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'jsonc-parser',
'node-pty',
'posthog-node',
'proper-lockfile',
// serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too.
'serve-sim',
'qrcode',
+15 -1
View File
@@ -8519,6 +8519,7 @@
"remote terminal reveal and input",
"paired host relaunch with preserved daemon PTYs",
"paired host session inventory publication authority",
"paired host session inventory subscription linearizability",
"manual server disconnect"
],
"platforms": ["macos", "linux", "windows"],
@@ -8537,7 +8538,7 @@
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/session-tabs-inventory-publication.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts src/main/daemon/terminal-host-agent-session.test.ts tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/session-tabs-inventory-publication.test.ts src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts src/main/daemon/terminal-host-agent-session.test.ts tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts",
@@ -8571,6 +8572,7 @@
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
"src/main/runtime/session-tabs-inventory-publication.test.ts",
"src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts",
"src/main/runtime/rpc/methods/session-tabs.test.ts",
"src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts",
"src/main/daemon/terminal-host-agent-session.test.ts",
@@ -8658,6 +8660,18 @@
"file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts",
"assertions": ["retries an existing subscriber when provider inventory becomes ready"]
},
{
"file": "src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts",
"assertions": [
"pre-boundary creation and removal are subsumed by the census exactly once",
"post-boundary creation and removal replay after the initial snapshot in sequence order",
"create/remove/recreate transitions are preserved without collapse",
"projected PTY state already visible in the census is deduplicated while later projected state is delivered",
"coalesced delivery uses its scheduling watermark across every subscriber",
"initialization churn is memory-bounded and repeated structural overflow fails after bounded recollection",
"initialization clears buffered transitions on cancellation"
]
},
{
"file": "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts",
"assertions": [
@@ -32,9 +32,12 @@ import {
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
import {
nodeGypRebuildInvocation,
WINDOWS_PROCESS_TREE_PACKAGE_DIR as PACKAGE_DIR
} from './windows-process-tree-gyp-rebuild.mjs'
const ROOT = resolve(import.meta.dirname, '..', '..')
const PACKAGE_DIR = join(ROOT, 'node_modules', '@vscode', 'windows-process-tree')
const SUPPORTED_ARCHES = ['x64', 'arm64']
/** PE `IMAGE_FILE_HEADER.Machine` values, so a cross-build cannot silently emit host arch. */
@@ -169,12 +172,9 @@ function main() {
applyWindowsProcessTreeBuildFixes()
assertPatchApplied()
console.log(`[windows-process-tree] building ${arch} from ${PACKAGE_DIR}`)
execFileSync(
process.execPath,
[join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), 'rebuild', `--arch=${arch}`],
{ cwd: PACKAGE_DIR, stdio: 'inherit' }
)
const gyp = nodeGypRebuildInvocation(arch)
console.log(`[windows-process-tree] building ${arch} from ${gyp.cwd}`)
execFileSync(process.execPath, gyp.args, { cwd: gyp.cwd, stdio: 'inherit' })
const built = join(PACKAGE_DIR, 'build', 'Release', 'windows_process_tree.node')
if (!existsSync(built)) {
@@ -44,6 +44,7 @@ describe('electron-builder config', () => {
'!tests{,/**/*}',
'!examples{,/**/*}',
'!pr-evidence{,/**/*}',
'!{.claude,.grok,.agents,.codex}{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
'!out/**/*.test.js',
@@ -52,6 +53,23 @@ describe('electron-builder config', () => {
)
})
it('keeps local agent tooling out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
for (const toolingPath of [
'.grok/skills/review-and-submit/review-and-submit/SKILL.md',
'.claude/skills/review-and-submit/review-and-submit/SKILL.md',
'.agents/skills/electron/SKILL.md',
'.codex/sessions/session.json'
]) {
expect(packs(toolingPath)).toBe(false)
}
expect(packs('out/main/index.js')).toBe(true)
})
// Why: `files` is an all-negation list, so electron-builder's default `**/*` packs
// anything without an explicit `!` entry — examples/ landed without one and shipped
// hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar.
@@ -436,7 +454,7 @@ describe('electron-builder config', () => {
}
})
it('includes @parcel/watcher in the packaged runtime closure', () => {
it('includes external main dependencies in the packaged runtime closure', () => {
// Why: the main process imports '@parcel/watcher' for filesystem change
// events; if it is absent from the packaged closure the serve host silently
// stops propagating file changes to clients (regression guard for #4851).
@@ -448,6 +466,7 @@ describe('electron-builder config', () => {
target.startsWith(join('node_modules', '@parcel', 'watcher-'))
)
).toBe(true)
expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile'))
})
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
+10
View File
@@ -93,8 +93,18 @@ const CROSS_VERSION_WIRE_PREFIXES = [
'src/shared/browser-client-host-protocol',
'src/shared/browser-network-tunnel-protocol',
'src/shared/browser-client-host-placement',
'src/shared/agent-session-wire',
'src/shared/agent-session-mutation-envelope',
'src/shared/agent-session-journal-',
'src/main/ai-vault/structured-session-ownership.ts',
'src/main/native-chat/agent-session-journal/',
'src/main/native-chat/agent-session-wire/',
'src/main/runtime/agent-session-record-store',
'src/main/runtime/rpc/dispatcher',
'src/main/runtime/rpc/methods/ai-vault.ts',
'src/main/runtime/rpc/methods/browser-tab-create-schema',
'src/main/runtime/rpc/methods/session-tabs.ts',
'src/main/runtime/rpc/methods/structured-agent-session',
'src/main/runtime/rpc/methods/terminal',
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
]
@@ -181,8 +181,24 @@ describe('per-job path classification', () => {
for (const file of [
'src/shared/protocol-version.ts',
'src/shared/terminal-stream-protocol.ts',
'src/shared/agent-session-wire.ts',
'src/shared/agent-session-mutation-envelope.ts',
'src/shared/agent-session-journal-item-key.ts',
'src/shared/agent-session-journal-types.ts',
'src/main/ai-vault/structured-session-ownership.ts',
'src/main/native-chat/agent-session-journal/journal-cursor.ts',
'src/main/native-chat/agent-session-journal/journal-reducer.ts',
'src/main/native-chat/agent-session-journal/journal-row-schema.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-host.ts',
'src/main/runtime/agent-session-record-store.ts',
'src/main/runtime/rpc/dispatcher.ts',
'src/main/runtime/rpc/methods/ai-vault.ts',
'src/main/runtime/rpc/methods/browser-tab-create-schema.ts',
'src/main/runtime/rpc/methods/session-tabs.ts',
'src/main/runtime/rpc/methods/structured-agent-session.ts',
'src/main/runtime/rpc/methods/structured-agent-session-gate.ts',
'src/main/runtime/rpc/methods/structured-agent-session-hold.ts',
'src/main/runtime/rpc/methods/structured-agent-session-schemas.ts',
'src/main/runtime/rpc/methods/terminal.ts',
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
]) {
@@ -0,0 +1,33 @@
/**
* Where and how `@vscode/windows-process-tree` is rebuilt from source.
*
* node-gyp must run from the package's physical directory, never the
* `node_modules` symlink/junction pnpm installs there: gyp expands the
* node-addon-api dependency by probing node (whose cwd resolves to the
* physical path), gets back a store-relative `../../../../node-addon-api@…`
* hop, then resolves that hop against the rebuild cwd. From the link path the
* hop escapes the store and configure fails with "node_addon_api.gyp not
* found" (run 32999886072).
*/
import { realpathSync } from 'node:fs'
import { join, resolve } from 'node:path'
const ROOT = resolve(import.meta.dirname, '..', '..')
export const WINDOWS_PROCESS_TREE_PACKAGE_DIR = join(
ROOT,
'node_modules',
'@vscode',
'windows-process-tree'
)
export function nodeGypRebuildInvocation(arch, packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) {
return {
args: [
join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'),
'rebuild',
`--arch=${arch}`
],
cwd: realpathSync(packageDir)
}
}
@@ -0,0 +1,29 @@
import { execFileSync } from 'node:child_process'
import { existsSync, realpathSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
nodeGypRebuildInvocation,
WINDOWS_PROCESS_TREE_PACKAGE_DIR
} from './windows-process-tree-gyp-rebuild.mjs'
describe('windows-process-tree node-gyp rebuild', () => {
it("resolves node-addon-api's gyp target from the rebuild cwd", () => {
// gyp probes node-addon-api with the package's physical directory as cwd,
// so the emitted target is store-relative; gyp then resolves that hop
// against the rebuild cwd. Rebuilding from pnpm's node_modules link sends
// the hop outside the store and configure fails (run 32999886072).
const { cwd } = nodeGypRebuildInvocation('x64')
const targets = execFileSync(process.execPath, ['-p', "require('node-addon-api').targets"], {
cwd: realpathSync(WINDOWS_PROCESS_TREE_PACKAGE_DIR),
encoding: 'utf8'
}).trim()
expect(existsSync(resolve(cwd, targets))).toBe(true)
})
it('forwards the requested arch to node-gyp', () => {
const { args } = nodeGypRebuildInvocation('arm64')
expect(args).toContain('rebuild')
expect(args).toContain('--arch=arm64')
})
})
@@ -0,0 +1,22 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
describe('Windows process-tree patch contract', () => {
it('keeps the patch LF-only and hash-synced with the lockfile', () => {
const patchBytes = readFileSync(
join(projectDir, 'config/patches/@vscode__windows-process-tree@0.8.0.patch')
)
// pnpm hashes patches CRLF-normalized, so CR bytes cannot affect install
// behavior; keep the file LF-only so the bytes match main and diff clean.
expect(patchBytes.includes(0x0d)).toBe(false)
const patchHash = createHash('sha256')
.update(patchBytes.toString('utf8').replaceAll('\r\n', '\n'))
.digest('hex')
const lockfile = readFileSync(join(projectDir, 'pnpm-lock.yaml'), 'utf8')
expect(lockfile).toContain(`hash: ${patchHash}`)
})
})
+5
View File
@@ -17,6 +17,11 @@
"../src/main/wsl-distro-list-output.ts",
"../src/main/wsl-distro-retry.ts",
"../src/main/wsl.ts",
"../src/main/persistence/applying-settings/ui-state-read.ts",
"../src/main/persistence/applying-settings/ui-state-update.ts",
"../src/main/persistence/applying-settings/ui-selection-normalization.ts",
"../src/main/persistence/applying-settings/ui-interaction-merge.ts",
"../src/main/protected-secret-persistence.ts",
"../src/main/startup/serve-desktop-activation.ts",
"../src/main/startup/serve-mode-argv.ts",
"../src/main/startup/single-instance-lock.ts",
+23 -4
View File
@@ -95,10 +95,29 @@ 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.
## Worked example: `agentWait` on terminal and worker reads
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.44",
"version": "0.0.47",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 13
"versionCode": 15
},
"plugins": [
"expo-router",
+8 -12
View File
@@ -83,7 +83,7 @@ import {
} from '../../../src/host-route-action-state'
import {
applyDesktopViewSettings,
groupModeToDesktop,
buildWorkspaceViewSettingsUpdate,
type MobileGroupMode,
type MobileSortMode,
type MobileViewState,
@@ -249,7 +249,7 @@ export function HostScreen({
})
}, [])
// Apply the change locally, then push full settings to the desktop's shared store (ui.set) so both apps stay in sync.
// Apply the change locally, then patch the desktop's shared store (ui.set) so both apps stay in sync.
const persistViewSettings = useCallback(
(patch: Partial<MobileViewState>) => {
const next: MobileViewState = { ...viewStateRef.current, ...patch }
@@ -257,16 +257,12 @@ export function HostScreen({
if (!client) {
return
}
// alwaysShowDefaultBranchWorkspace is deliberately absent: mobile reads it
// but has no toggle, so echoing its local default would silently revert a
// desktop opt-out on the first filter tap before ui.get lands (#8873).
const payload: WorkspaceViewSettings = {
groupBy: groupModeToDesktop(next.groupMode),
sortBy: next.sortMode,
hideSleepingWorkspaces: next.hideSleeping,
hideDefaultBranchWorkspace: next.hideDefaultBranch,
filterRepoIds: next.filterRepoIds,
collapsedGroups: next.collapsedGroups
// Send only the touched fields: the host merges partial updates, so a stale
// mirror can no longer revert sibling settings another client just changed
// (STA-5781; supersedes the #8873 whole-payload special case).
const payload: WorkspaceViewSettings = buildWorkspaceViewSettingsUpdate(patch, next)
if (Object.keys(payload).length === 0) {
return
}
void client.sendRequest('ui.set', payload).catch(() => {
// Best-effort: view settings are a convenience preference.
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
import { Radio } from 'lucide-react-native'
import { Activity } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
@@ -50,7 +50,7 @@ export function AgentSpinner({
if (monitoring) {
return (
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
<Radio size={12} color={STATUS_COLORS.working} />
<Activity size={12} color={STATUS_COLORS.working} />
</View>
)
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
import { Radio } from 'lucide-react-native'
import { Activity } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import type { AgentDotState } from '../worktree/agent-row-display'
@@ -49,7 +49,7 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
if (state === 'monitoring') {
return (
<View style={styles.wrapper} accessibilityLabel="Monitoring background tasks">
<Radio size={10} color={WORKING_COLOR} />
<Activity size={10} color={WORKING_COLOR} />
</View>
)
}
+142 -2
View File
@@ -1,4 +1,4 @@
import { createElement } from 'react'
import { createElement, Suspense, type ReactElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
@@ -348,7 +348,11 @@ describe('NewWorktreeModal project targets', () => {
renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: freshClient }))
await Promise.resolve()
})
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
// The swap must not restart the form session — see the reconnect test below.
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([
'stale-client-name',
'stale-client-name'
])
resolveOldList?.({ ok: true, result: { repos } })
await flushUpdates()
@@ -377,6 +381,142 @@ describe('NewWorktreeModal project targets', () => {
expect(repoListCalls).toHaveLength(2)
})
// A reconnect / forceReconnect / foreground revival hands the same host a NEW
// RpcClient object (see useHostClient). Keying the form session on that object
// remounted the modal mid-edit and silently threw away the picked source.
it('keeps the picked source when a reconnect swaps the client for the same host', async () => {
const makeClient = () =>
({
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos } })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
return new Promise(() => {})
})
}) as unknown as RpcClient
const modalProps = {
visible: true,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
}
await act(async () => {
renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: makeClient() }))
})
act(() => sourceInputs(renderer)[0]!.props.onChangeText('feat/keep-me'))
await act(async () => {
renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: makeClient() }))
await Promise.resolve()
})
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([
'feat/keep-me',
'feat/keep-me'
])
})
// react-native-screens freezes a blurred screen by suspending its subtree
// (react-freeze), so React runs this component and then throws that render away.
// Anything the render mutated in place outlives the work React discarded.
function suspendableTree(frozen: () => boolean, child: ReactElement) {
const never = new Promise<void>(() => {})
const Freezer = () => {
if (frozen()) {
throw never
}
return null
}
// The modal renders first, so its render completes before the freeze throws.
return createElement(Suspense, { fallback: null }, child, createElement(Freezer, null))
}
it('keeps the form when a host switch renders but never commits', async () => {
setCachedRepos('host-2', repos)
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} }
let frozen = false
const tree = (hostId: string) =>
suspendableTree(() => frozen, createElement(NewWorktreeModal, { ...modalProps, hostId }))
await act(async () => {
renderer = create(tree('host-1'))
})
act(() => sourceInputs(renderer)[0]!.props.onChangeText('keep-me'))
frozen = true
await act(async () => {
renderer.update(tree('host-2'))
})
frozen = false
await act(async () => {
renderer.update(tree('host-1'))
})
// host-2 never committed, so host-1's session was never superseded.
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['keep-me', 'keep-me'])
})
it('keeps the form when a close renders but never commits', async () => {
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const modalProps = {
client,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
}
let frozen = false
const tree = (visible: boolean) =>
suspendableTree(() => frozen, createElement(NewWorktreeModal, { ...modalProps, visible }))
await act(async () => {
renderer = create(tree(true))
})
act(() => sourceInputs(renderer)[0]!.props.onChangeText('keep-me-too'))
frozen = true
await act(async () => {
renderer.update(tree(false))
})
frozen = false
await act(async () => {
renderer.update(tree(true))
})
// The drawer never committed a closed state, so this is not a reopening.
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([
'keep-me-too',
'keep-me-too'
])
})
it('starts a fresh form session when the modal switches hosts', async () => {
setCachedRepos('host-2', repos)
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} }
await act(async () => {
renderer = create(createElement(NewWorktreeModal, { ...modalProps, hostId: 'host-1' }))
})
act(() => sourceInputs(renderer)[0]!.props.onChangeText('host-one-name'))
await act(async () => {
renderer.update(createElement(NewWorktreeModal, { ...modalProps, hostId: 'host-2' }))
})
expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', ''])
})
it('starts with fresh form state after closing and reopening', async () => {
const client = {
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
+16 -17
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react'
import { useMemo, useState } from 'react'
import { Keyboard } from 'react-native'
import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection'
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
@@ -33,26 +33,25 @@ import { useNewWorkspaceSetupScript } from './use-new-workspace-setup-script'
import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation'
export function NewWorktreeModal(props: NewWorktreeModalProps) {
const openEpochRef = useRef(0)
const wasVisibleRef = useRef(false)
const clientEpochRef = useRef({ client: props.client, epoch: 0 })
// Why: each drawer opening is a fresh form session; remounting resets local
// form state before paint instead of clearing it in a visible-prop Effect.
if (props.visible && !wasVisibleRef.current) {
openEpochRef.current += 1
}
wasVisibleRef.current = props.visible
if (clientEpochRef.current.client !== props.client) {
clientEpochRef.current = { client: props.client, epoch: clientEpochRef.current.epoch + 1 }
// State, not a ref: react-native-screens freezes a blurred screen by suspending
// this subtree, and a counter bumped during a render React then throws away
// would restart the session for an opening that never committed.
const [session, setSession] = useState({ openEpoch: 0, visible: props.visible })
if (session.visible !== props.visible) {
setSession({
openEpoch: props.visible ? session.openEpoch + 1 : session.openEpoch,
visible: props.visible
})
}
return (
<NewWorktreeModalContent
key={`${openEpochRef.current}:${clientEpochRef.current.epoch}`}
{...props}
/>
)
// Why: key the session on the HOST, never on the RpcClient object. A reconnect,
// forceReconnect, or foreground revival swaps that object for the same host
// (see useHostClient), and keying on it silently remounted this form mid-edit
// and threw away the picked source. Every client-scoped hook below already
// drops responses from a superseded client, so no remount is needed for that.
return <NewWorktreeModalContent key={`${session.openEpoch}:${props.hostId}`} {...props} />
}
function NewWorktreeModalContent(props: NewWorktreeModalProps) {
@@ -19,7 +19,7 @@ const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({
setValue: vi.fn()
}))
vi.mock('lucide-react-native', () => ({ Radio: 'Radio' }))
vi.mock('lucide-react-native', () => ({ Activity: 'Activity' }))
vi.mock('react-native', () => ({
Animated: {
Value: function Value() {
@@ -48,12 +48,12 @@ describe('mobile monitoring indicators', () => {
renderer = null
})
it('renders a static Radio for a monitoring agent', async () => {
it('renders a static Activity heartbeat for a monitoring agent', async () => {
await act(async () => {
renderer = create(createElement(AgentStateDot, { state: 'monitoring' }))
})
expect(renderer?.root.findByType('Radio').props).toMatchObject({
expect(renderer?.root.findByType('Activity').props).toMatchObject({
color: DESKTOP_WORKING_COLOR,
size: 10
})
@@ -61,14 +61,14 @@ describe('mobile monitoring indicators', () => {
expect(animationLoop).not.toHaveBeenCalled()
})
it('renders a static Radio for an all-monitoring workspace', async () => {
it('renders a static Activity heartbeat for an all-monitoring workspace', async () => {
await act(async () => {
renderer = create(
createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' })
)
})
expect(renderer?.root.findByType('Radio').props).toMatchObject({
expect(renderer?.root.findByType('Activity').props).toMatchObject({
color: DESKTOP_WORKING_COLOR,
size: 12
})
@@ -0,0 +1,160 @@
import { createElement, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
const withTimingCalls = vi.hoisted(() => [] as { to: number; duration: number | undefined }[])
const sharedWrites = vi.hoisted(() => [] as { key: string; value: unknown }[])
vi.mock('react-native', () => ({
BackHandler: { addEventListener: () => ({ remove: () => {} }) },
Keyboard: {
addListener: () => ({ remove: () => {} }),
dismiss: () => {},
metrics: () => null
},
Modal: 'Modal',
Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: {
create: <T>(styles: T) => styles,
absoluteFillObject: {}
},
View: 'View',
useWindowDimensions: () => ({ width: 440, height: 956 })
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 62, bottom: 34, left: 0, right: 0 })
}))
vi.mock('react-native-gesture-handler', () => {
const chain: Record<string, unknown> = {}
for (const method of [
'activeOffsetY',
'simultaneousWithExternalGesture',
'onBegin',
'onUpdate',
'onEnd'
]) {
chain[method] = () => chain
}
return {
Gesture: { Pan: () => chain, Native: () => chain },
GestureDetector: 'GestureDetector',
GestureHandlerRootView: 'GestureHandlerRootView'
}
})
vi.mock('react-native-reanimated', () => {
function makeShared(key: string, initial: number) {
let value = initial
return {
get value() {
return value
},
set value(next: number) {
value = next
sharedWrites.push({ key, value: next })
}
}
}
let sharedIndex = 0
return {
default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' },
useSharedValue: (initial: number) => makeShared(`shared-${sharedIndex++}`, initial),
useAnimatedStyle: () => ({}),
useAnimatedScrollHandler: () => () => {},
withSpring: (to: number) => to,
withTiming: (to: number, config?: { duration?: number }) => {
withTimingCalls.push({ to, duration: config?.duration })
return to
},
runOnJS: (fn: () => void) => fn,
interpolate: () => 0,
Extrapolation: { CLAMP: 'clamp' }
}
})
import { MountedBottomDrawer } from './mounted-bottom-drawer'
const noop = () => {}
// Counts mounts of the sheet's CONTENT, so a test can tell an ordinary re-render
// apart from the subtree rebuild the fix relies on to repaint a stale native view.
const sheetBodyMounts = { count: 0 }
function SheetBody() {
useEffect(() => {
sheetBodyMounts.count += 1
}, [])
return null
}
function drawer(interactive: boolean) {
return createElement(
MountedBottomDrawer,
{ visible: true, interactive, onClose: noop, onHidden: noop },
createElement(SheetBody)
)
}
function render(interactive: boolean): ReactTestRenderer {
let renderer!: ReactTestRenderer
act(() => {
renderer = create(drawer(interactive))
})
return renderer
}
function update(renderer: ReactTestRenderer, interactive: boolean): void {
act(() => {
renderer.update(drawer(interactive))
})
}
// A sheet pinned under a fill picker keeps progress at 1 the whole time, so
// nothing re-applies its enter transform when the picker gives the window back.
// On device that left the create form laid out but unpainted — a dimmed screen
// with no sheet and no way back except dismissing the whole modal.
describe('bottom drawer window hand-back', () => {
afterEach(() => {
withTimingCalls.length = 0
sharedWrites.length = 0
sheetBodyMounts.count = 0
})
it('re-asserts the enter transform when a pinned sheet takes the window back', () => {
const renderer = render(true)
update(renderer, false)
const beforeHandback = withTimingCalls.filter((call) => call.to === 1).length
update(renderer, true)
expect(withTimingCalls.filter((call) => call.to === 1).length).toBe(beforeHandback + 1)
act(() => renderer.unmount())
})
// Shared-value writes cannot heal a sheet whose native view was rebuilt under
// Reanimated (verified on device); only a fresh view repaints. Counting content
// mounts asserts the subtree actually rebuilt, not merely that a prop changed.
it('rebuilds the sheet subtree when it takes the window back', () => {
const renderer = render(true)
update(renderer, false)
const mountsWhilePinned = sheetBodyMounts.count
expect(mountsWhilePinned).toBe(1)
update(renderer, true)
expect(sheetBodyMounts.count).toBe(2)
act(() => renderer.unmount())
})
it('does not re-assert or rebuild while the sheet stays pinned', () => {
const renderer = render(true)
update(renderer, false)
const pinned = withTimingCalls.filter((call) => call.to === 1).length
update(renderer, false)
expect(withTimingCalls.filter((call) => call.to === 1).length).toBe(pinned)
expect(sheetBodyMounts.count).toBe(1)
act(() => renderer.unmount())
})
})
@@ -1,4 +1,4 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import {
View,
Pressable,
@@ -89,6 +89,28 @@ export function MountedBottomDrawer({
})
: undefined
// Why: a sheet pinned under a fill picker holds progress at its target while the
// picker owns the window, so nothing re-applies its transform when the picker
// leaves. If the native view was rebuilt underneath, it keeps a stale transform
// and never paints — a dimmed, dead screen the user can only escape by dismissing
// the whole modal. A shared-value write alone cannot heal that (verified on
// device: an unchanged or nudged style lands on the stale native binding), so the
// remount is what repaints; the writes below keep the shared values authoritative
// for the fresh view, which matters because the drawer swap (166ms) hands back
// before the 180ms enter animation has finished.
const [windowEpoch, setWindowEpoch] = useState(0)
const wasInteractiveRef = useRef(interactive)
useEffect(() => {
const tookWindowBack = visible && interactive && !wasInteractiveRef.current
wasInteractiveRef.current = interactive
if (!tookWindowBack) {
return
}
translateY.value = 0
progress.value = withTiming(1, { duration: SHOW_DURATION })
setWindowEpoch((epoch) => epoch + 1)
}, [interactive, visible])
useEffect(() => {
if (visible) {
translateY.value = 0
@@ -358,6 +380,8 @@ export function MountedBottomDrawer({
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
// Why: remount per window hand-back — see the windowEpoch effect.
key={windowEpoch}
style={[
styles.drawer,
fillAvailable ? styles.drawerFill : null,
@@ -2,37 +2,30 @@ import { describe, expect, it } from 'vitest'
import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility'
describe('resolveNewWorktreeFormSheetVisible', () => {
it('keeps the form under the source picker and its close transition', () => {
it('keeps the form under the source picker', () => {
expect(resolveNewWorktreeFormSheetVisible({ modalVisible: true, drawerView: 'source' })).toBe(
true
)
})
// The host Modal stays mounted across every drawer swap, so a transition that
// renders no sheet is a transparent tap-swallowing screen with no way out if
// the queued transition never lands.
it('never leaves the mounted modal without a sheet during a drawer swap', () => {
expect(
resolveNewWorktreeFormSheetVisible({
modalVisible: true,
drawerView: 'source',
formPinnedUnderSource: true
})
).toBe(true)
expect(
resolveNewWorktreeFormSheetVisible({
modalVisible: true,
drawerView: 'transition',
formPinnedUnderSource: true
})
resolveNewWorktreeFormSheetVisible({ modalVisible: true, drawerView: 'transition' })
).toBe(true)
})
it('hides the form for sequential repo/agent transitions', () => {
expect(
resolveNewWorktreeFormSheetVisible({
modalVisible: true,
drawerView: 'transition',
formPinnedUnderSource: false
})
).toBe(false)
expect(
resolveNewWorktreeFormSheetVisible({
modalVisible: true,
drawerView: 'project',
formPinnedUnderSource: false
})
).toBe(false)
it('yields the window to the content-sized pickers and the trust prompt', () => {
for (const drawerView of ['project', 'runTarget', 'agent', 'trust']) {
expect(resolveNewWorktreeFormSheetVisible({ modalVisible: true, drawerView })).toBe(false)
}
})
it('hides everything once the modal closes', () => {
expect(resolveNewWorktreeFormSheetVisible({ modalVisible: false, drawerView: 'form' })).toBe(
false
)
})
})
@@ -1,16 +1,18 @@
// Why: pin the create form under the fill-height name picker (and during that
// picker's close transition) so dismiss reveals the original content height.
// Why: the create form is the modal's floor. Every other drawer layers above it,
// so the shared modal host is never mounted with no sheet in it — a beat with a
// transparent full-screen host swallows taps, and a dropped transition timer
// would strand the user there with no way back (#16165 follow-up).
export function resolveNewWorktreeFormSheetVisible(input: {
modalVisible: boolean
drawerView: string
formPinnedUnderSource: boolean
}): boolean {
if (!input.modalVisible) {
return false
}
if (input.drawerView === 'form' || input.drawerView === 'source') {
return true
}
return input.drawerView === 'transition' && input.formPinnedUnderSource
return (
input.drawerView === 'form' ||
input.drawerView === 'source' ||
input.drawerView === 'transition'
)
}
@@ -0,0 +1,63 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
useNewWorktreeDrawerNavigation,
type NewWorktreeDrawerView
} from './use-new-worktree-drawer-navigation'
type Nav = ReturnType<typeof useNewWorktreeDrawerNavigation>
function renderNavigation(modalVisible: boolean): { current: Nav } {
const handle = { current: null as unknown as Nav }
function Probe(props: { modalVisible: boolean }) {
handle.current = useNewWorktreeDrawerNavigation(props.modalVisible)
return null
}
act(() => {
create(createElement(Probe, { modalVisible }))
})
return handle
}
describe('useNewWorktreeDrawerNavigation', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
// BottomDrawerModalHost keeps one native Modal mounted for the whole flow. A
// transition beat that renders no sheet is therefore a transparent full-screen
// window that eats every tap, and the queued timer is the only way out of it.
it('shows the form sheet for the whole transition, even if the queued timer never lands', () => {
const nav = renderNavigation(true)
act(() => nav.current.openSourceDrawer())
expect(nav.current.drawerView).toBe<NewWorktreeDrawerView>('source')
act(() => nav.current.transitionDrawer('form'))
expect(nav.current.drawerView).toBe<NewWorktreeDrawerView>('transition')
expect(nav.current.formSheetVisible).toBe(true)
expect(nav.current.formSheetInteractive).toBe(false)
})
it('keeps a sheet on screen while swapping to a content-sized picker', () => {
const nav = renderNavigation(true)
act(() => nav.current.transitionDrawer('agent'))
expect(nav.current.drawerView).toBe<NewWorktreeDrawerView>('transition')
expect(nav.current.formSheetVisible).toBe(true)
act(() => vi.advanceTimersByTime(500))
expect(nav.current.drawerView).toBe<NewWorktreeDrawerView>('agent')
expect(nav.current.formSheetVisible).toBe(false)
})
it('hands the form back interactive once the transition lands', () => {
const nav = renderNavigation(true)
act(() => nav.current.openSourceDrawer())
act(() => nav.current.transitionDrawer('form'))
act(() => vi.advanceTimersByTime(500))
expect(nav.current.drawerView).toBe<NewWorktreeDrawerView>('form')
expect(nav.current.formSheetVisible).toBe(true)
expect(nav.current.formSheetInteractive).toBe(true)
})
})
@@ -23,11 +23,10 @@ export function useNewWorktreeDrawerNavigation(modalVisible: boolean): {
openSourceDrawer: () => void
} {
const [drawerView, setDrawerView] = useState<NewWorktreeDrawerView>('form')
const formPinnedUnderSourceRef = useRef(false)
const drawerTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Why: cancel any queued transition and reset when the modal closes, so a
// timer can't land after close and leave a stale drawer/pin for the next open.
// timer can't land after close and leave a stale drawer for the next open.
useEffect(() => {
if (modalVisible) {
return
@@ -36,7 +35,6 @@ export function useNewWorktreeDrawerNavigation(modalVisible: boolean): {
clearTimeout(drawerTransitionTimerRef.current)
drawerTransitionTimerRef.current = null
}
formPinnedUnderSourceRef.current = false
setDrawerView('form')
}, [modalVisible])
@@ -55,31 +53,23 @@ export function useNewWorktreeDrawerNavigation(modalVisible: boolean): {
setDrawerView('transition')
drawerTransitionTimerRef.current = setTimeout(() => {
drawerTransitionTimerRef.current = null
if (nextView === 'form') {
formPinnedUnderSourceRef.current = false
}
setDrawerView(nextView)
}, NEW_WORKTREE_DRAWER_TRANSITION_MS)
}
function openSourceDrawer(): void {
// Why: same-beat open; pin form under fill picker so outer content height
// is preserved when the name dialog dismisses.
// Why: same-beat open; the form stays mounted underneath so the outer
// content height is preserved when the fill picker dismisses.
if (drawerTransitionTimerRef.current) {
clearTimeout(drawerTransitionTimerRef.current)
}
drawerTransitionTimerRef.current = null
formPinnedUnderSourceRef.current = true
setDrawerView('source')
}
return {
drawerView,
formSheetVisible: resolveNewWorktreeFormSheetVisible({
modalVisible,
drawerView,
formPinnedUnderSource: formPinnedUnderSourceRef.current
}),
formSheetVisible: resolveNewWorktreeFormSheetVisible({ modalVisible, drawerView }),
formSheetInteractive: drawerView === 'form',
transitionDrawer,
openSourceDrawer
@@ -0,0 +1,59 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useSmartWorkspaceSource } from './use-smart-workspace-source'
function Probe(props: { client: RpcClient; query: string }) {
useSmartWorkspaceSource({
client: props.client,
enabled: true,
mode: 'smart',
query: props.query,
repoId: 'repo-1',
githubAvailable: true,
gitlabAvailable: false,
linearAvailable: false,
mrStateFilter: 'opened',
repos: [{ id: 'repo-1', displayName: 'orca', slug: { owner: 'stablyai', repo: 'orca' } }]
})
return null
}
// The picker makes two independent host round trips for a pasted PR number: the
// provider fan-out and the exact-item lookup. Awaiting the fan-out first stacked
// them, so the rows appeared a whole extra round trip late.
describe('smart source paste lookup concurrency', () => {
const mounted: ReactTestRenderer[] = []
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
act(() => {
for (const renderer of mounted) {
renderer.unmount()
}
})
mounted.length = 0
vi.useRealTimers()
})
it('issues the pasted-number lookup while the fan-out is still in flight', async () => {
const sent: string[] = []
const sendRequest = vi.fn((method: string) => {
sent.push(method)
// Nothing ever settles: only requests issued concurrently can be observed.
return new Promise(() => {})
})
const client = { sendRequest } as unknown as RpcClient
await act(async () => {
mounted.push(create(createElement(Probe, { client, query: '16831' })))
})
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
expect(sent).toContain('github.listWorkItems')
expect(sent).toContain('github.workItem')
})
})
+70 -33
View File
@@ -180,6 +180,64 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) {
}
}
type PasteLookup = { paste: PasteResolved; crossRepoPrompt: SmartCrossRepoPrompt | null }
const EMPTY_PASTE_LOOKUP: PasteLookup = {
paste: { github: null, gitlab: null },
crossRepoPrompt: null
}
// Resolves a pasted issue/PR/MR reference to the exact item it names.
async function resolvePastedItem(args: {
client: RpcClient
intent: NonNullable<ReturnType<typeof resolvePasteIntent>>
repoId: string
repos: readonly PasteRepoCandidate[]
repoSlugCache: Map<string, { owner: string; repo: string; host?: string } | null>
}): Promise<PasteLookup> {
const { client, intent, repoId, repos, repoSlugCache } = args
if (intent.kind === 'github-number') {
return {
paste: {
github: await lookupGitHubItemByNumber(client, repoId, intent.number),
gitlab: null
},
crossRepoPrompt: null
}
}
if (intent.kind === 'github-link') {
const matchingRepo = await findRepoMatchingSlugForPaste(
client,
repos,
intent.link.slug,
repoSlugCache
)
if (matchingRepo && matchingRepo.id !== repoId) {
return {
paste: { github: null, gitlab: null },
crossRepoPrompt: { link: intent.link, matchingRepo }
}
}
return {
paste: {
github: await lookupGitHubItemByOwnerRepo(
client,
repoId,
intent.link.slug,
intent.link.number,
intent.link.type
),
gitlab: null
},
crossRepoPrompt: null
}
}
return {
paste: { github: null, gitlab: await lookupGitLabItemByPath(client, repoId, intent.link) },
crossRepoPrompt: null
}
}
async function runSmartSearch(args: {
client: RpcClient
mode: SmartNameMode
@@ -199,42 +257,21 @@ async function runSmartSearch(args: {
crossRepoPrompt: SmartCrossRepoPrompt | null
}> {
const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = args
const fan = await fanOutSmartSearch(args)
const paste: PasteResolved = { github: null, gitlab: null }
let crossRepoPrompt: SmartCrossRepoPrompt | null = null
const intent =
mode === 'branches' || dismissedPasteRef.current === query.trim()
? null
: resolvePasteIntent(query)
if (intent && repoId) {
try {
if (intent.kind === 'github-number') {
paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number)
} else if (intent.kind === 'github-link') {
const matchingRepo = await findRepoMatchingSlugForPaste(
client,
repos,
intent.link.slug,
repoSlugCache
// Why: the paste lookup and the provider fan-out hit different host endpoints,
// so awaiting the fan-out first stacked two full round trips on the one path a
// user is most likely to take — typing a PR/issue number. Run them together.
const [fan, pasteLookup] = await Promise.all([
fanOutSmartSearch(args),
intent && repoId
? resolvePastedItem({ client, intent, repoId, repos, repoSlugCache }).catch(
// Best-effort paste resolution; fall back to the fan-out results.
() => EMPTY_PASTE_LOOKUP
)
if (matchingRepo && matchingRepo.id !== repoId) {
crossRepoPrompt = { link: intent.link, matchingRepo }
} else {
paste.github = await lookupGitHubItemByOwnerRepo(
client,
repoId,
intent.link.slug,
intent.link.number,
intent.link.type
)
}
} else if (intent.kind === 'gitlab-link') {
paste.gitlab = await lookupGitLabItemByPath(client, repoId, intent.link)
}
} catch {
// Best-effort paste resolution; fall back to the fan-out results.
}
}
return { fan, paste, crossRepoPrompt }
: Promise.resolve(EMPTY_PASTE_LOOKUP)
])
return { fan, paste: pasteLookup.paste, crossRepoPrompt: pasteLookup.crossRepoPrompt }
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses'
import {
applyDesktopViewSettings,
buildWorkspaceViewSettingsUpdate,
groupModeFromDesktop,
groupModeToDesktop,
sortModeFromDesktop,
@@ -83,3 +84,51 @@ describe('applyDesktopViewSettings', () => {
expect(next.groupMode).toBe('repo')
})
})
describe('buildWorkspaceViewSettingsUpdate', () => {
const next: MobileViewState = {
...base,
alwaysShowDefaultBranch: true,
groupMode: 'workspaceStatus',
sortMode: 'name',
hideSleeping: true,
hideDefaultBranch: true,
filterRepoIds: ['repo-1'],
collapsedGroups: ['g1']
}
it('carries only the fields the patch touched (STA-5781)', () => {
expect(buildWorkspaceViewSettingsUpdate({ hideSleeping: true }, next)).toEqual({
hideSleepingWorkspaces: true
})
expect(buildWorkspaceViewSettingsUpdate({ groupMode: 'workspaceStatus' }, next)).toEqual({
groupBy: 'workspace-status'
})
expect(buildWorkspaceViewSettingsUpdate({ collapsedGroups: ['g1'] }, next)).toEqual({
collapsedGroups: ['g1']
})
})
it('maps a multi-field reset patch without dragging untouched siblings along', () => {
const update = buildWorkspaceViewSettingsUpdate(
{ hideSleeping: false, hideDefaultBranch: false, filterRepoIds: [] },
{ ...next, hideSleeping: false, hideDefaultBranch: false, filterRepoIds: [] }
)
expect(update).toEqual({
hideSleepingWorkspaces: false,
hideDefaultBranchWorkspace: false,
filterRepoIds: []
})
})
it('never invents alwaysShowDefaultBranchWorkspace for patches that omit it (#8873)', () => {
expect(
'alwaysShowDefaultBranchWorkspace' in
buildWorkspaceViewSettingsUpdate({ hideSleeping: true }, next)
).toBe(false)
})
it('returns an empty update for an empty patch', () => {
expect(buildWorkspaceViewSettingsUpdate({}, next)).toEqual({})
})
})
@@ -56,6 +56,49 @@ export function sortModeFromDesktop(
return sortBy && SORT_VALUES.includes(sortBy) ? sortBy : null
}
/**
* Map a user edit to the ui.set payload, carrying only the fields the edit touched.
*
* Why patch-only (STA-5781): the shared store is edited concurrently by desktop and
* web clients, and this screen's mirror refreshes only on connect/focus. Echoing the
* whole snapshot let a stale mirror revert sibling fields another client had just
* changed; the host merges partial updates field-by-field, so sending only the
* touched fields is lossless. This also supersedes the old #8873 special case:
* alwaysShowDefaultBranchWorkspace has no mobile toggle, so it is simply never in a
* patch and can no longer revert a desktop opt-out.
*/
export function buildWorkspaceViewSettingsUpdate(
patch: Partial<MobileViewState>,
next: MobileViewState
): WorkspaceViewSettings {
const update: WorkspaceViewSettings = {}
if ('groupMode' in patch) {
update.groupBy = groupModeToDesktop(next.groupMode)
}
if ('sortMode' in patch) {
update.sortBy = next.sortMode
}
if ('hideSleeping' in patch) {
update.hideSleepingWorkspaces = next.hideSleeping
}
if ('hideDefaultBranch' in patch) {
update.hideDefaultBranchWorkspace = next.hideDefaultBranch
}
if ('alwaysShowDefaultBranch' in patch) {
update.alwaysShowDefaultBranchWorkspace = next.alwaysShowDefaultBranch
}
if ('filterRepoIds' in patch) {
update.filterRepoIds = next.filterRepoIds
}
if ('collapsedGroups' in patch) {
update.collapsedGroups = next.collapsedGroups
}
if ('workspaceStatuses' in patch) {
update.workspaceStatuses = [...next.workspaceStatuses]
}
return update
}
export type MobileViewState = {
groupMode: MobileGroupMode
sortMode: MobileSortMode
+2
View File
@@ -159,6 +159,7 @@
"jsonc-parser": "^3.3.1",
"node-pty": "^1.1.0",
"posthog-node": "^5.33.3",
"proper-lockfile": "4.1.2",
"psl": "1.15.0",
"qrcode": "^1.5.4",
"react-i18next": "^17.0.8",
@@ -201,6 +202,7 @@
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@types/node": "^25.6.0",
"@types/proper-lockfile": "^4.1.4",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
+13 -1
View File
@@ -73,6 +73,9 @@ importers:
posthog-node:
specifier: ^5.33.3
version: 5.33.3
proper-lockfile:
specifier: 4.1.2
version: 4.1.2
psl:
specifier: 1.15.0
version: 1.15.0
@@ -194,6 +197,9 @@ importers:
'@types/node':
specifier: ^25.6.0
version: 25.9.5
'@types/proper-lockfile':
specifier: ^4.1.4
version: 4.1.4
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
@@ -3172,6 +3178,9 @@ packages:
'@types/node@25.9.5':
resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==}
'@types/proper-lockfile@4.1.4':
resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
@@ -3390,7 +3399,6 @@ packages:
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
'@xterm/addon-fit@0.12.0-beta.287':
resolution: {integrity: sha512-2MDj+J4x67bjOS/SuBPxSYEWH38NbX6ENV18RbKVOhfRYCX3yERnuHBOrgH9hYdY8rCtsLQmuVgF6cmvCJiV2w==}
peerDependencies:
@@ -9435,6 +9443,10 @@ snapshots:
dependencies:
undici-types: 7.24.6
'@types/proper-lockfile@4.1.4':
dependencies:
'@types/retry': 0.12.0
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 25.9.5
@@ -164,6 +164,52 @@ describe('orchestration worker-start CLI contract', () => {
expect(process.exitCode).toBe(1)
})
it('prints the Structured Chat recovery action for a refused worker start', async () => {
callMock.mockResolvedValue({
result: {
taskId: 'task_1',
dispatchId: 'ctx_1',
state: 'failed',
failedStage: 'dispatch_input',
lastError:
'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.',
effects: [],
residualResources: []
}
})
await ORCHESTRATION_HANDLERS['orchestration worker-start']({
flags: new Map<string, string | boolean>([
['task', 'task_1'],
['terminal', 'term_worker'],
['from', 'term_coord']
]),
client: { call: callMock },
cwd: '/tmp/repo',
json: false
} as never)
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as
| ((result: {
taskId: string
dispatchId: string
state: string
failedStage?: string
lastError?: string
}) => string)
| undefined
expect(
formatter?.({
taskId: 'task_1',
dispatchId: 'ctx_1',
state: 'failed',
failedStage: 'dispatch_input',
lastError:
'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.'
})
).toMatch(/Structured Chat.*Switch it to Terminal.*orca orchestration worker-start/s)
})
it('prints a reveal warning for a live background worker', async () => {
callMock.mockResolvedValue({
result: {
+41
View File
@@ -5,6 +5,8 @@ import { printHelp } from '../help'
import { COMMAND_SPECS } from '../specs'
import { TERMINAL_HANDLERS } from './terminal'
const ORIGINAL_EXIT_CODE = process.exitCode
describe('terminal close CLI', () => {
afterEach(() => {
vi.restoreAllMocks()
@@ -65,6 +67,7 @@ describe('terminal close CLI', () => {
describe('terminal send CLI', () => {
afterEach(() => {
vi.restoreAllMocks()
process.exitCode = ORIGINAL_EXIT_CODE
})
it('marks combined text and Enter as an agent prompt candidate', async () => {
@@ -94,6 +97,44 @@ describe('terminal send CLI', () => {
})
})
it('explains that Structured Chat blocked a refused send and how to recover', async () => {
const call = vi.fn().mockResolvedValue({
result: {
send: {
handle: 'term-1',
accepted: false,
bytesWritten: 0,
agentSessionRefusal: {
code: 'agent_session_conflict',
sessionId: 'session-1',
ownerRuntimeKind: 'native',
handoffStage: null,
ownerPid: 4242,
runtimeFence: 7
}
}
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
process.exitCode = undefined
await TERMINAL_HANDLERS['terminal send']({
flags: new Map<string, string | true>([
['terminal', 'term-1'],
['text', 'review'],
['enter', true]
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: false
})
expect(console.log).toHaveBeenCalledWith(
expect.stringMatching(/Structured Chat.*Switch it to Terminal.*orca terminal send/s)
)
expect(process.exitCode).toBe(1)
})
it('keeps text-only and bare Enter sends as direct terminal input', async () => {
const call = vi.fn().mockResolvedValue({
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 1 } }
+3
View File
@@ -115,6 +115,9 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
client: { id: 'orca-cli', type: 'desktop' }
})
printResult(result, json, formatTerminalSend)
if (!result.result.send.accepted) {
process.exitCode = 1
}
},
'terminal wait': async ({ flags, client, cwd, json }) => {
const timeoutMs = getOptionalPositiveIntegerFlag(flags, 'timeout-ms')
+7
View File
@@ -1,4 +1,5 @@
import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict'
import { structuredChatPtyWriteRefusalCopy } from '../shared/agent-session-pty-write-refusal-copy'
import type {
RuntimeTerminalClose,
RuntimeTerminalCreate,
@@ -181,6 +182,12 @@ function formatTerminalReadLimitedWarning(terminal: RuntimeTerminalRead): string
}
export function formatTerminalSend(result: { send: RuntimeTerminalSend }): string {
if (result.send.agentSessionRefusal) {
const copy = structuredChatPtyWriteRefusalCopy(result.send.agentSessionRefusal, 'terminal-send')
if (copy) {
return copy
}
}
return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.`
}
@@ -67,6 +67,12 @@ const aiVaultSessionSchema = z.object({
queuedMessageCount: z.number().default(0),
subagentTranscriptCount: z.number().default(0),
resumeCommand: z.string(),
structuredSession: z
.object({
sessionId: z.string().min(1).max(512),
workspaceId: z.string().min(1).max(512)
})
.optional(),
subagent: z
.object({
parentSessionId: z.string(),
@@ -0,0 +1,143 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import type { StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership'
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import {
assertLegacyAiVaultResumeAllowed,
assertLegacyAiVaultResumeCommandAllowed,
projectStructuredAiVaultSessions
} from './structured-session-ownership'
const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
describe('structured AI Vault ownership', () => {
afterEach(() => setStructuredAgentSessionHost(null))
it('hides owned rows from legacy clients and annotates them for capable clients', () => {
installOwnership()
const result = listResult()
expect(projectStructuredAiVaultSessions(result, false).sessions).toEqual([])
expect(projectStructuredAiVaultSessions(result, true).sessions[0]).toMatchObject({
structuredSession: { sessionId: 'session-alpha', workspaceId: 'workspace-1' }
})
})
it('derives typed refusals from the single writer predicate for live and proving leases', async () => {
installOwnership()
expect(() =>
assertLegacyAiVaultResumeAllowed({
agent: 'codex',
filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`,
codexHome: null,
executionHostId: 'local'
})
).toThrow('agent_session_conflict')
installOwnership({
lease: agentSessionLeaseFixture({
handoffStage: 'new-owner-proving',
claimStatus: 'reserved',
ownerProcess: null
})
})
await expect(
assertLegacyAiVaultResumeCommandAllowed(
`codex resume '${PROVIDER_SESSION}'`,
async () => undefined
)
).rejects.toThrow('agent_session_ownership_unknown')
})
it.each([
`codex resume --last`,
`claude --resume`,
`claude -r`,
`claude --continue`,
`claude -c`,
// `--continue` takes no session id, so the trailing token is a prompt —
// reading it as a target would admit a writer onto the owned session.
`claude --continue "keep going"`,
`claude -c 019fd532-7c11-7a90-b6de-4e1a2c3d5f61`
])('refuses resume commands without a provably different target: %s', async (command) => {
installOwnership(command.startsWith('claude') ? { provider: 'claude' } : {})
await expect(
assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined)
).rejects.toThrow('agent_session_conflict')
})
it('allows a resume command that names a different provider session', async () => {
installOwnership()
await expect(
assertLegacyAiVaultResumeCommandAllowed(
'codex resume 019fd532-7c11-7a90-b6de-4e1a2c3d5f61',
async () => undefined
)
).resolves.toBeUndefined()
})
})
function installOwnership(overrides: Partial<StructuredProviderSessionOwnership> = {}): void {
const ownership: StructuredProviderSessionOwnership = {
sessionId: 'session-alpha',
workspaceId: 'workspace-1',
provider: 'codex',
providerSessionId: PROVIDER_SESSION,
lease: agentSessionLeaseFixture(),
...overrides
}
const record = agentSessionRecordFixture(ownership.lease)
setStructuredAgentSessionHost({
deps: {
store: {
listRecords: () => [
{
...record,
sessionId: ownership.sessionId,
location: { ...record.location, workspaceId: ownership.workspaceId },
provider: ownership.provider,
providerHandleChain: [
{
...record.providerHandleChain[0]!,
handle: { provider: ownership.provider, threadId: ownership.providerSessionId }
}
],
lease: { ...ownership.lease, sessionId: ownership.sessionId }
}
]
}
}
} as never)
}
function listResult(): AiVaultListResult {
const session: AiVaultSession = {
id: `local:codex:${PROVIDER_SESSION}`,
executionHostId: 'local',
agent: 'codex',
sessionId: PROVIDER_SESSION,
title: 'Owned',
cwd: '/repo',
branch: null,
model: null,
filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`,
codexHome: null,
createdAt: null,
updatedAt: null,
modifiedAt: '2026-08-11T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume '${PROVIDER_SESSION}'`,
subagent: null
}
return { sessions: [session], issues: [], scannedAt: '2026-08-11T00:00:00.000Z' }
}
@@ -0,0 +1,186 @@
import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import {
listStructuredProviderSessionOwnership,
type StructuredProviderSessionOwnership
} from '../native-chat/agent-session-wire/structured-provider-session-ownership'
export function projectStructuredAiVaultSessions(
result: AiVaultListResult,
structuredSupported: boolean
): AiVaultListResult {
const host = getStructuredAgentSessionHost()
if (!host) {
return result
}
const sessions = result.sessions.flatMap((session) => {
const ownership = findSessionOwnership(session)
if (!ownership) {
return [session]
}
if (!structuredSupported) {
return []
}
return [
{
...session,
structuredSession: {
sessionId: ownership.sessionId,
workspaceId: ownership.workspaceId
}
}
]
})
return sessions.length === result.sessions.length &&
sessions.every((row, index) => row === result.sessions[index])
? result
: { ...result, sessions }
}
export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void {
const ownership = findResumeOwnership(args)
if (ownership) {
refuseLegacyWriter(ownership)
}
}
export async function assertLegacyAiVaultResumeCommandAllowed(
command: string,
ensureHost: () => Promise<void>
): Promise<void> {
if (!isPotentialStructuredResumeCommand(command)) {
return
}
await ensureHost()
const host = getStructuredAgentSessionHost()
if (!host) {
return
}
for (const ownership of listOwnership()) {
if (isResumeCommandFor(command, ownership)) {
refuseLegacyWriter(ownership)
}
}
}
function isPotentialStructuredResumeCommand(command: string): boolean {
return parseResumeInvocation(command) !== null
}
function findSessionOwnership(session: AiVaultSession): StructuredProviderSessionOwnership | null {
if (session.agent !== 'codex' && session.agent !== 'claude') {
return null
}
return findOwnership(session.agent, session.sessionId)
}
function findResumeOwnership(
args: AiVaultPrepareSessionResumeArgs
): StructuredProviderSessionOwnership | null {
if (args.agent !== 'codex' && args.agent !== 'claude') {
return null
}
const host = getStructuredAgentSessionHost()
if (!host) {
return null
}
const exact = args.sessionId ? findOwnership(args.agent, args.sessionId) : null
if (exact) {
return exact
}
const fileName = args.filePath.split(/[\\/]/).at(-1) ?? ''
return (
listOwnership().find(
(ownership) =>
ownership.provider === args.agent && fileName.includes(ownership.providerSessionId)
) ?? null
)
}
function findOwnership(
provider: 'claude' | 'codex',
providerSessionId: string
): StructuredProviderSessionOwnership | null {
return (
listOwnership().find(
(ownership) =>
ownership.provider === provider && ownership.providerSessionId === providerSessionId
) ?? null
)
}
function listOwnership(): StructuredProviderSessionOwnership[] {
const host = getStructuredAgentSessionHost()
return host ? listStructuredProviderSessionOwnership(host.deps.store.listRecords()) : []
}
function isResumeCommandFor(
command: string,
ownership: StructuredProviderSessionOwnership
): boolean {
const invocation = parseResumeInvocation(command)
if (!invocation || invocation.provider !== ownership.provider) {
return false
}
// A target-less resume (--last, --continue, or a bare --resume/-r) may pick
// any provider session, so it cannot be admitted while one is structured.
// Only an explicit target that differs from this owned session is safe.
return invocation.target === null || invocation.target === ownership.providerSessionId
}
type ResumeInvocation = {
provider: 'codex' | 'claude'
target: string | null
}
function parseResumeInvocation(command: string): ResumeInvocation | null {
// Keep this deliberately conservative: shell quoting is normalized only
// enough to identify executable/flag tokens; an unrecognized shape is not
// treated as proof that a different session is being resumed.
const tokens = command.match(/"[^"\\]*(?:\\.[^"\\]*)*"|'[^']*'|[^\s]+/g) ?? []
const normalized = tokens.map((token) => token.replace(/^['"]|['"]$/g, ''))
const executableIndex = normalized.findIndex((token) =>
/(?:^|[\\/])(?:codex|claude)(?:\.exe)?$/i.test(token)
)
if (executableIndex === -1) {
return null
}
const provider = /codex(?:\.exe)?$/i.test(normalized[executableIndex]!) ? 'codex' : 'claude'
const args = normalized.slice(executableIndex + 1)
// `--continue`/`-c` resume the most recent session and never take an id, so a
// following token is a prompt, not a target — they are always target-less.
const targetlessFlags = provider === 'codex' ? [] : ['--continue', '-c']
const targetlessIndex = args.findIndex((token) => targetlessFlags.includes(token.toLowerCase()))
if (targetlessIndex !== -1) {
return { provider, target: null }
}
const resumeFlags = provider === 'codex' ? ['resume'] : ['--resume', '-r']
const inlineIndex = args.findIndex(
(token) =>
provider === 'claude' &&
(token.toLowerCase().startsWith('--resume=') || token.toLowerCase().startsWith('-r='))
)
if (inlineIndex !== -1) {
const target = args[inlineIndex]!.slice(args[inlineIndex]!.indexOf('=') + 1)
return { provider, target: target.length > 0 ? target : null }
}
const markerIndex = args.findIndex((token) => resumeFlags.includes(token.toLowerCase()))
if (markerIndex === -1) {
return null
}
const candidate = args[markerIndex + 1]
return {
provider,
target: candidate && !candidate.startsWith('-') ? candidate : null
}
}
function refuseLegacyWriter(ownership: StructuredProviderSessionOwnership): never {
throw new Error(
agentSessionLeaseAdmitsWriter(ownership.lease)
? 'agent_session_conflict'
: 'agent_session_ownership_unknown'
)
}
@@ -0,0 +1,21 @@
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
export function claudeProviderHandleLink(input: {
sessionId: string
leafUuid: string | null
resumed: boolean
origin?: 'adopted'
fence: number
linkId?: string
observedAt: number
}): AgentSessionProviderHandleLink {
return {
linkId:
input.linkId ??
`claude-${input.fence}-${input.sessionId}-${input.leafUuid ?? 'empty'}`.slice(0, 128),
handle: { provider: 'claude', sessionId: input.sessionId, leafUuid: input.leafUuid },
origin: input.origin ?? (input.resumed ? 'resumed' : 'created'),
mintedAtFence: input.fence,
observedAt: input.observedAt
}
}
@@ -0,0 +1,128 @@
import { readFile } from 'node:fs/promises'
const MAX_CLAUDE_TRANSCRIPT_ANCESTRY = 10_000
type TranscriptNode = {
parentUuid: string | null
sessionId: string | null
}
export type ClaudeTranscriptBranchProof = {
leafUuid: string
relation: 'initial' | 'same' | 'descendant'
}
function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
}
function transcriptError(reason: string): Error {
return new Error(`Claude transcript branch proof failed: ${reason}`)
}
export class ClaudeTranscriptTailIncompleteError extends Error {
constructor() {
super('Claude transcript branch proof failed: malformed JSONL')
this.name = 'ClaudeTranscriptTailIncompleteError'
}
}
export function proveClaudeTranscriptBranchFromJsonl(input: {
contents: string
providerSessionId: string
previousLeafUuid: string | null
}): ClaudeTranscriptBranchProof {
const nodes = new Map<string, TranscriptNode>()
let leafUuid: string | null = null
const lines = input.contents.split('\n')
for (const [index, line] of lines.entries()) {
if (!line.trim()) {
continue
}
let record: unknown
try {
record = JSON.parse(line)
} catch {
if (index === lines.length - 1 && !input.contents.endsWith('\n')) {
throw new ClaudeTranscriptTailIncompleteError()
}
throw transcriptError('malformed JSONL')
}
if (typeof record !== 'object' || record === null || Array.isArray(record)) {
throw transcriptError('non-object record')
}
const row = record as Record<string, unknown>
if (row.type === 'last-prompt') {
const markerSessionId = nonEmptyString(row.sessionId)
const markerLeaf = nonEmptyString(row.leafUuid)
if (markerSessionId !== input.providerSessionId || !markerLeaf) {
throw transcriptError('invalid last-prompt marker')
}
leafUuid = markerLeaf
}
const uuid = nonEmptyString(row.uuid)
if (!uuid) {
continue
}
const parentUuid = row.parentUuid === null ? null : nonEmptyString(row.parentUuid)
if (row.parentUuid !== null && !parentUuid) {
throw transcriptError(`record ${uuid} has no parent identity`)
}
const sessionId = nonEmptyString(row.sessionId)
const existing = nodes.get(uuid)
if (existing && (existing.parentUuid !== parentUuid || existing.sessionId !== sessionId)) {
throw transcriptError(`record ${uuid} has conflicting ancestry`)
}
nodes.set(uuid, { parentUuid, sessionId })
}
if (!leafUuid) {
throw transcriptError('missing last-prompt marker')
}
const leaf = nodes.get(leafUuid)
if (!leaf || leaf.sessionId !== input.providerSessionId) {
throw transcriptError('marker leaf is missing from the session graph')
}
const previousLeafUuid = input.previousLeafUuid
if (!previousLeafUuid) {
return { leafUuid, relation: 'initial' }
}
const previous = nodes.get(previousLeafUuid)
if (!previous || previous.sessionId !== input.providerSessionId) {
throw transcriptError('previous cursor is missing from the session graph')
}
if (leafUuid === previousLeafUuid) {
return { leafUuid, relation: 'same' }
}
const visited = new Set<string>()
let cursor: string | null = leafUuid
for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
if (visited.has(cursor)) {
throw transcriptError('cycle in parentUuid ancestry')
}
visited.add(cursor)
const node = nodes.get(cursor)
if (!node || node.sessionId !== input.providerSessionId) {
throw transcriptError(`missing ancestor ${cursor}`)
}
cursor = node.parentUuid
if (cursor === previousLeafUuid) {
return { leafUuid, relation: 'descendant' }
}
}
if (cursor !== null) {
throw transcriptError('ancestry exceeds the bounded proof limit')
}
throw transcriptError('latest marker is on a sibling branch')
}
export async function proveClaudeTranscriptBranch(input: {
transcriptPath: string
providerSessionId: string
previousLeafUuid: string | null
}): Promise<ClaudeTranscriptBranchProof> {
return proveClaudeTranscriptBranchFromJsonl({
contents: await readFile(input.transcriptPath, 'utf8'),
providerSessionId: input.providerSessionId,
previousLeafUuid: input.previousLeafUuid
})
}
@@ -61,7 +61,7 @@ describe('CodexRuntimeHomeService', () => {
'',
'[model_providers.codex-lb]',
'base_url = "https://codex-lb.example.test/v1"',
'env_key = "CODEX_LB_API_KEY"',
'env_key = "EXAMPLE_GATEWAY_TOKEN"',
''
].join('\n')
writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8')
@@ -176,7 +176,7 @@ describe('CodexAccountService config sync', () => {
'[model_providers.codex-lb]',
'name = "Codex load balancer"',
'base_url = "https://codex-lb.example.test/v1"',
'env_key = "CODEX_LB_API_KEY"',
'env_key = "EXAMPLE_GATEWAY_TOKEN"',
''
].join('\n')
writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8')
@@ -192,16 +192,21 @@ describe('killCodexAppServerProcessTree', () => {
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
it('kills the direct app-server process on non-Windows hosts', () => {
it('kills the launcher descendants before the direct process on non-Windows hosts', () => {
const child = {
pid: 1234,
kill: vi.fn(() => true) as ChildProcess['kill']
}
const spawnImpl = vi.fn() as unknown as typeof spawn
const descendants = { unref: vi.fn(), on: vi.fn() }
const spawnImpl = vi.fn(() => descendants) as unknown as typeof spawn
killCodexAppServerProcessTree(child, { platform: 'linux', spawnImpl })
expect(spawnImpl).not.toHaveBeenCalled()
expect(spawnImpl).toHaveBeenCalledWith('pkill', ['-KILL', '-P', '1234'], { stdio: 'ignore' })
// A missing pkill arrives as an async 'error' event; unhandled, it would
// take down the main process.
expect(descendants.on).toHaveBeenCalledWith('error', expect.any(Function))
expect(descendants.unref).toHaveBeenCalledOnce()
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
})
})
@@ -0,0 +1,27 @@
export type CodexAppServerServerRequest = {
id: number | string
method: string
params: unknown
}
export type CodexAppServerConnectionHandlers = {
onNotification?: (method: string, params: unknown) => void
onServerRequest?: (request: CodexAppServerServerRequest) => void
onUnhandledFrame?: (kind: string, payload: unknown) => void
onExit?: (error: Error) => void
}
export type CodexAppServerConnection = {
readonly pid: number | undefined
readonly closed: boolean
request: (
method: string,
params?: Record<string, unknown>,
options?: { timeoutMs?: number }
) => Promise<unknown>
notify: (method: string, params?: Record<string, unknown>) => void
respond: (id: number | string, result: unknown) => void
respondWithError: (id: number | string, code: number, message: string) => void
/** Resolves true only after the child emitted `exit` or `close`; false is unproven. */
close: () => Promise<boolean>
}
@@ -0,0 +1,549 @@
import { EventEmitter } from 'node:events'
import { realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { spawnProcess } from '../../shared/child-process/run-process'
import {
isCodexAppServerRequestError,
openCodexAppServerConnection,
type CodexAppServerConnection,
type CodexAppServerConnectionHandlers
} from './codex-app-server-connection'
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
const originalCodexHome = process.env.CODEX_HOME
afterEach(() => {
vi.useRealTimers()
if (originalCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = originalCodexHome
}
})
/**
* A real `node -e` child speaking the same JSONL framing Codex does. Slower than
* a stub, but it is the only thing that proves the spawn, the environment, and
* both traffic directions actually work end to end.
*/
const FAKE_APP_SERVER = String.raw`
const readline = require('node:readline')
const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n')
readline.createInterface({ input: process.stdin }).on('line', (line) => {
const message = JSON.parse(line)
if (message.method === 'initialize') return send({ id: message.id, result: {} })
if (message.method === 'test/env') {
return send({ id: message.id, result: { codexHome: process.env.CODEX_HOME ?? null } })
}
if (message.method === 'test/cwd') {
return send({ id: message.id, result: { cwd: process.cwd() } })
}
if (message.method === 'test/notify') {
send({ method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-7' } } })
return send({ id: message.id, result: {} })
}
if (message.method === 'test/ask') {
return send({ id: 99, method: 'item/fileChange/requestApproval', params: { itemId: 'i1' } })
}
if (message.method === 'test/refuse') {
return send({ id: message.id, error: { code: -32602, message: 'bad params' } })
}
if (message.method === 'test/missing') {
return send({ id: message.id, error: { code: -32601, message: 'method not found' } })
}
if (message.id === 99) {
return send({ method: 'test/answered', params: message })
}
})
`
async function openFakeServer(
handlers: CodexAppServerConnectionHandlers = {},
env?: Record<string, string>,
envToDelete?: string[],
cwd?: string
): Promise<CodexAppServerConnection> {
return openCodexAppServerConnection(
{ command: process.execPath, args: ['-e', FAKE_APP_SERVER], env, envToDelete, cwd },
handlers
)
}
type StubChild = EventEmitter & {
stdout: PassThrough
stderr: PassThrough
stdin: PassThrough
pid: number
kill: ReturnType<typeof vi.fn>
}
/** Full control over framing and death, which a real child cannot give. */
function stubChild(options: { exitOnStdinEnd?: boolean } = {}): {
child: StubChild
spawnImpl: typeof spawnProcess
written: Record<string, unknown>[]
} {
const child = new EventEmitter() as StubChild
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.stdin = new PassThrough()
// Keep the synthetic pid outside any real process table so teardown never
// mistakes an unrelated process for this stub.
child.pid = 9_999_999
child.kill = vi.fn()
const written: Record<string, unknown>[] = []
child.stdin.on('data', (chunk: Buffer) => {
for (const line of chunk.toString('utf8').split('\n')) {
if (line.trim()) {
written.push(JSON.parse(line) as Record<string, unknown>)
}
}
})
if (options.exitOnStdinEnd !== false) {
child.stdin.on('finish', () => child.emit('exit', 0, null))
}
return { child, spawnImpl: (() => child) as unknown as typeof spawnProcess, written }
}
/** Answers the handshake so `openCodexAppServerConnection` can resolve. */
function answerInitialize(child: StubChild): void {
child.stdin.once('data', () => {
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`)
})
}
/** Stream writes land a tick later, so the stderr tail is only complete here. */
async function flushStreams(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve))
}
function rejection(promise: Promise<unknown>): Promise<Error> {
return promise.then(
() => {
throw new Error('expected the call to reject')
},
(error: Error) => error
)
}
describe('openCodexAppServerConnection', () => {
it('advertises the experimental API required for rollout-path resume', async () => {
const { child, spawnImpl, written } = stubChild()
answerInitialize(child)
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{},
spawnImpl
)
expect(written[0]).toMatchObject({
method: 'initialize',
params: { capabilities: { experimentalApi: true } }
})
await connection.close()
})
it('completes the handshake and keeps the child alive across calls', async () => {
const notifications: { method: string; params: unknown }[] = []
const connection = await openFakeServer({
onNotification: (method, params) => notifications.push({ method, params })
})
await connection.request('test/notify')
await connection.request('test/notify')
expect(connection.pid).toBeGreaterThan(0)
expect(connection.closed).toBe(false)
expect(notifications).toHaveLength(2)
expect(notifications[0]).toEqual({
method: 'turn/started',
params: { threadId: 'thread-1', turn: { id: 'turn-7' } }
})
await connection.close()
expect(connection.closed).toBe(true)
})
it('applies the environment overlay after stripping inherited keys', async () => {
process.env.CODEX_HOME = '/tmp/inherited-home'
const pinned = await openFakeServer({}, { CODEX_HOME: '/tmp/pinned-home' })
expect(await pinned.request('test/env')).toEqual({ codexHome: '/tmp/pinned-home' })
await pinned.close()
const stripped = await openFakeServer({}, undefined, ['CODEX_HOME'])
expect(await stripped.request('test/env')).toEqual({ codexHome: null })
await stripped.close()
})
it('starts the provider in the resolved workspace directory', async () => {
const workspace = realpathSync(tmpdir())
const connection = await openFakeServer({}, undefined, undefined, workspace)
await expect(connection.request('test/cwd')).resolves.toEqual({ cwd: workspace })
await connection.close()
})
it('routes a server request to the handler and writes the reply back', async () => {
const requests: { id: number | string; method: string }[] = []
let resolveAnswered: (params: unknown) => void = () => {}
const answered = new Promise<unknown>((resolve) => {
resolveAnswered = resolve
})
const connection = await openFakeServer({
onServerRequest: (request) => {
requests.push({ id: request.id, method: request.method })
connection.respond(request.id, { decision: 'accept' })
},
onNotification: (method, params) => {
if (method === 'test/answered') {
resolveAnswered(params)
}
}
})
connection.notify('test/ask')
expect(await answered).toEqual({ id: 99, result: { decision: 'accept' } })
expect(requests).toEqual([{ id: 99, method: 'item/fileChange/requestApproval' }])
await connection.close()
})
it('classifies a refusal apart from a missing method', async () => {
const connection = await openFakeServer()
const refusal = await connection.request('test/refuse').catch((error: unknown) => error)
const missing = await connection.request('test/missing').catch((error: unknown) => error)
expect(isCodexAppServerRequestError(refusal)).toBe(true)
expect((refusal as Error).message).toContain('bad params')
expect(isCodexAppServerUnsupportedError(missing)).toBe(true)
expect(isCodexAppServerRequestError(missing)).toBe(false)
await connection.close()
})
it('reassembles a message split mid-character across chunks', async () => {
const { child, spawnImpl } = stubChild()
answerInitialize(child)
const notifications: unknown[] = []
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{ onNotification: (_method, params) => notifications.push(params) },
spawnImpl
)
const payload = Buffer.from(
`${JSON.stringify({ method: 'item/agentMessage/delta', params: { delta: '日本語' } })}\n`,
'utf8'
)
const split = payload.indexOf(Buffer.from('日', 'utf8')) + 1
child.stdout.write(payload.subarray(0, split))
child.stdout.write(payload.subarray(split))
await vi.waitFor(() => expect(notifications).toHaveLength(1))
expect(notifications[0]).toEqual({ delta: '日本語' })
await connection.close()
})
it('surfaces valid but unclassified frames instead of dropping them', async () => {
const { child, spawnImpl } = stubChild()
answerInitialize(child)
const frames: { kind: string; payload: unknown }[] = []
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{ onUnhandledFrame: (kind, payload) => frames.push({ kind, payload }) },
spawnImpl
)
child.stdout.write(`${JSON.stringify({ id: 'late-string-id', result: { value: 1 } })}\n`)
child.stdout.write(`${JSON.stringify({ id: 999, result: { value: 2 } })}\n`)
await vi.waitFor(() => expect(frames).toHaveLength(2))
expect(frames.map((frame) => frame.kind)).toEqual(['frame:unclassified', 'response:unmatched'])
await connection.close()
})
it('fails in-flight requests and reports an unexpected exit once', async () => {
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
answerInitialize(child)
const exits: string[] = []
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{ onExit: (error) => exits.push(error.message) },
spawnImpl
)
const inFlight = rejection(connection.request('turn/start'))
child.stderr.write('codex crashed\n')
await flushStreams()
child.emit('exit', 1, null)
child.emit('close', 1, null)
expect((await inFlight).message).toContain('codex crashed')
expect(exits).toHaveLength(1)
await connection.close()
})
it('classifies a CLI without the app-server subcommand as unsupported', async () => {
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
const opening = openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{},
spawnImpl
).catch((error: unknown) => error)
child.stderr.write("error: unrecognized subcommand 'app-server'\n")
await flushStreams()
child.emit('exit', 2, null)
child.emit('close', 2, null)
expect(isCodexAppServerUnsupportedError(await opening)).toBe(true)
})
it('exposes an unproven handshake child for later cleanup', async () => {
vi.useFakeTimers()
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
child.stdin.once('data', () => {
child.stdout.write(
`${JSON.stringify({ id: 1, error: { code: -32602, message: 'initialize failed' } })}\n`
)
})
const opening = rejection(
openCodexAppServerConnection({ command: 'codex', args: ['app-server'] }, {}, spawnImpl)
)
await vi.advanceTimersByTimeAsync(5_000)
const error = (await opening) as Error & { connection?: CodexAppServerConnection }
expect(error.name).toBe('CodexAppServerHandshakeExitUnprovenError')
expect(error.connection).toBeDefined()
child.emit('close', 1, null)
await expect(error.connection?.close()).resolves.toBe(true)
})
it('times out one request without ending the connection', async () => {
vi.useFakeTimers()
const { child, spawnImpl } = stubChild()
answerInitialize(child)
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{},
spawnImpl
)
const slow = rejection(connection.request('turn/start', undefined, { timeoutMs: 50 }))
await vi.advanceTimersByTimeAsync(60)
expect((await slow).name).toBe('CodexAppServerTimeoutError')
expect(connection.closed).toBe(false)
await vi.advanceTimersByTimeAsync(0)
})
it('kills a child that ignores stdin EOF', async () => {
vi.useFakeTimers()
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
answerInitialize(child)
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{},
spawnImpl
)
child.kill.mockImplementation(() => {
child.emit('exit', null, 'SIGKILL')
return true
})
const closing = connection.close()
await vi.advanceTimersByTimeAsync(2_000)
await closing
await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL'))
})
it('reports unproven close when forced termination did not produce an exit event', async () => {
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
answerInitialize(child)
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{},
spawnImpl
)
await expect(connection.close()).resolves.toBe(false)
}, 10_000)
it('shares one eventual exit proof across concurrent close callers', async () => {
vi.useFakeTimers()
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
answerInitialize(child)
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{},
spawnImpl
)
child.kill.mockImplementation(() => {
setTimeout(() => child.emit('exit', null, 'SIGKILL'), 10)
return true
})
const first = connection.close()
const second = connection.close()
await vi.advanceTimersByTimeAsync(4_100)
await expect(Promise.all([first, second])).resolves.toEqual([true, true])
expect(child.kill.mock.calls.map(([signal]) => signal)).toEqual(['SIGSTOP', 'SIGKILL'])
})
it('allows a later close to observe exit after an unproven attempt', async () => {
vi.useFakeTimers()
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
answerInitialize(child)
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{},
spawnImpl
)
const first = connection.close()
await vi.advanceTimersByTimeAsync(5_000)
await expect(first).resolves.toBe(false)
child.emit('exit', 0, null)
await expect(connection.close()).resolves.toBe(true)
})
it('ends the connection rather than buffering an oversized line', async () => {
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
answerInitialize(child)
const exits: string[] = []
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{ onExit: (error) => exits.push(error.message) },
spawnImpl
)
child.kill.mockImplementation(() => {
child.emit('exit', null, 'SIGKILL')
return true
})
const inFlight = rejection(connection.request('turn/start'))
child.stdout.write('x'.repeat(1024 * 1024 + 1))
expect((await inFlight).message).toContain('oversized')
expect(exits[0]).toContain('oversized')
await connection.close()
})
it.each([
{
kind: 'notification',
frame: { method: 'turn/started', params: { turn: { id: 'turn-1' } } }
},
{
kind: 'server request',
frame: { id: 41, method: 'item/fileChange/requestApproval', params: { itemId: 'item-1' } }
}
])('surfaces a synchronous $kind handler failure as a terminal exit', async ({ frame }) => {
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
answerInitialize(child)
const exits: string[] = []
const fail = (): never => {
throw new Error('structured sink failed')
}
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{
onNotification: fail,
onServerRequest: fail,
onExit: (error) => exits.push(error.message)
},
spawnImpl
)
child.kill.mockImplementation(() => {
child.emit('exit', null, 'SIGKILL')
return true
})
const inFlight = rejection(connection.request('turn/start'))
child.stdout.write(`${JSON.stringify(frame)}\n`)
expect((await inFlight).message).toContain('structured sink failed')
expect(exits).toEqual([expect.stringContaining('structured sink failed')])
expect(connection.closed).toBe(true)
await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL'))
await connection.close()
})
it('reports one exit for a death that arrives through two listeners', async () => {
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
answerInitialize(child)
const exits: string[] = []
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{ onExit: (error) => exits.push(error.message) },
spawnImpl
)
// The oversized line kills the child, so its own `close` lands afterwards.
child.stdout.write('x'.repeat(1024 * 1024 + 1))
child.stderr.write('killed\n')
await flushStreams()
child.emit('exit', null, 'SIGKILL')
child.emit('close', null, 'SIGKILL')
expect(exits).toHaveLength(1)
// The first cause survives; the generic exit that follows does not overwrite it.
expect(exits[0]).toContain('oversized')
await connection.close()
})
it('treats a broken stdin pipe as the end of the transport', async () => {
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
answerInitialize(child)
const exits: string[] = []
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{ onExit: (error) => exits.push(error.message) },
spawnImpl
)
child.kill.mockImplementation(() => {
child.emit('exit', null, 'SIGKILL')
return true
})
const inFlight = rejection(connection.request('turn/start'))
child.stdin.emit('error', new Error('write EPIPE'))
expect((await inFlight).message).toContain('EPIPE')
expect(exits).toHaveLength(1)
// A child nobody can write to is not a live session: the owner must see the
// connection as gone rather than keep issuing calls that can only time out.
expect(connection.closed).toBe(true)
await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL'))
expect((await rejection(connection.request('turn/start'))).message).toContain('EPIPE')
await connection.close()
})
it('keeps a graceful close quiet when stdin breaks during the reap', async () => {
const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false })
answerInitialize(child)
const exits: string[] = []
const connection = await openCodexAppServerConnection(
{ command: 'codex', args: ['app-server'] },
{ onExit: (error) => exits.push(error.message) },
spawnImpl
)
child.stdin.on('finish', () => child.stdin.emit('error', new Error('write EPIPE')))
child.kill.mockImplementation(() => {
child.emit('exit', null, 'SIGKILL')
return true
})
const inFlight = rejection(connection.request('turn/start'))
await connection.close()
expect((await inFlight).message).toContain('EPIPE')
expect(exits).toHaveLength(0)
})
})
@@ -0,0 +1,349 @@
import { spawnProcess } from '../../shared/child-process/run-process'
import { RetryableProcessExitProof } from '../../shared/child-process/retryable-process-exit-proof'
import { createProviderSpawnSpec } from './codex-app-server-posix-supervisor'
import { buildCodexAppServerExitError } from './codex-app-server-exit-error'
import { initializeCodexAppServerConnection } from './codex-app-server-handshake'
import { CodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof'
import { isAppServerRecord, parseCodexAppServerJsonLine } from './codex-app-server-jsonl'
import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown'
import { CodexAppServerRequestError } from './codex-app-server-request-error'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
import { waitForProcessExitUntil } from './codex-process-exit-deadline'
import {
CodexAppServerTimeoutError,
CodexAppServerUnsupportedError,
isCodexMethodNotFoundError
} from './codex-app-server-session'
import type {
CodexAppServerConnection,
CodexAppServerConnectionHandlers
} from './codex-app-server-connection-types'
export type {
CodexAppServerConnection,
CodexAppServerConnectionHandlers,
CodexAppServerServerRequest
} from './codex-app-server-connection-types'
export {
CodexAppServerRequestError,
isCodexAppServerRequestError
} from './codex-app-server-request-error'
// Structured chat needs a persistent bidirectional child and per-request deadlines;
// the request-scoped app-server runner cannot carry approvals or streamed turns.
export type CodexAppServerLaunch = {
command: string
args: string[]
/** Workspace directory used by the provider process itself. */
cwd?: string
/** Overlay on the inherited environment — the pinned CODEX_HOME lives here. */
env?: Record<string, string>
/** Keys stripped after the overlay, matching `CodexAppServerInvocation`. */
envToDelete?: readonly string[]
}
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
const GRACEFUL_EXIT_MS = 1_500
const FORCED_EXIT_MS = 1_000
const STDERR_TAIL_MAX_BYTES = 8192
const STDOUT_LINE_MAX_BYTES = 1024 * 1024
type PendingRequest = {
method: string
resolve: (result: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
/**
* Spawns `codex app-server`, completes the initialize handshake, and returns a
* connection that stays open until `close()`. Rejects — after reaping the child
* — when the handshake cannot complete.
*/
export async function openCodexAppServerConnection(
launch: CodexAppServerLaunch,
handlers: CodexAppServerConnectionHandlers = {},
spawnImpl: typeof spawnProcess = spawnProcess
): Promise<CodexAppServerConnection> {
const childEnv: NodeJS.ProcessEnv = { ...process.env, ...launch.env }
for (const key of launch.envToDelete ?? []) {
delete childEnv[key]
}
const spawnSpec = createProviderSpawnSpec(launch, childEnv, process.platform)
const child = spawnImpl(spawnSpec)
const spawnToken = launch.env?.[CODEX_SPAWN_TOKEN_ENV]
function terminateProcessTree(): Promise<boolean> {
// The supervisor and provider own separate POSIX groups so the supervisor can prove the
// provider group empty before relaying its exit. Forced wrapper teardown uses descendant proof.
return terminateCodexAppServerProcessTree(child, spawnToken)
}
const pending = new Map<number, PendingRequest>()
let stderrTail = ''
let nextRequestId = 1
let exited = false
let exitObserved = false
let closing = false
const exitProof = new RetryableProcessExitProof()
/** First terminal cause, or null while the transport is still usable. Set once:
* a child that dies reaches us through several listeners, and the specific
* first cause is the one worth reporting. */
let terminalError: Error | null = null
let resolveExit = (): void => undefined
const exitPromise = new Promise<void>((resolve) => {
resolveExit = resolve
})
function observeExit(): void {
exited = true
exitObserved = true
resolveExit()
}
child.on('exit', observeExit)
function buildExitError(cause?: Error): Error {
return buildCodexAppServerExitError(stderrTail, cause)
}
function failPending(error: Error): void {
for (const waiter of pending.values()) {
clearTimeout(waiter.timer)
waiter.reject(error)
}
pending.clear()
}
/** A death nobody asked for kills every in-flight call AND tells the owner,
* which is the only signal the session has that its lease is now worthless.
* Once only: an oversized line kills the child and its `close` arrives after,
* and a spawn failure arrives as both `error` and `close`. */
function handleUnexpectedEnd(cause?: Error): void {
if (terminalError) {
return
}
terminalError = buildExitError(cause)
failPending(terminalError)
if (!closing) {
handlers.onExit?.(terminalError)
}
}
child.on('error', (error) => {
handleUnexpectedEnd(error)
})
child.on('close', () => {
observeExit()
handleUnexpectedEnd()
})
child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES)
})
child.stdin.on('error', (error) => {
// A broken pipe is terminal, not one failed write: every later request can
// only error or time out, so the session must learn its lease is worthless
// instead of staying live in front of a child nobody can reach. During a
// close the reap is already under way and `exited` must stay honest, or
// `close` would skip the kill it still owes.
if (closing) {
failPending(error)
return
}
void terminateProcessTree()
handleUnexpectedEnd(error)
})
function dispatchMessage(message: Record<string, unknown>): void {
const hasMethod = typeof message.method === 'string'
const hasId = typeof message.id === 'number' || typeof message.id === 'string'
if (hasMethod && hasId) {
handlers.onServerRequest?.({
id: message.id as number | string,
method: message.method as string,
params: message.params
})
return
}
if (hasMethod) {
handlers.onNotification?.(message.method as string, message.params)
return
}
if (typeof message.id !== 'number') {
handlers.onUnhandledFrame?.('frame:unclassified', message)
return
}
const waiter = pending.get(message.id)
if (!waiter) {
handlers.onUnhandledFrame?.('response:unmatched', message)
return
}
pending.delete(message.id)
clearTimeout(waiter.timer)
const error = message.error
if (isAppServerRecord(error)) {
const detail = typeof error.message === 'string' ? error.message : 'unknown error'
waiter.reject(
isCodexMethodNotFoundError(error)
? new CodexAppServerUnsupportedError(
`codex app-server does not support ${waiter.method}: ${detail}`
)
: new CodexAppServerRequestError(
waiter.method,
typeof error.code === 'number' ? error.code : null,
`codex app-server ${waiter.method} failed: ${detail}`
)
)
return
}
waiter.resolve(message.result)
}
let stdoutBuffer = ''
child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
stdoutBuffer += chunk
if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) {
child.stdout.destroy()
void terminateProcessTree()
handleUnexpectedEnd(new Error('codex app-server emitted an oversized JSONL line'))
return
}
let newlineIndex: number
while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) {
const line = stdoutBuffer.slice(0, newlineIndex).trim()
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1)
if (!line) {
continue
}
const parsed = parseCodexAppServerJsonLine(line)
if (!parsed) {
handlers.onUnhandledFrame?.('frame:invalid-json', line)
continue
}
try {
dispatchMessage(parsed)
} catch (error) {
child.stdout.destroy()
void terminateProcessTree()
handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error)))
return
}
}
})
function sendLine(payload: Record<string, unknown>): void {
child.stdin.write(`${JSON.stringify(payload)}\n`)
}
function notify(method: string, params?: Record<string, unknown>): void {
if (exited || terminalError) {
return
}
try {
sendLine(params === undefined ? { method } : { method, params })
} catch {
// Fire-and-forget; the next request surfaces a dead child.
}
}
function request(
method: string,
params?: Record<string, unknown>,
options: { timeoutMs?: number } = {}
): Promise<unknown> {
if (closing) {
return Promise.reject(new Error(`codex app-server connection is closed (${method})`))
}
if (terminalError) {
return Promise.reject(terminalError)
}
if (exited) {
return Promise.reject(buildExitError())
}
const id = nextRequestId++
const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
return new Promise<unknown>((resolve, reject) => {
// Why: per request, not per session — a chat session outlives every call,
// so only the individual call can carry a deadline.
const timer = setTimeout(() => {
pending.delete(id)
reject(new CodexAppServerTimeoutError(`codex app-server ${method} exceeded ${timeoutMs}ms`))
}, timeoutMs)
pending.set(id, { method, resolve, reject, timer })
try {
sendLine(params === undefined ? { method, id } : { method, id, params })
} catch (error) {
pending.delete(id)
clearTimeout(timer)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
function writeResponse(payload: Record<string, unknown>): void {
if (exited || terminalError || child.stdin.destroyed || !child.stdin.writable) {
return
}
try {
sendLine(payload)
} catch {
// The turn that asked is already gone with the child.
}
}
function close(): Promise<boolean> {
if (exitObserved) {
return Promise.resolve(true)
}
closing = true
return exitProof.run(async () => {
try {
child.stdin.end()
} catch {
// Already destroyed; the reap below still runs.
}
if (!exited) {
await waitForProcessExitUntil(exitPromise, GRACEFUL_EXIT_MS)
if (!exited) {
const treeExited = await terminateProcessTree()
if (!treeExited) {
failPending(new Error('codex app-server process-tree exit was not proven'))
return false
}
await waitForProcessExitUntil(exitPromise, FORCED_EXIT_MS)
}
}
failPending(new Error('codex app-server connection closed'))
return exitObserved
})
}
const connection: CodexAppServerConnection = {
get pid() {
return child.pid
},
get closed() {
return closing || exited || terminalError !== null
},
request,
notify,
respond: (id, result) => writeResponse({ id, result }),
respondWithError: (id, code, message) => writeResponse({ id, error: { code, message } }),
close
}
try {
await initializeCodexAppServerConnection(connection)
} catch (error) {
if ((await close()) !== true) {
throw new CodexAppServerHandshakeExitUnprovenError(connection, error)
}
throw error instanceof CodexAppServerUnsupportedError ||
error instanceof CodexAppServerTimeoutError
? error
: buildExitError(error instanceof Error ? error : new Error(String(error)))
}
return connection
}
@@ -0,0 +1,19 @@
// What a dead `codex app-server` child means to whoever was talking to it. The
// stderr tail is the only evidence: a CLI without the subcommand is a durable
// capability fact, and anything else is this run's crash.
import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal'
import { CodexAppServerUnsupportedError } from './codex-app-server-session'
const EXIT_DETAIL_MAX_CHARS = 400
export function buildCodexAppServerExitError(stderrTail: string, cause?: Error): Error {
const tail = stderrTail.trim().slice(0, EXIT_DETAIL_MAX_CHARS)
if (stderrIndicatesMissingAppServer(stderrTail)) {
return new CodexAppServerUnsupportedError(
`codex CLI does not support the app-server subcommand: ${tail}`
)
}
const detail = cause ? `: ${cause.message}` : tail ? `: ${tail}` : ''
return new Error(`codex app-server connection ended${detail}`)
}
@@ -0,0 +1,26 @@
import type { CodexAppServerConnection } from './codex-app-server-connection-types'
export class CodexAppServerHandshakeExitUnprovenError extends Error {
constructor(
readonly connection: CodexAppServerConnection,
cause: unknown
) {
super('codex app-server handshake failed without process-exit proof', { cause })
this.name = 'CodexAppServerHandshakeExitUnprovenError'
}
}
export function isCodexAppServerHandshakeExitUnprovenError(
error: unknown
): error is CodexAppServerHandshakeExitUnprovenError {
const connection =
error instanceof Error && 'connection' in error
? (error.connection as Partial<CodexAppServerConnection> | null)
: null
return (
error instanceof Error &&
error.name === 'CodexAppServerHandshakeExitUnprovenError' &&
connection !== null &&
typeof connection.close === 'function'
)
}
@@ -0,0 +1,22 @@
import type { CodexAppServerConnection } from './codex-app-server-connection-types'
const HANDSHAKE_TIMEOUT_MS = 15_000
export async function initializeCodexAppServerConnection(
connection: CodexAppServerConnection
): Promise<void> {
await connection.request(
'initialize',
{
clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' },
capabilities: {
experimentalApi: true,
requestAttestation: false,
mcpServerOpenaiFormElicitation: false,
extensions: {}
}
},
{ timeoutMs: HANDSHAKE_TIMEOUT_MS }
)
connection.notify('initialized')
}
+12
View File
@@ -0,0 +1,12 @@
export function isAppServerRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function parseCodexAppServerJsonLine(line: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(line)
return isAppServerRecord(parsed) ? parsed : null
} catch {
return null
}
}
@@ -0,0 +1,78 @@
// Stable ServerNotification method discriminators generated by codex-cli 0.147.0.
export const CODEX_APP_SERVER_NOTIFICATION_METHODS = [
'error',
'thread/started',
'thread/status/changed',
'thread/archived',
'thread/deleted',
'thread/unarchived',
'thread/closed',
'skills/changed',
'thread/name/updated',
'thread/goal/updated',
'thread/goal/cleared',
'thread/environment/connected',
'thread/environment/disconnected',
'thread/settings/updated',
'thread/tokenUsage/updated',
'turn/started',
'hook/started',
'turn/completed',
'hook/completed',
'turn/diff/updated',
'turn/plan/updated',
'item/started',
'item/autoApprovalReview/started',
'item/autoApprovalReview/completed',
'item/completed',
'rawResponseItem/completed',
'rawResponse/completed',
'item/agentMessage/delta',
'item/plan/delta',
'command/exec/outputDelta',
'process/outputDelta',
'process/exited',
'item/commandExecution/outputDelta',
'item/commandExecution/terminalInteraction',
'item/fileChange/outputDelta',
'item/fileChange/patchUpdated',
'serverRequest/resolved',
'item/mcpToolCall/progress',
'mcpServer/oauthLogin/completed',
'mcpServer/startupStatus/updated',
'account/updated',
'account/rateLimits/updated',
'app/list/updated',
'remoteControl/status/changed',
'externalAgentConfig/import/progress',
'externalAgentConfig/import/completed',
'fs/changed',
'item/reasoning/summaryTextDelta',
'item/reasoning/summaryPartAdded',
'item/reasoning/textDelta',
'thread/compacted',
'model/rerouted',
'model/verification',
'turn/moderationMetadata',
'model/safetyBuffering/updated',
'warning',
'guardianWarning',
'deprecationNotice',
'configWarning',
'fuzzyFileSearch/sessionUpdated',
'fuzzyFileSearch/sessionCompleted',
'thread/realtime/started',
'thread/realtime/itemAdded',
'thread/realtime/transcript/delta',
'thread/realtime/transcript/done',
'thread/realtime/outputAudio/delta',
'thread/realtime/sdp',
'thread/realtime/error',
'thread/realtime/closed',
'windows/worldWritableWarning',
'windowsSandbox/setupCompleted',
'account/login/completed'
] as const
export type CodexAppServerNotificationMethod =
(typeof CODEX_APP_SERVER_NOTIFICATION_METHODS)[number]
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import type { CodexAppServerLaunch } from './codex-app-server-connection'
import {
createProviderSpawnSpec,
POSIX_PROVIDER_SUPERVISOR_SCRIPT,
supervisedPosixLaunch
} from './codex-app-server-posix-supervisor'
const launch: CodexAppServerLaunch = {
command: '/opt/codex',
args: ['app-server', '--flag'],
cwd: '/work/repo',
env: { CODEX_HOME: '/tmp/codex' }
}
describe('structured provider supervision', () => {
it('wraps POSIX launches in a detached supervisor and preserves the launch spec', () => {
const childEnv = { PATH: '/bin', CODEX_HOME: '/tmp/codex' }
const spec = supervisedPosixLaunch(launch, childEnv)
expect(spec.command).toBe(process.execPath)
expect(spec.args).toEqual(['-e', POSIX_PROVIDER_SUPERVISOR_SCRIPT])
expect(spec.env.PATH).toBe('/bin')
expect(
JSON.parse(Buffer.from(spec.env.ORCA_PROVIDER_SUPERVISOR_SPEC!, 'base64').toString())
).toEqual(
expect.objectContaining({
command: '/opt/codex',
args: ['app-server', '--flag'],
cwd: '/work/repo'
})
)
expect(
JSON.parse(Buffer.from(spec.env.ORCA_PROVIDER_SUPERVISOR_SPEC!, 'base64').toString())
).not.toHaveProperty('env')
expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain(
'delete childEnv.ORCA_PROVIDER_SUPERVISOR_SPEC'
)
expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('delete childEnv.ELECTRON_RUN_AS_NODE')
expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1')
expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('process.ppid !== originalParent')
expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain(
"process.stdin.once('close', scheduleOwnerShutdown)"
)
expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('detached: true')
expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain("process.kill(-child.pid, 'SIGKILL')")
expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('providerGroupExists()')
expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('finishWithProviderOutcome(code, signal)')
expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).not.toContain('process.ppid === 1')
})
it('uses direct provider spawning on Windows because the job owns the tree', () => {
expect(createProviderSpawnSpec(launch, { PATH: '/bin' }, 'win32')).toEqual({
program: '/opt/codex',
args: ['app-server', '--flag'],
env: { PATH: '/bin' },
cwd: '/work/repo',
detached: false
})
})
})
@@ -0,0 +1,128 @@
import type { CodexAppServerLaunch } from './codex-app-server-connection'
/** Inline supervisor source kept dependency-free for the spawned Node child. */
export const POSIX_PROVIDER_SUPERVISOR_SCRIPT = `
const { spawn } = require('node:child_process')
const spec = JSON.parse(Buffer.from(process.env.ORCA_PROVIDER_SUPERVISOR_SPEC, 'base64').toString())
const childEnv = { ...process.env }
delete childEnv.ORCA_PROVIDER_SUPERVISOR_SPEC
delete childEnv.ELECTRON_RUN_AS_NODE
const child = spawn(spec.command, spec.args, {
cwd: spec.cwd,
env: childEnv,
stdio: ['pipe', 'pipe', 'pipe'],
detached: true
})
const originalParent = process.ppid
let timer
let ownerShutdownTimer
let settling = false
const providerGroupExists = () => {
if (!child.pid) return false
try {
process.kill(-child.pid, 0)
return true
} catch (error) {
return Boolean(error && error.code !== 'ESRCH')
}
}
const reapOwnedProviderGroup = async () => {
if (!child.pid) return false
try { process.kill(-child.pid, 'SIGKILL') } catch (error) {
if (error && error.code !== 'ESRCH') return false
}
const deadline = Date.now() + 1500
while (providerGroupExists()) {
if (Date.now() >= deadline) return false
await new Promise((resolve) => setTimeout(resolve, 25))
}
return true
}
const terminateOwnedGroup = () => {
if (settling) return
settling = true
clearInterval(timer)
void reapOwnedProviderGroup().then((reaped) => process.exit(reaped ? 137 : 1))
}
const scheduleOwnerShutdown = () => {
if (settling || ownerShutdownTimer) return
// A normal close ends the provider's stdin first; allow it to flush and
// exit before forcing the group, while still bounding an orphaned child.
ownerShutdownTimer = setTimeout(terminateOwnedGroup, 1250)
ownerShutdownTimer.unref()
}
process.stdin.once('end', scheduleOwnerShutdown)
process.stdin.once('close', scheduleOwnerShutdown)
process.stdin.pipe(child.stdin)
child.stdout.pipe(process.stdout)
child.stderr.pipe(process.stderr)
for (const stream of [process.stdin, child.stdin, child.stdout, child.stderr]) stream.on('error', () => {})
const finishWithProviderOutcome = (code, signal) => {
if (!signal) return process.exit(code ?? 1)
process.kill(process.pid, signal)
}
const reapProviderExit = async (code, signal) => {
if (settling) return
settling = true
clearInterval(timer)
if (ownerShutdownTimer) clearTimeout(ownerShutdownTimer)
if (!(await reapOwnedProviderGroup())) return process.exit(1)
finishWithProviderOutcome(code, signal)
}
timer = setInterval(() => {
// A detached supervisor is reparented when its owner exits. The new parent
// may be PID 1 or a platform subreaper, so any parent change is proof that
// this process group no longer has a live Orca owner.
if (process.ppid !== originalParent) {
terminateOwnedGroup()
}
}, 100)
timer.unref()
child.once('error', () => {
clearInterval(timer)
process.exit(127)
})
child.once('exit', (code, signal) => {
void reapProviderExit(code, signal)
})
`
export function supervisedPosixLaunch(
launch: CodexAppServerLaunch,
childEnv: NodeJS.ProcessEnv,
cwd = launch.cwd ?? process.cwd()
): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
const supervisorSpec = Buffer.from(
JSON.stringify({
command: launch.command,
args: launch.args,
cwd
})
).toString('base64')
return {
command: process.execPath,
args: ['-e', POSIX_PROVIDER_SUPERVISOR_SCRIPT],
// Electron's executable needs Node mode for the inline supervisor. The
// marker is removed above so providers never inherit Electron semantics.
env: {
...childEnv,
ELECTRON_RUN_AS_NODE: '1',
ORCA_PROVIDER_SUPERVISOR_SPEC: supervisorSpec
}
}
}
export function createProviderSpawnSpec(
launch: CodexAppServerLaunch,
childEnv: NodeJS.ProcessEnv,
platform: NodeJS.Platform
): { program: string; args: string[]; env: NodeJS.ProcessEnv; cwd: string; detached: boolean } {
const supervised = platform === 'win32' ? null : supervisedPosixLaunch(launch, childEnv)
return {
program: supervised?.command ?? launch.command,
args: supervised?.args ?? launch.args,
env: supervised?.env ?? childEnv,
cwd: launch.cwd ?? process.cwd(),
detached: platform !== 'win32'
}
}
@@ -0,0 +1,148 @@
import type { ChildProcess } from 'node:child_process'
import { describe, expect, it, vi } from 'vitest'
import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown'
function child() {
return {
pid: 1234,
kill: vi.fn(() => true) as ChildProcess['kill']
}
}
describe('terminateCodexAppServerProcessTree', () => {
it('waits for the Windows tree kill before releasing the wrapper', async () => {
const target = child()
const release = Promise.withResolvers<void>()
const terminateWindowsTree = vi.fn(() => release.promise)
const teardown = terminateCodexAppServerProcessTree(target, undefined, {
platform: 'win32',
terminateWindowsTree
})
expect(target.kill).not.toHaveBeenCalled()
release.resolve()
await teardown
expect(terminateWindowsTree).toHaveBeenCalledWith(1234)
expect(target.kill).toHaveBeenCalledWith('SIGKILL')
})
it('kills exact Linux spawn-token PIDs before the recorded wrapper', async () => {
const target = child()
const findSpawnTokenProcesses = vi
.fn<() => Promise<number[] | null>>()
.mockResolvedValueOnce([1234, 2345, 3456])
.mockResolvedValueOnce([1234])
.mockResolvedValueOnce([1234])
const signalPid = vi.fn()
await expect(
terminateCodexAppServerProcessTree(target, 'spawn-1', {
platform: 'linux',
findSpawnTokenProcesses,
signalPid,
isPidPresent: () => false,
wait: async () => undefined
})
).resolves.toBe(true)
expect(signalPid.mock.calls).toEqual([
[2345, 'SIGKILL'],
[3456, 'SIGKILL']
])
expect(target.kill).toHaveBeenCalledTimes(1)
expect(target.kill).toHaveBeenCalledWith('SIGKILL')
})
it('keeps the wrapper reachable when Linux cannot prove descendant exit', async () => {
const target = child()
await expect(
terminateCodexAppServerProcessTree(target, 'spawn-1', {
platform: 'linux',
findSpawnTokenProcesses: async () => null
})
).resolves.toBe(false)
expect(target.kill).not.toHaveBeenCalled()
})
it('waits for an owned POSIX snapshot before killing the wrapper', async () => {
const target = child()
const snapshot = { rootPgid: 1234, descendants: [], capturedAtMs: 1 }
const release = Promise.withResolvers<boolean>()
const teardown = terminateCodexAppServerProcessTree(target, undefined, {
platform: 'darwin',
captureDescendants: async () => snapshot,
terminateDescendants: () => release.promise
})
await vi.waitFor(() => expect(target.kill).toHaveBeenCalledWith('SIGSTOP'))
expect(target.kill).not.toHaveBeenCalledWith('SIGKILL')
release.resolve(true)
await teardown
expect(target.kill).toHaveBeenLastCalledWith('SIGKILL')
})
it('signals a proven dedicated POSIX process group without scanning descendants', async () => {
const target = child()
const captureDescendants = vi.fn()
const signalProcessGroup = vi.fn()
await expect(
terminateCodexAppServerProcessTree(target, undefined, {
platform: 'darwin',
dedicatedProcessGroup: true,
captureDescendants,
signalProcessGroup
})
).resolves.toBe(true)
expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL')
expect(captureDescendants).not.toHaveBeenCalled()
expect(target.kill).not.toHaveBeenCalled()
})
it('keeps the dedicated-group wrapper reachable when signalling is unproven', async () => {
const target = child()
await expect(
terminateCodexAppServerProcessTree(target, undefined, {
platform: 'linux',
dedicatedProcessGroup: true,
signalProcessGroup: () => {
throw Object.assign(new Error('denied'), { code: 'EPERM' })
}
})
).resolves.toBe(false)
expect(target.kill).not.toHaveBeenCalled()
})
it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => {
const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true))
const targets = killMocks.map((kill, index) => ({
pid: 10_000 + index,
kill: kill as ChildProcess['kill']
}))
const captureDescendants = vi.fn()
const signalProcessGroup = vi.fn()
const results = await Promise.all(
targets.map((target) =>
terminateCodexAppServerProcessTree(target, undefined, {
platform: 'linux',
dedicatedProcessGroup: true,
captureDescendants,
signalProcessGroup
})
)
)
expect(results).toEqual(Array.from({ length: targets.length }, () => true))
expect(signalProcessGroup.mock.calls).toEqual(targets.map((target) => [target.pid, 'SIGKILL']))
expect(captureDescendants).not.toHaveBeenCalled()
expect(killMocks.every((kill) => kill.mock.calls.length === 0)).toBe(true)
})
})
@@ -0,0 +1,183 @@
import type { ChildProcessHandle } from '../../shared/child-process/run-process'
import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination'
import { terminateDescendantSnapshotAndWait } from '../pty-descendant-exit-verification'
import { terminateWindowsProcessTree } from '../windows-process-tree-kill'
import { findAgentSessionSpawnTokenProcesses } from '../runtime/agent-session-spawn-token-process-scan'
const TOKEN_PROCESS_EXIT_TIMEOUT_MS = 3_500
const TOKEN_PROCESS_POLL_MS = 25
const activeTeardowns = new WeakMap<object, Promise<boolean>>()
type TeardownChild = Pick<ChildProcessHandle, 'pid' | 'kill'>
export type CodexAppServerProcessTeardownDeps = {
platform?: NodeJS.Platform
dedicatedProcessGroup?: boolean
/** Diagnostic/recovery injection only; never used by the primary teardown. */
findSpawnTokenProcesses?: (spawnToken: string) => Promise<number[] | null>
captureDescendants?: (rootPid: number) => Promise<DescendantSnapshot | null>
terminateDescendants?: (snapshot: DescendantSnapshot) => Promise<boolean>
terminateWindowsTree?: (rootPid: number) => Promise<void>
signalPid?: (pid: number, signal: NodeJS.Signals) => void
signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void
isPidPresent?: (pid: number) => boolean
wait?: (ms: number) => Promise<void>
now?: () => number
}
function terminateDedicatedPosixGroup(
rootPid: number,
deps: CodexAppServerProcessTeardownDeps
): boolean {
const signalGroup =
deps.signalProcessGroup ??
((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal))
try {
signalGroup(rootPid, 'SIGKILL')
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ESRCH'
}
}
function sendSignal(pid: number, signal: NodeJS.Signals): void {
try {
process.kill(pid, signal)
} catch {
// An already-gone exact PID is the desired outcome.
}
}
function isPidPresent(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
async function diagnosticTokenFallback(
rootPid: number,
spawnToken: string,
deps: CodexAppServerProcessTeardownDeps
): Promise<boolean> {
const find = deps.findSpawnTokenProcesses ?? findAgentSessionSpawnTokenProcesses
const signal = deps.signalPid ?? sendSignal
const pidPresent = deps.isPidPresent ?? isPidPresent
const delay =
deps.wait ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
const now = deps.now ?? Date.now
const deadline = now() + TOKEN_PROCESS_EXIT_TIMEOUT_MS
const signalled = new Set<number>()
while (now() < deadline) {
const pids = await find(spawnToken).catch(() => null)
if (pids === null) {
return false
}
for (const pid of pids.filter((candidate) => candidate !== rootPid)) {
signalled.add(pid)
signal(pid, 'SIGKILL')
}
if ([...signalled].every((pid) => !pidPresent(pid))) {
return true
}
await delay(TOKEN_PROCESS_POLL_MS)
}
return false
}
async function terminatePosixTree(
child: TeardownChild,
rootPid: number,
_spawnToken: string | undefined,
deps: CodexAppServerProcessTeardownDeps
): Promise<boolean> {
// Kept only for explicit recovery callers/tests. Production always follows
// the dedicated process-group path below; token enumeration is evidence,
// never the owner of orphan-reaping decisions.
if (_spawnToken && deps.findSpawnTokenProcesses) {
const reaped = await diagnosticTokenFallback(rootPid, _spawnToken, deps)
if (reaped) {
child.kill('SIGKILL')
return true
}
return false
}
child.kill('SIGSTOP')
const capture = deps.captureDescendants ?? captureDescendantSnapshot
const snapshot = await capture(rootPid).catch(() => null)
if (!snapshot) {
child.kill('SIGKILL')
return true
}
const terminate = deps.terminateDescendants ?? terminateDescendantSnapshotAndWait
const descendantsExited = await terminate(snapshot)
// A detached POSIX launch is the leader of its own process group. Group
// signalling reaches grandchildren even after they daemonise/reparent,
// while the stopped root and captured pgid make the ownership proof exact.
// The identity-gated descendant sweep remains the fallback for older hosts
// or launches that could not establish a dedicated group.
if (descendantsExited && snapshot.rootPgid === rootPid) {
const signalGroup =
deps.signalProcessGroup ??
((pgid: number, signal: NodeJS.Signals) => {
try {
process.kill(-pgid, signal)
} catch {
// Group already exited.
}
})
signalGroup(snapshot.rootPgid, 'SIGKILL')
}
if (!descendantsExited) {
child.kill('SIGCONT')
return false
}
child.kill('SIGKILL')
return true
}
/** Stops every process owned by one app-server launch before releasing its wrapper. */
async function terminateOnce(
child: TeardownChild,
spawnToken: string | undefined,
deps: CodexAppServerProcessTeardownDeps
): Promise<boolean> {
const rootPid = child.pid
if (!rootPid) {
child.kill('SIGKILL')
return false
}
if ((deps.platform ?? process.platform) === 'win32') {
const terminate = deps.terminateWindowsTree ?? terminateWindowsProcessTree
await terminate(rootPid)
// taskkill owns the tree; this preserves the prior direct-child fallback when it fails.
child.kill('SIGKILL')
return true
}
if (deps.dedicatedProcessGroup) {
return terminateDedicatedPosixGroup(rootPid, deps)
}
return terminatePosixTree(child, rootPid, spawnToken, deps)
}
export function terminateCodexAppServerProcessTree(
child: TeardownChild,
spawnToken?: string,
deps: CodexAppServerProcessTeardownDeps = {}
): Promise<boolean> {
const key = child as object
const active = activeTeardowns.get(key)
if (active) {
return active
}
const attempt = terminateOnce(child, spawnToken, deps).catch(() => false)
activeTeardowns.set(key, attempt)
void attempt.then(() => {
if (activeTeardowns.get(key) === attempt) {
activeTeardowns.delete(key)
}
})
return attempt
}
@@ -0,0 +1,15 @@
/** Codex answered the call and refused it, rather than timing out or exiting. */
export class CodexAppServerRequestError extends Error {
constructor(
readonly method: string,
readonly code: number | null,
message: string
) {
super(message)
this.name = 'CodexAppServerRequestError'
}
}
export function isCodexAppServerRequestError(error: unknown): error is CodexAppServerRequestError {
return error instanceof Error && error.name === 'CodexAppServerRequestError'
}
+27 -3
View File
@@ -100,11 +100,35 @@ export function killCodexAppServerProcessTree(
// Fall through to the direct-child best effort when taskkill cannot start.
}
}
if (child.pid) {
try {
// npm/package-manager launchers insert a shim child on POSIX. Reap its
// direct descendants before signalling the wrapper itself.
const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], {
stdio: 'ignore'
})
// A missing pkill surfaces as an async 'error' event, and an unhandled one
// takes down the main process.
descendants.on('error', () => undefined)
descendants.unref()
} catch {
// The direct kill below remains the fallback when pkill is unavailable.
}
}
child.kill('SIGKILL')
}
function isMethodNotFoundError(error: { code?: number; message?: string }): boolean {
return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '')
/** Codex answering "no such method" is the only response that proves the RPC
* surface is absent rather than temporarily failing. */
export function isCodexMethodNotFoundError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false
}
const { code, message } = error as { code?: unknown; message?: unknown }
return (
code === JSON_RPC_METHOD_NOT_FOUND ||
/method not found/i.test(typeof message === 'string' ? message : '')
)
}
/**
@@ -265,7 +289,7 @@ export async function runCodexAppServerSession<T>(
}
})
if (response.error) {
if (isMethodNotFoundError(response.error)) {
if (isCodexMethodNotFoundError(response.error)) {
throw new CodexAppServerUnsupportedError(
`codex app-server does not support ${method}: ${response.error.message ?? 'method not found'}`
)
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'vitest'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
import {
openCodexAppServerConnection,
type CodexAppServerConnection
} from './codex-app-server-connection'
const ITERATIONS = 40
const FORCE_KILL_APP_SERVER = String.raw`
const { spawn } = require('node:child_process')
const readline = require('node:readline')
const descendant = spawn(process.execPath, ['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 60000)"], {
stdio: 'ignore'
})
const exitMode = process.env.ORCA_TEST_PROVIDER_EXIT_MODE
const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n')
readline.createInterface({ input: process.stdin }).on('line', (line) => {
const message = JSON.parse(line)
if (message.method === 'initialize') return send({ id: message.id, result: {} })
if (message.method === 'initialized') {
send({ method: 'test/descendant', params: { pid: descendant.pid } })
if (exitMode === 'normal' || exitMode === 'stdin-race') {
setTimeout(() => process.exit(0), 25)
} else if (exitMode === 'signal') {
setTimeout(() => process.kill(process.pid, 'SIGTERM'), 25)
}
}
})
setInterval(() => {}, 60000)
`
function processExists(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
type RunningServer = {
connection: CodexAppServerConnection
descendantPid: number
exit: Promise<Error>
supervisorPid: number
}
async function openServer(
iteration: number,
exitMode?: 'normal' | 'signal' | 'stdin-race'
): Promise<RunningServer> {
const descendant = Promise.withResolvers<number>()
const exit = Promise.withResolvers<Error>()
const connection = await openCodexAppServerConnection(
{
command: process.execPath,
args: ['-e', FORCE_KILL_APP_SERVER],
env: {
[CODEX_SPAWN_TOKEN_ENV]: `teardown-test-${process.pid}-${iteration}`,
...(exitMode ? { ORCA_TEST_PROVIDER_EXIT_MODE: exitMode } : {})
}
},
{
onExit: (error) => exit.resolve(error),
onNotification: (method, params) => {
if (method === 'test/descendant') {
descendant.resolve((params as { pid: number }).pid)
}
}
}
)
return {
connection,
descendantPid: await descendant.promise,
exit: exit.promise,
supervisorPid: connection.pid ?? 0
}
}
async function cleanupServer(server: RunningServer): Promise<void> {
await server.connection.close().catch(() => false)
for (const pid of [server.descendantPid, server.supervisorPid]) {
if (pid > 0 && processExists(pid)) {
process.kill(pid, 'SIGKILL')
}
}
}
describe.runIf(process.platform !== 'win32')('Codex app-server process teardown', () => {
it('reaps the forced-close descendant in 40 consecutive launches', async () => {
const running: RunningServer[] = []
try {
for (let iteration = 0; iteration < ITERATIONS; iteration += 1) {
running.push(await openServer(iteration))
}
expect(running.every(({ descendantPid }) => processExists(descendantPid))).toBe(true)
const closed = await Promise.all(running.map(({ connection }) => connection.close()))
expect(closed).toEqual(Array.from({ length: ITERATIONS }, () => true))
expect(running.filter(({ descendantPid }) => processExists(descendantPid))).toEqual([])
} finally {
for (const server of running) {
await cleanupServer(server)
}
}
}, 30_000)
it.each(['normal', 'signal'] as const)(
'reaps provider descendants before relaying a %s root exit in 40 consecutive launches',
async (exitMode) => {
for (let iteration = 0; iteration < ITERATIONS; iteration += 1) {
const server = await openServer(iteration, exitMode)
try {
await server.exit
expect(processExists(server.supervisorPid)).toBe(false)
expect(processExists(server.descendantPid)).toBe(false)
await expect(server.connection.close()).resolves.toBe(true)
} finally {
await cleanupServer(server)
}
}
},
60_000
)
it('does not settle a stdin-close/root-exit race before the descendant is reaped', async () => {
for (let iteration = 0; iteration < ITERATIONS; iteration += 1) {
const server = await openServer(iteration, 'stdin-race')
try {
await expect(server.connection.close()).resolves.toBe(true)
expect(processExists(server.supervisorPid)).toBe(false)
expect(processExists(server.descendantPid)).toBe(false)
} finally {
await cleanupServer(server)
}
}
}, 60_000)
})
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import {
isCodexResumeProcessCommandLine,
readCodexResumeProcessIdentity
} from './codex-resume-process-proof'
const THREAD_ID = '01a03a0d-acbd-74e0-86f2-2615984d3b37'
describe('Codex resume process proof', () => {
it('binds the exact resumed thread while ignoring a generic Codex sibling', async () => {
await expect(
readCodexResumeProcessIdentity({
hostId: 'local',
rootPid: 100,
spawnToken: 'spawn-1',
threadId: THREAD_ID,
platform: 'darwin',
readPosixRows: async () => [
{ pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' },
{
pid: 101,
ppid: 100,
stat: 'S+',
command: `node /opt/codex/bin/codex resume ${THREAD_ID}`
},
{
pid: 103,
ppid: 101,
stat: 'S+',
command: `/opt/codex/vendor/codex resume ${THREAD_ID}`
},
{
pid: 102,
ppid: 100,
stat: 'S+',
command: `node /opt/codex/bin/codex --profile work resume ${THREAD_ID}`
}
],
excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: null }],
readStartTime: async () => 1_700_000_000_000,
timeoutMs: 0
})
).resolves.toMatchObject({ pid: 102 })
})
it('rejects the previous owner process tree when no new resume child appears', async () => {
await expect(
readCodexResumeProcessIdentity({
hostId: 'local',
rootPid: 100,
spawnToken: 'spawn-1',
threadId: THREAD_ID,
platform: 'darwin',
readPosixRows: async () => [
{ pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' },
{ pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex' },
{
pid: 102,
ppid: 101,
stat: 'S+',
command: `/opt/codex/vendor/codex resume ${THREAD_ID}`
}
],
excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: null }],
timeoutMs: 0
})
).rejects.toThrow('one exact Codex child process')
})
it.each([
['another thread', `node /opt/codex/bin/codex resume thread-other`],
['a generic Codex child', 'node /opt/codex/bin/codex --profile work']
])('rejects %s', async (_case, command) => {
await expect(
readCodexResumeProcessIdentity({
hostId: 'local',
rootPid: 100,
spawnToken: 'spawn-1',
threadId: THREAD_ID,
platform: 'darwin',
readPosixRows: async () => [
{ pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' },
{ pid: 101, ppid: 100, stat: 'S+', command }
],
timeoutMs: 0
})
).rejects.toThrow('one exact Codex child process')
})
it('accepts an exact resume process after the previous PID was recycled', async () => {
await expect(
readCodexResumeProcessIdentity({
hostId: 'local',
rootPid: 100,
spawnToken: 'spawn-1',
threadId: THREAD_ID,
platform: 'darwin',
readPosixRows: async () => [
{ pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' },
{
pid: 101,
ppid: 100,
stat: 'S+',
command: `node /opt/codex/bin/codex resume ${THREAD_ID}`
}
],
excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: 10 }],
readStartTime: async () => 5_000,
timeoutMs: 0
})
).resolves.toMatchObject({ pid: 101 })
})
it('parses a quoted Windows executable path with the exact resume argv', () => {
expect(
isCodexResumeProcessCommandLine(
`"C:\\Program Files\\Codex\\codex.exe" --profile work resume ${THREAD_ID}`,
THREAD_ID,
'win32'
)
).toBe(true)
})
})
@@ -0,0 +1,101 @@
import { tokenizeCustomCommandTemplate } from '../../shared/commit-message-prompt'
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity'
const PROCESS_COMMAND_LINE_MAX_CHARS = 16 * 1024
function tokenizeWindowsProcessCommandLine(commandLine: string): string[] {
const tokens: string[] = []
let current = ''
let quoted = false
let started = false
let index = 0
while (index < commandLine.length) {
const char = commandLine[index]!
if (!started && /\s/.test(char)) {
index += 1
continue
}
started = true
if (char === '\\') {
let backslashes = 0
while (commandLine[index] === '\\') {
backslashes += 1
index += 1
}
if (commandLine[index] === '"') {
current += '\\'.repeat(Math.floor(backslashes / 2))
if (backslashes % 2 === 1) {
current += '"'
index += 1
}
} else {
current += '\\'.repeat(backslashes)
}
continue
}
if (char === '"') {
if (quoted && commandLine[index + 1] === '"') {
current += '"'
index += 2
continue
}
quoted = !quoted
index += 1
continue
}
if (!quoted && /\s/.test(char)) {
tokens.push(current)
current = ''
started = false
index += 1
continue
}
current += char
index += 1
}
if (started) {
tokens.push(current)
}
return tokens
}
function tokenizeProcessCommandLine(
commandLine: string,
platform: NodeJS.Platform
): string[] | null {
if (!commandLine || commandLine.length > PROCESS_COMMAND_LINE_MAX_CHARS) {
return null
}
if (platform === 'win32') {
return tokenizeWindowsProcessCommandLine(commandLine)
}
const parsed = tokenizeCustomCommandTemplate(commandLine)
return parsed.ok ? parsed.tokens : null
}
export function isCodexResumeProcessCommandLine(
commandLine: string,
threadId: string,
platform: NodeJS.Platform = process.platform
): boolean {
const tokens = tokenizeProcessCommandLine(commandLine, platform)
if (!tokens || !threadId) {
return false
}
return tokens.some((token, index) => token === 'resume' && tokens[index + 1] === threadId)
}
type StructuredTuiIdentityInput = Parameters<typeof readStructuredTuiProcessIdentity>[0]
export function readCodexResumeProcessIdentity(
input: Omit<StructuredTuiIdentityInput, 'agent' | 'processCommandMatches'> & { threadId: string }
): Promise<AgentSessionProcessIdentity> {
const { threadId, ...identityInput } = input
const platform = input.platform ?? process.platform
return readStructuredTuiProcessIdentity({
...identityInput,
agent: 'codex',
processCommandMatches: (command) => isCodexResumeProcessCommandLine(command, threadId, platform)
})
}
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from 'vitest'
import {
CODEX_ATTESTATION_METHOD,
CODEX_AUTH_TOKEN_REFRESH_METHOD,
CODEX_BLOCKING_SERVER_REQUEST_METHODS,
CODEX_DYNAMIC_TOOL_CALL_METHOD,
CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD,
CODEX_LEGACY_EXEC_APPROVAL_METHOD,
CODEX_MCP_ELICITATION_METHOD,
CODEX_PERMISSIONS_APPROVAL_METHOD,
disposeCodexServerRequest
} from './codex-server-request-disposition'
import {
CODEX_COMMAND_APPROVAL_METHOD,
CODEX_FILE_CHANGE_APPROVAL_METHOD,
CODEX_USER_INPUT_METHOD,
CodexPromptRegistry
} from './codex-structured-prompt-replies'
function harness() {
return {
registry: new CodexPromptRegistry(),
connection: { respond: vi.fn(), respondWithError: vi.fn() }
}
}
const promptParams = { threadId: 'thread-1', turnId: 'turn-1', itemId: 'item-1' }
describe('Codex blocking server request dispositions', () => {
it.each([
CODEX_COMMAND_APPROVAL_METHOD,
CODEX_FILE_CHANGE_APPROVAL_METHOD,
CODEX_USER_INPUT_METHOD
])('routes %s to the durable prompt registry', (method) => {
const { registry, connection } = harness()
const result = disposeCodexServerRequest(registry, connection, {
id: 1,
method,
params:
method === CODEX_USER_INPUT_METHOD
? { ...promptParams, questions: [{ id: 'q1' }] }
: promptParams
})
expect(result.kind).toBe('prompt')
expect(connection.respond).not.toHaveBeenCalled()
expect(connection.respondWithError).not.toHaveBeenCalled()
})
it.each([
[CODEX_MCP_ELICITATION_METHOD, { action: 'decline', content: null, _meta: null }],
[CODEX_PERMISSIONS_APPROVAL_METHOD, { permissions: {}, scope: 'turn', strictAutoReview: true }],
[CODEX_DYNAMIC_TOOL_CALL_METHOD, { contentItems: [], success: false }],
[CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, { decision: 'abort' }],
[CODEX_LEGACY_EXEC_APPROVAL_METHOD, { decision: 'abort' }]
])('safely responds to %s', (method, response) => {
const { registry, connection } = harness()
expect(disposeCodexServerRequest(registry, connection, { id: 2, method, params: {} })).toEqual({
kind: 'responded',
method
})
expect(connection.respond).toHaveBeenCalledWith(2, response)
})
it.each([
[CODEX_AUTH_TOKEN_REFRESH_METHOD, 'cannot refresh app-server auth tokens'],
[CODEX_ATTESTATION_METHOD, 'did not negotiate attestation']
])('explicitly refuses %s', (method, message) => {
const { registry, connection } = harness()
disposeCodexServerRequest(registry, connection, { id: 3, method, params: {} })
expect(connection.respondWithError).toHaveBeenCalledWith(
3,
-32001,
expect.stringContaining(message)
)
})
it('cancels a malformed interactive request instead of using method-not-found', () => {
const { registry, connection } = harness()
disposeCodexServerRequest(registry, connection, {
id: 4,
method: CODEX_COMMAND_APPROVAL_METHOD,
params: {}
})
expect(connection.respond).toHaveBeenCalledWith(4, { decision: 'cancel' })
})
it('enumerates every server request in the negotiated stable schema', () => {
expect(new Set(CODEX_BLOCKING_SERVER_REQUEST_METHODS)).toEqual(
new Set([
CODEX_COMMAND_APPROVAL_METHOD,
CODEX_FILE_CHANGE_APPROVAL_METHOD,
CODEX_USER_INPUT_METHOD,
CODEX_MCP_ELICITATION_METHOD,
CODEX_PERMISSIONS_APPROVAL_METHOD,
CODEX_DYNAMIC_TOOL_CALL_METHOD,
CODEX_AUTH_TOKEN_REFRESH_METHOD,
CODEX_ATTESTATION_METHOD,
CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD,
CODEX_LEGACY_EXEC_APPROVAL_METHOD
])
)
})
it('bounds the future-method fallback to one explicit rejection', () => {
const { registry, connection } = harness()
disposeCodexServerRequest(registry, connection, {
id: 5,
method: 'future/blockingRequest',
params: { opaque: true }
})
expect(connection.respond).not.toHaveBeenCalled()
expect(connection.respondWithError).toHaveBeenCalledOnce()
expect(connection.respondWithError).toHaveBeenCalledWith(
5,
-32000,
'Orca rejected unrecognized blocking request future/blockingRequest'
)
})
})
@@ -0,0 +1,86 @@
import type {
CodexAppServerConnection,
CodexAppServerServerRequest
} from './codex-app-server-connection'
import {
CODEX_COMMAND_APPROVAL_METHOD,
CODEX_FILE_CHANGE_APPROVAL_METHOD,
CODEX_USER_INPUT_METHOD,
type CodexPromptRegistry,
type CodexPendingPrompt
} from './codex-structured-prompt-replies'
export const CODEX_MCP_ELICITATION_METHOD = 'mcpServer/elicitation/request'
export const CODEX_PERMISSIONS_APPROVAL_METHOD = 'item/permissions/requestApproval'
export const CODEX_DYNAMIC_TOOL_CALL_METHOD = 'item/tool/call'
export const CODEX_AUTH_TOKEN_REFRESH_METHOD = 'account/chatgptAuthTokens/refresh'
export const CODEX_ATTESTATION_METHOD = 'attestation/generate'
export const CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD = 'applyPatchApproval'
export const CODEX_LEGACY_EXEC_APPROVAL_METHOD = 'execCommandApproval'
export const CODEX_BLOCKING_SERVER_REQUEST_METHODS = [
CODEX_COMMAND_APPROVAL_METHOD,
CODEX_FILE_CHANGE_APPROVAL_METHOD,
CODEX_USER_INPUT_METHOD,
CODEX_MCP_ELICITATION_METHOD,
CODEX_PERMISSIONS_APPROVAL_METHOD,
CODEX_DYNAMIC_TOOL_CALL_METHOD,
CODEX_AUTH_TOKEN_REFRESH_METHOD,
CODEX_ATTESTATION_METHOD,
CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD,
CODEX_LEGACY_EXEC_APPROVAL_METHOD
] as const
export type CodexServerRequestDisposition =
| { kind: 'prompt'; prompt: CodexPendingPrompt }
| { kind: 'responded'; method: string }
type ResponseConnection = Pick<CodexAppServerConnection, 'respond' | 'respondWithError'>
/** Every app-server request either becomes a durable prompt or receives a safe reply. */
export function disposeCodexServerRequest(
registry: CodexPromptRegistry,
connection: ResponseConnection,
request: CodexAppServerServerRequest
): CodexServerRequestDisposition {
const prompt = registry.register(request)
if (prompt) {
return { kind: 'prompt', prompt }
}
switch (request.method) {
case CODEX_COMMAND_APPROVAL_METHOD:
case CODEX_FILE_CHANGE_APPROVAL_METHOD:
connection.respond(request.id, { decision: 'cancel' })
break
case CODEX_USER_INPUT_METHOD:
connection.respond(request.id, { answers: {} })
break
case CODEX_MCP_ELICITATION_METHOD:
connection.respond(request.id, { action: 'decline', content: null, _meta: null })
break
case CODEX_PERMISSIONS_APPROVAL_METHOD:
connection.respond(request.id, { permissions: {}, scope: 'turn', strictAutoReview: true })
break
case CODEX_DYNAMIC_TOOL_CALL_METHOD:
connection.respond(request.id, { contentItems: [], success: false })
break
case CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD:
case CODEX_LEGACY_EXEC_APPROVAL_METHOD:
connection.respond(request.id, { decision: 'abort' })
break
case CODEX_AUTH_TOKEN_REFRESH_METHOD:
connection.respondWithError(request.id, -32001, 'Orca cannot refresh app-server auth tokens')
break
case CODEX_ATTESTATION_METHOD:
connection.respondWithError(request.id, -32001, 'Orca did not negotiate attestation')
break
default:
connection.respondWithError(
request.id,
-32000,
`Orca rejected unrecognized blocking request ${request.method}`
)
}
return { kind: 'responded', method: request.method }
}
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { CodexAppServerConnection } from './codex-app-server-connection-types'
import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'workspace-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
}
describe('Codex failed-acquisition exit proof', () => {
it('retains a connection whose handshake cleanup could not prove exit', async () => {
const close = vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
const connection: CodexAppServerConnection = {
pid: 4321,
closed: true,
request: async () => ({}),
notify: () => undefined,
respond: () => undefined,
respondWithError: () => undefined,
close
}
const handshakeError = Object.assign(new Error('initialize failed'), {
name: 'CodexAppServerHandshakeExitUnprovenError',
connection
})
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: 'thread-1'
}),
openConnection: async () => {
throw handshakeError
},
readProcessStartTime: async () => 1_700_000_000_000
})
await expect(
adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' })
).rejects.toThrow('agent_session_acquisition_exit_unproven')
await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true)
expect(close).toHaveBeenCalledTimes(2)
})
it('retains an uncommitted child until a later close proves exit', async () => {
const close = vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValue(true)
const connection: CodexAppServerConnection = {
pid: 4321,
closed: false,
request: async (method) =>
method === 'thread/resume'
? { thread: { id: 'thread-1', path: '/rollouts/thread-1.jsonl' } }
: {},
notify: () => undefined,
respond: () => undefined,
respondWithError: () => undefined,
close
}
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: 'thread-1'
}),
openConnection: async () => connection,
readProcessStartTime: async () => null
})
await expect(
adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' })
).rejects.toThrow('agent_session_acquisition_exit_unproven')
await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true)
expect(close).toHaveBeenCalledTimes(2)
})
it('keeps closeAll blocked by an unproven canceled acquisition', async () => {
const processStart = Promise.withResolvers<number | null>()
const readStarted = Promise.withResolvers<void>()
const close = vi.fn<() => Promise<boolean>>().mockResolvedValue(false)
const connection: CodexAppServerConnection = {
pid: 4321,
closed: false,
request: async () => ({ thread: { id: 'thread-1' } }),
notify: () => undefined,
respond: () => undefined,
respondWithError: () => undefined,
close
}
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: 'thread-1'
}),
openConnection: async () => connection,
readProcessStartTime: () => {
readStarted.resolve()
return processStart.promise
}
})
const acquiring = adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' })
await readStarted.promise
await expect(adapter.closeAll()).rejects.toThrow(
'codex structured session shutdown could not prove every child stopped'
)
processStart.resolve(null)
await expect(acquiring).rejects.toThrow('agent_session_acquisition_exit_unproven')
expect(close).toHaveBeenCalledTimes(4)
})
it('retains a child that opens after closeAll starts when exit remains unproven', async () => {
const openStarted = Promise.withResolvers<void>()
const releaseOpen = Promise.withResolvers<void>()
const close = vi.fn<() => Promise<boolean>>().mockResolvedValue(false)
const connection: CodexAppServerConnection = {
pid: 4321,
closed: false,
request: async () => ({ thread: { id: 'thread-1' } }),
notify: () => undefined,
respond: () => undefined,
respondWithError: () => undefined,
close
}
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: 'thread-1'
}),
openConnection: async () => {
openStarted.resolve()
await releaseOpen.promise
return connection
},
readProcessStartTime: async () => 1_700_000_000_000
})
const acquiring = adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' })
await openStarted.promise
const closing = adapter.closeAll()
releaseOpen.resolve()
await expect(closing).rejects.toThrow(
'codex structured session shutdown could not prove every child stopped'
)
await expect(acquiring).rejects.toThrow('agent_session_acquisition_exit_unproven')
expect(close).toHaveBeenCalledTimes(4)
})
})
@@ -0,0 +1,53 @@
import { AgentSessionAcquisitionExitUnprovenError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { isCodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof'
import {
cancelCodexAcquisitionAttempt,
type CodexAcquisitionAttempt,
type CodexAcquisitionRegistry
} from './codex-structured-session-state'
export async function stopSupersededCodexAcquisition(input: {
sessionId: string
registry: CodexAcquisitionRegistry
replacement: CodexAcquisitionAttempt
previous: CodexAcquisitionAttempt | undefined
}): Promise<void> {
try {
if (!(await cancelCodexAcquisitionAttempt(input.previous))) {
throw new AgentSessionAcquisitionExitUnprovenError(
new Error(`codex acquisition for session ${input.sessionId} could not be stopped`)
)
}
} catch (error) {
if (input.previous) {
input.registry.restoreIfCurrent(input.sessionId, input.replacement, input.previous)
}
throw error
}
}
export async function closeFailedCodexAcquisition(input: {
sessionId: string
registry: CodexAcquisitionRegistry
attempt: CodexAcquisitionAttempt
cause: unknown
dispose: () => void
}): Promise<never> {
if (isCodexAppServerHandshakeExitUnprovenError(input.cause)) {
input.attempt.window.connection = input.cause.connection
}
input.dispose()
try {
if (!(await input.registry.closeFailedAttempt(input.sessionId, input.attempt))) {
throw new AgentSessionAcquisitionExitUnprovenError(input.cause)
}
} catch (cleanupError) {
if (cleanupError instanceof AgentSessionAcquisitionExitUnprovenError) {
throw cleanupError
}
throw new AgentSessionAcquisitionExitUnprovenError(
new AggregateError([input.cause, cleanupError], 'codex acquisition cleanup failed')
)
}
throw input.cause
}
@@ -0,0 +1,33 @@
// The gap between spawning `codex app-server` and publishing the session it
// belongs to. Codex talks during that gap — the handshake, an early
// notification, even an approval request — and those events belong to the
// session that is still being acquired, so they wait here instead of arriving
// before anything can route them. The gap is bounded by the thread-open request
// timeout; a failed acquisition discards the buffer along with the child.
import type { CodexAppServerConnection } from './codex-app-server-connection'
import { CodexPromptRegistry } from './codex-structured-prompt-replies'
export class CodexAcquisitionWindow {
readonly prompts = new CodexPromptRegistry()
/** Null until the spawn resolves; the handshake can already emit events. */
connection: CodexAppServerConnection | null = null
private readonly buffered: (() => void)[] = []
private open = true
/** Returns false once the session is published, which is the caller's cue to
* deliver live rather than buffer. */
buffer(event: () => void): boolean {
if (!this.open) {
return false
}
this.buffered.push(event)
return true
}
/** Closes the window and hands back what arrived while it was open, in order. */
drain(): (() => void)[] {
this.open = false
return this.buffered.splice(0)
}
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { resolveCodexStructuredAppServerArgs } from './codex-structured-app-server-args'
describe('structured Codex app-server arguments', () => {
it('keeps configuration flags and converts effort to the app-server config contract', () => {
expect(
resolveCodexStructuredAppServerArgs(
'--profile review -c approval_policy=never --model gpt-5.6 --effort high --search',
'posix'
)
).toEqual([
'--profile',
'review',
'-c',
'approval_policy=never',
'--model',
'gpt-5.6',
'-c',
'model_reasoning_effort=high',
'--search'
])
})
it.each(['--no-alt-screen', '--remote ws://host', '-C /tmp/elsewhere', 'resume thread-1'])(
'reports an incompatible configured argument instead of dropping %s',
(configured) => {
expect(() => resolveCodexStructuredAppServerArgs(configured, 'posix')).toThrow(
/cannot apply the configured CLI arguments.*Settings or use terminal view/
)
}
)
})
@@ -0,0 +1,84 @@
import {
tokenizeStartupCommand,
type AgentStartupShell
} from '../../shared/tui-agent-startup-shell'
const VALUE_FLAGS = new Set([
'-a',
'--add-dir',
'--ask-for-approval',
'-c',
'--config',
'--disable',
'--effort',
'--enable',
'--local-provider',
'-m',
'--model',
'-p',
'--profile',
'--reasoning-effort',
'-s',
'--sandbox'
])
const BOOLEAN_FLAGS = new Set([
'--approve-for-me',
'--dangerously-bypass-approvals-and-sandbox',
'--dangerously-bypass-hook-trust',
'--oss',
'--search',
'--strict-config'
])
const EFFORT_FLAGS = new Set(['--effort', '--reasoning-effort'])
function configuredArgsError(detail: string): Error {
return new Error(
`Structured Codex chat cannot apply the configured CLI arguments to app-server: ${detail}. Update Codex CLI arguments in Settings or use terminal view.`
)
}
function splitOption(token: string): { flag: string; inlineValue?: string } {
const separator = token.indexOf('=')
return separator > 0
? { flag: token.slice(0, separator), inlineValue: token.slice(separator + 1) }
: { flag: token }
}
/** Keeps config-affecting Codex flags and refuses every TUI-only or unknown token visibly. */
export function resolveCodexStructuredAppServerArgs(
configuredArgs: string,
shell: AgentStartupShell
): string[] {
const parsed = tokenizeStartupCommand(configuredArgs.trim(), shell)
if (!parsed.ok) {
throw configuredArgsError(parsed.error)
}
const divergent = parsed.spans.find((span) => span.divergesFromShell)
if (divergent) {
throw configuredArgsError(configuredArgs.slice(divergent.start, divergent.end))
}
const result: string[] = []
for (let index = 0; index < parsed.tokens.length; index += 1) {
const token = parsed.tokens[index]
const { flag, inlineValue } = splitOption(token)
if (BOOLEAN_FLAGS.has(flag) && inlineValue === undefined) {
result.push(flag)
continue
}
if (!VALUE_FLAGS.has(flag)) {
throw configuredArgsError(token || 'an empty positional argument')
}
const value = inlineValue ?? parsed.tokens[++index]
if (value === undefined || value.length === 0) {
throw configuredArgsError(`${flag} requires a value`)
}
if (EFFORT_FLAGS.has(flag)) {
result.push('-c', `model_reasoning_effort=${value}`)
} else {
result.push(flag, value)
}
}
return result
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment'
describe('buildCodexStructuredChildEnvironment', () => {
it('keeps shell exports while pinned launch values win', () => {
expect(
buildCodexStructuredChildEnvironment(
{
command: 'codex',
args: ['app-server'],
cwd: '/worktree',
codexHome: '/pinned/home',
resumeThreadId: null,
env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' }
},
'spawn-token'
)
).toEqual({
EXAMPLE_GATEWAY_TOKEN: 'shell-exported',
CODEX_HOME: '/pinned/home',
[CODEX_SPAWN_TOKEN_ENV]: 'spawn-token'
})
})
})
@@ -0,0 +1,13 @@
import type { CodexStructuredLaunch } from './codex-structured-session-state'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
export function buildCodexStructuredChildEnvironment(
launch: CodexStructuredLaunch,
spawnToken: string
): Record<string, string> {
return {
...launch.env,
...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}),
[CODEX_SPAWN_TOKEN_ENV]: spawnToken
}
}
@@ -0,0 +1,176 @@
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import {
createAgentSessionDeltaCoalescer,
type AgentSessionDeltaCoalescerDeps
} from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
codexJournalItem,
codexStreamingJournalItem,
type CodexThreadItem
} from './codex-structured-item-translation'
const CODEX_ITEM_STREAM_TYPES = {
'item/agentMessage/delta': 'agentMessage',
'item/plan/delta': 'plan',
'item/commandExecution/outputDelta': 'commandExecution',
'item/fileChange/outputDelta': 'fileChange',
'item/reasoning/summaryTextDelta': 'reasoning',
'item/reasoning/textDelta': 'reasoning'
} as const
const PATCH_UPDATED_METHOD = 'item/fileChange/patchUpdated'
const REASONING_PART_METHOD = 'item/reasoning/summaryPartAdded'
const TERMINAL_INTERACTION_METHOD = 'item/commandExecution/terminalInteraction'
type CodexItemStreamDeps = {
sink: StructuredAgentSessionEventSink
identityFor: (
threadId: string,
params: unknown,
item: CodexThreadItem
) => AgentJournalItemIdentity
coalesceMs?: number
schedule?: AgentSessionDeltaCoalescerDeps['schedule']
}
type StreamState = { identity: AgentJournalItemIdentity; item: CodexThreadItem }
export type CodexStructuredItemStreams = {
track: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => void
handle: (threadId: string, method: string, params: unknown) => boolean
forget: (threadId: string, itemId: string) => void
flush: () => void
dispose: () => void
}
function readRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
export function codexStructuredItemKey(threadId: string, itemId: string): string {
return `${encodeURIComponent(threadId)}:${encodeURIComponent(itemId)}`
}
export function createCodexStructuredItemStreams(
deps: CodexItemStreamDeps
): CodexStructuredItemStreams {
const states = new Map<string, StreamState>()
const latestText = new Map<string, string>()
const checkpointLengths = new Map<string, number>()
const append = (state: StreamState, text: string): void => {
const translated = codexStreamingJournalItem(state.item, text)
if (!translated.body) {
return
}
deps.sink.appendItem(state.identity, translated.body, translated.blobs)
deps.sink.publish()
}
const persist = (key: string, text: string, force: boolean): void => {
latestText.set(key, text)
const checkpointLength = checkpointLengths.get(key) ?? 0
const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125))
if (!force && checkpointLength > 0 && text.length < nextLength) {
return
}
checkpointLengths.set(key, text.length)
const state = states.get(key)
if (state) {
append(state, text)
}
}
const coalescer = createAgentSessionDeltaCoalescer({
windowMs: deps.coalesceMs,
schedule: deps.schedule,
emit: (key, text) => persist(key, text, false)
})
const ensureState = (
threadId: string,
itemId: string,
type: string,
params: unknown
): StreamState => {
const key = codexStructuredItemKey(threadId, itemId)
const existing = states.get(key)
if (existing) {
return existing
}
const item = { type, id: itemId }
const state = { item, identity: deps.identityFor(threadId, params, item) }
states.set(key, state)
return state
}
const flush = (): void => {
coalescer.flushAll()
for (const [key, text] of latestText) {
if (checkpointLengths.get(key) !== text.length) {
persist(key, text, true)
}
}
}
return {
track: (threadId, item, identity) => {
states.set(codexStructuredItemKey(threadId, item.id), { item, identity })
},
handle: (threadId, method, params) => {
const paramsRecord = readRecord(params)
const itemId = readString(paramsRecord, 'itemId')
if (method === PATCH_UPDATED_METHOD) {
if (!itemId || !Array.isArray(paramsRecord.changes)) {
return true
}
const key = codexStructuredItemKey(threadId, itemId)
coalescer.flush(key)
const state = ensureState(threadId, itemId, 'fileChange', params)
state.item = { ...state.item, changes: paramsRecord.changes }
const translated = codexJournalItem(state.item)
if (translated.body) {
deps.sink.appendItem(state.identity, translated.body, translated.blobs)
deps.sink.publish()
}
return true
}
if (method === TERMINAL_INTERACTION_METHOD) {
return true
}
const type = CODEX_ITEM_STREAM_TYPES[method as keyof typeof CODEX_ITEM_STREAM_TYPES]
if (!type && method !== REASONING_PART_METHOD) {
return false
}
if (!itemId) {
return true
}
const state = ensureState(threadId, itemId, type ?? 'reasoning', params)
const delta = method === REASONING_PART_METHOD ? '\n' : paramsRecord.delta
if (typeof delta === 'string') {
coalescer.append(codexStructuredItemKey(threadId, state.item.id), delta)
}
return true
},
forget: (threadId, itemId) => {
const key = codexStructuredItemKey(threadId, itemId)
coalescer.forget(key)
states.delete(key)
latestText.delete(key)
checkpointLengths.delete(key)
},
flush,
dispose: () => {
coalescer.dispose()
states.clear()
latestText.clear()
checkpointLengths.clear()
}
}
}
@@ -0,0 +1,239 @@
import { describe, expect, it } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import {
codexItemBody,
codexItemIdentity,
codexMessageBlocks,
CodexTurnOrdinals,
isCodexMessageItemType,
readCodexThreadItem,
type CodexThreadItem
} from './codex-structured-item-translation'
const THREAD_ID = 'thread-abc'
const TURN_ID = 'turn-1'
/**
* Captured from a live `codex app-server` turn: Codex numbers items in arrival
* order and includes the command it ran.
*/
const LIVE_TURN: CodexThreadItem[] = [
{ type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'list the files' }] },
{ type: 'agentMessage', id: 'item-1', text: 'Let me look.' },
{
type: 'commandExecution',
id: 'item-2',
command: 'ls',
cwd: '/tmp',
status: 'completed',
exitCode: 0,
aggregatedOutput: 'a\nb\n'
},
{ type: 'agentMessage', id: 'item-3', text: 'Two files.' }
]
/**
* The SAME turn read back after `thread/resume`: ids are renumbered from 1 and
* the command execution is gone entirely, because Codex does not persist it.
*/
const RESUMED_TURN: CodexThreadItem[] = [
{ type: 'userMessage', id: 'item-1', content: [{ type: 'text', text: 'list the files' }] },
{ type: 'agentMessage', id: 'item-2', text: 'Let me look.' },
{ type: 'agentMessage', id: 'item-3', text: 'Two files.' }
]
function keysFor(items: CodexThreadItem[]): string[] {
const ordinals = new CodexTurnOrdinals()
return items
.filter((item) => isCodexMessageItemType(item.type))
.map((item) =>
agentJournalItemKey(
codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals })
)
)
}
describe('codex turn ordinals', () => {
it('releases a forgotten turn without ever reusing an ordinal it assigned', () => {
const ordinals = new CodexTurnOrdinals()
expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(0)
expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-2')).toBe(1)
ordinals.forgetTurn('thread-1', 'turn-1')
// A straggler for the released turn — even a previously seen item id — gets
// a FRESH ordinal: reusing a released slot would upsert another item's row.
expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(2)
expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-3')).toBe(3)
// Other turns are untouched.
expect(ordinals.ordinalFor('thread-1', 'turn-2', 'item-1')).toBe(0)
})
})
describe('codex item identity', () => {
it('gives a resumed turn the same message keys as the live turn it renumbered', () => {
expect(keysFor(LIVE_TURN)).toEqual(keysFor(RESUMED_TURN))
})
it('numbers messages 0,1,2 on both sides — the projection skips the dropped command', () => {
const ordinals = new CodexTurnOrdinals()
const live = LIVE_TURN.map((item) =>
codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals })
)
expect(live.map((id) => (id.provider === 'codex' ? id.ordinal : null))).toEqual([0, 1, null, 2])
})
it('survives an item type this build does not model without consuming a message ordinal', () => {
const withUnknown = [
LIVE_TURN[0] as CodexThreadItem,
{ type: 'somethingCodexAddedLater', id: 'item-9' },
LIVE_TURN[1] as CodexThreadItem
]
expect(keysFor(withUnknown)).toEqual(keysFor([LIVE_TURN[0], LIVE_TURN[1]] as CodexThreadItem[]))
})
it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => {
const ordinals = new CodexTurnOrdinals()
ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')
expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1)
expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')).toBe(0)
})
it('restarts numbering per turn', () => {
const ordinals = new CodexTurnOrdinals()
ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')
expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0)
})
it('keys a non-message item and a turnless message in the orca namespace', () => {
const ordinals = new CodexTurnOrdinals()
const command = codexItemIdentity({
threadId: THREAD_ID,
turnId: TURN_ID,
item: LIVE_TURN[2] as CodexThreadItem,
ordinals
})
const orphan = codexItemIdentity({
threadId: THREAD_ID,
turnId: null,
item: LIVE_TURN[1] as CodexThreadItem,
ordinals
})
expect(command).toEqual({ provider: 'orca', clientMessageId: 'codex-item:thread-abc:item-2' })
expect(orphan).toEqual({ provider: 'orca', clientMessageId: 'codex-item:thread-abc:item-1' })
})
})
describe('codex item bodies', () => {
it('reads structured user content and flat agent text alike', () => {
expect(codexMessageBlocks(LIVE_TURN[0] as CodexThreadItem)).toEqual([
{ type: 'text', text: 'list the files' }
])
expect(codexMessageBlocks(LIVE_TURN[1] as CodexThreadItem)).toEqual([
{ type: 'text', text: 'Let me look.' }
])
})
it('keeps provider image echoes in mixed user content', () => {
expect(
codexMessageBlocks({
type: 'userMessage',
id: 'm',
content: [
{ type: 'text', text: 'look' },
{ type: 'image', url: 'https://example.test/a.png' },
{ type: 'localImage', path: '/tmp/a.png' }
]
})
).toEqual([
{ type: 'text', text: 'look' },
{ type: 'image-ref', url: 'https://example.test/a.png' },
{ type: 'image-ref', path: '/tmp/a.png' }
])
})
it('maps a finished zero-exit command to a completed shell tool call', () => {
expect(codexItemBody(LIVE_TURN[2] as CodexThreadItem)).toEqual({
kind: 'tool-call',
name: 'shell',
input: { command: 'ls', cwd: '/tmp' },
state: 'completed',
output: { head: 'a\nb\n', byteLength: 4, truncated: false, digest: expect.any(String) }
})
})
it('calls a nonzero exit a failure even though codex calls the status completed', () => {
const body = codexItemBody({
type: 'commandExecution',
id: 'item-2',
command: 'false',
status: 'completed',
exitCode: 1
})
expect(body).toMatchObject({ state: 'failed' })
})
it('treats an unfinished command as running and an aborted one as failed', () => {
expect(
codexItemBody({ type: 'commandExecution', id: 'a', command: 'sleep', status: 'inProgress' })
).toMatchObject({ state: 'running' })
expect(
codexItemBody({ type: 'commandExecution', id: 'a', command: 'sleep', status: 'aborted' })
).toMatchObject({ state: 'failed' })
})
it('maps file changes to one bounded diff item', () => {
expect(
codexItemBody({
type: 'fileChange',
id: 'patch-1',
status: 'completed',
changes: [
{ path: 'src/a.ts', diff: '@@ a @@' },
{ path: 'src/b.ts', diff: '@@ b @@' }
]
})
).toMatchObject({
kind: 'diff',
path: '2 files',
patch: { head: '@@ a @@\n@@ b @@', truncated: false }
})
})
it('renders reasoning as status and exposes an unknown item as a provider frame', () => {
expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({
kind: 'status',
text: 'thinking'
})
expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull()
expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull()
expect(codexItemBody({ type: 'webSearch', id: 'w' })).toMatchObject({
kind: 'status',
text: 'codex · item:webSearch',
providerFrame: { provider: 'codex', kind: 'item:webSearch' }
})
})
it('renders array-shaped reasoning content', () => {
expect(
codexItemBody({
type: 'reasoning',
id: 'r',
summary: ['first', 'second'],
content: [{ text: 'fallback' }]
})
).toEqual({ kind: 'status', text: 'first\nsecond' })
})
it('refuses a value that is not a thread item at all', () => {
expect(readCodexThreadItem({ type: 'agentMessage' })).toBeNull()
expect(readCodexThreadItem(null)).toBeNull()
expect(readCodexThreadItem({ type: 'agentMessage', id: 'm' })).not.toBeNull()
})
})
@@ -0,0 +1,321 @@
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
// Codex thread items → journal item bodies and durable identities.
//
// THE ORDINAL RULE, and why it is not "index within the turn". Codex renumbers
// item ids positionally on resume (`item-1`…`item-N` across the whole thread),
// and a resumed turn does NOT contain every item the live turn emitted —
// reasoning and command execution are dropped from persisted history. Numbering
// by live position would therefore shift every message after the first tool
// call and hand the user a duplicate of the assistant's answer after a resume.
//
// So the ordinal counts MESSAGE items only, and the same projection is applied
// to the live stream and to a resumed turn's item list. Any other item type —
// including ones this build does not model — is skipped identically on both
// sides, which is what makes the key survive a Codex release that adds one.
/** Only these carry a durable `(threadId, turnId, ordinal)` identity. */
const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage'])
export type CodexThreadItem = {
type: string
id: string
[key: string]: unknown
}
export function isCodexMessageItemType(type: string): boolean {
return CODEX_MESSAGE_ITEM_TYPES.has(type)
}
export function readCodexThreadItem(value: unknown): CodexThreadItem | null {
if (typeof value !== 'object' || value === null) {
return null
}
const record = value as Record<string, unknown>
return typeof record.type === 'string' && typeof record.id === 'string'
? (record as CodexThreadItem)
: null
}
/**
* Ordinals for one thread, assigned on first sight and never reassigned.
*
* Non-message items are given no ordinal at all rather than a number from a
* second counter: a counter that a resumed history cannot reproduce is worse
* than no key, because it would look reconcilable and reconcile wrongly.
*/
export class CodexTurnOrdinals {
private readonly turns = new Map<string, { assigned: Map<string, number>; next: number }>()
ordinalFor(threadId: string, turnId: string, codexItemId: string): number {
const turnKey = `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}`
let turn = this.turns.get(turnKey)
if (!turn) {
turn = { assigned: new Map(), next: 0 }
this.turns.set(turnKey, turn)
}
const existing = turn.assigned.get(codexItemId)
if (existing !== undefined) {
return existing
}
const ordinal = turn.next
turn.assigned.set(codexItemId, ordinal)
turn.next += 1
return ordinal
}
/** Releases a finished turn's per-item map while keeping its counter, so a
* straggler frame can never be assigned an ordinal the turn already used —
* a reused slot would upsert another item's journal row. */
forgetTurn(threadId: string, turnId: string): void {
const turn = this.turns.get(`${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}`)
if (turn) {
turn.assigned = new Map()
}
}
}
function readRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
/**
* Durable identity for a Codex item, or null for one that has none.
*
* Non-message items fall back to the `orca` namespace keyed by the Codex item
* id. That id is unstable across resume, so those rows are live-session detail
* that a recovered journal simply will not contain — which is correct: Codex
* itself does not persist them either.
*/
export function codexItemIdentity(input: {
threadId: string
turnId: string | null
item: CodexThreadItem
ordinals: CodexTurnOrdinals
}): AgentJournalItemIdentity {
const { item, turnId } = input
if (turnId && isCodexMessageItemType(item.type)) {
return {
provider: 'codex',
threadId: input.threadId,
turnId,
ordinal: input.ordinals.ordinalFor(input.threadId, turnId, item.id)
}
}
return { provider: 'orca', clientMessageId: `codex-item:${input.threadId}:${item.id}` }
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
function readTextContent(source: Record<string, unknown>, key: string): string | null {
const direct = readString(source, key)
if (direct) {
return direct
}
const value = source[key]
if (!Array.isArray(value)) {
return null
}
const parts = value.flatMap((part) => {
if (typeof part === 'string') {
return part.length > 0 ? [part] : []
}
if (typeof part !== 'object' || part === null) {
return []
}
const text = readString(part as Record<string, unknown>, 'text')
return text ? [text] : []
})
return parts.length > 0 ? parts.join('\n') : null
}
/** `userMessage` carries structured content parts; `agentMessage` a flat text. */
export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] {
const text = readString(item, 'text')
if (text !== null) {
return [{ type: 'text', text }]
}
const content = item.content
if (!Array.isArray(content)) {
return []
}
const blocks: NativeChatBlock[] = []
for (const part of content) {
if (typeof part !== 'object' || part === null) {
continue
}
const partText = readString(part as Record<string, unknown>, 'text')
if (partText !== null) {
blocks.push({ type: 'text', text: partText })
continue
}
const record = part as Record<string, unknown>
if (record.type === 'image' && typeof record.url === 'string') {
blocks.push({ type: 'image-ref', url: record.url })
} else if (record.type === 'localImage' && typeof record.path === 'string') {
blocks.push({ type: 'image-ref', path: record.path })
}
}
return blocks
}
/** Codex reports `inProgress` then a terminal status; a zero exit code is the
* only thing that makes a finished command a success. */
function commandState(item: CodexThreadItem): 'running' | 'completed' | 'failed' {
const status = readString(item, 'status')
if (status === null || status === 'inProgress') {
return 'running'
}
if (status !== 'completed') {
return 'failed'
}
const exitCode = item.exitCode
return typeof exitCode === 'number' && exitCode !== 0 ? 'failed' : 'completed'
}
export type CodexJournalItem = {
body: AgentJournalItemBody | null
blobs: { digest: string; payload: string }[]
handled: boolean
}
function commandItem(item: CodexThreadItem): CodexJournalItem {
const output = readString(item, 'aggregatedOutput')
const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
return {
body: {
kind: 'tool-call',
name: 'shell',
input: { command: item.command ?? null, cwd: item.cwd ?? null },
state: commandState(item),
...(bounded === null ? {} : { output: bounded.bounded })
},
blobs:
output !== null && bounded?.bounded.truncated
? [{ digest: bounded.bounded.digest, payload: output }]
: [],
handled: true
}
}
function fileChangeItem(item: CodexThreadItem): CodexJournalItem {
const changes = Array.isArray(item.changes)
? item.changes.flatMap((change) => {
const record = typeof change === 'object' && change !== null ? readRecord(change) : {}
const path = readString(record, 'path')
const diff = readString(record, 'diff')
return path && diff ? [{ path, diff }] : []
})
: []
if (changes.length === 0) {
return {
body: {
kind: 'tool-call',
name: 'apply_patch',
input: { changes: item.changes ?? null },
state: commandState(item)
},
blobs: [],
handled: true
}
}
const patch = changes.map((change) => change.diff).join('\n')
const bounded = boundInlineText(patch, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded
return {
body: {
kind: 'diff',
path: changes.length === 1 ? changes[0]!.path : `${changes.length} files`,
patch: bounded
},
blobs: bounded.truncated ? [{ digest: bounded.digest, payload: patch }] : [],
handled: true
}
}
/**
* Journal body for a Codex item, or null for one with nothing to render.
*
* Known empty items wait for later deltas. Unknown types become bounded status
* rows so a provider release cannot make new activity invisible.
*/
export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
if (item.type === 'userMessage' || item.type === 'agentMessage') {
const blocks = codexMessageBlocks(item)
return {
body:
blocks.length === 0
? null
: { kind: 'message', role: item.type === 'userMessage' ? 'user' : 'assistant', blocks },
blobs: [],
handled: true
}
}
if (item.type === 'commandExecution') {
return commandItem(item)
}
if (item.type === 'fileChange') {
return fileChangeItem(item)
}
if (item.type === 'reasoning' || item.type === 'plan') {
const text =
readTextContent(item, 'text') ??
readTextContent(item, 'summary') ??
readTextContent(item, 'content')
return {
body:
text === null
? null
: { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text },
blobs: [],
handled: true
}
}
const unhandled = unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item)
return unhandled
? { body: unhandled.body, blobs: unhandled.blobs, handled: false }
: { body: null, blobs: [], handled: true }
}
export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | null {
return codexJournalItem(item).body
}
/** Snapshot body for text still streaming, before its item completes. */
export function codexStreamingMessageBody(text: string): AgentJournalItemBody {
return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] }
}
/** Snapshot body for any item-level stream, keyed onto its parent item. */
export function codexStreamingJournalItem(item: CodexThreadItem, text: string): CodexJournalItem {
if (item.type === 'agentMessage') {
return { body: codexStreamingMessageBody(text), blobs: [], handled: true }
}
if (item.type === 'commandExecution') {
return commandItem({ ...item, aggregatedOutput: text })
}
if (item.type === 'fileChange') {
const path = Array.isArray(item.changes)
? readString(readRecord(item.changes[0]), 'path')
: null
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded
return {
body: { kind: 'diff', path: path ?? 'pending patch', patch: bounded },
blobs: bounded.truncated ? [{ digest: bounded.digest, payload: text }] : [],
handled: true
}
}
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
return { body: { kind: 'status', text: bounded.text }, blobs: [], handled: true }
}
@@ -0,0 +1,785 @@
import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import {
projectStructuredAgentSessionStatus,
projectStructuredItemsToNativeChat
} from '../../shared/structured-agent-session-projection'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CodexTurnOrdinals } from './codex-structured-item-translation'
import {
createCodexJournalTranslator,
MAX_CODEX_GENERIC_ROWS_PER_TURN
} from './codex-structured-journal-translation'
import {
CODEX_COMMAND_APPROVAL_METHOD,
CODEX_USER_INPUT_METHOD
} from './codex-structured-prompt-replies'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
const SESSION_ID = 'session-1'
const THREAD_ID = 'thread-abc'
const TURN_ID = 'turn-1'
type Row = { key: string; body: AgentJournalItemBody }
function recorder() {
const rows: Row[] = []
const tombstones: string[] = []
const bound: [string, string, string][] = []
let publishes = 0
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity: AgentJournalItemIdentity, body) =>
rows.push({ key: agentJournalItemKey(identity), body }),
appendTombstone: (identity) => tombstones.push(agentJournalItemKey(identity)),
publish: () => {
publishes += 1
}
}
return {
sink,
rows,
tombstones,
bound,
publishes: () => publishes,
bindPromptItemId: (journalItemId: string, threadId: string, promptKey: string) =>
bound.push([journalItemId, threadId, promptKey])
}
}
/** Fires the coalescing window on demand instead of on wall time. */
function manualWindow() {
let pending: (() => void) | null = null
return {
schedule: (run: () => void) => {
pending = run
return () => {
pending = null
}
},
fire: () => {
const run = pending
pending = null
run?.()
},
idle: () => pending === null
}
}
function notification(method: string, params: unknown): CodexStructuredSessionEvent {
return { type: 'notification', sessionId: SESSION_ID, threadId: THREAD_ID, method, params }
}
const TURN_STARTED = notification('turn/started', { turn: { id: TURN_ID } })
function translatorWith(tap = recorder(), window = manualWindow()) {
const translator = createCodexJournalTranslator({
sink: tap.sink,
bindPromptItemId: tap.bindPromptItemId,
schedule: window.schedule
})
return { translator, tap, window }
}
describe('codex journal translation', () => {
it('projects turns restored by thread/resume into durable conversation rows', () => {
const { translator, tap } = translatorWith()
translator.restoreThread(THREAD_ID, {
turns: [
{
id: 'turn-restored',
items: [
{
type: 'userMessage',
id: 'user-restored',
content: [{ type: 'text', text: 'existing question' }]
},
{ type: 'agentMessage', id: 'agent-restored', text: 'existing answer' }
]
}
]
})
expect(tap.rows.map((row) => row.body)).toEqual([
{
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'existing question' }]
},
{
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'existing answer' }]
}
])
})
it('durably opens and closes the primary turn cancellation lifecycle', () => {
const tap = recorder()
const translator = createCodexJournalTranslator({
sink: tap.sink,
primaryThreadId: () => THREAD_ID
})
translator.handle(TURN_STARTED)
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
expect(tap.rows).toEqual([
{
key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-1',
body: {
kind: 'status',
text: 'Codex is working…',
turnLifecycle: { turnId: TURN_ID, state: 'running' }
}
}
])
expect(tap.tombstones).toEqual(['legacy:codex:session-1:turn-lifecycle%3Aturn-1'])
})
it('closes every active turn when the provider session ends after a later turn starts', () => {
const tap = recorder()
const translator = createCodexJournalTranslator({
sink: tap.sink,
primaryThreadId: () => THREAD_ID
})
translator.handle(notification('turn/started', { turn: { id: 'turn-stale' } }))
translator.handle(notification('turn/started', { turn: { id: 'turn-later' } }))
translator.handle({ type: 'ended', sessionId: SESSION_ID, reason: 'app-server exited' })
expect(tap.rows.filter((row) => row.body.kind === 'status')).toHaveLength(2)
expect(tap.rows.map((row) => row.body)).toEqual([
expect.objectContaining({ turnLifecycle: { turnId: 'turn-stale', state: 'running' } }),
expect.objectContaining({ turnLifecycle: { turnId: 'turn-later', state: 'running' } })
])
expect(tap.tombstones).toEqual([
'legacy:codex:session-1:turn-lifecycle%3Aturn-stale',
'legacy:codex:session-1:turn-lifecycle%3Aturn-later'
])
// The tombstones remove both running rows from the reduced journal; no
// lifecycle identity remains live after a session end.
expect(
projectStructuredAgentSessionStatus(
tap.rows
.filter((row) => !tap.tombstones.includes(row.key))
.map((row, sequence) => ({
itemId: row.key,
revision: 1,
sequence: sequence + 1,
observedAt: sequence + 1,
body: row.body
}))
)
).toBe('idle')
})
it('matches out-of-order completions to each turn identity', () => {
const tap = recorder()
const translator = createCodexJournalTranslator({
sink: tap.sink,
primaryThreadId: () => THREAD_ID
})
translator.handle(notification('turn/started', { turn: { id: 'turn-stale' } }))
translator.handle(notification('turn/started', { turn: { id: 'turn-later' } }))
translator.handle(notification('turn/completed', { turn: { id: 'turn-stale' } }))
translator.handle(notification('turn/completed', { turn: { id: 'turn-later' } }))
expect(tap.tombstones).toEqual([
'legacy:codex:session-1:turn-lifecycle%3Aturn-stale',
'legacy:codex:session-1:turn-lifecycle%3Aturn-later'
])
expect(
projectStructuredAgentSessionStatus(
tap.rows
.filter((row) => !tap.tombstones.includes(row.key))
.map((row, sequence) => ({
itemId: row.key,
revision: 1,
sequence: sequence + 1,
observedAt: sequence + 1,
body: row.body
}))
)
).toBe('idle')
})
it('journals a user turn and the assistant answer under durable codex keys', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/completed', {
item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'hi' }] }
})
)
translator.handle(
notification('item/completed', {
item: { type: 'agentMessage', id: 'item-1', text: 'hello' }
})
)
expect(tap.rows.map((row) => row.key)).toEqual([
'codex:thread-abc:turn-1:0',
'codex:thread-abc:turn-1:1'
])
expect(tap.rows[1]?.body).toEqual({
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'hello' }]
})
})
it('folds streamed deltas into one snapshot row on the same key the item started under', () => {
const { translator, tap, window } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } })
)
translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'he' }))
translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'llo' }))
window.fire()
// `item/started` had no text to journal; only the coalesced snapshot lands.
expect(tap.rows).toEqual([
{
key: 'codex:thread-abc:turn-1:0',
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] }
}
])
})
it('upserts the streamed text and the completed body onto one row, body last', () => {
const { translator, tap, window } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } })
)
translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'part' }))
translator.handle(
notification('item/completed', {
item: { type: 'agentMessage', id: 'item-1', text: 'partial' }
})
)
window.fire()
// One key, so the reducer keeps the last write; the stale snapshot cannot
// come back after the window it was pending on fires.
expect(new Set(tap.rows.map((row) => row.key))).toEqual(new Set(['codex:thread-abc:turn-1:0']))
expect(tap.rows.map((row) => row.body)).toEqual([
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'part' }] },
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'partial' }] }
])
})
it('flushes pending text before a lifecycle event, so nothing is journaled ahead of it', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } })
)
translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'text' }))
translator.handle(
notification('item/started', {
item: { type: 'commandExecution', id: 'item-2', command: 'ls', status: 'inProgress' }
})
)
expect(tap.rows.map((row) => row.key)).toEqual([
'codex:thread-abc:turn-1:0',
'orca:codex-item%3Athread-abc%3Aitem-2'
])
})
it('flushes what streamed when the child dies unannounced', () => {
const { translator, tap, window } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } })
)
translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'half' }))
translator.handle({ type: 'ended', sessionId: SESSION_ID, reason: 'app-server exited' })
expect(tap.rows.at(-1)?.body).toMatchObject({ blocks: [{ type: 'text', text: 'half' }] })
expect(window.idle()).toBe(true)
})
it('journals an approval naming the command the item already announced, and binds it', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/started', {
item: {
type: 'commandExecution',
id: 'item-2',
command: 'rm -rf build',
status: 'inProgress'
}
})
)
translator.handle({
type: 'prompt',
sessionId: SESSION_ID,
threadId: THREAD_ID,
method: CODEX_COMMAND_APPROVAL_METHOD,
params: { availableDecisions: ['accept', 'decline'] },
codexItemId: 'item-2',
promptKey: 'item-2'
})
const approval = tap.rows.at(-1)
expect(approval?.key).toBe('orca:codex-prompt%3Athread-abc%3Aitem-2')
expect(approval?.body).toMatchObject({ kind: 'approval', detail: 'rm -rf build' })
expect(tap.bound).toEqual([['orca:codex-prompt%3Athread-abc%3Aitem-2', THREAD_ID, 'item-2']])
})
it('journals one row per approval when a tool item asks twice', () => {
const { translator, tap } = translatorWith()
const ask = (promptKey: string): void => {
translator.handle({
type: 'prompt',
sessionId: SESSION_ID,
threadId: THREAD_ID,
method: CODEX_COMMAND_APPROVAL_METHOD,
params: { availableDecisions: ['accept', 'decline'] },
codexItemId: 'item-2',
promptKey
})
}
translator.handle(TURN_STARTED)
translator.handle(
notification('item/started', {
item: { type: 'commandExecution', id: 'item-2', command: 'ls', status: 'inProgress' }
})
)
ask('approval-a')
ask('approval-b')
// Two asks, two answerable rows — keying by the tool item would have made the
// second ask overwrite the first, leaving the turn blocked.
const approvals = tap.rows.slice(-2)
expect(approvals.map((row) => row.key)).toEqual([
'orca:codex-prompt%3Athread-abc%3Aapproval-a',
'orca:codex-prompt%3Athread-abc%3Aapproval-b'
])
// Both still name the command the shared item announced.
expect(approvals.every((row) => (row.body as { detail?: string }).detail === 'ls')).toBe(true)
expect(tap.bound.map(([, , promptKey]) => promptKey)).toEqual(['approval-a', 'approval-b'])
})
it('journals and binds one row per question in a user-input request', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle({
type: 'prompt',
sessionId: SESSION_ID,
threadId: THREAD_ID,
method: CODEX_USER_INPUT_METHOD,
params: {
questions: [
{ id: 'q1', question: 'Which branch?', options: [{ label: 'main' }] },
{ id: 'q2', question: 'Proceed?', options: [{ label: 'yes' }] }
]
},
codexItemId: 'item-3',
promptKey: 'item-3'
})
expect(tap.rows.map((row) => row.key)).toEqual([
'orca:codex-prompt%3Athread-abc%3Aitem-3%3Aq1',
'orca:codex-prompt%3Athread-abc%3Aitem-3%3Aq2'
])
expect(tap.bound.map(([, , promptKey]) => promptKey)).toEqual(['item-3', 'item-3'])
})
it('starts a new turn at ordinal zero and refuses to adopt an ended turn', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'one' } })
)
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
translator.handle(
notification('item/completed', {
item: { type: 'agentMessage', id: 'item-1', text: 'orphan' }
})
)
translator.handle(notification('turn/started', { turn: { id: 'turn-2' } }))
translator.handle(
notification('item/completed', { item: { type: 'userMessage', id: 'item-2', text: 'two' } })
)
expect(tap.rows.map((row) => row.key)).toEqual([
'codex:thread-abc:turn-1:0',
'orca:codex-item%3Athread-abc%3Aitem-1',
'codex:thread-abc:turn-2:0'
])
})
it('prefers a turn id the event carries over the turn currently open', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/completed', {
turnId: 'turn-9',
item: { type: 'userMessage', id: 'item-0', text: 'late' }
})
)
expect(tap.rows[0]?.key).toBe('codex:thread-abc:turn-9:0')
})
it('keeps interleaved thread turns, items, and deltas separate', () => {
const { translator, tap } = translatorWith()
const child = (method: string, params: unknown): CodexStructuredSessionEvent => ({
type: 'notification',
sessionId: SESSION_ID,
threadId: 'thread-child',
method,
params
})
translator.handle(TURN_STARTED)
translator.handle(child('turn/started', { threadId: 'thread-child', turnId: 'turn-child' }))
translator.handle(
notification('item/completed', {
item: { type: 'agentMessage', id: 'item-0', text: 'root' }
})
)
translator.handle(
child('item/completed', { item: { type: 'agentMessage', id: 'item-0', text: 'child' } })
)
translator.handle(child('turn/completed', { turnId: 'turn-child' }))
translator.handle(
notification('item/completed', {
item: { type: 'agentMessage', id: 'item-1', text: 'still root' }
})
)
expect(tap.rows.map((row) => row.key)).toEqual([
'codex:thread-abc:turn-1:0',
'codex:thread-child:turn-child:0',
'codex:thread-abc:turn-1:1'
])
})
it('checkpoints long streams geometrically and flushes the final snapshot', () => {
const { translator, tap, window } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } })
)
for (let index = 0; index < 512; index += 1) {
translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'x' }))
window.fire()
}
translator.flush()
expect(tap.rows.length).toBeLessThan(40)
expect(tap.rows.at(-1)?.body).toMatchObject({
blocks: [{ type: 'text', text: 'x'.repeat(512) }]
})
})
it('folds long-running command output into one exec item and zero generic rows', () => {
const { translator, tap, window } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/started', {
item: { type: 'commandExecution', id: 'exec-1', command: 'long-task', status: 'inProgress' }
})
)
for (let index = 0; index < 512; index += 1) {
translator.handle(
notification('item/commandExecution/outputDelta', { itemId: 'exec-1', delta: 'x' })
)
window.fire()
}
translator.flush()
expect(new Set(tap.rows.map((row) => row.key))).toEqual(
new Set(['orca:codex-item%3Athread-abc%3Aexec-1'])
)
expect(tap.rows.every((row) => row.body.kind === 'tool-call')).toBe(true)
expect(tap.rows.length).toBeLessThan(40)
expect(tap.rows.at(-1)?.body).toMatchObject({
kind: 'tool-call',
output: { head: 'x'.repeat(512) }
})
})
it('folds reasoning and patch streams into their parent rows', () => {
const { translator, tap, window } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(notification('item/started', { item: { type: 'reasoning', id: 'r-1' } }))
translator.handle(
notification('item/reasoning/summaryTextDelta', { itemId: 'r-1', delta: 'thinking' })
)
translator.handle(
notification('item/started', {
item: { type: 'fileChange', id: 'patch-1', changes: [], status: 'inProgress' }
})
)
translator.handle(
notification('item/fileChange/patchUpdated', {
itemId: 'patch-1',
changes: [{ path: 'src/app.ts', kind: { type: 'update' }, diff: '@@ -1 +1 @@' }]
})
)
window.fire()
const reduced = new Map(tap.rows.map((row) => [row.key, row.body]))
expect(reduced.get('orca:codex-item%3Athread-abc%3Ar-1')).toEqual({
kind: 'status',
text: 'thinking'
})
expect(reduced.get('orca:codex-item%3Athread-abc%3Apatch-1')).toMatchObject({
kind: 'diff',
path: 'src/app.ts',
patch: { head: '@@ -1 +1 @@' }
})
})
it('publishes after every write so a subscriber never trails the journal', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'hi' } })
)
expect(tap.publishes()).toBe(1)
})
it('releases a turn ordinal map when the turn completes', () => {
const spy = vi.spyOn(CodexTurnOrdinals.prototype, 'forgetTurn')
try {
const { translator } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
expect(spy).toHaveBeenCalledWith(THREAD_ID, TURN_ID)
} finally {
spy.mockRestore()
}
})
it('journals malformed item events but never malformed deltas', () => {
const { translator, tap, window } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(notification('item/completed', {}))
translator.handle(notification('item/agentMessage/delta', { delta: 'orphan' }))
window.fire()
expect(tap.rows.map((row) => row.body)).toEqual([
expect.objectContaining({
kind: 'status',
providerFrame: expect.objectContaining({ kind: 'notification:item/completed' })
})
])
})
it('journals unknown notifications, server requests, and decoded provider frames', () => {
const { translator, tap } = translatorWith()
translator.handle(notification('future/notification', { value: 1 }))
translator.handle({
type: 'server-request',
sessionId: SESSION_ID,
threadId: THREAD_ID,
method: 'future/request',
params: { value: 2 }
})
translator.handle({
type: 'provider-frame',
sessionId: SESSION_ID,
threadId: THREAD_ID,
kind: 'frame:unclassified',
payload: { value: 3 }
})
expect(
tap.rows.map((row) => (row.body.kind === 'status' ? row.body.providerFrame?.kind : undefined))
).toEqual(['notification:future/notification', 'request:future/request', 'frame:unclassified'])
})
it('bounds generic rows per turn while keeping the suppression visible and countable', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN + 20; index += 1) {
translator.handle(notification('future/notification', { value: index }))
}
translator.handle(notification('item/future/outputDelta', { itemId: 'future', delta: 'x' }))
const generic = tap.rows.filter(
(row) => row.body.kind === 'status' && row.body.providerFrame !== undefined
)
expect(generic).toHaveLength(MAX_CODEX_GENERIC_ROWS_PER_TURN)
expect(generic[0]?.body).toMatchObject({
kind: 'status',
providerFrame: { kind: 'notification:future/notification' }
})
// The 20 capped frames reduce to ONE summary row whose count is exact, so
// suppressed provider activity is never invisible.
const summaries = new Map(
tap.rows
.filter((row) => row.key.includes('provider-frame-suppressed'))
.map((row) => [row.key, row.body])
)
expect(summaries.size).toBe(1)
expect([...summaries.values()][0]).toEqual({
kind: 'status',
text: '20 more provider notifications not shown for this turn'
})
expect(
tap.rows.some(
(row) =>
row.body.kind === 'status' &&
row.body.providerFrame?.kind === 'notification:item/future/outputDelta'
)
).toBe(false)
})
it('never lets the generic-row cap hide an error frame', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN + 3; index += 1) {
translator.handle(notification('future/notification', { value: index }))
}
translator.handle(notification('future/failure', { error: 'provider exploded' }))
expect(
tap.rows.some(
(row) =>
row.body.kind === 'status' &&
row.body.providerFrame?.kind === 'notification:future/failure'
)
).toBe(true)
})
it('keeps a fresh session timeline empty through startup and status notifications', () => {
const { translator, tap } = translatorWith()
translator.handle(notification('thread/started', { thread: { id: THREAD_ID } }))
for (let index = 0; index < 8; index += 1) {
translator.handle(
notification('mcpServer/startupStatus/updated', {
server: `server-${index}`,
status: 'starting'
})
)
}
translator.handle(notification('remoteControl/status/changed', { status: 'disabled' }))
const timeline = projectStructuredItemsToNativeChat(
tap.rows.map((row, index) => ({
itemId: row.key,
revision: 1,
sequence: index + 1,
observedAt: index + 1,
body: row.body
}))
)
expect(timeline).toEqual([])
})
it('projects only user and assistant content for a complete turn with hooks', () => {
const { translator, tap } = translatorWith()
translator.handle(notification('thread/started', { thread: { id: THREAD_ID } }))
translator.handle(notification('hook/started', { run: { id: 'hook-1', status: 'running' } }))
translator.handle(notification('account/rateLimits/updated', { rateLimits: { primary: null } }))
translator.handle(TURN_STARTED)
translator.handle(
notification('item/completed', {
item: { type: 'userMessage', id: 'item-0', text: 'hi' }
})
)
translator.handle(
notification('hook/completed', { run: { id: 'hook-1', status: 'completed' } })
)
translator.handle(
notification('item/completed', {
item: { type: 'agentMessage', id: 'item-1', text: 'hello' }
})
)
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
const timeline = projectStructuredItemsToNativeChat(
tap.rows.map((row, index) => ({
itemId: row.key,
revision: 1,
sequence: index + 1,
observedAt: index + 1,
body: row.body
}))
)
expect(timeline.map(({ role, blocks }) => ({ role, blocks }))).toEqual([
{ role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
{ role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] }
])
})
it('renders a system error carried by a suppressed status kind', () => {
const { translator, tap } = translatorWith()
translator.handle(
notification('thread/status/changed', {
threadId: THREAD_ID,
status: { type: 'systemError' }
})
)
const timeline = projectStructuredItemsToNativeChat(
tap.rows.map((row, index) => ({
itemId: row.key,
revision: 1,
sequence: index + 1,
observedAt: index + 1,
body: row.body
}))
)
expect(timeline).toEqual([
expect.objectContaining({
role: 'system',
blocks: [
expect.objectContaining({
providerFrame: expect.objectContaining({
kind: 'notification:thread/status/changed'
})
})
]
})
])
})
it('writes nothing more after dispose', () => {
const { translator, tap, window } = translatorWith()
translator.handle(TURN_STARTED)
translator.handle(
notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } })
)
translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'gone' }))
translator.dispose()
window.fire()
expect(tap.rows).toEqual([])
})
})
@@ -0,0 +1,335 @@
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import {
codexItemIdentity,
codexJournalItem,
CodexTurnOrdinals,
readCodexThreadItem
} from './codex-structured-item-translation'
import {
codexStructuredItemKey,
createCodexStructuredItemStreams
} from './codex-structured-item-streams'
import {
codexApprovalItem,
codexPromptIdentity,
codexQuestionItems
} from './codex-structured-prompt-items'
import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies'
import { readCodexTurnId } from './codex-structured-thread-facts'
// The one place Codex events become journal rows.
//
// Every durable decision lives here rather than in the adapter: the adapter
// knows the protocol, this knows what a user is owed after a reconnect. It is
// per-session and per-acquisition — a new lease gets a new translator and a new
// sink, so a superseded child cannot keep writing.
export const MAX_CODEX_GENERIC_ROWS_PER_TURN = 8
export type CodexJournalTranslatorDeps = {
sink: StructuredAgentSessionEventSink
/** Points an answered journal item back at the live Codex request. */
bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void
primaryThreadId?: () => string | null
coalesceMs?: number
schedule?: AgentSessionDeltaCoalescerDeps['schedule']
}
export type CodexJournalTranslator = {
handle: (event: CodexStructuredSessionEvent) => void
restoreThread: (threadId: string, thread: Record<string, unknown>) => void
flush: () => void
dispose: () => void
}
function readRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
export function createCodexJournalTranslator(
deps: CodexJournalTranslatorDeps
): CodexJournalTranslator {
const ordinals = new CodexTurnOrdinals()
/** Identity assigned when an item was announced, reused by its deltas and by
* its completion so all three upsert one row. */
const identities = new Map<string, AgentJournalItemIdentity>()
/** What each announced item is, so an approval can name what it approves. */
const details = new Map<string, string>()
/** Turns announced by the provider and not yet closed. */
const currentTurnIds = new Map<string, Set<string>>()
const genericRowsByTurn = new Map<string, number>()
const suppressedRowsByTurn = new Map<string, number>()
let fallbackSequence = 0
const currentTurnIdFor = (threadId: string): string | null =>
[...(currentTurnIds.get(threadId) ?? [])].at(-1) ?? null
const rememberTurn = (threadId: string, turnId: string): void => {
currentTurnIds.set(threadId, new Set([...(currentTurnIds.get(threadId) ?? []), turnId]))
}
const forgetTurn = (threadId: string, turnId: string): void => {
const active = currentTurnIds.get(threadId)
active?.delete(turnId)
if (!active?.size) {
currentTurnIds.delete(threadId)
}
}
const appendUnhandled = (kind: string, payload: unknown, threadId = 'session'): void => {
const translated = unhandledProviderFrameJournalItem('codex', kind, payload)
if (!translated) {
return
}
const turnId = readCodexTurnId(payload) ?? currentTurnIdFor(threadId) ?? 'outside-turn'
const bucket = `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}`
const rowCount = genericRowsByTurn.get(bucket) ?? 0
// The cap bounds noise, never evidence: an error frame is always journaled,
// and capped frames stay countable through one summary row per turn.
const capped =
rowCount >= MAX_CODEX_GENERIC_ROWS_PER_TURN && translated.classification !== 'error-surface'
if (capped) {
const suppressed = (suppressedRowsByTurn.get(bucket) ?? 0) + 1
suppressedRowsByTurn.set(bucket, suppressed)
deps.sink.appendItem(
{ provider: 'orca', clientMessageId: `provider-frame-suppressed:codex:${bucket}` },
{
kind: 'status',
text: `${suppressed} more provider notification${suppressed === 1 ? '' : 's'} not shown for this turn`
}
)
deps.sink.publish()
return
}
genericRowsByTurn.set(bucket, rowCount + 1)
fallbackSequence += 1
deps.sink.appendItem(
{ provider: 'orca', clientMessageId: `provider-frame:codex:${fallbackSequence}` },
translated.body,
translated.blobs
)
deps.sink.publish()
}
const publishTurnLifecycle = (
sessionId: string,
threadId: string,
turnId: string,
state: 'running' | 'completed'
): void => {
if (deps.primaryThreadId?.() !== threadId) {
return
}
const identity = {
provider: 'legacy' as const,
agent: 'codex' as const,
sessionId,
recordId: `turn-lifecycle:${turnId}`
}
if (state === 'completed') {
deps.sink.appendTombstone(identity)
} else {
deps.sink.appendItem(identity, {
kind: 'status',
text: 'Codex is working…',
turnLifecycle: { turnId, state }
})
}
deps.sink.publish()
}
const identityFor = (
threadId: string,
turnId: string | null,
item: { type: string; id: string }
): AgentJournalItemIdentity => {
const key = codexStructuredItemKey(threadId, item.id)
const existing = identities.get(key)
if (existing) {
return existing
}
const identity = codexItemIdentity({ threadId, turnId, item, ordinals })
identities.set(key, identity)
return identity
}
const streams = createCodexStructuredItemStreams({
sink: deps.sink,
coalesceMs: deps.coalesceMs,
schedule: deps.schedule,
identityFor: (threadId, params, item) => {
const turnId = readCodexTurnId(params) ?? currentTurnIdFor(threadId)
return identityFor(threadId, turnId, item)
}
})
const handleItemEvent = (event: {
threadId: string
method: string
params: unknown
}): boolean => {
const params = readRecord(event.params)
const item = readCodexThreadItem(params.item)
if (!item) {
return false
}
const turnId = readCodexTurnId(event.params) ?? currentTurnIdFor(event.threadId)
const identity = identityFor(event.threadId, turnId, item)
const translated = codexJournalItem(item)
const command = readString(item, 'command')
if (command) {
details.set(codexStructuredItemKey(event.threadId, item.id), command)
}
if (event.method === 'item/completed') {
// The completed body is authoritative; the coalesced text is now stale.
streams.forget(event.threadId, item.id)
} else {
streams.track(event.threadId, item, identity)
}
if (!translated.body) {
return true
}
deps.sink.appendItem(identity, translated.body, translated.blobs)
deps.sink.publish()
return true
}
// The row is keyed by the prompt and the announced command is looked up by the
// tool item, because one item can ask more than once.
const handlePrompt = (event: {
threadId: string
method: string
params: unknown
codexItemId: string
promptKey: string
}): void => {
if (event.method === CODEX_USER_INPUT_METHOD) {
for (const question of codexQuestionItems({
threadId: event.threadId,
promptKey: event.promptKey,
params: event.params
})) {
deps.sink.appendItem(question.identity, question.body)
deps.bindPromptItemId?.(
agentJournalItemKey(question.identity),
event.threadId,
event.promptKey
)
}
deps.sink.publish()
return
}
const identity = codexPromptIdentity({
threadId: event.threadId,
promptKey: event.promptKey
})
deps.sink.appendItem(
identity,
codexApprovalItem({
method: event.method,
params: event.params,
detail: details.get(codexStructuredItemKey(event.threadId, event.codexItemId)) ?? null
})
)
deps.bindPromptItemId?.(agentJournalItemKey(identity), event.threadId, event.promptKey)
deps.sink.publish()
}
return {
restoreThread: (threadId, thread) => {
const turns = Array.isArray(thread.turns) ? thread.turns : []
for (const rawTurn of turns) {
const turn = readRecord(rawTurn)
const turnId = readString(turn, 'id')
if (!turnId) {
continue
}
currentTurnIds.set(threadId, new Set([turnId]))
for (const item of Array.isArray(turn.items) ? turn.items : []) {
handleItemEvent({ threadId, method: 'item/completed', params: { turnId, item } })
}
currentTurnIds.delete(threadId)
ordinals.forgetTurn(threadId, turnId)
}
streams.flush()
},
handle: (event) => {
if (event.type === 'ended') {
streams.flush()
for (const [threadId, turnIds] of currentTurnIds) {
for (const turnId of turnIds) {
publishTurnLifecycle(event.sessionId, threadId, turnId, 'completed')
ordinals.forgetTurn(threadId, turnId)
}
}
currentTurnIds.clear()
return
}
if (
event.type === 'notification' &&
streams.handle(event.threadId, event.method, event.params)
) {
return
}
// Lifecycle bypass: nothing may be journaled ahead of the text it follows.
streams.flush()
if (event.type === 'prompt') {
handlePrompt(event)
return
}
if (event.type === 'server-request') {
appendUnhandled(`request:${event.method}`, event.params, event.threadId)
return
}
if (event.type === 'provider-frame') {
appendUnhandled(event.kind, event.payload, event.threadId)
return
}
if (event.method === 'turn/started') {
const turnId = readCodexTurnId(event.params)
if (turnId) {
rememberTurn(event.threadId, turnId)
publishTurnLifecycle(event.sessionId, event.threadId, turnId, 'running')
}
return
}
if (event.method === 'turn/completed') {
const turnId = readCodexTurnId(event.params) ?? currentTurnIdFor(event.threadId)
if (turnId) {
publishTurnLifecycle(event.sessionId, event.threadId, turnId, 'completed')
ordinals.forgetTurn(event.threadId, turnId)
forgetTurn(event.threadId, turnId)
}
// A later item without its own turn id falls back to another active
// turn, if one exists; completed turns are never adopted again.
return
}
if (event.method === 'item/started' || event.method === 'item/completed') {
if (!handleItemEvent(event)) {
appendUnhandled(`notification:${event.method}`, event.params, event.threadId)
}
return
}
appendUnhandled(`notification:${event.method}`, event.params, event.threadId)
},
flush: streams.flush,
dispose: () => {
streams.dispose()
identities.clear()
details.clear()
currentTurnIds.clear()
genericRowsByTurn.clear()
suppressedRowsByTurn.clear()
}
}
}
@@ -0,0 +1,168 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution'
const SESSION_ID = 'session-1'
const IDENTITY = { sessionId: SESSION_ID } as Parameters<
ReturnType<typeof createCodexStructuredLaunchResolver>
>[0]['identity']
async function withPlatform<T>(platform: NodeJS.Platform, run: () => Promise<T>): Promise<T> {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await run()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
}
function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord {
return {
sessionId: SESSION_ID,
provider: 'codex',
location: {
executionHostId: LOCAL_EXECUTION_HOST_ID,
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
accountHome: { variable: 'CODEX_HOME', path: '/home/work/.codex' },
providerHandleChain: [],
...overrides
} as AgentSessionRecord
}
function resolverFor(
value: AgentSessionRecord | null,
resolveWorkspacePath: (workspaceId: string) => Promise<string> = async (id) => `/repos/${id}`,
resolveRollout: () => Promise<string | null> = async () => null
) {
return createCodexStructuredLaunchResolver({
store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
resolveWorkspacePath,
resolveCommand: () => '/usr/local/bin/codex',
resolveRollout
})
}
describe('codex structured launch resolution', () => {
it('launches the app server in the workspace and account home the record pinned', async () => {
const launch = await resolverFor(record())({ identity: IDENTITY })
expect(launch).toEqual({
command: '/usr/local/bin/codex',
args: ['app-server'],
cwd: '/repos/workspace-1',
codexHome: '/home/work/.codex',
resumeThreadId: null
})
})
it('passes a Windows .cmd path containing cmd syntax directly to the safe spawn layer', async () => {
const command = String.raw`C:\Users\r&d\npm-prefix\codex.cmd`
await withPlatform('win32', async () => {
const resolveLaunch = createCodexStructuredLaunchResolver({
store: { getRecord: () => record() } as unknown as AgentSessionRecordStore,
resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`,
resolveCommand: () => command
})
await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({
command,
args: ['app-server']
})
})
})
it('resumes the last thread this session actually proved, not one a caller names', async () => {
const launch = await resolverFor(
record({
providerHandleChain: [
{ handle: { provider: 'codex', threadId: 'thread-old' } },
{ handle: { provider: 'codex', threadId: 'thread-current' } }
] as AgentSessionRecord['providerHandleChain']
})
)({ identity: IDENTITY })
expect(launch.resumeThreadId).toBe('thread-current')
})
it('places the durable user configuration before the app-server subcommand', async () => {
const launch = await resolverFor(
record({ launchArgs: ['--profile', 'review', '-c', 'model_reasoning_effort=high'] })
)({ identity: IDENTITY })
expect(launch.args).toEqual([
'--profile',
'review',
'-c',
'model_reasoning_effort=high',
'app-server'
])
})
it('pins resume to the rollout file that proved the durable thread', async () => {
const resolveRollout = vi.fn(async () => '/home/work/.codex/sessions/rollout.jsonl')
const launch = await resolverFor(
record({
providerHandleChain: [
{ handle: { provider: 'codex', threadId: 'thread-current' } }
] as AgentSessionRecord['providerHandleChain']
}),
async (id) => `/repos/${id}`,
resolveRollout
)({ identity: IDENTITY })
expect(resolveRollout).toHaveBeenCalledWith('/home/work/.codex', 'thread-current')
expect(launch.resumePath).toBe('/home/work/.codex/sessions/rollout.jsonl')
})
it('refuses a session pinned to another host rather than starting a second writer here', async () => {
await expect(
resolverFor(
record({
location: { ...record().location, executionHostId: 'ssh:build-box' }
} as Partial<AgentSessionRecord>)
)({ identity: IDENTITY })
).rejects.toThrow(/local host/)
})
it('refuses a WSL session, which is a separate filesystem and process namespace', async () => {
await expect(
resolverFor(record({ location: { ...record().location, wslDistro: 'Ubuntu' } }))({
identity: IDENTITY
})
).rejects.toThrow(/local host/)
})
it('refuses a record this adapter does not speak for', async () => {
await expect(
resolverFor(record({ provider: 'claude' } as Partial<AgentSessionRecord>))({
identity: IDENTITY
})
).rejects.toThrow(/is a claude session/)
await expect(
resolverFor(
record({ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/.claude' } })
)({
identity: IDENTITY
})
).rejects.toThrow(/CODEX_HOME/)
})
it('refuses to launch for a session the store has no record of', async () => {
await expect(resolverFor(null)({ identity: IDENTITY })).rejects.toThrow(/no durable/)
})
it('surfaces a workspace that no longer resolves instead of falling back to a default cwd', async () => {
await expect(
resolverFor(record(), async () => {
throw new Error('workspace-1 is gone')
})({ identity: IDENTITY })
).rejects.toThrow('workspace-1 is gone')
})
})
@@ -0,0 +1,81 @@
// How a durable session record becomes a Codex process launch.
//
// Every input is read back from the record the store already made durable, not
// from the call that triggered the acquire. A client that attaches twice must
// land in the same working directory under the same account home, and a resume
// must name the thread this session actually proved — never one a caller asks
// for, which is how a resume becomes a fork wearing a resume's name.
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { resolveCodexCommand } from '../codex-cli/command'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
import { resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof'
export type CodexStructuredLaunchResolverDeps = {
store: AgentSessionRecordStore
/** Absolute path of a workspace on this host. Rejects when the workspace no
* longer resolves, which is the case a stale mobile client hits. */
resolveWorkspacePath: (workspaceId: string) => Promise<string>
/** Overridden in tests; production scans the boot-cached PATH and version-manager dirs. */
resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
/** Fresh shell/configured environment for this spawn; never written to the session record. */
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
resolveRollout?: typeof resolvePinnedCodexRolloutProof
}
export function createCodexStructuredLaunchResolver(
deps: CodexStructuredLaunchResolverDeps
): (input: { identity: AgentSessionJournalIdentity }) => Promise<CodexStructuredLaunch> {
return async ({ identity }) => {
const record = deps.store.getRecord(identity.sessionId)
if (!record) {
throw new Error(`no durable agent-session record for ${identity.sessionId}`)
}
const { location, accountHome } = record
if (record.provider !== 'codex') {
throw new Error(`session ${identity.sessionId} is a ${record.provider} session`)
}
// This adapter spawns a child on the machine the runtime itself runs on.
// A session pinned elsewhere belongs to that host's runtime, and quietly
// starting it here would put a second writer on the same thread.
if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) {
throw new Error(
`codex structured sessions run on the local host, not ${location.executionHostId}`
)
}
if (accountHome.variable !== 'CODEX_HOME') {
throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`)
}
const environment = await deps.resolveEnvironment?.()
const pathEnv = environment?.PATH ?? environment?.Path ?? null
const homePath = environment?.HOME ?? environment?.USERPROFILE
const command = (deps.resolveCommand ?? resolveCodexCommand)({
pathEnv,
...(homePath ? { homePath } : {})
})
const args = [...(record.launchArgs ?? []), 'app-server']
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null
return {
command,
args,
cwd: await deps.resolveWorkspacePath(location.workspaceId),
codexHome: accountHome.path,
...(environment ? { env: { ...environment } as Record<string, string> } : {}),
// An empty chain is a session that has never proved a thread, so it
// starts one; anything else resumes the last link this session proved.
resumeThreadId,
...(resumeThreadId
? {
resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)(
accountHome.path,
resumeThreadId
)
}
: {})
}
}
}
@@ -0,0 +1,11 @@
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
export function supportsCodexStructuredLocation(location: AgentSessionExecutionLocation): boolean {
return (
location.executionHostId === LOCAL_EXECUTION_HOST_ID &&
location.wslDistro === null &&
(process.platform !== 'win32' || isWindowsProcessStartTimeAvailable())
)
}
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import { codexProcessIdentity } from './codex-structured-owner-identity'
const IDENTITY = {
sessionId: 'session-identity',
workspaceId: 'workspace-1',
hostId: 'local',
agent: 'codex' as const,
providerHandle: { kind: 'codex' as const, threadId: 'thread-1' }
}
describe('codex process identity', () => {
it('records the observed start time alongside the spawn token', async () => {
await expect(
codexProcessIdentity(
{ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 },
async () => 123
)
).resolves.toEqual({
hostId: 'local',
pid: 4242,
processStartTimeMs: 123,
spawnToken: 'spawn-a'
})
})
it('retries a failed start-time read before giving up', async () => {
const readStartTime = vi
.fn<(pid: number) => Promise<number | null>>()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(456)
await expect(
codexProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime)
).resolves.toMatchObject({ processStartTimeMs: 456 })
expect(readStartTime).toHaveBeenCalledTimes(3)
})
it('refuses an owner whose start time is unreadable rather than record one no probe can verify', async () => {
// A null start time guarantees every later owner probe answers indeterminate, which is
// a durable latch; refusing here is a retryable failure instead.
const readStartTime = vi.fn(async () => null)
await expect(
codexProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime)
).rejects.toThrow('start time')
expect(readStartTime).toHaveBeenCalledTimes(3)
})
})
@@ -0,0 +1,64 @@
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe'
// What the lease records about the child Codex just handed back: the process it
// will later re-prove, and the provider handle link the journal binds to. Both
// must describe the thread Codex actually opened, never the one a client asked
// for.
/** The child echoes its spawn token here so the owner probe can tell a live
* child of THIS reservation from a same-pid stranger. */
export const CODEX_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN'
const START_TIME_READ_ATTEMPTS = 3
export async function codexProcessIdentity(
input: {
identity: AgentSessionJournalIdentity
spawnToken: string
pid: number | undefined
},
readStartTime: (pid: number) => Promise<number | null> = readProcessStartTimeMs
): Promise<AgentSessionProcessIdentity> {
if (input.pid === undefined) {
throw new Error('codex app-server started without a pid')
}
let processStartTimeMs: number | null = null
for (
let attempt = 0;
attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null;
attempt += 1
) {
processStartTimeMs = await readStartTime(input.pid)
}
if (processStartTimeMs === null) {
// Why: recording null makes every later owner probe indeterminate — a durable latch.
// Failing here reaps the child and leaves a retryable refusal instead.
throw new Error(`codex app-server start time for pid ${input.pid} could not be read`)
}
return {
hostId: input.identity.hostId,
pid: input.pid,
processStartTimeMs,
spawnToken: input.spawnToken
}
}
export function codexProviderHandleLink(input: {
threadId: string
resumed: boolean
origin?: 'adopted'
fence: number
linkId?: string
observedAt: number
}): AgentSessionProviderHandleLink {
return {
linkId: input.linkId ?? `codex-${input.fence}-${input.threadId}`.slice(0, 128),
handle: { provider: 'codex', threadId: input.threadId },
origin: input.origin ?? (input.resumed ? 'resumed' : 'created'),
mintedAtFence: input.fence,
observedAt: input.observedAt
}
}
@@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest'
import {
codexApprovalItem,
codexApprovalOptions,
codexPromptIdentity,
codexQuestionItems
} from './codex-structured-prompt-items'
import {
CODEX_COMMAND_APPROVAL_METHOD,
CODEX_FILE_CHANGE_APPROVAL_METHOD,
encodeCodexQuestionOptionId
} from './codex-structured-prompt-replies'
const THREAD_ID = 'thread-abc'
const CODEX_ITEM_ID = 'item-4'
describe('codex approval items', () => {
it('offers only the decisions this request named', () => {
expect(codexApprovalOptions({ availableDecisions: ['accept', 'decline'] })).toEqual([
{ id: 'accept', label: 'Allow' },
{ id: 'decline', label: 'Deny' }
])
})
it('offers the full set when the request names none, so the turn stays answerable', () => {
expect(codexApprovalOptions({}).map((option) => option.id)).toEqual([
'accept',
'acceptForSession',
'decline',
'cancel'
])
})
it('drops a decision this build cannot send rather than offering a dead button', () => {
expect(
codexApprovalOptions({ availableDecisions: ['accept', 'teleport'] }).map((o) => o.id)
).toEqual(['accept'])
})
it('titles the prompt by what codex asked for and starts it pending', () => {
const command = codexApprovalItem({
method: CODEX_COMMAND_APPROVAL_METHOD,
params: { availableDecisions: ['accept'] },
detail: 'rm -rf build'
})
expect(command).toMatchObject({
kind: 'approval',
title: 'Run a command?',
detail: 'rm -rf build',
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
})
expect(
codexApprovalItem({ method: CODEX_FILE_CHANGE_APPROVAL_METHOD, params: {}, detail: null })
).toMatchObject({ title: 'Apply file changes?', detail: null })
expect(
codexApprovalItem({ method: 'item/other/requestApproval', params: {}, detail: null })
).toMatchObject({ title: 'Approve this action?' })
})
it("prefers codex's own reason over the command the item announced", () => {
const item = codexApprovalItem({
method: CODEX_COMMAND_APPROVAL_METHOD,
params: { reason: 'writes outside the workspace' },
detail: 'rm -rf build'
})
expect(item.detail).toBe('writes outside the workspace')
})
it('prefers the approval request command and describes file-change grants', () => {
expect(
codexApprovalItem({
method: CODEX_COMMAND_APPROVAL_METHOD,
params: { command: ['git', 'status'] },
detail: 'parent command'
}).detail
).toBe('git status')
expect(
codexApprovalItem({
method: CODEX_COMMAND_APPROVAL_METHOD,
params: { command: ['pnpm', 'test'], reason: 'same parent reason' },
detail: 'parent command'
}).detail
).toBe('pnpm test')
expect(
codexApprovalItem({
method: CODEX_FILE_CHANGE_APPROVAL_METHOD,
params: { grantRoot: '/outside' },
detail: null
}).detail
).toBe('"/outside"')
})
})
describe('codex question items', () => {
const params = {
questions: [
{
id: 'q1',
question: 'Which branch?',
options: [{ label: 'main' }, { label: 'release/1.0' }]
},
{ id: 'q2', header: 'Proceed?', options: [{ label: 'yes' }] }
]
}
it('makes one journal item per question, each with its own resolution', () => {
const items = codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params })
expect(items.map((item) => item.questionId)).toEqual(['q1', 'q2'])
expect(items.map((item) => item.body.question)).toEqual(['Which branch?', 'Proceed?'])
expect(items[0]?.body.resolution.state).toBe('pending')
})
it('keys each question separately so two answers cannot collide on one row', () => {
const items = codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params })
expect(items.map((item) => item.identity)).toEqual([
{ provider: 'orca', clientMessageId: 'codex-prompt:thread-abc:item-4:q1' },
{ provider: 'orca', clientMessageId: 'codex-prompt:thread-abc:item-4:q2' }
])
})
it('names the question inside every option id, because codex replies by question', () => {
const items = codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params })
expect(items[0]?.body.options).toEqual([
{ id: encodeCodexQuestionOptionId('q1', 'main'), label: 'main' },
{ id: encodeCodexQuestionOptionId('q1', 'release/1.0'), label: 'release/1.0' }
])
expect(items[0]?.body.options[1]?.id).toBe('q1:release%2F1.0')
})
it('skips a question with no id or no prompt rather than minting an unanswerable row', () => {
const items = codexQuestionItems({
threadId: THREAD_ID,
promptKey: CODEX_ITEM_ID,
params: { questions: [{ question: 'no id' }, { id: 'q3' }, { id: 'q4', question: 'ok' }] }
})
expect(items.map((item) => item.questionId)).toEqual(['q4'])
})
it('returns nothing when the request carries no questions at all', () => {
expect(
codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params: {} })
).toEqual([])
})
it('preserves a free-text path for null options and Other', () => {
const [withoutOptions, withOther] = codexQuestionItems({
threadId: THREAD_ID,
promptKey: CODEX_ITEM_ID,
params: {
questions: [
{ id: 'q1', question: 'Describe it', options: null },
{
id: 'q2',
question: 'Pick or type',
options: [{ label: 'Known' }, { label: 'Other', isOther: true }]
}
]
}
})
expect(withoutOptions?.body).toMatchObject({ options: [], freeTextQuestionId: 'q1' })
expect(withOther?.body).toMatchObject({
options: [{ id: 'q2:Known', label: 'Known' }],
freeTextQuestionId: 'q2'
})
})
it('keys an approval without a question id', () => {
expect(codexPromptIdentity({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID })).toEqual({
provider: 'orca',
clientMessageId: 'codex-prompt:thread-abc:item-4'
})
})
})
@@ -0,0 +1,188 @@
import type {
AgentJournalApprovalItem,
AgentJournalItemIdentity,
AgentJournalPromptOption,
AgentJournalQuestionItem
} from '../../shared/agent-session-journal-types'
import {
CODEX_APPROVAL_DECISIONS,
CODEX_COMMAND_APPROVAL_METHOD,
CODEX_FILE_CHANGE_APPROVAL_METHOD,
encodeCodexQuestionOptionId,
type CodexApprovalDecision
} from './codex-structured-prompt-replies'
// Codex prompt requests → durable journal items.
//
// Codex blocks the turn on these, but the answer may arrive minutes later from
// a different device, so the prompt has to exist as a journal item with its own
// resolution state rather than as live callback state. The reply path already
// lives in `codex-structured-prompt-replies.ts`; this is only the render model.
const APPROVAL_DECISION_LABELS: Record<CodexApprovalDecision, string> = {
accept: 'Allow',
acceptForSession: 'Allow for this session',
decline: 'Deny',
cancel: 'Stop'
}
const PENDING = {
state: 'pending',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
} as const
function readParams(params: unknown): Record<string, unknown> {
return typeof params === 'object' && params !== null ? (params as Record<string, unknown>) : {}
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
/**
* Codex offers a per-request decision set, so the options come off the request
* when it names them. Falling back to the full set is deliberate: a build that
* omits the field still accepts all four, and offering nothing would leave the
* turn blocked with no way to answer it.
*/
export function codexApprovalOptions(params: unknown): AgentJournalPromptOption[] {
const available = readParams(params).availableDecisions
const offered = Array.isArray(available)
? available.filter((decision): decision is CodexApprovalDecision =>
(CODEX_APPROVAL_DECISIONS as readonly unknown[]).includes(decision)
)
: []
const decisions = offered.length > 0 ? offered : CODEX_APPROVAL_DECISIONS
return decisions.map((decision) => ({ id: decision, label: APPROVAL_DECISION_LABELS[decision] }))
}
export function codexApprovalItem(input: {
method: string
params: unknown
/** What is being approved, taken from the item Codex already announced —
* the approval request itself does not repeat the command or the patch. */
detail: string | null
}): AgentJournalApprovalItem {
const params = readParams(input.params)
return {
kind: 'approval',
title:
input.method === CODEX_FILE_CHANGE_APPROVAL_METHOD
? 'Apply file changes?'
: input.method === CODEX_COMMAND_APPROVAL_METHOD
? 'Run a command?'
: 'Approve this action?',
detail: approvalDetail(params) ?? input.detail,
options: codexApprovalOptions(input.params),
resolution: { ...PENDING }
}
}
function approvalDetail(params: Record<string, unknown>): string | null {
const command = params.command
if (typeof command === 'string' && command.length > 0) {
return command
}
if (Array.isArray(command) && command.every((part) => typeof part === 'string')) {
return command.join(' ')
}
const reason = readString(params, 'reason')
if (reason) {
return reason
}
const detail = params.grantRoot ?? params.changes
return detail === undefined ? null : JSON.stringify(detail)
}
export type CodexQuestionItem = {
questionId: string
identity: AgentJournalItemIdentity
body: AgentJournalQuestionItem
}
/**
* One journal item per question, not one per request. Codex takes a single
* reply covering every question, but a client answers them one at a time, and
* each answer has to win its own compare-and-set — so each question needs its
* own resolution state. The reply fires when the last one lands.
*/
export function codexQuestionItems(input: {
threadId: string
promptKey: string
params: unknown
}): CodexQuestionItem[] {
const questions = readParams(input.params).questions
if (!Array.isArray(questions)) {
return []
}
const items: CodexQuestionItem[] = []
for (const entry of questions) {
const question = readParams(entry)
const questionId = readString(question, 'id')
const prompt = readString(question, 'question') ?? readString(question, 'header')
if (!questionId || !prompt) {
continue
}
items.push({
questionId,
identity: codexPromptIdentity({ ...input, questionId }),
body: {
kind: 'question',
question: prompt,
options: questionOptions(question, questionId),
...(questionAllowsFreeText(question) ? { freeTextQuestionId: questionId } : {}),
resolution: { ...PENDING }
}
})
}
return items
}
function questionAllowsFreeText(question: Record<string, unknown>): boolean {
const options = question.options
return (
!Array.isArray(options) ||
options.length === 0 ||
options.some((option) => readParams(option).isOther === true)
)
}
function questionOptions(
question: Record<string, unknown>,
questionId: string
): AgentJournalPromptOption[] {
const options = question.options
if (!Array.isArray(options)) {
return []
}
const mapped: AgentJournalPromptOption[] = []
for (const entry of options) {
const option = readParams(entry)
const label = readString(option, 'label')
if (label !== null && option.isOther !== true) {
// The option id has to name its question: Codex's reply is a map keyed by
// question id, and the client only ever hands back an option id.
mapped.push({ id: encodeCodexQuestionOptionId(questionId, label), label })
}
}
return mapped
}
/** Prompts are live-session state Codex does not persist, so they are keyed in
* the Orca namespace rather than by `(threadId, turnId, ordinal)`. */
/** Keyed by the prompt, not by the tool item it is about: one shell item can
* ask several times, and each ask is its own journal row to answer. */
export function codexPromptIdentity(input: {
threadId: string
promptKey: string
questionId?: string
}): AgentJournalItemIdentity {
const suffix = input.questionId ? `:${input.questionId}` : ''
return {
provider: 'orca',
clientMessageId: `codex-prompt:${input.threadId}:${input.promptKey}${suffix}`
}
}
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import {
applyCodexPromptAnswer,
CodexPromptRegistry,
decodeCodexQuestionOptionId,
encodeCodexQuestionOptionId
} from './codex-structured-prompt-replies'
function userInputRequest(questionIds: string[]): {
id: number
method: string
params: unknown
} {
return {
id: 5,
method: 'item/tool/requestUserInput',
params: {
itemId: 'codex-item-1',
threadId: 'thread-1',
turnId: 'turn-1',
questions: questionIds.map((id) => ({ id }))
}
}
}
describe('codex question option ids', () => {
it('round-trips a question id that itself contains the separator', () => {
const optionId = encodeCodexQuestionOptionId('scope:write', 'yes / no')
expect(decodeCodexQuestionOptionId(optionId)).toEqual({
questionId: 'scope:write',
answer: 'yes / no'
})
})
it('reads nothing from an id with no separator', () => {
expect(decodeCodexQuestionOptionId('accept')).toBeNull()
})
})
describe('CodexPromptRegistry', () => {
it('ignores a request that names no item or thread', () => {
const registry = new CodexPromptRegistry()
expect(
registry.register({ id: 1, method: 'item/tool/requestUserInput', params: { itemId: 'i1' } })
).toBeNull()
expect(registry.register({ id: 2, method: 'account/refresh', params: {} })).toBeNull()
})
it('keeps two prompts that share one tool item apart', () => {
const registry = new CodexPromptRegistry()
const ask = (id: number, approvalId: string): void => {
registry.register({
id,
method: 'item/commandExecution/requestApproval',
params: { itemId: 'codex-item-1', approvalId, threadId: 'thread-1' }
})
}
ask(1, 'approval-a')
ask(2, 'approval-b')
// The second ask must not have replaced the first, or the turn blocks on a
// request nobody can address any more.
expect(registry.find('approval-a')?.requestId).toBe(1)
expect(registry.find('approval-b')?.requestId).toBe(2)
// Nothing addresses the shared item id, because it names two live prompts.
expect(registry.find('codex-item-1')).toBeNull()
})
it('addresses a prompt by its journal item id once bound, and forgets both', () => {
const registry = new CodexPromptRegistry()
const prompt = registry.register(userInputRequest(['q1']))
registry.bindJournalItemId('codex:thread-1:turn-1:2', 'thread-1', 'codex-item-1')
expect(registry.find('codex:thread-1:turn-1:2')).toBe(prompt)
expect(registry.find('codex-item-1')).toBe(prompt)
registry.forget(prompt as NonNullable<typeof prompt>)
expect(registry.find('codex:thread-1:turn-1:2')).toBeNull()
expect(registry.find('codex-item-1')).toBeNull()
})
it('keeps identical item ids on different threads independently answerable', () => {
const registry = new CodexPromptRegistry()
const register = (id: number, threadId: string) =>
registry.register({
id,
method: 'item/commandExecution/requestApproval',
params: { itemId: 'item-2', threadId }
})
register(1, 'thread-root')
register(2, 'thread-child')
registry.bindJournalItemId('journal-root', 'thread-root', 'item-2')
registry.bindJournalItemId('journal-child', 'thread-child', 'item-2')
expect(registry.find('journal-root')?.requestId).toBe(1)
expect(registry.find('journal-child')?.requestId).toBe(2)
expect(registry.find('item-2')).toBeNull()
})
})
describe('applyCodexPromptAnswer', () => {
it('accepts a bare answer only when the request has one question', () => {
const registry = new CodexPromptRegistry()
const single = registry.register(userInputRequest(['q1']))
expect(applyCodexPromptAnswer(single as NonNullable<typeof single>, 'sure')).toEqual({
answers: { q1: { answers: ['sure'] } }
})
})
it('refuses an answer that names no question of a multi-question request', () => {
const registry = new CodexPromptRegistry()
const many = registry.register(userInputRequest(['q1', 'q2']))
expect(() => applyCodexPromptAnswer(many as NonNullable<typeof many>, 'sure')).toThrow(
'does not name a question'
)
expect(() =>
applyCodexPromptAnswer(
many as NonNullable<typeof many>,
encodeCodexQuestionOptionId('q3', 'sure')
)
).toThrow('does not name a question')
})
it('keeps the last answer when a question is answered twice', () => {
const registry = new CodexPromptRegistry()
const single = registry.register(userInputRequest(['q1']))
const prompt = single as NonNullable<typeof single>
applyCodexPromptAnswer(prompt, encodeCodexQuestionOptionId('q1', 'first'))
expect(applyCodexPromptAnswer(prompt, encodeCodexQuestionOptionId('q1', 'second'))).toEqual({
answers: { q1: { answers: ['second'] } }
})
})
})
@@ -0,0 +1,209 @@
import type { CodexAppServerConnection } from './codex-app-server-connection'
// Codex asks for approvals and tool input by sending JSON-RPC REQUESTS back to
// Orca, and the turn blocks until each one is answered. The journal answers them
// much later, through a durable item id, so this module holds the live request
// ids and turns a chosen option back into the reply payload Codex expects.
export const CODEX_COMMAND_APPROVAL_METHOD = 'item/commandExecution/requestApproval'
export const CODEX_FILE_CHANGE_APPROVAL_METHOD = 'item/fileChange/requestApproval'
export const CODEX_USER_INPUT_METHOD = 'item/tool/requestUserInput'
/** The decisions Codex accepts for both approval requests. Anything else is a
* client-supplied option id that never came from a Codex prompt. */
export const CODEX_APPROVAL_DECISIONS = ['accept', 'acceptForSession', 'decline', 'cancel'] as const
export type CodexApprovalDecision = (typeof CODEX_APPROVAL_DECISIONS)[number]
export type CodexPendingPrompt = {
requestId: number | string
method: string
threadId: string
turnId: string | null
codexItemId: string
/** What addresses this prompt. One tool item can ask more than once — a shell
* bridge re-asks per command under the same `itemId` — so the request's own
* `approvalId` is the identity whenever Codex sends one. */
promptKey: string
/** One entry per question for a user-input request; empty for an approval. */
questionIds: readonly string[]
answers: Map<string, string>
}
/** A user-input request can carry several questions but takes ONE reply, so an
* option id has to name the question it answers. */
export function encodeCodexQuestionOptionId(questionId: string, answer: string): string {
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
}
export function decodeCodexQuestionOptionId(
optionId: string
): { questionId: string; answer: string } | null {
const separator = optionId.indexOf(':')
if (separator <= 0) {
return null
}
try {
return {
questionId: decodeURIComponent(optionId.slice(0, separator)),
answer: decodeURIComponent(optionId.slice(separator + 1))
}
} catch {
return null
}
}
function readString(params: unknown, key: string): string | null {
if (typeof params !== 'object' || params === null) {
return null
}
const value = (params as Record<string, unknown>)[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
function readQuestionIds(params: unknown): string[] {
const questions = (params as { questions?: unknown } | null)?.questions
if (!Array.isArray(questions)) {
return []
}
return questions
.map((question) => (question as { id?: unknown })?.id)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
}
export function isCodexPromptMethod(method: string): boolean {
return (
method === CODEX_COMMAND_APPROVAL_METHOD ||
method === CODEX_FILE_CHANGE_APPROVAL_METHOD ||
method === CODEX_USER_INPUT_METHOD
)
}
/**
* Live Codex prompt requests for one session, addressable by the journal item
* id the client will eventually answer with. The binding is registered by the
* translation module, because only it knows which journal item a Codex item
* became.
*/
export class CodexPromptRegistry {
private readonly byAddress = new Map<string, CodexPendingPrompt>()
/** Journal item id to thread-scoped prompt address. */
private readonly journalItemIds = new Map<string, string>()
private address(threadId: string, promptKey: string): string {
return `${encodeURIComponent(threadId)}:${encodeURIComponent(promptKey)}`
}
/** Returns null for a request this build does not model, so the caller can
* refuse it instead of leaving Codex blocked on an answer forever. */
register(request: {
id: number | string
method: string
params: unknown
}): CodexPendingPrompt | null {
const codexItemId = readString(request.params, 'itemId')
const threadId = readString(request.params, 'threadId')
if (!isCodexPromptMethod(request.method) || !codexItemId || !threadId) {
return null
}
const prompt: CodexPendingPrompt = {
requestId: request.id,
method: request.method,
threadId,
turnId: readString(request.params, 'turnId'),
codexItemId,
promptKey: readString(request.params, 'approvalId') ?? codexItemId,
questionIds:
request.method === CODEX_USER_INPUT_METHOD ? readQuestionIds(request.params) : [],
answers: new Map()
}
this.byAddress.set(this.address(prompt.threadId, prompt.promptKey), prompt)
return prompt
}
/** Called by the translation module once the prompt has a journal id. */
bindJournalItemId(journalItemId: string, threadId: string, promptKey: string): void {
this.journalItemIds.set(journalItemId, this.address(threadId, promptKey))
}
/** Falls back to treating the id as a prompt key, which is what it is before
* any binding exists. */
find(journalItemId: string): CodexPendingPrompt | null {
const address = this.journalItemIds.get(journalItemId)
if (address) {
return this.byAddress.get(address) ?? null
}
const matches = [...this.byAddress.values()].filter(
(prompt) => prompt.promptKey === journalItemId
)
return matches.length === 1 ? matches[0]! : null
}
forget(prompt: CodexPendingPrompt): void {
const address = this.address(prompt.threadId, prompt.promptKey)
this.byAddress.delete(address)
for (const [journalItemId, boundAddress] of this.journalItemIds) {
if (boundAddress === address) {
this.journalItemIds.delete(journalItemId)
}
}
}
clear(): void {
this.byAddress.clear()
this.journalItemIds.clear()
}
}
/**
* Records one answer and returns the reply payload once the request is fully
* answered. A multi-question user-input request stays pending until every
* question has an answer, because Codex takes one reply for all of them.
*/
export function applyCodexPromptAnswer(
prompt: CodexPendingPrompt,
optionId: string
): Record<string, unknown> | null {
if (prompt.method !== CODEX_USER_INPUT_METHOD) {
if (!(CODEX_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) {
throw new Error(`${optionId} is not a Codex approval decision`)
}
return { decision: optionId }
}
const decoded = decodeCodexQuestionOptionId(optionId)
const questionId =
decoded?.questionId ?? (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null)
const answer = decoded?.answer ?? optionId
if (!questionId || !prompt.questionIds.includes(questionId)) {
throw new Error(`${optionId} does not name a question on Codex item ${prompt.codexItemId}`)
}
prompt.answers.set(questionId, answer)
if (prompt.questionIds.some((id) => !prompt.answers.has(id))) {
return null
}
const answers: Record<string, { answers: string[] }> = {}
for (const id of prompt.questionIds) {
answers[id] = { answers: [prompt.answers.get(id) as string] }
}
return { answers }
}
/** Throws for a prompt Codex is no longer waiting on, which the wire reports as
* "recorded but not confirmed" rather than as a delivered answer. */
export function answerCodexPrompt(
registry: CodexPromptRegistry,
connection: Pick<CodexAppServerConnection, 'respond'>,
itemId: string,
optionId: string
): void {
const prompt = registry.find(itemId)
if (!prompt) {
throw new Error(`codex app-server is no longer waiting on ${itemId}`)
}
const reply = applyCodexPromptAnswer(prompt, optionId)
if (reply === null) {
return
}
// Forget first: a second answer must find nothing rather than reply twice.
registry.forget(prompt)
connection.respond(prompt.requestId, reply)
}
@@ -0,0 +1,77 @@
import type { CodexAppServerServerRequest } from './codex-app-server-connection'
import { disposeCodexServerRequest } from './codex-server-request-disposition'
import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state'
import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts'
type EmitCodexEvent = (session: CodexSession, event: CodexStructuredSessionEvent) => void
export function deliverCodexNotification(
sessionId: string,
session: CodexSession | undefined,
method: string,
params: unknown,
emit: EmitCodexEvent
): void {
if (!session) {
return
}
const threadId = readCodexThreadId(params) ?? session.threadId
if (method === 'turn/started' && threadId === session.threadId) {
const turnId = readCodexTurnId(params)
const waiter = turnId ? session.turnIdWaiters.shift() : undefined
waiter?.(turnId as string)
}
emit(session, { type: 'notification', sessionId, threadId, method, params })
}
export function deliverCodexServerRequest(
sessionId: string,
session: CodexSession | undefined,
request: CodexAppServerServerRequest,
emit: EmitCodexEvent
): void {
if (!session) {
return
}
const disposition = disposeCodexServerRequest(session.prompts, session.connection, request)
const threadId = readCodexThreadId(request.params) ?? session.threadId
if (disposition.kind === 'responded') {
emit(session, {
type: 'server-request',
sessionId,
threadId,
method: request.method,
params: request.params
})
return
}
const prompt = disposition.prompt
emit(session, {
type: 'prompt',
sessionId,
threadId: prompt.threadId,
method: request.method,
params: request.params,
codexItemId: prompt.codexItemId,
promptKey: prompt.promptKey
})
}
export function deliverCodexUnhandledFrame(
sessionId: string,
session: CodexSession | undefined,
kind: string,
payload: unknown,
emit: EmitCodexEvent
): void {
if (!session) {
return
}
emit(session, {
type: 'provider-frame',
sessionId,
threadId: readCodexThreadId(payload) ?? session.threadId,
kind,
payload
})
}
@@ -0,0 +1,883 @@
import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalMessageItem,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import { CodexAppServerRequestError } from './codex-app-server-connection'
import type {
CodexAppServerConnection,
CodexAppServerConnectionHandlers,
CodexAppServerLaunch,
openCodexAppServerConnection
} from './codex-app-server-connection'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity'
import { encodeCodexQuestionOptionId } from './codex-structured-prompt-replies'
import {
CodexStructuredSessionAdapter,
type CodexStructuredLaunch,
type CodexStructuredSessionAdapterDeps,
type CodexStructuredSessionEvent
} from './codex-structured-session-adapter'
const THREAD_ID = 'thread-abc'
function identityFor(sessionId: string): AgentSessionJournalIdentity {
return {
sessionId,
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: THREAD_ID }
}
}
const USER_MESSAGE: AgentJournalMessageItem = {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'ship it' }]
}
type Route = (params: Record<string, unknown> | undefined) => unknown
// `closed` is readonly on the real connection; the fake flips it so a test can
// kill the child at a chosen moment.
type FakeConnection = Omit<CodexAppServerConnection, 'closed'> & {
closed: boolean
launch: CodexAppServerLaunch
handlers: CodexAppServerConnectionHandlers
calls: { method: string; params?: Record<string, unknown> }[]
replies: { id: number | string; result?: unknown; code?: number; message?: string }[]
closeCount: number
}
/** Stands in for a live `codex app-server`: every RPC is answered from `routes`,
* and the test drives Codex's own traffic through `handlers`. */
function fakeCodex(routes: Record<string, Route> = {}): {
connections: FakeConnection[]
openConnection: typeof openCodexAppServerConnection
routes: Record<string, Route>
} {
const connections: FakeConnection[] = []
const openConnection = (async (launch, handlers = {}) => {
const connection: FakeConnection = {
launch,
handlers,
calls: [],
replies: [],
closeCount: 0,
pid: 4321,
closed: false,
request: async (method, params) => {
connection.calls.push({ method, params })
const route = routes[method]
return route ? route(params) : {}
},
notify: () => {},
respond: (id, result) => connection.replies.push({ id, result }),
respondWithError: (id, code, message) => connection.replies.push({ id, code, message }),
close: async () => {
connection.closeCount += 1
connection.closed = true
return true
}
}
connections.push(connection)
return connection
}) as typeof openCodexAppServerConnection
routes['thread/start'] ??= () => ({
thread: { id: THREAD_ID, path: '/rollouts/abc.jsonl' },
model: 'gpt-live',
reasoningEffort: 'medium'
})
routes['thread/resume'] ??= (params) => ({
thread: { id: (params as { threadId: string }).threadId },
model: 'gpt-live',
reasoningEffort: 'medium'
})
return { connections, openConnection, routes }
}
function adapterFor(
codex: ReturnType<typeof fakeCodex>,
launch: Partial<CodexStructuredLaunch> = {},
events: CodexStructuredSessionEvent[] = [],
processControl: Partial<
Pick<CodexStructuredSessionAdapterDeps, 'captureTurnProcesses' | 'terminateTurnProcesses'>
> = {}
): CodexStructuredSessionAdapter {
return new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: null,
...launch
}),
onEvent: (event) => events.push(event),
openConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
captureTurnProcesses: async () => ({ platform: 'win32', identities: new Map() }),
terminateTurnProcesses: async () => true,
now: () => 1_700_000_000_500,
...processControl
})
}
async function acquired(
codex: ReturnType<typeof fakeCodex>,
launch: Partial<CodexStructuredLaunch> = {},
events: CodexStructuredSessionEvent[] = []
): Promise<CodexStructuredSessionAdapter> {
const adapter = adapterFor(codex, launch, events)
await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' })
return adapter
}
describe('CodexStructuredSessionAdapter.acquire', () => {
it('starts a new thread and reports the process and link the lease will prove', async () => {
const codex = fakeCodex()
const adapter = adapterFor(codex, { codexHome: '/codex/home' })
const acquisition = await adapter.acquire({
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-9'
})
expect(codex.connections[0].launch.env).toEqual({
[CODEX_SPAWN_TOKEN_ENV]: 'spawn-9',
CODEX_HOME: '/codex/home'
})
expect(codex.connections[0].launch.cwd).toBe('/work/repo')
expect(codex.connections[0].calls[0]).toEqual({
method: 'thread/start',
params: { cwd: '/work/repo' }
})
expect(acquisition.process).toEqual({
hostId: 'host-1',
pid: 4321,
processStartTimeMs: 1_700_000_000_000,
spawnToken: 'spawn-9'
})
expect(acquisition.link).toEqual({
linkId: `codex-7-${THREAD_ID}`,
handle: { provider: 'codex', threadId: THREAD_ID },
origin: 'created',
mintedAtFence: 7,
observedAt: 1_700_000_000_500
})
})
it('resumes the thread the durable handle chain names, not the client one', async () => {
const codex = fakeCodex()
const adapter = adapterFor(codex, {
resumeThreadId: 'thread-proven',
resumePath: '/rollouts/thread-proven.jsonl'
})
const acquisition = await adapter.acquire({
identity: identityFor('session-1'),
fence: 9,
spawnToken: 'spawn-9'
})
expect(codex.connections[0].calls[0]).toEqual({
method: 'thread/resume',
params: {
threadId: 'thread-proven',
cwd: '/work/repo',
path: '/rollouts/thread-proven.jsonl'
}
})
expect(acquisition.link.origin).toBe('resumed')
expect(acquisition.link.handle).toEqual({ provider: 'codex', threadId: 'thread-proven' })
})
it('refuses a resume that lands on a different thread and reaps the child', async () => {
const codex = fakeCodex({ 'thread/resume': () => ({ thread: { id: 'thread-other' } }) })
const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven' })
await expect(
adapter.acquire({ identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' })
).rejects.toThrow('resumed thread-other instead of thread-proven')
expect(codex.connections[0].closeCount).toBe(1)
})
it('refuses a thread Codex never named', async () => {
const codex = fakeCodex({ 'thread/start': () => ({}) })
const adapter = adapterFor(codex)
await expect(
adapter.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-9' })
).rejects.toThrow('did not name the thread')
expect(codex.connections[0].closeCount).toBe(1)
})
it('closes the previous child before re-acquiring at a new fence', async () => {
const codex = fakeCodex()
const adapter = await acquired(codex)
await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' })
expect(codex.connections).toHaveLength(2)
expect(codex.connections[0].closeCount).toBe(1)
expect(codex.connections[1].closeCount).toBe(0)
})
it('keeps the traffic Codex sends before the session is published', async () => {
const codex = fakeCodex()
const events: CodexStructuredSessionEvent[] = []
codex.routes['thread/start'] = () => {
// Codex talks as soon as the child is up, which is before the adapter has
// a thread id to publish the session under.
codex.connections[0].handlers.onNotification?.('item/started', { threadId: THREAD_ID })
codex.connections[0].handlers.onServerRequest?.({
id: 5,
method: 'item/commandExecution/requestApproval',
params: { itemId: 'codex-item-early', threadId: THREAD_ID, turnId: 'turn-1' }
})
return { thread: { id: THREAD_ID } }
}
const adapter = await acquired(codex, {}, events)
expect(events.map((event) => event.type)).toEqual(['notification', 'prompt'])
// The early approval is answerable, so Codex is not left blocked on a
// request that arrived a moment too soon.
await adapter.answerPrompt({
sessionId: 'session-1',
itemId: 'codex-item-early',
kind: 'approval',
optionId: 'accept',
fence: 7
})
expect(codex.connections[0].replies).toEqual([{ id: 5, result: { decision: 'accept' } }])
})
it('refuses to publish a session whose child died while it was being acquired', async () => {
const codex = fakeCodex()
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: null
}),
openConnection: codex.openConnection,
// The child dies while the acquisition is still reading its identity.
readProcessStartTime: async () => {
codex.connections[0].closed = true
return 1_700_000_000_000
}
})
await expect(
adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' })
).rejects.toThrow('exited while being acquired')
expect(codex.connections[0].closeCount).toBe(1)
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
).rejects.toThrow('no live codex app-server')
})
it('classifies launch validation failure as pre-spawn without opening a child', async () => {
const codex = fakeCodex()
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => {
throw new Error('workspace no longer exists')
},
openConnection: codex.openConnection
})
const error = await adapter
.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' })
.catch((cause: unknown) => cause)
expect(error).toMatchObject({
name: 'AgentSessionPreSpawnError',
message: 'workspace no longer exists'
})
expect(codex.connections).toHaveLength(0)
})
it('reports the rollout path Codex named, and null when it named none', async () => {
const withPath = fakeCodex()
const adapter = await acquired(withPath)
expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe(
'/rollouts/abc.jsonl'
)
const withoutPath = fakeCodex({ 'thread/start': () => ({ thread: { id: THREAD_ID } }) })
const bare = await acquired(withoutPath)
expect(await bare.historyFilePath({ identity: identityFor('session-1') })).toBeNull()
})
it('lets closeAll cancel and reap an acquisition still opening', async () => {
const codex = fakeCodex()
let releaseOpen = (): void => {}
let markOpenEntered = (): void => {}
const gate = new Promise<void>((resolve) => {
releaseOpen = resolve
})
const openEntered = new Promise<void>((resolve) => {
markOpenEntered = resolve
})
const openConnection: typeof openCodexAppServerConnection = async (...args) => {
markOpenEntered()
await gate
return codex.openConnection(...args)
}
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: null
}),
openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
const acquiring = adapter.acquire({
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-9'
})
await openEntered
const closing = adapter.closeAll()
releaseOpen()
await expect(acquiring).rejects.toThrow('superseded while being acquired')
await closing
expect(codex.connections[0]?.closeCount).toBe(1)
})
it('fences an acquisition while launch resolution is still pending', async () => {
const launch = Promise.withResolvers<CodexStructuredLaunch>()
const codex = fakeCodex()
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: () => launch.promise,
openConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
const acquiring = adapter.acquire({
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-9'
})
const closing = adapter.closeAll()
launch.resolve({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: null
})
await expect(acquiring).rejects.toThrow('superseded while being acquired')
await closing
expect(codex.connections).toHaveLength(0)
})
})
describe('CodexStructuredSessionAdapter.dispatch', () => {
it('accepts a turn Codex names in its response', async () => {
const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) })
const adapter = await acquired(codex)
const outcome = await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: {
kind: 'message',
role: 'user',
blocks: [
{ type: 'text', text: 'ship it' },
{ type: 'image-ref', path: '/tmp/shot.png' },
{ type: 'image-ref', url: 'https://example.test/a.png' }
]
},
fence: 7
})
expect(outcome).toEqual({
state: 'accepted',
providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-1', ordinal: 0 }
})
expect(codex.connections[0].calls[1].params).toEqual({
threadId: THREAD_ID,
clientUserMessageId: 'client-1',
input: [
{ type: 'text', text: 'ship it' },
{ type: 'localImage', path: '/tmp/shot.png' },
{ type: 'image', url: 'https://example.test/a.png' }
]
})
})
it('accepts a turn named only by the notification that raced the ack', async () => {
const codex = fakeCodex()
const events: CodexStructuredSessionEvent[] = []
const adapter = await acquired(codex, {}, events)
codex.routes['turn/start'] = () => {
codex.connections[0].handlers.onNotification?.('turn/started', {
threadId: THREAD_ID,
turn: { id: 'turn-late' }
})
return {}
}
const outcome = await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
expect(outcome).toMatchObject({ state: 'accepted' })
expect(outcome).toMatchObject({ providerIdentity: { turnId: 'turn-late' } })
expect(events.at(-1)).toMatchObject({ type: 'notification', method: 'turn/started' })
})
it('does not let a child thread answer for the root thread', async () => {
const codex = fakeCodex()
const events: CodexStructuredSessionEvent[] = []
codex.routes['turn/start'] = () => {
// A subagent runs its own thread over the same connection, and its turn
// starts first.
const notify = codex.connections[0].handlers.onNotification
notify?.('turn/started', { threadId: 'thread-child', turn: { id: 'turn-child' } })
notify?.('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-root' } })
return {}
}
const adapter = await acquired(codex, {}, events)
const outcome = await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
expect(outcome).toEqual({
state: 'accepted',
providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-root', ordinal: 0 }
})
// Each event carries the thread it actually came from, so the journal can
// keep a subagent's turn out of the root conversation.
expect(events.map((event) => (event.type === 'notification' ? event.threadId : null))).toEqual([
'thread-child',
THREAD_ID
])
})
it('settles unknown rather than failed when Codex never names the turn', async () => {
vi.useFakeTimers()
try {
const codex = fakeCodex()
const adapter = await acquired(codex)
const dispatching = adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
await vi.advanceTimersByTimeAsync(10_000)
expect(await dispatching).toEqual({
state: 'unknown',
reason: 'codex app-server started a turn it did not name in time'
})
} finally {
vi.useRealTimers()
}
})
it('rejects only when Codex answered and declined', async () => {
const codex = fakeCodex({
'turn/start': () => {
throw new CodexAppServerRequestError('turn/start', -32602, 'turn already running')
}
})
const adapter = await acquired(codex)
expect(
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
).toEqual({ state: 'rejected', reason: 'turn already running' })
})
it('rethrows a dead child so the wire settles the submission unknown', async () => {
const codex = fakeCodex({
'turn/start': () => {
throw new Error('codex app-server connection ended')
}
})
const adapter = await acquired(codex)
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
).rejects.toThrow('connection ended')
})
it('applies an option change to the next turn only', async () => {
const codex = fakeCodex({
'model/list': () => ({
data: [
{
model: 'gpt-live',
supportedReasoningEfforts: [{ reasoningEffort: 'medium' }],
defaultReasoningEffort: 'medium'
},
{
model: 'gpt-5',
supportedReasoningEfforts: [{ reasoningEffort: 'high' }],
defaultReasoningEffort: 'high'
}
],
nextCursor: null
}),
'turn/start': () => ({ turn: { id: 'turn-1' } })
})
const adapter = await acquired(codex)
await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'gpt-5', fence: 7 })
await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 })
await expect(
adapter.setOption({ sessionId: 'session-1', key: 'sandboxEscape', value: 'yes', fence: 7 })
).rejects.toThrow('no thread option named sandboxEscape')
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
const turnStart = codex.connections[0].calls.findLast((call) => call.method === 'turn/start')
expect(turnStart?.params).toMatchObject({ model: 'gpt-5', effort: 'high' })
expect(turnStart?.params).not.toHaveProperty('sandboxEscape')
})
})
describe('CodexStructuredSessionAdapter prompts', () => {
function askApproval(codex: ReturnType<typeof fakeCodex>): void {
codex.connections[0].handlers.onServerRequest?.({
id: 11,
method: 'item/commandExecution/requestApproval',
params: { itemId: 'codex-item-1', threadId: THREAD_ID, turnId: 'turn-1' }
})
}
it('surfaces an approval request and answers it exactly once', async () => {
const codex = fakeCodex()
const events: CodexStructuredSessionEvent[] = []
const adapter = await acquired(codex, {}, events)
askApproval(codex)
adapter.bindPromptItemId('session-1', 'codex:thread-abc:turn-1:3', 'codex-item-1')
await adapter.answerPrompt({
sessionId: 'session-1',
itemId: 'codex:thread-abc:turn-1:3',
kind: 'approval',
optionId: 'accept',
fence: 7
})
expect(events.at(-1)).toMatchObject({ type: 'prompt', codexItemId: 'codex-item-1' })
expect(codex.connections[0].replies).toEqual([{ id: 11, result: { decision: 'accept' } }])
await expect(
adapter.answerPrompt({
sessionId: 'session-1',
itemId: 'codex:thread-abc:turn-1:3',
kind: 'approval',
optionId: 'decline',
fence: 7
})
).rejects.toThrow('no longer waiting on')
expect(codex.connections[0].replies).toHaveLength(1)
})
it('answers each approval a tool item asks for separately', async () => {
const codex = fakeCodex()
const events: CodexStructuredSessionEvent[] = []
const adapter = await acquired(codex, {}, events)
// A shell bridge re-asks per command under one parent tool item, so only the
// approval id tells the two requests apart.
const ask = (id: number, approvalId: string): void => {
codex.connections[0].handlers.onServerRequest?.({
id,
method: 'item/commandExecution/requestApproval',
params: { itemId: 'codex-item-1', approvalId, threadId: THREAD_ID, turnId: 'turn-1' }
})
}
ask(11, 'approval-a')
ask(12, 'approval-b')
adapter.bindPromptItemId('session-1', 'journal-a', 'approval-a')
adapter.bindPromptItemId('session-1', 'journal-b', 'approval-b')
for (const [itemId, optionId] of [
['journal-b', 'decline'],
['journal-a', 'accept']
]) {
await adapter.answerPrompt({
sessionId: 'session-1',
itemId,
kind: 'approval',
optionId,
fence: 7
})
}
expect(codex.connections[0].replies).toEqual([
{ id: 12, result: { decision: 'decline' } },
{ id: 11, result: { decision: 'accept' } }
])
expect(events.map((event) => (event.type === 'prompt' ? event.promptKey : null))).toEqual([
'approval-a',
'approval-b'
])
})
it('rejects an option id that is not a Codex decision', async () => {
const codex = fakeCodex()
const adapter = await acquired(codex)
askApproval(codex)
await expect(
adapter.answerPrompt({
sessionId: 'session-1',
itemId: 'codex-item-1',
kind: 'approval',
optionId: 'yolo',
fence: 7
})
).rejects.toThrow('is not a Codex approval decision')
expect(codex.connections[0].replies).toEqual([])
})
it('holds a multi-question request until every question is answered', async () => {
const codex = fakeCodex()
const adapter = await acquired(codex)
codex.connections[0].handlers.onServerRequest?.({
id: 12,
method: 'item/tool/requestUserInput',
params: {
itemId: 'codex-item-2',
threadId: THREAD_ID,
turnId: 'turn-1',
questions: [{ id: 'q1' }, { id: 'q2' }]
}
})
await adapter.answerPrompt({
sessionId: 'session-1',
itemId: 'codex-item-2',
kind: 'question',
optionId: encodeCodexQuestionOptionId('q1', 'yes'),
fence: 7
})
expect(codex.connections[0].replies).toEqual([])
await adapter.answerPrompt({
sessionId: 'session-1',
itemId: 'codex-item-2',
kind: 'question',
optionId: encodeCodexQuestionOptionId('q2', 'no'),
fence: 7
})
expect(codex.connections[0].replies).toEqual([
{ id: 12, result: { answers: { q1: { answers: ['yes'] }, q2: { answers: ['no'] } } } }
])
})
it('declines MCP elicitation and journals the explicit disposition', async () => {
const codex = fakeCodex()
const events: CodexStructuredSessionEvent[] = []
await acquired(codex, {}, events)
codex.connections[0].handlers.onServerRequest?.({
id: 13,
method: 'mcpServer/elicitation/request',
params: { itemId: 'codex-item-3', threadId: THREAD_ID }
})
expect(codex.connections[0].replies).toEqual([
{ id: 13, result: { action: 'decline', content: null, _meta: null } }
])
expect(events.some((event) => event.type === 'prompt')).toBe(false)
})
it('surfaces an answer to a prompt Codex already forgot', async () => {
const codex = fakeCodex()
const adapter = await acquired(codex)
await expect(
adapter.answerPrompt({
sessionId: 'session-1',
itemId: 'codex-item-gone',
kind: 'approval',
optionId: 'accept',
fence: 7
})
).rejects.toThrow('no longer waiting on codex-item-gone')
})
})
describe('CodexStructuredSessionAdapter lifecycle', () => {
it('keeps sessions isolated and closes each child once', async () => {
const codex = fakeCodex()
const adapter = adapterFor(codex)
await adapter.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-a' })
await adapter.acquire({ identity: identityFor('session-2'), fence: 1, spawnToken: 'spawn-b' })
codex.connections[0].handlers.onServerRequest?.({
id: 21,
method: 'item/fileChange/requestApproval',
params: { itemId: 'codex-item-1', threadId: THREAD_ID, turnId: 'turn-1' }
})
await expect(
adapter.answerPrompt({
sessionId: 'session-2',
itemId: 'codex-item-1',
kind: 'approval',
optionId: 'accept',
fence: 1
})
).rejects.toThrow('no longer waiting on')
await adapter.closeAll()
expect(codex.connections.map((connection) => connection.closeCount)).toEqual([1, 1])
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 1 })
).rejects.toThrow('no live codex app-server for session session-1')
})
it('retains ownership until a child exit is proven and reports it once', async () => {
const codex = fakeCodex()
const events: CodexStructuredSessionEvent[] = []
const adapter = await acquired(codex, {}, events)
const connection = codex.connections[0]
connection.close = async () => {
connection.closeCount += 1
return false
}
connection.handlers.onExit?.(new Error('codex app-server connection ended'))
expect(events.at(-1)).toEqual({
type: 'ended',
sessionId: 'session-1',
reason: 'codex app-server connection ended'
})
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
).rejects.toThrow('no live codex app-server')
expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe(
'/rollouts/abc.jsonl'
)
await expect(adapter.closeSession('session-1')).resolves.toBe(false)
expect(events.filter((event) => event.type === 'ended')).toHaveLength(1)
})
it('keeps the live session when a child it already replaced dies', async () => {
const codex = fakeCodex()
const events: CodexStructuredSessionEvent[] = []
const adapter = await acquired(codex, {}, events)
await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' })
const endedBeforeStaleExit = events.filter((event) => event.type === 'ended').length
codex.connections[0].handlers.onExit?.(new Error('the superseded child died'))
expect(events.filter((event) => event.type === 'ended')).toHaveLength(endedBeforeStaleExit)
expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe(
'/rollouts/abc.jsonl'
)
})
it('ignores Codex traffic that arrives after the session is gone', async () => {
const codex = fakeCodex()
const adapter = await acquired(codex)
const connection = codex.connections[0]
await adapter.closeSession('session-1')
connection.handlers.onNotification?.('item/agentMessage/delta', { delta: 'x' })
connection.handlers.onServerRequest?.({
id: 31,
method: 'item/fileChange/requestApproval',
params: { itemId: 'codex-item-9', threadId: THREAD_ID }
})
expect(connection.replies).toEqual([])
})
it('flushes the final coalesced text before a graceful close', async () => {
const codex = fakeCodex()
const bodies: AgentJournalMessageItem[] = []
const tombstones: unknown[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (_identity, body) => {
if (body.kind === 'message') {
bodies.push(body)
}
},
appendTombstone: (identity) => tombstones.push(identity),
publish: () => {}
}
const adapter = adapterFor(codex)
await adapter.acquire({
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn-9',
events: sink
})
const notify = codex.connections[0]!.handlers.onNotification
notify?.('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-1' } })
notify?.('item/started', {
threadId: THREAD_ID,
item: { type: 'agentMessage', id: 'item-1', text: '' }
})
notify?.('item/agentMessage/delta', {
threadId: THREAD_ID,
itemId: 'item-1',
delta: 'last words'
})
await adapter.closeSession('session-1')
expect(bodies.at(-1)?.blocks).toEqual([{ type: 'text', text: 'last words' }])
expect(tombstones).toContainEqual({
provider: 'legacy',
agent: 'codex',
sessionId: 'session-1',
recordId: 'turn-lifecycle:turn-1'
})
})
})
@@ -0,0 +1,325 @@
import type {
AgentJournalMessageItem,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import {
AgentSessionPreSpawnError,
type AgentSessionAcquisition,
type AgentSessionDispatchOutcome,
type StructuredAgentSessionAcquireInput,
type StructuredAgentSessionAdapter,
type StructuredAgentSessionSetOptionInput
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
closeFailedCodexAcquisition,
stopSupersededCodexAcquisition
} from './codex-structured-acquisition-lifecycle'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity'
import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment'
import { answerCodexPrompt } from './codex-structured-prompt-replies'
import { openCodexThread } from './codex-structured-thread-open'
import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start'
import { supportsCodexStructuredLocation } from './codex-structured-location-support'
import {
closeAllCodexSessions,
closeCodexPublishedSession,
closeCodexSession,
handleCodexSessionExit
} from './codex-structured-session-close'
import {
applyCodexStructuredSessionOption,
readLiveCodexSessionOptions,
reportedCodexThreadOptions,
restoredCodexSessionOptions
} from './codex-structured-session-options'
import {
CodexAcquisitionRegistry,
type CodexAcquisitionAttempt,
type CodexSession,
type CodexStructuredSessionAdapterDeps,
type CodexStructuredSessionEvent
} from './codex-structured-session-state'
import {
deliverCodexNotification,
deliverCodexServerRequest,
deliverCodexUnhandledFrame
} from './codex-structured-provider-events'
import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation'
export type {
CodexStructuredLaunch,
CodexStructuredSessionAdapterDeps,
CodexStructuredSessionEvent
} from './codex-structured-session-state'
export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdapter {
private readonly sessions = new Map<string, CodexSession>()
private readonly acquisitions = new CodexAcquisitionRegistry()
private readonly turnCancellation: CodexStructuredTurnCancellation
constructor(private readonly deps: CodexStructuredSessionAdapterDeps) {
this.turnCancellation = new CodexStructuredTurnCancellation({
captureTurnProcesses: deps.captureTurnProcesses,
terminateTurnProcesses: deps.terminateTurnProcesses,
requestTimeoutMs: deps.requestTimeoutMs,
emit: (session, event) => this.emit(session, event)
})
}
supportsLocation = supportsCodexStructuredLocation
async acquire(input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> {
const sessionId = input.identity.sessionId
const { previousAttempt, attempt } = this.acquisitions.start(sessionId)
const acquisition = attempt.window
let primaryThreadId =
input.identity.providerHandle.kind === 'codex' ? input.identity.providerHandle.threadId : null
const translator = input.events
? createCodexJournalTranslator({
sink: input.events,
primaryThreadId: () => primaryThreadId,
bindPromptItemId: (journalItemId, threadId, promptKey) =>
acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey)
})
: null
const open = this.deps.openConnection ?? openCodexAppServerConnection
try {
await stopSupersededCodexAcquisition({
sessionId,
registry: this.acquisitions,
replacement: attempt,
previous: previousAttempt
})
this.acquisitions.assertCurrent(sessionId, attempt)
if (!(await closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent))) {
throw new Error(`codex app-server for session ${sessionId} could not be stopped`)
}
this.acquisitions.assertCurrent(sessionId, attempt)
const launch = await this.deps
.resolveLaunch({ identity: input.identity })
.catch((error: unknown) => {
throw new AgentSessionPreSpawnError(error)
})
this.acquisitions.assertCurrent(sessionId, attempt)
const connection = await open(
{
command: launch.command,
args: launch.args,
cwd: launch.cwd,
env: buildCodexStructuredChildEnvironment(launch, input.spawnToken)
},
{
onNotification: (method, params) =>
this.deliver(acquisition, sessionId, () =>
this.handleNotification(sessionId, method, params)
),
onServerRequest: (request) =>
this.deliver(acquisition, sessionId, () =>
this.handleServerRequest(sessionId, request)
),
onUnhandledFrame: (kind, payload) =>
this.deliver(acquisition, sessionId, () =>
this.handleUnhandledFrame(sessionId, kind, payload)
),
onExit: (error) => {
acquisition.prompts.clear()
handleCodexSessionExit({
sessions: this.sessions,
sessionId,
connection: acquisition.connection,
error,
...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {})
})
}
}
)
acquisition.connection = connection
this.acquisitions.assertCurrent(sessionId, attempt)
const opened = await openCodexThread(connection, launch, this.deps.requestTimeoutMs)
this.acquisitions.assertCurrent(sessionId, attempt)
primaryThreadId = opened.threadId
translator?.restoreThread(opened.threadId, opened.thread ?? {})
const process = await codexProcessIdentity(
{ ...input, pid: connection.pid },
this.deps.readProcessStartTime
)
this.acquisitions.assertCurrent(sessionId, attempt)
const acquired: AgentSessionAcquisition = {
process,
link: codexProviderHandleLink({
threadId: opened.threadId,
resumed: launch.resumeThreadId !== null,
fence: input.fence,
linkId: this.deps.mintLinkId?.(),
observedAt: this.deps.now?.() ?? Date.now()
})
}
// Publish only after every promised identity is proven and this attempt still owns the child.
if (connection.closed) {
throw new Error(`codex app-server for session ${sessionId} exited while being acquired`)
}
this.acquisitions.assertCurrent(sessionId, attempt)
this.acquisitions.deleteIfCurrent(sessionId, attempt)
const session: CodexSession = {
connection,
ended: false,
threadId: opened.threadId,
historyPath: opened.historyPath,
prompts: acquisition.prompts,
options: restoredCodexSessionOptions(input.options),
reportedOptions: reportedCodexThreadOptions(opened),
turnIdWaiters: [],
translator
}
this.turnCancellation.register(session)
this.sessions.set(sessionId, session)
for (const event of acquisition.drain()) {
event()
}
return acquired
} catch (error) {
// Reap this attempt's child only. A replacement already published for the
// same session keeps running.
if (this.sessions.get(sessionId)?.connection !== acquisition.connection) {
return closeFailedCodexAcquisition({
sessionId,
registry: this.acquisitions,
attempt,
cause: error,
dispose: () => translator?.dispose()
})
}
this.acquisitions.deleteIfCurrent(sessionId, attempt)
throw error
} finally {
attempt.finish()
}
}
/** Buffers pre-publication events and drops events from superseded children. */
private deliver(
acquisition: CodexAcquisitionAttempt['window'],
sessionId: string,
event: () => void
): void {
if (acquisition.buffer(event)) {
return
}
if (this.sessions.get(sessionId)?.connection === acquisition.connection) {
event()
}
}
private handleNotification(sessionId: string, method: string, params: unknown): void {
const session = this.sessions.get(sessionId)
if (session && this.turnCancellation.handleNotification(sessionId, session, method, params)) {
return
}
deliverCodexNotification(sessionId, session, method, params, (session, event) =>
this.emit(session, event)
)
}
/** Journal first so observers never see an event ahead of its durable row. */
private emit(session: CodexSession, event: CodexStructuredSessionEvent): void {
session.translator?.handle(event)
this.deps.onEvent?.(event)
}
private handleServerRequest(
sessionId: string,
request: Parameters<typeof deliverCodexServerRequest>[2]
): void {
deliverCodexServerRequest(sessionId, this.sessions.get(sessionId), request, (session, event) =>
this.emit(session, event)
)
}
private handleUnhandledFrame(sessionId: string, kind: string, params: unknown): void {
deliverCodexUnhandledFrame(
sessionId,
this.sessions.get(sessionId),
kind,
params,
(session, event) => this.emit(session, event)
)
}
bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void =>
this.sessions
.get(sessionId)
?.prompts.bindJournalItemId(journalItemId, this.session(sessionId).threadId, promptKey)
async dispatch(input: {
sessionId: string
clientMessageId: string
body: AgentJournalMessageItem
fence: number
}): Promise<AgentSessionDispatchOutcome> {
const session = this.session(input.sessionId)
await this.turnCancellation.captureBaseline(session)
return dispatchCodexTurn(session, input, this.deps.requestTimeoutMs)
}
async cancelTurn(input: {
sessionId: string
turnId: string
fence: number
}): Promise<{ cancelled: boolean }> {
const session = this.session(input.sessionId)
return this.turnCancellation.cancel(session, input.turnId)
}
async answerPrompt(input: {
sessionId: string
itemId: string
kind: 'approval' | 'question'
optionId: string
fence: number
}): Promise<void> {
const session = this.session(input.sessionId)
answerCodexPrompt(session.prompts, session.connection, input.itemId, input.optionId)
}
async setOption(
input: StructuredAgentSessionSetOptionInput
): Promise<Readonly<Record<string, string>>> {
if (!isCodexTurnOptionKey(input.key)) {
throw new Error(`codex app-server has no thread option named ${input.key}`)
}
return applyCodexStructuredSessionOption(
this.session(input.sessionId),
input.key,
input.value,
this.deps.requestTimeoutMs
)
}
readOptions = (input: { sessionId: string; fence: number }) =>
readLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs)
historyFilePath = async (input: {
identity: AgentSessionJournalIdentity
}): Promise<string | null> => this.sessions.get(input.identity.sessionId)?.historyPath ?? null
closeSession = (sessionId: string): Promise<boolean> =>
closeCodexSession(sessionId, this.sessions, this.acquisitions, this.deps.onEvent)
disposeSession = (sessionId: string): Promise<boolean> => this.closeSession(sessionId)
closeAll = (): Promise<void> =>
closeAllCodexSessions(this.sessions, this.acquisitions, (sessionId) =>
this.disposeSession(sessionId)
)
releaseAcquisition = (input: { sessionId: string }): Promise<boolean> =>
this.closeSession(input.sessionId)
private session(sessionId: string): CodexSession {
const session = this.sessions.get(sessionId)
if (!session || session.ended) {
throw new Error(`no live codex app-server for session ${sessionId}`)
}
return session
}
}
@@ -0,0 +1,279 @@
import { describe, expect, it, vi } from 'vitest'
import type {
AgentJournalMessageItem,
AgentSessionJournalIdentity
} from '../../shared/agent-session-journal-types'
import {
CodexAppServerRequestError,
type CodexAppServerConnection,
type CodexAppServerConnectionHandlers,
type CodexAppServerLaunch,
type openCodexAppServerConnection
} from './codex-app-server-connection'
import { CodexAppServerUnsupportedError } from './codex-app-server-session'
import {
CodexStructuredSessionAdapter,
type CodexStructuredSessionAdapterDeps,
type CodexStructuredSessionEvent
} from './codex-structured-session-adapter'
const THREAD_ID = 'thread-abc'
const USER_MESSAGE: AgentJournalMessageItem = {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'ship it' }]
}
type Route = (params: Record<string, unknown> | undefined) => unknown
type FakeConnection = Omit<CodexAppServerConnection, 'closed'> & {
closed: boolean
launch: CodexAppServerLaunch
handlers: CodexAppServerConnectionHandlers
calls: { method: string; params?: Record<string, unknown> }[]
}
function identity(): AgentSessionJournalIdentity {
return {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: THREAD_ID }
}
}
function fakeCodex(): {
connections: FakeConnection[]
openConnection: typeof openCodexAppServerConnection
routes: Record<string, Route>
} {
const connections: FakeConnection[] = []
const routes: Record<string, Route> = {
'thread/resume': () => ({ thread: { id: THREAD_ID } })
}
const openConnection = (async (launch, handlers = {}) => {
const connection: FakeConnection = {
launch,
handlers,
calls: [],
pid: 4321,
closed: false,
request: async (method, params) => {
connection.calls.push({ method, params })
return routes[method]?.(params) ?? {}
},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => {
connection.closed = true
return true
}
}
connections.push(connection)
return connection
}) as typeof openCodexAppServerConnection
return { connections, openConnection, routes }
}
async function acquired(
codex: ReturnType<typeof fakeCodex>,
events: CodexStructuredSessionEvent[] = [],
processControl: Partial<
Pick<CodexStructuredSessionAdapterDeps, 'captureTurnProcesses' | 'terminateTurnProcesses'>
> = {}
): Promise<CodexStructuredSessionAdapter> {
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => ({
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: THREAD_ID
}),
onEvent: (event) => events.push(event),
openConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000,
captureTurnProcesses: async () => ({ platform: 'win32', identities: new Map() }),
terminateTurnProcesses: async () => true,
...processControl
})
await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-9' })
return adapter
}
function completeTurn(codex: ReturnType<typeof fakeCodex>, turnId = 'turn-1'): void {
codex.connections[0].handlers.onNotification?.('turn/completed', {
threadId: THREAD_ID,
turn: { id: turnId, status: 'interrupted' }
})
}
describe('CodexStructuredSessionAdapter.cancelTurn', () => {
it('confirms an interrupt Codex acknowledged', async () => {
const codex = fakeCodex()
const adapter = await acquired(codex)
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
).resolves.toEqual({ cancelled: true })
expect(codex.connections[0].calls.at(-1)).toEqual({
method: 'turn/interrupt',
params: { threadId: THREAD_ID, turnId: 'turn-1' }
})
})
it('reports not-cancelled when Codex declines or lacks the method', async () => {
const declined = fakeCodex()
declined.routes['turn/interrupt'] = () => {
throw new CodexAppServerRequestError('turn/interrupt', -32602, 'no such turn')
}
const absent = fakeCodex()
absent.routes['turn/interrupt'] = () => {
throw new CodexAppServerUnsupportedError('no turn/interrupt')
}
await expect(
(await acquired(declined)).cancelTurn({
sessionId: 'session-1',
turnId: 'turn-1',
fence: 7
})
).resolves.toEqual({ cancelled: false })
await expect(
(await acquired(absent)).cancelTurn({
sessionId: 'session-1',
turnId: 'turn-1',
fence: 7
})
).resolves.toEqual({ cancelled: false })
})
it('rethrows an unsettled interrupt so the turn is not shown as cancelled', async () => {
const codex = fakeCodex()
codex.routes['turn/interrupt'] = () => {
throw new Error('codex app-server turn/interrupt exceeded 30000ms')
}
await expect(
(await acquired(codex)).cancelTurn({
sessionId: 'session-1',
turnId: 'turn-1',
fence: 7
})
).rejects.toThrow('exceeded 30000ms')
})
it('publishes terminal state only after streaming interruption is physically settled', async () => {
const events: CodexStructuredSessionEvent[] = []
let finishTermination!: (terminated: boolean) => void
const termination = new Promise<boolean>((resolve) => {
finishTermination = resolve
})
const codex = fakeCodex()
codex.routes['turn/interrupt'] = () => {
completeTurn(codex)
return {}
}
const adapter = await acquired(codex, events, {
terminateTurnProcesses: async () => termination
})
codex.connections[0].handlers.onNotification?.('item/agentMessage/delta', {
threadId: THREAD_ID,
turnId: 'turn-1',
itemId: 'item-1',
delta: 'still streaming'
})
const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('turn/interrupt'))
expect(events).toContainEqual(expect.objectContaining({ method: 'item/agentMessage/delta' }))
expect(events).not.toContainEqual(expect.objectContaining({ method: 'turn/completed' }))
finishTermination(true)
await expect(pending).resolves.toEqual({ cancelled: true })
expect(events.at(-1)).toMatchObject({ method: 'turn/completed' })
})
it('starts physical termination without waiting for the interrupt receipt', async () => {
let finishInterrupt!: () => void
const interruptReceipt = new Promise<void>((resolve) => {
finishInterrupt = resolve
})
const terminateTurnProcesses = vi.fn(async () => true)
const codex = fakeCodex()
codex.routes['turn/interrupt'] = () => interruptReceipt
const adapter = await acquired(codex, [], { terminateTurnProcesses })
const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
await vi.waitFor(() => expect(terminateTurnProcesses).toHaveBeenCalledOnce())
finishInterrupt()
await expect(pending).resolves.toEqual({ cancelled: true })
})
it('keeps the turn live when process termination cannot be verified', async () => {
const events: CodexStructuredSessionEvent[] = []
const codex = fakeCodex()
codex.routes['turn/interrupt'] = () => {
completeTurn(codex)
return {}
}
const adapter = await acquired(codex, events, {
terminateTurnProcesses: async () => false
})
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
).resolves.toEqual({ cancelled: false })
expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' }))
})
it('accepts an immediate resend after verified interruption', async () => {
let nextTurn = 0
const codex = fakeCodex()
codex.routes['turn/start'] = () => ({ turn: { id: `turn-${++nextTurn}` } })
codex.routes['turn/interrupt'] = () => {
completeTurn(codex)
return {}
}
const adapter = await acquired(codex)
await adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-1',
body: USER_MESSAGE,
fence: 7
})
await adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-2',
body: USER_MESSAGE,
fence: 7
})
).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { turnId: 'turn-2' }
})
})
it('does not strand a deferred completion when the interrupt receipt fails', async () => {
const events: CodexStructuredSessionEvent[] = []
const codex = fakeCodex()
codex.routes['turn/interrupt'] = () => {
completeTurn(codex)
throw new Error('interrupt receipt lost')
}
const adapter = await acquired(codex, events, {
terminateTurnProcesses: async () => true
})
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 })
).rejects.toThrow('interrupt receipt lost')
expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' }))
})
})
@@ -0,0 +1,89 @@
import type { CodexAppServerConnection } from './codex-app-server-connection-types'
import { closeProcessRegistry } from '../../shared/child-process/close-process-registry'
import {
cancelCodexAcquisitionAttempt,
type CodexAcquisitionRegistry,
type CodexSession,
type CodexStructuredSessionEvent
} from './codex-structured-session-state'
export function handleCodexSessionExit(input: {
sessions: Map<string, CodexSession>
sessionId: string
connection: CodexAppServerConnection | null
error: Error
onEvent?: (event: CodexStructuredSessionEvent) => void
}): void {
const session = input.sessions.get(input.sessionId)
if (!session || session.connection !== input.connection || session.ended) {
return
}
session.ended = true
const event = { type: 'ended', sessionId: input.sessionId, reason: input.error.message } as const
session.translator?.handle(event)
input.onEvent?.(event)
session.translator?.dispose()
}
export async function closeCodexPublishedSession(
sessions: Map<string, CodexSession>,
sessionId: string,
onEvent?: (event: CodexStructuredSessionEvent) => void
): Promise<boolean> {
const session = sessions.get(sessionId)
if (!session) {
return true
}
session.prompts.clear()
// Keep the session indexed until the child exit is observed. A timeout or
// failed kill must leave the live connection available for a safe retry.
const exited = await session.connection.close()
if (exited !== true) {
return false
}
sessions.delete(sessionId)
if (!session.ended) {
session.ended = true
const event: CodexStructuredSessionEvent = {
type: 'ended',
sessionId,
reason: 'codex session closed'
}
session.translator?.handle(event)
onEvent?.(event)
session.translator?.flush()
session.translator?.dispose()
}
return true
}
export async function closeCodexSession(
sessionId: string,
sessions: Map<string, CodexSession>,
acquisitions: CodexAcquisitionRegistry,
onEvent?: (event: CodexStructuredSessionEvent) => void
): Promise<boolean> {
const attempt = acquisitions.get(sessionId)
if (!(await cancelCodexAcquisitionAttempt(attempt))) {
return false
}
if (attempt) {
acquisitions.deleteIfCurrent(sessionId, attempt)
}
return closeCodexPublishedSession(sessions, sessionId, onEvent)
}
export async function closeAllCodexSessions(
sessions: Map<string, CodexSession>,
acquisitions: CodexAcquisitionRegistry,
close: (sessionId: string) => Promise<boolean>
): Promise<void> {
acquisitions.close()
await closeProcessRegistry({
attempts: 3,
hasEntries: () => sessions.size > 0 || acquisitions.size > 0,
entryIds: () => new Set([...sessions.keys(), ...acquisitions.sessionIds()]),
closeEntry: close,
failureMessage: 'codex structured session shutdown could not prove every child stopped'
})
}
@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from 'vitest'
import type { CodexAppServerConnection } from './codex-app-server-connection'
import { CodexAcquisitionWindow } from './codex-structured-acquisition-window'
import {
applyCodexStructuredSessionOption,
readCodexStructuredSessionOptions,
reportedCodexThreadOptions,
restoredCodexSessionOptions
} from './codex-structured-session-options'
import type { CodexSession } from './codex-structured-session-state'
function optionSession(request: CodexAppServerConnection['request']): CodexSession {
return {
connection: {
pid: 1,
closed: false,
request,
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => true
},
ended: false,
threadId: 'thread-1',
historyPath: null,
prompts: new CodexAcquisitionWindow().prompts,
options: new Map(),
reportedOptions: { model: 'gpt-live', effort: 'high' },
turnIdWaiters: [],
translator: null
}
}
describe('structured Codex session options', () => {
it('filters restored records to recognized turn options', () => {
expect(
Object.fromEntries(
restoredCodexSessionOptions({
model: 'gpt-live',
effort: 'high',
threadId: 'thread-injected',
input: 'input-injected'
})
)
).toEqual({ model: 'gpt-live', effort: 'high' })
})
it('hydrates paged provider models and their supported efforts', async () => {
const request = vi.fn(async (_method: string, params?: Record<string, unknown>) =>
params?.cursor
? {
data: [
{
model: 'gpt-second',
displayName: 'GPT Second',
description: 'Fast',
hidden: false,
supportedReasoningEfforts: [
{ reasoningEffort: 'low', description: 'Quick reasoning' }
],
defaultReasoningEffort: 'low',
isDefault: false
}
],
nextCursor: null
}
: {
data: [
{
model: 'gpt-live',
displayName: 'GPT Live',
hidden: false,
supportedReasoningEfforts: [
{ reasoningEffort: 'medium', description: 'Balanced' },
{ reasoningEffort: 'high', description: 'Deep reasoning' }
],
defaultReasoningEffort: 'medium',
isDefault: true
}
],
nextCursor: 'page-2'
}
)
await expect(
readCodexStructuredSessionOptions({
connection: { request } as never,
current: { model: 'gpt-live', effort: 'medium' }
})
).resolves.toEqual({
models: [
{
id: 'gpt-live',
label: 'GPT Live',
isDefault: true,
defaultEffort: 'medium',
efforts: [
{ value: 'medium', label: 'Medium', description: 'Balanced' },
{ value: 'high', label: 'High', description: 'Deep reasoning' }
]
},
{
id: 'gpt-second',
label: 'GPT Second',
description: 'Fast',
isDefault: false,
defaultEffort: 'low',
efforts: [{ value: 'low', label: 'Low', description: 'Quick reasoning' }]
}
],
current: { model: 'gpt-live', effort: 'medium' }
})
expect(request).toHaveBeenNthCalledWith(
2,
'model/list',
{ limit: 100, includeHidden: false, cursor: 'page-2' },
{ timeoutMs: undefined }
)
})
it('hydrates current values from thread start or resume', () => {
expect(
reportedCodexThreadOptions({
threadId: 'thread-1',
historyPath: null,
model: 'gpt-live',
effort: 'high'
})
).toEqual({ model: 'gpt-live', effort: 'high' })
})
it('reconciles an incompatible effort when only the model changes', async () => {
const session = optionSession(
vi.fn(async () => ({
data: [
{
model: 'gpt-live',
supportedReasoningEfforts: [{ reasoningEffort: 'high' }],
defaultReasoningEffort: 'high'
},
{
model: 'gpt-fast',
supportedReasoningEfforts: [{ reasoningEffort: 'low' }],
defaultReasoningEffort: 'low'
}
],
nextCursor: null
}))
)
await expect(
applyCodexStructuredSessionOption(session, 'model', 'gpt-fast', undefined)
).resolves.toEqual({ model: 'gpt-fast', effort: 'low' })
})
it('rejects values absent from the provider catalog', async () => {
const session = optionSession(
vi.fn(async () => ({
data: [{ model: 'gpt-live', supportedReasoningEfforts: [] }],
nextCursor: null
}))
)
await expect(
applyCodexStructuredSessionOption(session, 'model', 'not-entitled', undefined)
).rejects.toThrow('does not offer model not-entitled')
await expect(
applyCodexStructuredSessionOption(session, 'effort', 'high', undefined)
).rejects.toThrow('does not support high')
})
})
@@ -0,0 +1,203 @@
import type {
AgentSessionModelOption,
AgentSessionOptionChoice,
AgentSessionOptionsResult
} from '../../shared/agent-session-wire'
import type { CodexAppServerConnection } from './codex-app-server-connection'
import type { CodexOpenedThread } from './codex-structured-thread-open'
import type { CodexSession } from './codex-structured-session-state'
import { isCodexTurnOptionKey } from './codex-structured-turn-start'
import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error'
const MODEL_PAGE_LIMIT = 100
const MAX_MODEL_PAGES = 20
export function restoredCodexSessionOptions(
options: Readonly<Record<string, string>> | undefined
): Map<string, string> {
return new Map(Object.entries(options ?? {}).filter(([key]) => isCodexTurnOptionKey(key)))
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value : null
}
function effortLabel(value: string): string {
return value === 'xhigh'
? 'Extra high'
: value === 'minimal'
? 'Minimal'
: `${value.charAt(0).toUpperCase()}${value.slice(1)}`
}
function effortChoice(value: unknown): AgentSessionOptionChoice | null {
const row = record(value)
const effort = text(row?.reasoningEffort)
if (!effort) {
return null
}
const description = text(row?.description)
return {
value: effort,
label: effortLabel(effort),
...(description ? { description } : {})
}
}
function modelOption(value: unknown): AgentSessionModelOption | null {
const row = record(value)
if (!row) {
return null
}
const id = text(row.model) ?? text(row.id)
const label = text(row.displayName) ?? id
if (!id || !label || row.hidden === true) {
return null
}
const description = text(row.description)
const defaultEffort = text(row.defaultReasoningEffort)
const efforts = Array.isArray(row.supportedReasoningEfforts)
? row.supportedReasoningEfforts
.map(effortChoice)
.filter((choice): choice is AgentSessionOptionChoice => choice !== null)
: []
return {
id,
label,
...(description ? { description } : {}),
isDefault: row.isDefault === true,
...(defaultEffort ? { defaultEffort } : {}),
efforts
}
}
export async function readCodexStructuredSessionOptions(input: {
connection: Pick<CodexAppServerConnection, 'request'>
current: { model?: string; effort?: string }
timeoutMs?: number
}): Promise<AgentSessionOptionsResult> {
const models: AgentSessionModelOption[] = []
let cursor: string | null = null
for (let page = 0; page < MAX_MODEL_PAGES; page += 1) {
const response = record(
await input.connection.request(
'model/list',
{ limit: MODEL_PAGE_LIMIT, includeHidden: false, ...(cursor ? { cursor } : {}) },
{ timeoutMs: input.timeoutMs }
)
)
const rows = Array.isArray(response?.data) ? response.data : []
for (const row of rows) {
const parsed = modelOption(row)
if (parsed && !models.some((model) => model.id === parsed.id)) {
models.push(parsed)
}
}
cursor = text(response?.nextCursor)
if (!cursor) {
break
}
}
if (input.current.model && !models.some((model) => model.id === input.current.model)) {
models.push({
id: input.current.model,
label: input.current.model,
isDefault: false,
efforts: []
})
}
const model = input.current.model ?? models.find((entry) => entry.isDefault)?.id ?? models[0]?.id
if (!model) {
throw new Error('codex app-server returned no available models')
}
return {
models,
current: { model, ...(input.current.effort ? { effort: input.current.effort } : {}) }
}
}
export function reportedCodexThreadOptions(
opened: CodexOpenedThread
): CodexSession['reportedOptions'] {
return {
...(opened.model ? { model: opened.model } : {}),
...(opened.effort ? { effort: opened.effort } : {})
}
}
export function readLiveCodexSessionOptions(
session: CodexSession,
timeoutMs: number | undefined
): Promise<AgentSessionOptionsResult> {
const model = session.options.get('model') ?? session.reportedOptions.model
const effort = session.options.get('effort') ?? session.reportedOptions.effort
return readCodexStructuredSessionOptions({
connection: session.connection,
current: { ...(model ? { model } : {}), ...(effort ? { effort } : {}) },
timeoutMs
})
}
export async function applyCodexStructuredSessionOption(
session: CodexSession,
key: string,
value: string,
timeoutMs: number | undefined
): Promise<Readonly<Record<string, string>>> {
try {
return await applyValidatedCodexStructuredSessionOption(session, key, value, timeoutMs)
} catch (error) {
throw new AgentSessionOptionRejectedError(error)
}
}
async function applyValidatedCodexStructuredSessionOption(
session: CodexSession,
key: string,
value: string,
timeoutMs: number | undefined
): Promise<Readonly<Record<string, string>>> {
if (key !== 'model' && key !== 'effort') {
session.options.set(key, value)
return Object.fromEntries(session.options)
}
const priorModel = session.options.get('model') ?? session.reportedOptions.model
const priorEffort = session.options.get('effort') ?? session.reportedOptions.effort
const catalog = await readCodexStructuredSessionOptions({
connection: session.connection,
current: {
...(priorModel ? { model: priorModel } : {}),
...(priorEffort ? { effort: priorEffort } : {})
},
timeoutMs
})
if (key === 'model' && !catalog.models.some((entry) => entry.id === value)) {
throw new Error(`codex app-server does not offer model ${value}`)
}
const modelId = key === 'model' ? value : catalog.current.model
const model = catalog.models.find((entry) => entry.id === modelId)
const requestedEffort = key === 'effort' ? value : priorEffort
if (
key === 'effort' &&
(!model?.efforts.length || !model.efforts.some((effort) => effort.value === requestedEffort))
) {
throw new Error(`codex app-server model ${modelId} does not support ${value}`)
}
const effort =
model?.efforts.length === 0
? undefined
: (model?.efforts.find((entry) => entry.value === requestedEffort)?.value ??
model?.defaultEffort ??
model?.efforts[0]?.value)
session.options.set('model', modelId)
if (effort) {
session.options.set('effort', effort)
} else {
session.options.delete('effort')
}
return Object.fromEntries(session.options)
}
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import type {
CodexAppServerConnection,
openCodexAppServerConnection
} from './codex-app-server-connection'
import {
CodexStructuredSessionAdapter,
type CodexStructuredLaunch
} from './codex-structured-session-adapter'
const SESSION_ID = 'session-1'
const THREAD_ID = 'thread-1'
const LAUNCH: CodexStructuredLaunch = {
command: 'codex',
args: ['app-server'],
cwd: '/work/repo',
codexHome: null,
resumeThreadId: null
}
function identity(): AgentSessionJournalIdentity {
return {
sessionId: SESSION_ID,
workspaceId: 'workspace-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: THREAD_ID }
}
}
describe('CodexStructuredSessionAdapter shutdown', () => {
it('refuses acquisitions that enter after closeAll starts', async () => {
const connections: CodexAppServerConnection[] = []
const openConnection = (async () => {
const connection = {
pid: 4321,
closed: false,
request: async (method: string) =>
method === 'thread/start' ? { thread: { id: THREAD_ID } } : {},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => true
} satisfies CodexAppServerConnection
connections.push(connection)
return connection
}) as typeof openCodexAppServerConnection
const firstLaunch = Promise.withResolvers<CodexStructuredLaunch>()
let launchCount = 0
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: () => {
launchCount += 1
return launchCount === 1 ? firstLaunch.promise : Promise.resolve(LAUNCH)
},
openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
const first = adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-1' })
await vi.waitFor(() => expect(launchCount).toBe(1))
const closing = adapter.closeAll()
const second = adapter.acquire({ identity: identity(), fence: 8, spawnToken: 'spawn-2' })
const acquisitions = Promise.allSettled([first, second])
firstLaunch.resolve(LAUNCH)
const [firstResult, secondResult] = await acquisitions
await closing
expect(firstResult).toMatchObject({ status: 'rejected' })
expect(secondResult).toMatchObject({
status: 'rejected',
reason: expect.objectContaining({ message: 'codex structured session adapter is closing' })
})
expect(connections).toHaveLength(0)
})
it('bounds shutdown when a provider child never proves exit', async () => {
const close = vi.fn(async () => false)
const openConnection = (async () =>
({
pid: 4321,
closed: false,
request: async (method: string) =>
method === 'thread/start' ? { thread: { id: THREAD_ID } } : {},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close
}) satisfies CodexAppServerConnection) as typeof openCodexAppServerConnection
const adapter = new CodexStructuredSessionAdapter({
resolveLaunch: async () => LAUNCH,
openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-1' })
await expect(adapter.closeAll()).rejects.toThrow(
'codex structured session shutdown could not prove every child stopped'
)
expect(close).toHaveBeenCalledTimes(3)
})
})
@@ -0,0 +1,166 @@
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition'
import type {
CodexAppServerConnection,
openCodexAppServerConnection
} from './codex-app-server-connection'
import { CodexAcquisitionWindow } from './codex-structured-acquisition-window'
import type { CodexJournalTranslator } from './codex-structured-journal-translation'
import type { CodexTurnProcessSnapshot } from './codex-structured-turn-processes'
export type CodexStructuredLaunch = {
command: string
args: string[]
cwd: string
codexHome: string | null
resumeThreadId: string | null
resumePath?: string | null
env?: Record<string, string>
}
export type CodexStructuredSessionEvent =
| { type: 'notification'; sessionId: string; threadId: string; method: string; params: unknown }
| { type: 'server-request'; sessionId: string; threadId: string; method: string; params: unknown }
| { type: 'provider-frame'; sessionId: string; threadId: string; kind: string; payload: unknown }
| {
type: 'prompt'
sessionId: string
threadId: string
method: string
params: unknown
codexItemId: string
promptKey: string
}
| { type: 'ended'; sessionId: string; reason: string }
export type CodexStructuredSessionAdapterDeps = {
resolveLaunch: (input: {
identity: AgentSessionJournalIdentity
}) => Promise<CodexStructuredLaunch>
onEvent?: (event: CodexStructuredSessionEvent) => void
openConnection?: typeof openCodexAppServerConnection
readProcessStartTime?: (pid: number) => Promise<number | null>
mintLinkId?: () => string
now?: () => number
requestTimeoutMs?: number
captureTurnProcesses?: (rootPid: number) => Promise<CodexTurnProcessSnapshot | null>
terminateTurnProcesses?: (
rootPid: number,
baseline: CodexTurnProcessSnapshot | null
) => Promise<boolean>
}
export type CodexSession = {
connection: CodexAppServerConnection
ended: boolean
threadId: string
historyPath: string | null
prompts: CodexAcquisitionWindow['prompts']
options: Map<string, string>
reportedOptions: { model?: string; effort?: string }
turnIdWaiters: ((turnId: string) => void)[]
translator: CodexJournalTranslator | null
}
export type CodexAcquisitionAttempt = {
window: CodexAcquisitionWindow
cancelled: boolean
exitProven: boolean
finished: Promise<void>
finish: () => void
}
export function createCodexAcquisitionAttempt(): CodexAcquisitionAttempt {
let finish = (): void => {}
const finished = new Promise<void>((resolve) => {
finish = resolve
})
return {
window: new CodexAcquisitionWindow(),
cancelled: false,
exitProven: false,
finished,
finish
}
}
export class CodexAcquisitionRegistry {
private readonly attempts = new Map<string, CodexAcquisitionAttempt>()
private closing = false
get size(): number {
return this.attempts.size
}
start(sessionId: string): {
previousAttempt: CodexAcquisitionAttempt | undefined
attempt: CodexAcquisitionAttempt
} {
if (this.closing) {
throw new Error('codex structured session adapter is closing')
}
const previousAttempt = this.attempts.get(sessionId)
const attempt = createCodexAcquisitionAttempt()
this.attempts.set(sessionId, attempt)
return { previousAttempt, attempt }
}
assertCurrent(sessionId: string, attempt: CodexAcquisitionAttempt): void {
if (this.closing || attempt.cancelled || this.attempts.get(sessionId) !== attempt) {
throw new Error(`codex session ${sessionId} was superseded while being acquired`)
}
}
get(sessionId: string): CodexAcquisitionAttempt | undefined {
return this.attempts.get(sessionId)
}
deleteIfCurrent(sessionId: string, attempt: CodexAcquisitionAttempt): void {
if (this.attempts.get(sessionId) === attempt) {
this.attempts.delete(sessionId)
}
}
restoreIfCurrent(
sessionId: string,
replacement: CodexAcquisitionAttempt,
previous: CodexAcquisitionAttempt
): void {
if (this.attempts.get(sessionId) === replacement) {
this.attempts.set(sessionId, previous)
}
}
async closeFailedAttempt(sessionId: string, attempt: CodexAcquisitionAttempt): Promise<boolean> {
const stopped = (await attempt.window.connection?.close()) ?? true
if (stopped) {
attempt.exitProven = true
this.deleteIfCurrent(sessionId, attempt)
}
return stopped
}
sessionIds(): IterableIterator<string> {
return this.attempts.keys()
}
close(): void {
this.closing = true
}
}
export async function cancelCodexAcquisitionAttempt(
attempt: CodexAcquisitionAttempt | undefined
): Promise<boolean> {
if (!attempt) {
return true
}
return cancelProcessAcquisition({
cancel: () => {
attempt.cancelled = true
},
connection: () => attempt.window.connection,
exitProven: () => attempt.exitProven,
finished: attempt.finished
})
}
@@ -0,0 +1,39 @@
// The handful of facts Orca reads out of Codex app-server payloads. Codex has
// moved these fields between the envelope and a nested `thread` / `turn` object
// across releases, so each reader accepts both shapes rather than pinning one.
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
/** `thread/start`, `thread/resume`, and `thread/started` all name the thread. */
export function readCodexThreadId(payload: unknown): string | null {
const root = record(payload)
if (!root) {
return null
}
return nonEmptyString(record(root.thread)?.id) ?? nonEmptyString(root.threadId)
}
/** Rollout file for the thread, when Codex reports one. Journal recovery reads
* it; a null just falls back to the existing session-file resolver. */
export function readCodexThreadPath(payload: unknown): string | null {
const root = record(payload)
return root ? nonEmptyString(record(root.thread)?.path) : null
}
/** `turn/start` responses carry `turn.id`; `turn/started` notifications carry
* the same under `turn`, and older builds put `turnId` on the envelope. */
export function readCodexTurnId(payload: unknown): string | null {
const root = record(payload)
if (!root) {
return null
}
return nonEmptyString(record(root.turn)?.id) ?? nonEmptyString(root.turnId)
}
@@ -0,0 +1,61 @@
// Starting or resuming the single Codex thread a structured session owns.
//
// The reply is verified before the caller registers the session, because a
// resume that lands on a different thread is a fork wearing a resume's name —
// recording it would make the durable handle chain lie about what this session
// actually proved.
import type { CodexAppServerConnection } from './codex-app-server-connection'
import { readCodexThreadId, readCodexThreadPath } from './codex-structured-thread-facts'
export type CodexOpenedThread = {
threadId: string
thread?: Record<string, unknown>
/** Rollout file Codex named, when it named one. */
historyPath: string | null
model?: string
effort?: string
}
function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value : null
}
export async function openCodexThread(
connection: CodexAppServerConnection,
launch: { cwd: string; resumeThreadId: string | null; resumePath?: string | null },
timeoutMs: number | undefined
): Promise<CodexOpenedThread> {
const opened = await connection.request(
launch.resumeThreadId ? 'thread/resume' : 'thread/start',
launch.resumeThreadId
? {
threadId: launch.resumeThreadId,
cwd: launch.cwd,
...(launch.resumePath ? { path: launch.resumePath } : {})
}
: { cwd: launch.cwd },
{ timeoutMs }
)
const threadId = readCodexThreadId(opened)
if (!threadId) {
throw new Error('codex app-server did not name the thread it opened')
}
if (launch.resumeThreadId && threadId !== launch.resumeThreadId) {
throw new Error(`codex app-server resumed ${threadId} instead of ${launch.resumeThreadId}`)
}
const result = opened as Record<string, unknown>
const thread =
typeof result.thread === 'object' && result.thread !== null
? (result.thread as Record<string, unknown>)
: {}
const model = nonEmptyString(result.model)
const effort = nonEmptyString(result.reasoningEffort)
return {
threadId,
thread,
historyPath: readCodexThreadPath(opened),
...(model ? { model } : {}),
...(effort ? { effort } : {})
}
}
@@ -0,0 +1,154 @@
import {
isCodexAppServerRequestError,
type CodexAppServerConnection
} from './codex-app-server-connection'
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
import type {
CodexSession,
CodexStructuredSessionAdapterDeps,
CodexStructuredSessionEvent
} from './codex-structured-session-state'
import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts'
import {
captureCodexTurnProcesses,
terminateCodexTurnProcesses,
type CodexTurnProcessSnapshot
} from './codex-structured-turn-processes'
type TurnProcessState = {
baseline: Promise<CodexTurnProcessSnapshot | null>
blockedCompletions: Set<string>
deferredCompletions: Map<string, CodexStructuredSessionEvent>
}
type TurnCancellationDeps = Pick<
CodexStructuredSessionAdapterDeps,
'captureTurnProcesses' | 'requestTimeoutMs' | 'terminateTurnProcesses'
> & {
emit: (session: CodexSession, event: CodexStructuredSessionEvent) => void
}
export class CodexStructuredTurnCancellation {
private readonly states = new WeakMap<CodexSession, TurnProcessState>()
constructor(private readonly deps: TurnCancellationDeps) {}
register(session: CodexSession): void {
this.states.set(session, {
baseline: Promise.resolve(null),
blockedCompletions: new Set(),
deferredCompletions: new Map()
})
}
captureBaseline(session: CodexSession): Promise<CodexTurnProcessSnapshot | null> {
this.refreshBaseline(session)
return this.state(session).baseline
}
handleNotification(
sessionId: string,
session: CodexSession,
method: string,
params: unknown
): boolean {
const threadId = readCodexThreadId(params) ?? session.threadId
if (method !== 'turn/completed' || threadId !== session.threadId) {
return false
}
const turnId = readCodexTurnId(params)
const state = this.state(session)
if (!turnId || !state.blockedCompletions.has(turnId)) {
return false
}
const event = {
type: 'notification' as const,
sessionId,
threadId,
method,
params
}
state.deferredCompletions.set(turnId, event)
return true
}
async cancel(session: CodexSession, turnId: string): Promise<{ cancelled: boolean }> {
const state = this.state(session)
state.blockedCompletions.add(turnId)
const baseline = await state.baseline
let requestError: unknown
const interruptReceipt = session.connection
.request(
'turn/interrupt',
{ threadId: session.threadId, turnId },
{ timeoutMs: this.deps.requestTimeoutMs }
)
.then(
() => true,
(error: unknown) => {
requestError = error
return false
}
)
const [acknowledged, terminated] = await Promise.all([
interruptReceipt,
this.terminate(session.connection, baseline)
])
if (terminated && acknowledged) {
this.releaseCompletion(session, turnId)
return { cancelled: true }
}
if (
requestError &&
!isCodexAppServerRequestError(requestError) &&
!isCodexAppServerUnsupportedError(requestError)
) {
this.releaseCompletion(session, turnId)
throw requestError
}
// A failed cancellation must not permanently divert the provider's later
// completion for this turn. Let the normal completion path settle it.
this.releaseCompletion(session, turnId)
return { cancelled: false }
}
private capture(pid: number | undefined): Promise<CodexTurnProcessSnapshot | null> {
return pid
? (this.deps.captureTurnProcesses ?? captureCodexTurnProcesses)(pid)
: Promise.resolve(null)
}
private terminate(
connection: Pick<CodexAppServerConnection, 'pid'>,
baseline: CodexTurnProcessSnapshot | null
): Promise<boolean> {
return connection.pid
? (this.deps.terminateTurnProcesses ?? terminateCodexTurnProcesses)(connection.pid, baseline)
: Promise.resolve(false)
}
private refreshBaseline(session: CodexSession): void {
this.state(session).baseline = this.capture(session.connection.pid)
}
private releaseCompletion(
session: CodexSession,
turnId: string,
completion = this.state(session).deferredCompletions.get(turnId)
): void {
const state = this.state(session)
state.blockedCompletions.delete(turnId)
state.deferredCompletions.delete(turnId)
if (completion) {
this.deps.emit(session, completion)
}
}
private state(session: CodexSession): TurnProcessState {
const state = this.states.get(session)
if (!state) {
throw new Error('codex turn process state is unavailable')
}
return state
}
}
@@ -0,0 +1,103 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { describe, expect, it } from 'vitest'
import {
captureCodexTurnProcesses,
terminateCodexTurnProcesses
} from './codex-structured-turn-processes'
function nextLine(child: ChildProcessWithoutNullStreams): Promise<string> {
return new Promise((resolve, reject) => {
let buffer = ''
const onData = (chunk: Buffer): void => {
buffer += chunk.toString('utf8')
const newline = buffer.indexOf('\n')
if (newline === -1) {
return
}
child.stdout.off('data', onData)
resolve(buffer.slice(0, newline))
}
child.once('error', reject)
child.stdout.on('data', onData)
})
}
function processExists(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
describe.runIf(process.platform !== 'win32')('Codex structured turn process termination', () => {
it('removes the exact PID of a stopped 60-second command', async () => {
const root = spawn(
process.execPath,
[
'-e',
`const { spawn } = require('node:child_process');
process.stdin.once('data', () => {
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' });
process.stdout.write(String(child.pid) + '\\n');
});
setTimeout(() => {}, 60000);`
],
{ stdio: ['pipe', 'pipe', 'pipe'] }
)
let commandPid = 0
try {
const baseline = await captureCodexTurnProcesses(root.pid!)
root.stdin.write('start\n')
commandPid = Number(await nextLine(root))
expect(processExists(commandPid)).toBe(true)
await expect(terminateCodexTurnProcesses(root.pid!, baseline)).resolves.toBe(true)
expect(processExists(commandPid)).toBe(false)
} finally {
if (commandPid > 0 && processExists(commandPid)) {
process.kill(commandPid, 'SIGKILL')
}
root.kill('SIGKILL')
}
}, 15_000)
it('preserves descendants that predate the turn', async () => {
const root = spawn(
process.execPath,
[
'-e',
`const { spawn } = require('node:child_process');
const persistent = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' });
process.stdout.write(String(persistent.pid) + '\\n');
process.stdin.once('data', () => {
const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' });
process.stdout.write(String(child.pid) + '\\n');
});
setTimeout(() => {}, 60000);`
],
{ stdio: ['pipe', 'pipe', 'pipe'] }
)
let persistentPid = 0
let commandPid = 0
try {
persistentPid = Number(await nextLine(root))
const baseline = await captureCodexTurnProcesses(root.pid!)
root.stdin.write('start\n')
commandPid = Number(await nextLine(root))
await expect(terminateCodexTurnProcesses(root.pid!, baseline)).resolves.toBe(true)
expect(processExists(persistentPid)).toBe(true)
expect(processExists(commandPid)).toBe(false)
} finally {
if (persistentPid > 0 && processExists(persistentPid)) {
process.kill(persistentPid, 'SIGKILL')
}
if (commandPid > 0 && processExists(commandPid)) {
process.kill(commandPid, 'SIGKILL')
}
root.kill('SIGKILL')
}
}, 15_000)
})
@@ -0,0 +1,85 @@
import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination'
import { terminateDescendantSnapshotAndWait } from '../pty-descendant-exit-verification'
import { queryWindowsProcessDescendants } from '../providers/windows-foreground-process-rows'
import { terminateWindowsProcessTree } from '../windows-process-tree-kill'
export type CodexTurnProcessSnapshot =
| { platform: 'posix'; snapshot: DescendantSnapshot }
| { platform: 'win32'; identities: ReadonlyMap<number, string> }
function windowsIdentity(row: {
ppid: number
name: string
command: string
executablePath?: string
}): string {
return [row.ppid, row.name, row.command, row.executablePath ?? ''].join('\0')
}
export async function captureCodexTurnProcesses(
rootPid: number
): Promise<CodexTurnProcessSnapshot | null> {
if (process.platform === 'win32') {
const descendants = await queryWindowsProcessDescendants(rootPid, { fresh: true })
return descendants
? {
platform: 'win32',
identities: new Map(descendants.map((row) => [row.pid, windowsIdentity(row)]))
}
: null
}
const snapshot = await captureDescendantSnapshot(rootPid)
return snapshot ? { platform: 'posix', snapshot } : null
}
function addedPosixDescendants(
baseline: DescendantSnapshot,
current: DescendantSnapshot
): DescendantSnapshot {
const baselineRows = new Map(baseline.descendants.map((row) => [row.pid, row]))
return {
...current,
descendants: current.descendants.filter((row) => {
const prior = baselineRows.get(row.pid)
return prior?.startedAt !== row.startedAt || prior.pgid !== row.pgid
})
}
}
async function terminateWindowsAddedProcesses(
rootPid: number,
baseline: ReadonlyMap<number, string>
): Promise<boolean> {
const current = await queryWindowsProcessDescendants(rootPid, { fresh: true })
if (!current) {
return false
}
const added = current.filter((row) => baseline.get(row.pid) !== windowsIdentity(row))
const addedPids = new Set(added.map((row) => row.pid))
const roots = added.filter((row) => !addedPids.has(row.ppid))
await Promise.all(roots.map((row) => terminateWindowsProcessTree(row.pid)))
const targetIdentities = new Map(added.map((row) => [row.pid, windowsIdentity(row)]))
const remaining = await queryWindowsProcessDescendants(rootPid, { fresh: true })
return (
remaining !== null &&
remaining.every((row) => targetIdentities.get(row.pid) !== windowsIdentity(row))
)
}
export async function terminateCodexTurnProcesses(
rootPid: number,
baseline: CodexTurnProcessSnapshot | null
): Promise<boolean> {
if (!baseline) {
return false
}
if (baseline.platform === 'win32') {
return terminateWindowsAddedProcesses(rootPid, baseline.identities)
}
const current = await captureDescendantSnapshot(rootPid)
if (!current) {
return false
}
const added = addedPosixDescendants(baseline.snapshot, current)
return terminateDescendantSnapshotAndWait(added)
}
@@ -0,0 +1,128 @@
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
isCodexAppServerRequestError,
type CodexAppServerConnection
} from './codex-app-server-connection'
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
import { readCodexTurnId } from './codex-structured-thread-facts'
// Starting a Codex turn and learning its id, which are not the same event:
// `turn/start` returns the id on newer builds and acks before it exists on
// older ones, where it arrives as a `turn/started` notification instead.
/** Codex records the user message first in a turn, so the submission Orca just
* accepted is ordinal 0 of `(threadId, turnId)`. */
export const CODEX_USER_MESSAGE_ORDINAL = 0
/** Past this the turn is real but unnameable, which the journal renders as
* delivery unconfirmed rather than failure. */
const TURN_ID_WAIT_MS = 10_000
/** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise
* become an arbitrary client-controlled `turn/start` parameter. */
const CODEX_TURN_OPTION_KEYS = new Set([
'model',
'effort',
'approvalPolicy',
'approvalsReviewer',
'personality',
'serviceTier'
])
export function isCodexTurnOptionKey(key: string): boolean {
return CODEX_TURN_OPTION_KEYS.has(key)
}
/** The session state one turn needs. `turnIdWaiters` is shared with the
* notification handler, which resolves the head of the queue — correct because
* Codex runs one turn per thread, so starts and `turn/started` share an order. */
export type CodexTurnHost = {
connection: Pick<CodexAppServerConnection, 'request'>
threadId: string
options: Map<string, string>
turnIdWaiters: ((turnId: string) => void)[]
}
function turnInputFor(body: AgentJournalMessageItem): Record<string, unknown>[] {
const input: Record<string, unknown>[] = []
for (const block of body.blocks as NativeChatBlock[]) {
if (block.type === 'text' && block.text.length > 0) {
input.push({ type: 'text', text: block.text })
} else if (block.type === 'image-ref' && block.path) {
input.push({ type: 'localImage', path: block.path })
} else if (block.type === 'image-ref' && block.url) {
input.push({ type: 'image', url: block.url })
}
}
return input
}
/**
* Resolves the turn id, or null when Codex owns a turn it never named. Throws
* only for outcomes the wire must not read as acceptance.
*/
export async function startCodexTurn(
host: CodexTurnHost,
input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number }
): Promise<string | null> {
// Registered BEFORE the call: on builds that ack first, `turn/started` can
// land while the response is still in flight.
let notified: ((turnId: string) => void) | null = null
const fromNotification = new Promise<string | null>((resolve) => {
notified = resolve
host.turnIdWaiters.push(resolve)
setTimeout(() => resolve(null), TURN_ID_WAIT_MS).unref?.()
})
try {
const started = await host.connection.request(
'turn/start',
{
threadId: host.threadId,
clientUserMessageId: input.clientMessageId,
input: turnInputFor(input.body),
...Object.fromEntries(host.options)
},
{ timeoutMs: input.timeoutMs }
)
return readCodexTurnId(started) ?? (await fromNotification)
} finally {
const index = notified ? host.turnIdWaiters.indexOf(notified) : -1
if (index !== -1) {
host.turnIdWaiters.splice(index, 1)
}
}
}
/**
* One submission's outcome as the wire must read it: accepted names the turn,
* rejected is Codex answering and declining, and unknown covers a turn that is
* real but unnameable — never a failure the user is told their message hit.
*/
export async function dispatchCodexTurn(
session: CodexTurnHost,
input: { clientMessageId: string; body: AgentJournalMessageItem },
timeoutMs: number | undefined
): Promise<AgentSessionDispatchOutcome> {
let turnId: string | null
try {
turnId = await startCodexTurn(session, { ...input, timeoutMs })
} catch (error) {
if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) {
return { state: 'rejected', reason: (error as Error).message }
}
throw error
}
return turnId === null
? { state: 'unknown', reason: 'codex app-server started a turn it did not name in time' }
: {
state: 'accepted',
providerIdentity: {
provider: 'codex',
threadId: session.threadId,
turnId,
ordinal: CODEX_USER_MESSAGE_ORDINAL
}
}
}
@@ -0,0 +1,160 @@
import { spawnSync } from 'node:child_process'
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as pty from 'node-pty'
import { afterEach, describe, expect, it } from 'vitest'
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
import { resolveCodexCommand } from '../codex-cli/command'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { openCodexThread } from './codex-structured-thread-open'
import { proveCodexTuiRollout } from './codex-tui-rollout-proof'
const codexCommand = resolveCodexCommand()
const codexAvailable = spawnSync(codexCommand, ['--version']).status === 0
const itWithCodex = codexAvailable ? it : it.skip
const tempHomes: string[] = []
async function waitForTuiStart(proc: pty.IPty): Promise<string> {
let output = ''
return new Promise<string>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Codex TUI did not initialize: ${output.slice(-500)}`)),
15_000
)
proc.onData((data) => {
output += data
if (/OpenAI Codex|Welcome to Codex|Sign in with ChatGPT/i.test(output)) {
clearTimeout(timeout)
resolve(output)
}
})
proc.onExit(({ exitCode }) => {
clearTimeout(timeout)
reject(
new Error(`Codex TUI exited before initialization (${exitCode}): ${output.slice(-500)}`)
)
})
})
}
async function waitForRollout(path: string, threadId: string): Promise<string> {
const deadline = Date.now() + 5_000
while (Date.now() < deadline) {
try {
const rollout = await readFile(path, 'utf8')
if (rollout.includes(threadId)) {
return rollout
}
} catch {
// The rollout is created asynchronously after thread/start.
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
throw new Error('Codex did not materialize the resumed rollout')
}
afterEach(async () => {
await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true })))
})
describe('real Codex structured-to-TUI resume', () => {
itWithCodex(
'resumes the exact isolated rollout and reaches the initial TUI screen',
async () => {
const codexHome = await mkdtemp(join(tmpdir(), 'orca-codex-tui-resume-'))
tempHomes.push(codexHome)
await writeFile(
join(codexHome, 'config.toml'),
[
'model_provider = "orca-integration"',
'model = "gpt-5"',
'',
'[model_providers.orca-integration]',
'name = "Orca integration"',
'base_url = "http://127.0.0.1:9/v1"',
'wire_api = "responses"',
'requires_openai_auth = false',
'',
`[projects.${JSON.stringify(process.cwd())}]`,
'trust_level = "trusted"',
''
].join('\n')
)
const connection = await openCodexAppServerConnection({
command: codexCommand,
args: ['app-server'],
env: { CODEX_HOME: codexHome }
})
const opened = await openCodexThread(
connection,
{ cwd: process.cwd(), resumeThreadId: null },
15_000
)
await connection.request(
'turn/start',
{
threadId: opened.threadId,
clientUserMessageId: 'real-binary-resume-fixture',
input: [{ type: 'text', text: 'materialize the isolated resume fixture' }]
},
{ timeoutMs: 15_000 }
)
expect(await waitForRollout(opened.historyPath!, opened.threadId)).toContain(opened.threadId)
await connection.close()
expect(opened.historyPath).toContain(opened.threadId)
expect(opened.historyPath).toContain(join(codexHome, 'sessions'))
const tui = pty.spawn(codexCommand, ['resume', '--no-alt-screen', opened.threadId], {
name: 'xterm-256color',
cols: 100,
rows: 30,
cwd: process.cwd(),
env: {
...process.env,
CODEX_HOME: codexHome,
ORCA_AGENT_LAUNCH_TOKEN: 'real-binary-resume-proof',
TERM: 'xterm-256color'
}
})
const tuiExit = new Promise<number>((resolve) =>
tui.onExit(({ exitCode }) => resolve(exitCode))
)
const kittyKeyboard = new TerminalKittyKeyboardModeTracker()
let tuiOutput = ''
let lastOutputAt: number | null = null
tui.onData((data) => {
tuiOutput += data
lastOutputAt = Date.now()
kittyKeyboard.scan(data)
})
try {
await expect(waitForTuiStart(tui)).resolves.toMatch(/Codex/i)
const proof = await proveCodexTuiRollout({
codexHome,
threadId: opened.threadId,
kittyKeyboardFlags: kittyKeyboard.flags,
readOutput: () => ({ text: tuiOutput, lastOutputAt }),
write: (data) => {
tui.write(data)
return true
}
})
expect(await realpath(proof.transcriptPath)).toBe(await realpath(opened.historyPath!))
} finally {
try {
tui.kill()
} catch {
// Already exited.
}
await Promise.race([
tuiExit,
new Promise<never>((_resolve, reject) =>
setTimeout(() => reject(new Error('Codex TUI did not exit after cleanup')), 5_000)
)
])
}
},
30_000
)
})

Some files were not shown because too many files have changed in this diff Show More