mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(() => {}))
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+13
-1
@@ -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: {
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user