From cfa61d88d40511332d271cdd4952111c01778f48 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 18 Jul 2026 10:03:55 -0700 Subject: [PATCH 1/5] feat(codex): add generated app-server protocol typings - Map updated Codex notifications, approvals, usage, and thread state - Add protocol generation workspace and expand Codex integration tests --- package.json | 4 +- packages/codex-protocol/.gitignore | 1 + packages/codex-protocol/README.md | 13 ++ packages/codex-protocol/index.ts | 9 + packages/codex-protocol/package.json | 22 ++ packages/codex-protocol/scripts/generate.mjs | 48 ++++ pnpm-lock.yaml | 77 +++++++ pnpm-workspace.yaml | 3 + src/supervisor/agents/codex/acp.ts | 168 ++++++++++++-- src/supervisor/agents/codex/acpProtocol.ts | 15 +- .../agents/codex/acpQuestionAnswer.ts | 14 +- src/supervisor/agents/codex/acpTurn.ts | 11 +- .../agents/codex/appServerRpc.test.ts | 35 ++- src/supervisor/agents/codex/appServerRpc.ts | 33 ++- .../agents/codex/canonicalMapping.test.ts | 86 +++++++- .../agents/codex/canonicalMapping/dispatch.ts | 3 + .../agents/codex/canonicalMapping/goal.ts | 23 +- .../agents/codex/canonicalMapping/readers.ts | 29 ++- .../codex/canonicalMapping/serverRequest.ts | 94 ++++++-- .../agents/codex/canonicalMapping/usage.ts | 30 ++- src/supervisor/agents/codex/codex.test.ts | 208 +++++++++++++++++- .../agents/codex/protocol/README.md | 11 + .../agents/codex/protocol/client.ts | 90 ++++++++ src/supervisor/agents/codex/protocol/index.ts | 3 + .../agents/codex/protocol/notifications.ts | 67 ++++++ .../agents/codex/protocol/server.ts | 79 +++++++ tsconfig.json | 1 + 27 files changed, 1067 insertions(+), 110 deletions(-) create mode 100644 packages/codex-protocol/.gitignore create mode 100644 packages/codex-protocol/README.md create mode 100644 packages/codex-protocol/index.ts create mode 100644 packages/codex-protocol/package.json create mode 100644 packages/codex-protocol/scripts/generate.mjs create mode 100644 src/supervisor/agents/codex/protocol/README.md create mode 100644 src/supervisor/agents/codex/protocol/client.ts create mode 100644 src/supervisor/agents/codex/protocol/index.ts create mode 100644 src/supervisor/agents/codex/protocol/notifications.ts create mode 100644 src/supervisor/agents/codex/protocol/server.ts diff --git a/package.json b/package.json index d8d9ef8ed..8aefc53fc 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "type": "module", "main": "dist/main/main.cjs", "scripts": { - "postinstall": "electron-rebuild --only better-sqlite3 && node scripts/ensure-native-deps.mjs", + "postinstall": "electron-rebuild --only better-sqlite3 && node scripts/ensure-native-deps.mjs && pnpm run codex-protocol:gen", "setup:native": "electron-rebuild --only better-sqlite3 && node scripts/ensure-native-deps.mjs", "dev": "concurrently -k -n renderer,electron,app -c cyan,magenta,yellow \"pnpm run dev:renderer\" \"pnpm run dev:electron\" \"pnpm run dev:app\"", "devtools": "node scripts/dev-react-devtools.mjs", @@ -49,6 +49,7 @@ "prepare:mobile:ssh": "pnpm run build:electron && pnpm run prepare:package-assets && node scripts/build-mobile-ssh-runtime.mjs", "sentry:sourcemaps": "node scripts/sentry-sourcemaps.mjs", "clean:sourcemaps": "node scripts/clean-sourcemaps.mjs", + "codex-protocol:gen": "pnpm --filter @poracode/codex-protocol run generate", "dist": "node scripts/build-desktop-artifact.mjs", "dist:win": "node scripts/build-desktop-artifact.mjs --platform win --target nsis --arch x64", "dist:win:all": "node scripts/build-desktop-artifact.mjs --platform win", @@ -97,6 +98,7 @@ "@opencode-ai/sdk": "^1.18.3", "@poracode/activity-bridge": "file:native/activity-bridge", "@poracode/agents-usage": "workspace:*", + "@poracode/codex-protocol": "workspace:*", "@poracode/ssh-bridge": "file:native/ssh-bridge", "@sentry/electron": "^7.15.0", "@sentry/node": "10.63.0", diff --git a/packages/codex-protocol/.gitignore b/packages/codex-protocol/.gitignore new file mode 100644 index 000000000..9ab870da8 --- /dev/null +++ b/packages/codex-protocol/.gitignore @@ -0,0 +1 @@ +generated/ diff --git a/packages/codex-protocol/README.md b/packages/codex-protocol/README.md new file mode 100644 index 000000000..d684e46d3 --- /dev/null +++ b/packages/codex-protocol/README.md @@ -0,0 +1,13 @@ +# @poracode/codex-protocol + +TypeScript definitions generated during `pnpm install` from `@openai/codex`, pinned to `0.144.5`. + +The generated sources live in `generated/` and are intentionally gitignored. Regenerate them manually from the repository root with: + +```sh +pnpm codex-protocol:gen +``` + +If TypeScript reports that `./generated/index` cannot be found, run `pnpm install` or the generation command above. + +Bump the exact `@openai/codex` devDependency in `package.json` to update the generated protocol version. diff --git a/packages/codex-protocol/index.ts b/packages/codex-protocol/index.ts new file mode 100644 index 000000000..3a3705f61 --- /dev/null +++ b/packages/codex-protocol/index.ts @@ -0,0 +1,9 @@ +export type { + ApplyPatchApprovalParams, + ApplyPatchApprovalResponse, + ExecCommandApprovalParams, + ExecCommandApprovalResponse, + InitializeParams, + InitializeResponse, +} from "./generated/index"; +export * from "./generated/v2/index"; diff --git a/packages/codex-protocol/package.json b/packages/codex-protocol/package.json new file mode 100644 index 000000000..bf1a0015d --- /dev/null +++ b/packages/codex-protocol/package.json @@ -0,0 +1,22 @@ +{ + "name": "@poracode/codex-protocol", + "version": "0.1.0", + "private": true, + "description": "Install-time-generated TypeScript definitions for the Codex app-server protocol.", + "type": "module", + "sideEffects": false, + "main": "./index.ts", + "types": "./index.ts", + "exports": { + ".": { + "types": "./index.ts", + "default": "./index.ts" + } + }, + "scripts": { + "generate": "node ./scripts/generate.mjs" + }, + "devDependencies": { + "@openai/codex": "0.144.5" + } +} diff --git a/packages/codex-protocol/scripts/generate.mjs b/packages/codex-protocol/scripts/generate.mjs new file mode 100644 index 000000000..f2cc0ba06 --- /dev/null +++ b/packages/codex-protocol/scripts/generate.mjs @@ -0,0 +1,48 @@ +import { existsSync, rmSync } from "node:fs"; +import { delimiter, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const workspaceDir = resolve(packageDir, "../.."); +const executableName = process.platform === "win32" ? "codex.CMD" : "codex"; +const binDirectories = [ + resolve(packageDir, "node_modules/.bin"), + resolve(workspaceDir, "node_modules/.bin"), +]; +const codexBinDirectory = binDirectories.find((directory) => + existsSync(resolve(directory, executableName)), +); +const pnpmCli = process.env.npm_execpath; + +if (!codexBinDirectory || !pnpmCli) { + console.error( + "Cannot generate Codex protocol types: the pinned @openai/codex binary or pnpm CLI is missing. Run `pnpm install` from the repository root, then try again.", + ); + process.exit(1); +} + +rmSync(resolve(packageDir, "generated"), { recursive: true, force: true }); + +const result = spawnSync( + process.execPath, + [pnpmCli, "exec", "codex", "app-server", "generate-ts", "--experimental", "--out", "./generated"], + { + cwd: packageDir, + env: { + ...process.env, + PATH: `${codexBinDirectory}${delimiter}${process.env.PATH ?? ""}`, + }, + stdio: "inherit", + }, +); + +if (result.error) { + console.error(`Cannot generate Codex protocol types: ${result.error.message}`); + process.exit(1); +} + +if (result.status !== 0) { + console.error(`Codex protocol generation failed with exit code ${result.status ?? "unknown"}.`); + process.exit(result.status ?? 1); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da0da364f..ea3ca3e98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: '@poracode/agents-usage': specifier: workspace:* version: link:packages/agents-usage + '@poracode/codex-protocol': + specifier: workspace:* + version: link:packages/codex-protocol '@poracode/ssh-bridge': specifier: file:native/ssh-bridge version: file:native/ssh-bridge(@capacitor/core@8.4.1) @@ -380,6 +383,12 @@ importers: specifier: ^4.4.3 version: 4.4.3 + packages/codex-protocol: + devDependencies: + '@openai/codex': + specifier: 0.144.5 + version: 0.144.5 + website: dependencies: '@heroui/react': @@ -1481,6 +1490,47 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} + '@openai/codex@0.144.5': + resolution: {integrity: sha512-jjB+K+OMv572mKhS+2QuLxWXDJNdpwbPenf+V+8bdq7wg4Scqt3cn6WEekD8wPqDVZqck0HSX17K9rD9kbDJQA==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.144.5-darwin-arm64': + resolution: {integrity: sha512-zcT6NfBCqLFt+BReNSETTZW6v6PdbH0dzNtm9j7l7mDGqwPbKZDGJdnpkBao2389I0ZacyIKgSZoI0vez1d4Dw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.144.5-darwin-x64': + resolution: {integrity: sha512-//Mo0m1MwaoT6psu5xsmofXpKx4/0irIkeq10xJvk59+886EG355ibjA+ZmlRcKhE3bLjsKD7p81nTbAdRL/bw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.144.5-linux-arm64': + resolution: {integrity: sha512-zAHggxVwR2TBxKmybXY7ZMiB0G8DMonY2YPdwNNjwXcf+LOIqNGgswwNCDMbP/HEe6r8j+R9ZX/yYoo8f+n/RQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.144.5-linux-x64': + resolution: {integrity: sha512-FalLJlBQGFdK8Gc3kj9sa/ekNdgkHhUawLaKkvy5CtB18JaP2YxtTP/Pe1pD2iBiq8mMUliRnafpF6AdBdQMbg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.144.5-win32-arm64': + resolution: {integrity: sha512-0Pj7iqjEOEvPQPO3kFfCy9vGX4BTu76ChFFZHr2eNNIfVc3FOENAv/X98u4L+iIUtDOK9DbqmfUudW3DPapshg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.144.5-win32-x64': + resolution: {integrity: sha512-DnsSTlnnzleTxvLwIGnBitKInscxn2I7qASqosS8Fv+qysBygd+ZiBn/SQsRCgQ28PAlsNzmd3Gf3ZTecolAmg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@opencode-ai/sdk@1.18.3': resolution: {integrity: sha512-Mevo4e6kQwbvto9E+42KSIVMhp+JBu+SwQhC5AomAvrV6Xkio3U249T+xDILDCXhl5Z/Hi/DlAuVLzpGnuh0gg==} @@ -8346,6 +8396,33 @@ snapshots: '@noble/hashes@2.2.0': {} + '@openai/codex@0.144.5': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.144.5-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.144.5-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.144.5-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.144.5-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.144.5-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.144.5-win32-x64' + + '@openai/codex@0.144.5-darwin-arm64': + optional: true + + '@openai/codex@0.144.5-darwin-x64': + optional: true + + '@openai/codex@0.144.5-linux-arm64': + optional: true + + '@openai/codex@0.144.5-linux-x64': + optional: true + + '@openai/codex@0.144.5-win32-arm64': + optional: true + + '@openai/codex@0.144.5-win32-x64': + optional: true + '@opencode-ai/sdk@1.18.3': dependencies: cross-spawn: 7.0.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 53031bf08..9dbbdd450 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - "." - "website" - "packages/agents-usage" + - "packages/codex-protocol" # Share the virtual store across worktrees/checkouts via symlinks instead of # hardlinking every package into each node_modules/.pnpm (see pnpm.io/git-worktrees). @@ -199,6 +200,8 @@ minimumReleaseAgeExclude: - "yuku-ast@0.1.7" - "yuku-codegen@0.5.44" - "yuku-parser@0.5.44" + # The platform aliases are prerelease versions of this same package name. + - "@openai/codex" # Supply-chain guard: refuse to install any package version published less # than this many minutes ago. 7 days catches the typical window in which diff --git a/src/supervisor/agents/codex/acp.ts b/src/supervisor/agents/codex/acp.ts index 04e3f609a..f13611df0 100644 --- a/src/supervisor/agents/codex/acp.ts +++ b/src/supervisor/agents/codex/acp.ts @@ -48,7 +48,8 @@ import { parseCodexGoalCommand, type CodexGoalCommand, } from "./acpTurn"; -import { CodexAppServerRpc } from "./appServerRpc"; +import { CodexAppServerRpc, isUnsupportedCodexRequestError } from "./appServerRpc"; +import type { CodexClientRequestMap } from "./protocol"; import { CodexStdioTransport } from "./stdioTransport"; import { mapCodexSkillsToSlashCommands, @@ -60,6 +61,9 @@ import { CodexSubAgentRouter } from "./subAgentRouting"; export { deriveCodexStructuredState, parseCodexSocketMessage } from "./acpProtocol"; export type { CodexThreadStatus } from "./acpProtocol"; +type ThreadStartParams = CodexClientRequestMap["thread/start"]["params"]; +type TurnStartParams = CodexClientRequestMap["turn/start"]["params"]; + function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -68,13 +72,15 @@ function sleep(ms: number): Promise { // A `thread/status/changed → systemError` notification carries no message, so // we surface a generic fallback. But Codex often follows it with a specific -// error (a `turn/completed(failed)` or `thread/error` notification carrying the -// real reason, e.g. a usage-limit / "remote compact task" failure). Defer the -// generic fallback briefly so a specific error can preempt it — otherwise the +// error (a `turn/completed(failed)`, `error`, or legacy `thread/error` +// notification carrying the real reason, e.g. a usage-limit / "remote compact +// task" failure). Defer the generic fallback briefly so a specific error can +// preempt it — otherwise the // user sees the generic notice *plus* the real error. The delay only postpones // an error message; the error status icon updates synchronously. const CODEX_SYSTEM_ERROR_FALLBACK_DELAY_MS = 250; const CODEX_RESUME_STATUS_REPLAY_SUPPRESSION_MS = 500; +const CODEX_FORK_NOTIFICATION_BUFFER_LIMIT = 100; const CODEX_EVENT_DEBUG_ENV = "PORACODE_DEBUG_CODEX_EVENTS"; type CodexEventDebugDirection = @@ -191,7 +197,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { // Error messages already surfaced for the current turn. Codex can report one // underlying failure (e.g. a usage limit) through several channels — the // turn/start rejection, a `turn/completed(failed)` notification, and a - // `thread/error` notification — each carrying the same text. Deduping here + // `error` notification — each carrying the same text. Deduping here // keeps the user from seeing the same error two or three times. Cleared on // the next user turn. private readonly seenErrorMessages = new Set(); @@ -201,6 +207,13 @@ export class CodexStructuredSession implements StructuredSessionHandle { private pendingSystemErrorFallback: ReturnType | undefined; private mapperState: CodexMapperState | undefined; private subAgentRouter: CodexSubAgentRouter | undefined; + private forkNotificationBuffer: + | Array<{ + method: string; + params: Record | undefined; + threadId: string; + }> + | undefined; private readonly hasUserMcpServers: boolean; /** * Codex can report a plain `active` status while `thread/resume` is only @@ -481,11 +494,25 @@ export class CodexStructuredSession implements StructuredSessionHandle { async openThread(config: ThreadConfig, sessionRef?: SessionRef): Promise { // `mode` does not exist on `thread/start` or `thread/resume`; plan mode is // a per-turn override sent via `collaborationMode` on `turn/start`. - const threadOverrides = { + const threadOverrides: ThreadStartParams = { model: config.model, - ...(config.approvalPolicy ? { approvalPolicy: config.approvalPolicy } : {}), - ...(config.approvalsReviewer ? { approvalsReviewer: config.approvalsReviewer } : {}), - ...(config.sandboxMode ? { sandbox: config.sandboxMode } : {}), + ...(config.approvalPolicy + ? { + approvalPolicy: config.approvalPolicy as NonNullable< + ThreadStartParams["approvalPolicy"] + >, + } + : {}), + ...(config.approvalsReviewer + ? { + approvalsReviewer: config.approvalsReviewer as NonNullable< + ThreadStartParams["approvalsReviewer"] + >, + } + : {}), + ...(config.sandboxMode + ? { sandbox: config.sandboxMode as NonNullable } + : {}), config: { ...(config.effort ? { model_reasoning_effort: config.effort } : {}), model_reasoning_summary: "auto", @@ -680,7 +707,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { const collaborationMode = buildCodexCollaborationMode(config); try { if (this.hasUserMcpServers) { - await this.rpc.request("config/mcpServer/reload", null); + await this.rpc.request("config/mcpServer/reload", undefined); } const result = await this.rpc.request("turn/start", { threadId, @@ -688,9 +715,23 @@ export class CodexStructuredSession implements StructuredSessionHandle { model: config.model, ...(config.effort ? { effort: config.effort } : {}), summary: "auto", - ...(config.approvalPolicy ? { approvalPolicy: config.approvalPolicy } : {}), - ...(config.approvalsReviewer ? { approvalsReviewer: config.approvalsReviewer } : {}), - ...(sandboxPolicy ? { sandboxPolicy } : {}), + ...(config.approvalPolicy + ? { + approvalPolicy: config.approvalPolicy as NonNullable< + TurnStartParams["approvalPolicy"] + >, + } + : {}), + ...(config.approvalsReviewer + ? { + approvalsReviewer: config.approvalsReviewer as NonNullable< + TurnStartParams["approvalsReviewer"] + >, + } + : {}), + ...(sandboxPolicy + ? { sandboxPolicy: sandboxPolicy as NonNullable } + : {}), collaborationMode, // Fast toggle is authoritative and the server tier is sticky, so force it // every turn: "fast" selects the Fast lane, null clears it to the default. @@ -736,15 +777,84 @@ export class CodexStructuredSession implements StructuredSessionHandle { throw new Error(`rollbackThread: numTurns must be a positive integer (got ${numTurns}).`); } const threadId = await this.waitForRemoteThreadId(); - await this.rpc.request("thread/rollback", { + this.forkNotificationBuffer = undefined; + + const rollbackLegacy = async (reason: string): Promise => { + this.forkNotificationBuffer = undefined; + console.log(`[codex] ${reason}; falling back to thread/rollback.`); + try { + await this.rpc.request("thread/rollback", { + threadId, + numTurns, + }); + } catch (error) { + if (isUnsupportedCodexRequestError(error)) { + throw new Error( + "Codex cannot roll back this thread because neither thread/fork nor thread/rollback is supported.", + { cause: error }, + ); + } + throw error; + } + this.pendingTurnInterrupt = false; + this.activeTurnId = undefined; + await this.syncRemoteThreadState(threadId, toSessionRef(threadId)); + return { + providerSessionId: threadId, + messages: [], + }; + }; + + const readResult = await this.rpc.request("thread/read", { threadId, - numTurns, + includeTurns: true, + }); + const turns = readResult.thread?.turns; + if (!Array.isArray(turns)) { + return rollbackLegacy("thread/read omitted turn history"); + } + if (turns.length <= numTurns) { + return rollbackLegacy("thread/fork has no retained turn"); + } + + const retainedTurn = turns.at(turns.length - numTurns - 1); + if (!retainedTurn || typeof retainedTurn.id !== "string") { + return rollbackLegacy("thread/read omitted the retained turn id"); + } + + let forkResult: CodexClientRequestMap["thread/fork"]["result"]; + this.forkNotificationBuffer = []; + try { + forkResult = await this.rpc.request("thread/fork", { + threadId, + lastTurnId: retainedTurn.id, + }); + } catch (error) { + this.forkNotificationBuffer = undefined; + if (!isUnsupportedCodexRequestError(error)) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + return rollbackLegacy(`thread/fork is unsupported (${message})`); + } + + const newThreadId = forkResult.thread?.id; + if (!newThreadId) { + this.forkNotificationBuffer = undefined; + throw new Error("thread/fork response did not contain a thread id."); + } + + this.remoteThreadId = newThreadId; + this.launchOptions = { ...this.launchOptions, resumeThreadId: newThreadId }; + this.replayForkNotifications(newThreadId); + void this.rpc.request("thread/unsubscribe", { threadId }).catch((error) => { + console.log("[codex] failed to unsubscribe old thread after fork:", error); }); this.pendingTurnInterrupt = false; this.activeTurnId = undefined; - await this.syncRemoteThreadState(threadId, toSessionRef(threadId)); + await this.syncRemoteThreadState(newThreadId, toSessionRef(newThreadId)); return { - providerSessionId: threadId, + providerSessionId: newThreadId, messages: [], }; } @@ -793,6 +903,17 @@ export class CodexStructuredSession implements StructuredSessionHandle { return; } const notificationThreadId = readNotificationThreadId(params, this.remoteThreadId); + if ( + this.forkNotificationBuffer && + notificationThreadId && + this.remoteThreadId !== undefined && + notificationThreadId !== this.remoteThreadId + ) { + if (this.forkNotificationBuffer.length < CODEX_FORK_NOTIFICATION_BUFFER_LIMIT) { + this.forkNotificationBuffer.push({ method, params, threadId: notificationThreadId }); + } + return; + } const suppressResumeReplay = this.isResumeReplaySuppressed(notificationThreadId); const childEvents = this.ensureSubAgentRouter().routeChildNotification( method, @@ -857,7 +978,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { } const nextStatus = params.status as CodexThreadStatus; // A systemError status alone gives the renderer a red icon but no - // message. If Codex didn't already send a paired `thread/error` + // message. If Codex didn't already send a paired `error` // notification or a turn/start rejection (which set `errorSticky`), // surface a fallback runtime error event so `ThreadErrorDock` // renders something instead of leaving the user with an empty dock. @@ -903,6 +1024,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { return; } + // `turn/aborted` is retained only for older app-server compatibility. if (method === "turn/completed" || method === "turn/aborted") { if (suppressResumeReplay) { return; @@ -965,6 +1087,16 @@ export class CodexStructuredSession implements StructuredSessionHandle { return this.remoteThreadId === undefined || this.remoteThreadId === threadId; } + private replayForkNotifications(threadId: string): void { + const notifications = this.forkNotificationBuffer ?? []; + this.forkNotificationBuffer = undefined; + for (const notification of notifications) { + if (notification.threadId === threadId) { + this.handleNotification(notification.method, notification.params); + } + } + } + private async waitForRemoteThreadId(): Promise { for (let attempt = 0; attempt < 400; attempt += 1) { if (this.remoteThreadId) { diff --git a/src/supervisor/agents/codex/acpProtocol.ts b/src/supervisor/agents/codex/acpProtocol.ts index 0afa2b1ee..d9c5f520c 100644 --- a/src/supervisor/agents/codex/acpProtocol.ts +++ b/src/supervisor/agents/codex/acpProtocol.ts @@ -1,10 +1,11 @@ -import type { ThreadAttention, ThreadServerRequestId, ThreadStatus } from "@/shared/contracts"; +import type { + ThreadAttention, + ThreadServerRequestId, + ThreadStatus as CanonicalThreadStatus, +} from "@/shared/contracts"; +import type { ThreadStatus as CodexProtocolThreadStatus } from "./protocol"; -export type CodexThreadStatus = - | { type: "active"; activeFlags?: string[] } - | { type: "idle" } - | { type: "notLoaded" } - | { type: "systemError" }; +export type CodexThreadStatus = CodexProtocolThreadStatus; export type CodexSocketMessage = | { @@ -68,7 +69,7 @@ function extractObjectStringField( } export function deriveCodexStructuredState(status: CodexThreadStatus): { - status: ThreadStatus; + status: CanonicalThreadStatus; attention: ThreadAttention; } { if (status.type === "systemError") { diff --git a/src/supervisor/agents/codex/acpQuestionAnswer.ts b/src/supervisor/agents/codex/acpQuestionAnswer.ts index 97747cd4d..f0463c203 100644 --- a/src/supervisor/agents/codex/acpQuestionAnswer.ts +++ b/src/supervisor/agents/codex/acpQuestionAnswer.ts @@ -37,13 +37,17 @@ function codexQuestionSources( ? q.options.flatMap((opt) => { if (!opt || typeof opt !== "object") return []; const o = opt as Record; - if (typeof o.label !== "string" || o.label.length === 0) return []; - const optionId = - typeof o.optionId === "string" && o.optionId.length > 0 ? o.optionId : o.label; + const label = + typeof o.label === "string" && o.label.length > 0 + ? o.label + : typeof o.optionId === "string" && o.optionId.length > 0 + ? o.optionId + : undefined; + if (!label) return []; return [ { - optionId, - label: o.label, + optionId: label, + label, ...(typeof o.description === "string" && o.description.length > 0 ? { description: o.description } : {}), diff --git a/src/supervisor/agents/codex/acpTurn.ts b/src/supervisor/agents/codex/acpTurn.ts index be550044d..ec8021b4f 100644 --- a/src/supervisor/agents/codex/acpTurn.ts +++ b/src/supervisor/agents/codex/acpTurn.ts @@ -1,4 +1,7 @@ import type { PromptSegment, ThreadConfig } from "@/shared/contracts"; +import type { CodexClientRequestMap } from "./protocol"; + +type TurnStartParams = CodexClientRequestMap["turn/start"]["params"]; // Codex's `turn/start` requires a non-empty `developer_instructions` string // inside `collaborationMode.settings`. We send these on every turn so that @@ -34,8 +37,8 @@ export function buildCodexTurnInput( prompt: string, segments: PromptSegment[] | undefined, inlineInstructions?: string, -): Record[] { - const input: Record[] = []; +): TurnStartParams["input"] { + const input: TurnStartParams["input"] = []; const hasSkillSegment = segments?.some((segment) => segment.kind === "skill") === true; for (const seg of segments ?? []) { @@ -83,7 +86,9 @@ export function buildCodexTurnInput( return input; } -export function buildCodexCollaborationMode(config: ThreadConfig): Record { +export function buildCodexCollaborationMode( + config: ThreadConfig, +): NonNullable { return { mode: config.mode === "plan" ? "plan" : "default", settings: { diff --git a/src/supervisor/agents/codex/appServerRpc.test.ts b/src/supervisor/agents/codex/appServerRpc.test.ts index 746d76724..cf7011416 100644 --- a/src/supervisor/agents/codex/appServerRpc.test.ts +++ b/src/supervisor/agents/codex/appServerRpc.test.ts @@ -115,7 +115,7 @@ describe("CodexAppServerRpc", () => { it("rejects error responses with the app-server message", async () => { const { listener, rpc } = createRpcHarness(); - const pending = rpc.request("turn/start", { threadId: "provider-thread" }); + const pending = rpc.request("turn/start", { threadId: "provider-thread", input: [] }); listener().onMessage({ jsonrpc: "2.0", @@ -210,12 +210,25 @@ describe("CodexAppServerRpc", () => { id: "question-1", method: "item/tool/requestUserInput", params: { - questions: [{ id: "scope", header: "Scope", question: "Which scope?" }], + threadId: "provider-thread", + turnId: "turn-1", + itemId: "item-1", + autoResolutionMs: null, + questions: [ + { + id: "scope", + header: "Scope", + question: "Which scope?", + isOther: false, + isSecret: false, + options: [{ label: "Workspace", description: "Current workspace" }], + }, + ], }, }); runtimeEvents.splice(0); - const response = { answers: { scope: { answers: ["workspace"] } } }; + const response = { answers: { scope: { answers: ["Workspace"] } } }; rpc.resolveServerRequest("question-1", response); expect(writes.at(-1)).toEqual({ @@ -223,12 +236,24 @@ describe("CodexAppServerRpc", () => { result: response, }); expect(runtimeEvents).toEqual([ - expect.objectContaining({ type: "item.started", threadId: "local-thread" }), + expect.objectContaining({ + type: "item.started", + threadId: "local-thread", + payload: { + questions: [ + { + header: "Scope", + question: "Which scope?", + selected: [{ label: "Workspace", description: "Current workspace" }], + }, + ], + }, + }), expect.objectContaining({ type: "item.completed", threadId: "local-thread" }), ]); }); - it("answers unsupported inbound requests with method-not-found", () => { + it("returns method-not-found for unimplemented ChatGPT auth-token refresh", () => { const { listener, runtimeEvents, writes } = createRpcHarness(); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); diff --git a/src/supervisor/agents/codex/appServerRpc.ts b/src/supervisor/agents/codex/appServerRpc.ts index 4002571dd..0ab634e2e 100644 --- a/src/supervisor/agents/codex/appServerRpc.ts +++ b/src/supervisor/agents/codex/appServerRpc.ts @@ -2,6 +2,7 @@ import type { RuntimeEvent, ThreadServerRequestId } from "@/shared/contracts"; import { mapCodexServerRequest, translateCodexCanonicalResponse } from "./canonicalMapping"; import { parseCodexSocketMessage } from "./acpProtocol"; import { buildCodexQuestionAnswerEvents } from "./acpQuestionAnswer"; +import type { CodexClientRequestMap } from "./protocol"; import type { CodexStdioTransport } from "./stdioTransport"; export type CodexRpcDebugDirection = "codex->poracode" | "poracode->codex" | "transport"; @@ -34,7 +35,7 @@ type InboundRequest = { const SERVER_OVERLOADED_ERROR_CODE = -32001; const MAX_OVERLOAD_RETRIES = 2; -class CodexRpcResponseError extends Error { +export class CodexRpcResponseError extends Error { constructor( message: string, readonly code: number | undefined, @@ -43,6 +44,14 @@ class CodexRpcResponseError extends Error { } } +export function isUnsupportedCodexRequestError(error: unknown): boolean { + return ( + error instanceof CodexRpcResponseError && + (error.code === -32601 || + (error.code === -32602 && /invalid params|unknown (?:field|parameter)/iu.test(error.message))) + ); +} + /** Owns JSON-RPC correlation and server-request bookkeeping above stdio framing. */ export class CodexAppServerRpc { private listener: CodexAppServerRpcListener | undefined; @@ -64,7 +73,17 @@ export class CodexAppServerRpc { }); } - request( + request( + method: M, + params: CodexClientRequestMap[M]["params"], + timeoutMs = 30_000, + ): Promise { + return this.requestWithRetry(method, params, timeoutMs) as Promise< + CodexClientRequestMap[M]["result"] + >; + } + + requestUnmapped( method: string, params: Record | null, timeoutMs = 30_000, @@ -74,7 +93,7 @@ export class CodexAppServerRpc { private async requestWithRetry( method: string, - params: Record | null, + params: unknown, timeoutMs: number, ): Promise { for (let attempt = 0; ; attempt += 1) { @@ -95,11 +114,7 @@ export class CodexAppServerRpc { } } - private requestOnce( - method: string, - params: Record | null, - timeoutMs: number, - ): Promise { + private requestOnce(method: string, params: unknown, timeoutMs: number): Promise { const id = `poracode-${this.requestSequence++}`; const pending = new Promise((resolve, reject) => { @@ -214,6 +229,8 @@ export class CodexAppServerRpc { console.warn( `[codex] no canonical mapping for app-server request method "${message.method}"; replying method not found.`, ); + // Rejecting account/chatgptAuthTokens/refresh here is intentional: external-auth mode + // alone sends it, and Poracode never enables that mode (codex-rs app-server/src/external_auth.rs). this.write({ id: message.id, error: { diff --git a/src/supervisor/agents/codex/canonicalMapping.test.ts b/src/supervisor/agents/codex/canonicalMapping.test.ts index a795a7e4f..c1decccc5 100644 --- a/src/supervisor/agents/codex/canonicalMapping.test.ts +++ b/src/supervisor/agents/codex/canonicalMapping.test.ts @@ -49,7 +49,7 @@ describe("mapCodexNotification — turn lifecycle", () => { expect(state.currentTurnId).toBeUndefined(); }); - it("treats turn/aborted as turn.completed with state=interrupted", () => { + it("treats legacy turn/aborted as turn.completed with state=interrupted", () => { const state = createCodexMapperState("t-codex"); mapCodexNotification("turn/started", { turnId: "t-1", threadId: "x" }, state); const events = mapCodexNotification("turn/aborted", { threadId: "x" }, state); @@ -87,6 +87,14 @@ describe("mapCodexNotification — turn lifecycle", () => { ]); }); + it("falls back from whitespace-only Codex error messages", () => { + const state = createCodexMapperState("t-codex"); + + expect(mapCodexNotification("error", { error: { message: " " } }, state)).toEqual([ + { type: "error", threadId: "t-codex", message: "Codex thread error" }, + ]); + }); + it("maps turn usage into context usage when the app-server provides it", () => { const state = createCodexMapperState("t-codex"); mapCodexNotification("turn/started", { turnId: "t-1", threadId: "x" }, state); @@ -244,6 +252,36 @@ describe("mapCodexNotification — goals", () => { ]); }); + it.each([ + ["blocked", "paused"], + ["usageLimited", "budget_limited"], + ] as const)("maps Codex %s goals to canonical %s goals", (status, expectedStatus) => { + const state = createCodexMapperState("t-codex"); + const events = mapCodexNotification( + "thread/goal/updated", + { + threadId: "provider-thread", + turnId: null, + goal: { + threadId: "provider-thread", + objective: "finish protocol integration", + status, + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1778570000, + updatedAt: 1778570000, + }, + }, + state, + ); + + expect(events[0]).toMatchObject({ + type: "item.started", + payload: { status: expectedStatus }, + }); + }); + it("updates the existing Codex goal item when the goal is cleared", () => { const state = createCodexMapperState("t-codex"); const started = mapCodexNotification( @@ -1538,7 +1576,7 @@ describe("mapCodexNotification — streaming deltas", () => { }); }); - it("maps file-read approval requests", () => { + it("maps legacy file-read approval requests", () => { const event = mapCodexServerRequest("thread-1", "read-1", "item/fileRead/requestApproval", { path: "src/index.ts", reason: "Read outside workspace", @@ -1594,6 +1632,28 @@ describe("mapCodexServerRequest — approvals", () => { }); }); + it("skips structured command approval decisions when building options", () => { + const event = mapCodexServerRequest("thread-1", "0", "item/commandExecution/requestApproval", { + command: "pnpm test", + availableDecisions: [ + "accept", + { acceptWithExecpolicyAmendment: { execpolicy_amendment: {} } }, + { applyNetworkPolicyAmendment: { network_policy_amendment: {} } }, + "decline", + ], + }); + + expect(event).toMatchObject({ + type: "request.opened", + payload: { + options: [ + { optionId: "accept", label: "Allow" }, + { optionId: "decline", label: "Deny" }, + ], + }, + }); + }); + it("offers and translates session approval for file edits", () => { const event = mapCodexServerRequest("thread-1", "edit-1", "item/fileChange/requestApproval", { reason: "File changes need approval", @@ -1655,17 +1715,25 @@ describe("mapCodexServerRequest — approvals", () => { describe("mapCodexServerRequest — user input", () => { it("carries multi-question requestUserInput payloads as structured form details", () => { const event = mapCodexServerRequest("thread-1", "req-1", "item/tool/requestUserInput", { + threadId: "provider-thread", + turnId: "turn-1", + itemId: "item-1", + autoResolutionMs: null, questions: [ { id: "scope", header: "Scope", question: "Which scope?", + isOther: false, + isSecret: false, options: [{ label: "Scope A", description: "Minimal" }], }, { id: "validation", header: "Validation", question: "Which validation?", + isOther: false, + isSecret: false, options: [{ label: "After each phase", description: "Incremental" }], }, ], @@ -1684,11 +1752,25 @@ describe("mapCodexServerRequest — user input", () => { id: "scope", header: "Scope", question: "Which scope?", + options: [ + { + optionId: "Scope A", + label: "Scope A", + description: "Minimal", + }, + ], }, { id: "validation", header: "Validation", question: "Which validation?", + options: [ + { + optionId: "After each phase", + label: "After each phase", + description: "Incremental", + }, + ], }, ], }, diff --git a/src/supervisor/agents/codex/canonicalMapping/dispatch.ts b/src/supervisor/agents/codex/canonicalMapping/dispatch.ts index 7d094fd2f..a5d7c942f 100644 --- a/src/supervisor/agents/codex/canonicalMapping/dispatch.ts +++ b/src/supervisor/agents/codex/canonicalMapping/dispatch.ts @@ -53,6 +53,8 @@ export function mapCodexNotification( return [{ type: "turn.started", threadId, turnId }]; } + // `turn/aborted` is a legacy-only compatibility path; 0.144.5 reports + // interruption through `turn/completed` with `turn.status: "interrupted"`. if (method === "turn/completed" || method === "turn/aborted") { const events: RuntimeEvent[] = []; const usageEvent = createCodexContextUsageEvent(threadId, params); @@ -100,6 +102,7 @@ export function mapCodexNotification( return events; } + // `thread/error` is legacy-only; current app-server errors use `error`. if (method === "thread/error" || method === "error") { const message = readCodexErrorMessage(params) ?? "Codex thread error"; return [{ type: "error", threadId, message }]; diff --git a/src/supervisor/agents/codex/canonicalMapping/goal.ts b/src/supervisor/agents/codex/canonicalMapping/goal.ts index 216e6f34f..30c934a95 100644 --- a/src/supervisor/agents/codex/canonicalMapping/goal.ts +++ b/src/supervisor/agents/codex/canonicalMapping/goal.ts @@ -4,6 +4,16 @@ import type { ProviderGoalState } from "../../goalRuntime"; import type { CodexMapperState } from "../canonicalMappingState"; +import type { ThreadGoalStatus } from "../protocol"; + +const CODEX_GOAL_STATUS_MAP = { + active: "active", + paused: "paused", + blocked: "paused", + usageLimited: "budget_limited", + budgetLimited: "budget_limited", + complete: "complete", +} as const satisfies Record>; export function readCodexGoal( params: Record | undefined, @@ -30,16 +40,9 @@ export function readCodexGoal( } export function readCodexGoalStatus(status: unknown): ProviderGoalState["status"] | undefined { - switch (status) { - case "active": - case "paused": - case "complete": - return status; - case "budgetLimited": - return "budget_limited"; - default: - return undefined; - } + return typeof status === "string" && status in CODEX_GOAL_STATUS_MAP + ? CODEX_GOAL_STATUS_MAP[status as ThreadGoalStatus] + : undefined; } export function isNewCodexGoal(goal: ProviderGoalState, state: CodexMapperState): boolean { diff --git a/src/supervisor/agents/codex/canonicalMapping/readers.ts b/src/supervisor/agents/codex/canonicalMapping/readers.ts index 1eedf0d2a..1ad271670 100644 --- a/src/supervisor/agents/codex/canonicalMapping/readers.ts +++ b/src/supervisor/agents/codex/canonicalMapping/readers.ts @@ -7,6 +7,13 @@ */ import { readStringField } from "../../fileChangeSummary"; +import type { TurnPlanStepStatus } from "../protocol"; + +const CODEX_PLAN_STATUS_MAP = { + pending: "pending", + inProgress: "in_progress", + completed: "completed", +} as const satisfies Record; export interface CodexItemPayload { id?: string; @@ -97,6 +104,7 @@ export function readTurnState( method: string, params: Record | undefined, ): "completed" | "failed" | "interrupted" | "cancelled" { + // Legacy-only; current app-server sends `turn/completed` with an interrupted status. if (method === "turn/aborted") return "interrupted"; const turn = params?.turn; const status = turn && typeof turn === "object" ? (turn as Record).status : null; @@ -117,16 +125,20 @@ export function readCodexErrorMessage( ): string | undefined { const direct = readStringField(params, "message", "errorMessage"); if (direct) return direct; - const message = readStringField(params?.error, "message"); + const message = readTurnErrorMessage(params?.error); if (message) return message; const turn = params?.turn; if (turn && typeof turn === "object") { - const turnMessage = readStringField((turn as Record).error, "message"); + const turnMessage = readTurnErrorMessage((turn as Record).error); if (turnMessage) return turnMessage; } return undefined; } +function readTurnErrorMessage(value: unknown): string | undefined { + return readStringField(value, "message"); +} + export function readCodexPlanSteps( params: Record | undefined, ): Array<{ step: string; status: "pending" | "in_progress" | "completed" }> { @@ -147,15 +159,10 @@ export function readCodexPlanSteps( } function codexPlanStepStatus(raw: unknown): "pending" | "in_progress" | "completed" { - switch (raw) { - case "completed": - return "completed"; - case "inProgress": - case "in_progress": - return "in_progress"; - default: - return "pending"; - } + if (raw === "in_progress") return "in_progress"; + return typeof raw === "string" && raw in CODEX_PLAN_STATUS_MAP + ? CODEX_PLAN_STATUS_MAP[raw as TurnPlanStepStatus] + : "pending"; } export function readRecord(value: unknown): Record | undefined { diff --git a/src/supervisor/agents/codex/canonicalMapping/serverRequest.ts b/src/supervisor/agents/codex/canonicalMapping/serverRequest.ts index c9281085c..780b373d3 100644 --- a/src/supervisor/agents/codex/canonicalMapping/serverRequest.ts +++ b/src/supervisor/agents/codex/canonicalMapping/serverRequest.ts @@ -15,12 +15,24 @@ import type { UserInputOption, } from "@/shared/contracts"; import { readStringField } from "../../fileChangeSummary"; +import type { CommandExecutionApprovalDecision, ToolRequestUserInputParams } from "../protocol"; + +type StringCommandExecutionApprovalDecision = Extract; + +const DEFAULT_APPROVAL_DECISIONS = [ + "accept", + "acceptForSession", + "decline", + "cancel", +] as const satisfies readonly StringCommandExecutionApprovalDecision[]; const CODEX_APPROVAL_METHODS = new Set([ + // Legacy-only: this request is absent from the 0.144.5 protocol. "item/fileRead/requestApproval", "item/fileChange/requestApproval", "applyPatchApproval", "execCommandApproval", + // Legacy-only: this request is absent from the 0.144.5 protocol. "item/tool/requestApproval", "item/commandExecution/requestApproval", "item/permissions/requestApproval", @@ -28,7 +40,7 @@ const CODEX_APPROVAL_METHODS = new Set([ const CODEX_FORM_METHODS = new Set(["mcpServer/elicitation/request", "item/tool/requestUserInput"]); -function decisionLabel(decision: string): string { +function decisionLabel(decision: StringCommandExecutionApprovalDecision): string { switch (decision) { case "accept": return "Allow"; @@ -37,14 +49,17 @@ function decisionLabel(decision: string): string { case "decline": case "cancel": return "Deny"; - default: - return decision; } } -function codexDecisionOptions(decisions: readonly string[]): UserInputOption[] { - const hasDecline = decisions.includes("decline"); - return decisions +function codexDecisionOptions( + decisions: readonly CommandExecutionApprovalDecision[], +): UserInputOption[] { + const stringDecisions = decisions.filter( + (decision): decision is StringCommandExecutionApprovalDecision => typeof decision === "string", + ); + const hasDecline = stringDecisions.includes("decline"); + return stringDecisions .filter((decision) => decision !== "cancel" || !hasDecline) .map((decision) => ({ optionId: decision, @@ -54,13 +69,50 @@ function codexDecisionOptions(decisions: readonly string[]): UserInputOption[] { function readAvailableDecisions( params: Record | undefined, - fallback: readonly string[], -): string[] { + fallback: readonly StringCommandExecutionApprovalDecision[], +): CommandExecutionApprovalDecision[] { return Array.isArray(params?.availableDecisions) - ? (params.availableDecisions as unknown[]).filter((d): d is string => typeof d === "string") + ? (params.availableDecisions as unknown[]).filter(isCommandExecutionApprovalDecision) : [...fallback]; } +function isCommandExecutionApprovalDecision( + value: unknown, +): value is CommandExecutionApprovalDecision { + if (typeof value === "string") { + return DEFAULT_APPROVAL_DECISIONS.includes(value as StringCommandExecutionApprovalDecision); + } + return ( + value !== null && + typeof value === "object" && + ("acceptWithExecpolicyAmendment" in value || "applyNetworkPolicyAmendment" in value) + ); +} + +function normalizeCodexUserInputQuestions( + questions: ToolRequestUserInputParams["questions"], +): unknown[] { + return questions.map((question) => { + if (!question || typeof question !== "object") return question; + const record = question as Record; + if (!Array.isArray(record.options)) return question; + return { + ...record, + options: record.options.map((option) => { + if (!option || typeof option !== "object") return option; + const optionRecord = option as Record; + const label = + typeof optionRecord.label === "string" && optionRecord.label.length > 0 + ? optionRecord.label + : typeof optionRecord.optionId === "string" && optionRecord.optionId.length > 0 + ? optionRecord.optionId + : undefined; + return label ? { ...optionRecord, optionId: label, label } : option; + }), + }; + }); +} + function codexPermissionDetails(input: { toolName: string; displayName?: string; @@ -113,7 +165,11 @@ export function mapCodexServerRequest( } if (method === "item/tool/requestUserInput") { - const questions = Array.isArray(params?.questions) ? params.questions : []; + const questions = Array.isArray(params?.questions) + ? normalizeCodexUserInputQuestions( + params.questions as ToolRequestUserInputParams["questions"], + ) + : []; if (questions.length === 0) return undefined; return { type: "request.opened", @@ -159,12 +215,7 @@ export function mapCodexServerRequest( if (method === "item/commandExecution/requestApproval") { const command = readStringField(params, "command") ?? "command"; - const decisions = readAvailableDecisions(params, [ - "accept", - "acceptForSession", - "decline", - "cancel", - ]); + const decisions = readAvailableDecisions(params, DEFAULT_APPROVAL_DECISIONS); return { type: "request.opened", threadId, @@ -204,7 +255,7 @@ export function mapCodexServerRequest( ...(readStringField(params, "cwd") ? { cwd: readStringField(params, "cwd") } : {}), }, }), - options: codexDecisionOptions(["accept", "acceptForSession", "decline", "cancel"]), + options: codexDecisionOptions(DEFAULT_APPROVAL_DECISIONS), }, }; } @@ -232,12 +283,7 @@ export function mapCodexServerRequest( if (method === "item/fileChange/requestApproval" || method === "applyPatchApproval") { const summary = reason ?? "File changes need approval"; - const decisions = readAvailableDecisions(params, [ - "accept", - "acceptForSession", - "decline", - "cancel", - ]); + const decisions = readAvailableDecisions(params, DEFAULT_APPROVAL_DECISIONS); return { type: "request.opened", threadId, @@ -279,7 +325,7 @@ export function mapCodexServerRequest( ...(approvalToolName ? { displayName: approvalToolName } : {}), toolInput: params?.input, }), - options: codexDecisionOptions(["accept", "acceptForSession", "decline", "cancel"]), + options: codexDecisionOptions(DEFAULT_APPROVAL_DECISIONS), }, }; } diff --git a/src/supervisor/agents/codex/canonicalMapping/usage.ts b/src/supervisor/agents/codex/canonicalMapping/usage.ts index 4b0bc03ca..b39f90ff5 100644 --- a/src/supervisor/agents/codex/canonicalMapping/usage.ts +++ b/src/supervisor/agents/codex/canonicalMapping/usage.ts @@ -8,6 +8,7 @@ import { readNonNegativeInteger, usageFromTokenCounts, } from "../../contextUsage"; +import type { ThreadTokenUsage } from "../protocol"; import { readRecord } from "./readers"; export function createCodexContextUsageEvent( @@ -26,20 +27,29 @@ export function createCodexTokenUsageEvent( threadId: string, params: Record | undefined, ): RuntimeEvent | undefined { - const tokenUsage = readRecord(params?.tokenUsage) ?? readRecord(params?.token_usage); - if (tokenUsage) { + const currentTokenUsageRecord = readRecord(params?.tokenUsage); + const currentLastUsage = readRecord(currentTokenUsageRecord?.last); + if (currentTokenUsageRecord && currentLastUsage) { + const currentTokenUsage = currentTokenUsageRecord as ThreadTokenUsage; + return createCodexUsageEvent(threadId, currentLastUsage, { + maxTokens: readNonNegativeInteger(currentTokenUsage.modelContextWindow), + }); + } + + const legacyTokenUsage = readRecord(params?.token_usage) ?? currentTokenUsageRecord; + if (legacyTokenUsage) { const usage = - readRecord(tokenUsage.last) ?? - readRecord(tokenUsage.lastTokenUsage) ?? - readRecord(tokenUsage.last_token_usage) ?? - readRecord(tokenUsage.total) ?? - readRecord(tokenUsage.totalTokenUsage) ?? - readRecord(tokenUsage.total_token_usage); + readRecord(legacyTokenUsage.last) ?? + readRecord(legacyTokenUsage.lastTokenUsage) ?? + readRecord(legacyTokenUsage.last_token_usage) ?? + readRecord(legacyTokenUsage.total) ?? + readRecord(legacyTokenUsage.totalTokenUsage) ?? + readRecord(legacyTokenUsage.total_token_usage); if (!usage) return undefined; return createCodexUsageEvent(threadId, usage, { maxTokens: - readNonNegativeInteger(tokenUsage.modelContextWindow) ?? - readNonNegativeInteger(tokenUsage.model_context_window), + readNonNegativeInteger(legacyTokenUsage.modelContextWindow) ?? + readNonNegativeInteger(legacyTokenUsage.model_context_window), }); } diff --git a/src/supervisor/agents/codex/codex.test.ts b/src/supervisor/agents/codex/codex.test.ts index fb8852342..1ba2641aa 100644 --- a/src/supervisor/agents/codex/codex.test.ts +++ b/src/supervisor/agents/codex/codex.test.ts @@ -14,7 +14,8 @@ import { parseCodexLoginStatusOutput, } from "./detection"; import { CodexStructuredSession } from "./acp"; -import type { CodexAppServerRpcListener } from "./appServerRpc"; +import { CodexRpcResponseError, type CodexAppServerRpcListener } from "./appServerRpc"; +import type { CodexThreadStatus } from "./acpProtocol"; import type { OscNotification, OscTitle } from "@/shared/osc"; import type { RuntimeEvent, ToolCallPayload } from "@/shared/contracts"; import { codexIntentFor } from "./plugin/intentMap"; @@ -67,6 +68,18 @@ describe("deriveCodexStructuredState", () => { }); }); + it("ignores unknown active flags from newer app-server versions", () => { + const status = { + type: "active", + activeFlags: ["newerUnknownFlag"], + } as unknown as CodexThreadStatus; + + expect(deriveCodexStructuredState(status)).toEqual({ + status: "working", + attention: "working", + }); + }); + it("maps idle state to idle", () => { expect(deriveCodexStructuredState({ type: "idle" })).toEqual({ status: "idle", @@ -88,7 +101,11 @@ describe("deriveCodexStructuredState", () => { id: "req-1", method: "item/tool/requestUserInput", params: { + threadId: "provider-thread", + turnId: "turn-1", + itemId: "item-1", questions: [], + autoResolutionMs: null, }, }), ).toEqual({ @@ -96,7 +113,11 @@ describe("deriveCodexStructuredState", () => { id: "req-1", method: "item/tool/requestUserInput", params: { + threadId: "provider-thread", + turnId: "turn-1", + itemId: "item-1", questions: [], + autoResolutionMs: null, }, }); }); @@ -806,6 +827,7 @@ describe("CodexStructuredSession", () => { session["threadId"] = "local-thread"; session["remoteThreadId"] = "provider-thread"; + session["launchOptions"] = {}; session["bufferedRuntimeEvents"] = []; session["isDisposed"] = false; session["currentThreadStatus"] = { type: "idle" }; @@ -877,19 +899,193 @@ describe("CodexStructuredSession", () => { }); }); - it("rolls back Codex app-server threads with thread/rollback", async () => { + it("forks Codex app-server threads through the retained turn", async () => { const requests: Array<{ method: string; params: Record }> = []; const structuredSession = makeStructuredSession(requests); + const runtimeEvents: RuntimeEvent[] = []; + (structuredSession as unknown as Record)["listener"] = { + onRuntimeEvent: (event: RuntimeEvent) => runtimeEvents.push(event), + }; + (structuredSession as unknown as Record)["rpc"] = { + request: (method: string, params: Record) => { + requests.push({ method, params }); + if (method === "thread/read" && params.includeTurns === true) { + return Promise.resolve({ + thread: { + turns: ["turn-1", "turn-2", "turn-3", "turn-4"].map((id) => ({ id })), + }, + }); + } + if (method === "thread/fork") { + const response = Promise.resolve({ thread: { id: "forked-thread" } }); + dispatchNotification(structuredSession, { + jsonrpc: "2.0", + method: "thread/tokenUsage/updated", + params: { + threadId: "forked-thread", + turnId: "turn-2", + tokenUsage: { + total: { + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 20, + reasoningOutputTokens: 5, + totalTokens: 120, + }, + last: { + inputTokens: 80, + cachedInputTokens: 0, + outputTokens: 15, + reasoningOutputTokens: 5, + totalTokens: 100, + }, + modelContextWindow: 258_400, + }, + }, + }); + return response; + } + return Promise.resolve({ thread: { status: { type: "idle" } } }); + }, + }; + + const history = await structuredSession.rollbackThread(2); - await structuredSession.rollbackThread(2); + expect(requests.slice(0, 2)).toEqual([ + { + method: "thread/read", + params: { + threadId: "provider-thread", + includeTurns: true, + }, + }, + { + method: "thread/fork", + params: { + threadId: "provider-thread", + lastTurnId: "turn-2", + }, + }, + ]); + expect(requests[2]).toEqual({ + method: "thread/unsubscribe", + params: { threadId: "provider-thread" }, + }); + expect(history).toEqual({ providerSessionId: "forked-thread", messages: [] }); + expect((structuredSession as unknown as { remoteThreadId: string }).remoteThreadId).toBe( + "forked-thread", + ); + expect(structuredSession.launchOptions.resumeThreadId).toBe("forked-thread"); + expect(runtimeEvents).toContainEqual({ + type: "context.updated", + threadId: "local-thread", + usage: { + usedTokens: 100, + maxTokens: 258_400, + breakdown: [ + { id: "input", label: "Input", tokens: 80 }, + { id: "output", label: "Output", tokens: 15 }, + { id: "reasoning", label: "Reasoning", tokens: 5 }, + ], + }, + }); + }); - expect(requests[0]).toEqual({ + it("falls back to thread/rollback when thread/fork is unavailable", async () => { + const requests: Array<{ method: string; params: Record }> = []; + const structuredSession = makeStructuredSession(requests); + (structuredSession as unknown as Record)["rpc"] = { + request: async (method: string, params: Record) => { + requests.push({ method, params }); + if (method === "thread/read" && params.includeTurns === true) { + return { thread: { turns: [{ id: "turn-1" }, { id: "turn-2" }] } }; + } + if (method === "thread/fork") { + throw new CodexRpcResponseError("Method not found", -32601); + } + return { thread: { status: { type: "idle" } } }; + }, + }; + + const history = await structuredSession.rollbackThread(1); + + expect(requests.map((request) => request.method)).toEqual([ + "thread/read", + "thread/fork", + "thread/rollback", + "thread/read", + ]); + expect(requests[2]).toEqual({ method: "thread/rollback", params: { threadId: "provider-thread", - numTurns: 2, + numTurns: 1, }, }); + expect(requests.map((request) => request.method)).not.toContain("thread/unsubscribe"); + expect(history).toEqual({ providerSessionId: "provider-thread", messages: [] }); + }); + + it("clears buffered notifications when thread/fork fails", async () => { + const requests: Array<{ method: string; params: Record }> = []; + const structuredSession = makeStructuredSession(requests); + const runtimeEvents: RuntimeEvent[] = []; + (structuredSession as unknown as Record)["listener"] = { + onRuntimeEvent: (event: RuntimeEvent) => runtimeEvents.push(event), + }; + (structuredSession as unknown as Record)["rpc"] = { + request: async (method: string, params: Record) => { + requests.push({ method, params }); + if (method === "thread/read") { + return { thread: { turns: [{ id: "turn-1" }, { id: "turn-2" }] } }; + } + dispatchNotification(structuredSession, { + jsonrpc: "2.0", + method: "thread/tokenUsage/updated", + params: { + threadId: "failed-fork-thread", + turnId: "turn-1", + tokenUsage: { + total: {}, + last: { totalTokens: 25 }, + modelContextWindow: 258_400, + }, + }, + }); + throw new Error("fork failed"); + }, + }; + + await expect(structuredSession.rollbackThread(1)).rejects.toThrow("fork failed"); + + expect( + (structuredSession as unknown as { forkNotificationBuffer?: unknown }).forkNotificationBuffer, + ).toBeUndefined(); + expect(runtimeEvents).toEqual([]); + expect(requests.map((request) => request.method)).not.toContain("thread/unsubscribe"); + }); + + it("uses legacy rollback when dropping every turn leaves nothing to fork through", async () => { + const requests: Array<{ method: string; params: Record }> = []; + const structuredSession = makeStructuredSession(requests); + (structuredSession as unknown as Record)["rpc"] = { + request: async (method: string, params: Record) => { + requests.push({ method, params }); + if (method === "thread/read" && params.includeTurns === true) { + return { thread: { turns: [{ id: "turn-1" }, { id: "turn-2" }] } }; + } + return { thread: { status: { type: "idle" } } }; + }, + }; + + const history = await structuredSession.rollbackThread(2); + + expect(requests.map((request) => request.method)).toEqual([ + "thread/read", + "thread/rollback", + "thread/read", + ]); + expect(history).toEqual({ providerSessionId: "provider-thread", messages: [] }); }); it("requests Codex reasoning summaries for GUI turns", async () => { @@ -1163,7 +1359,7 @@ describe("CodexStructuredSession", () => { expect.objectContaining({ method: "thread/start" }), { method: "config/mcpServer/reload", - params: null, + params: undefined, }, expect.objectContaining({ method: "turn/start" }), ]); diff --git a/src/supervisor/agents/codex/protocol/README.md b/src/supervisor/agents/codex/protocol/README.md new file mode 100644 index 000000000..4b40dc012 --- /dev/null +++ b/src/supervisor/agents/codex/protocol/README.md @@ -0,0 +1,11 @@ +# Codex app-server protocol types + +The protocol façades import live generated definitions from `@poracode/codex-protocol`. Its generator uses the official `@openai/codex` package pinned to `0.144.5` and writes the app-server TypeScript output to `packages/codex-protocol/generated/` during `pnpm install`. + +The generated directory is gitignored. Regenerate it manually from the repository root with: + +```sh +pnpm codex-protocol:gen +``` + +If typecheck reports that `packages/codex-protocol/generated` is missing, run `pnpm install` or the command above. Bumping the exact `@openai/codex` devDependency in `packages/codex-protocol/package.json` updates the protocol typings on the next install or regeneration. diff --git a/src/supervisor/agents/codex/protocol/client.ts b/src/supervisor/agents/codex/protocol/client.ts new file mode 100644 index 000000000..30f279268 --- /dev/null +++ b/src/supervisor/agents/codex/protocol/client.ts @@ -0,0 +1,90 @@ +import type { + ConfigRequirementsReadResponse, + GetAccountParams, + GetAccountResponse, + InitializeParams, + InitializeResponse, + ModelListParams, + ModelListResponse, + SkillsListParams, + SkillsListResponse, + McpServerRefreshResponse, + ThreadForkParams, + ThreadForkResponse, + ThreadGoalClearParams, + ThreadGoalClearResponse, + ThreadGoalSetParams, + ThreadGoalSetResponse, + ThreadReadParams, + ThreadReadResponse, + ThreadResumeParams, + ThreadResumeResponse, + ThreadRollbackParams, + ThreadRollbackResponse, + ThreadStartParams, + ThreadStartResponse, + ThreadUnsubscribeParams, + ThreadUnsubscribeResponse, + TurnInterruptParams, + TurnInterruptResponse, + TurnStartParams, + TurnStartResponse, +} from "@poracode/codex-protocol"; + +type InitializeRequestParams = Omit & { + clientInfo: Omit & { + title?: InitializeParams["clientInfo"]["title"]; + }; +}; + +export type { + ConfigRequirementsReadResponse, + GetAccountParams, + GetAccountResponse, + InitializeParams, + InitializeResponse, + McpServerRefreshResponse, + ModelListParams, + ModelListResponse, + SkillsListParams, + SkillsListResponse, + ThreadForkParams, + ThreadForkResponse, + ThreadGoalClearParams, + ThreadGoalClearResponse, + ThreadGoalSetParams, + ThreadGoalSetResponse, + ThreadReadParams, + ThreadReadResponse, + ThreadResumeParams, + ThreadResumeResponse, + ThreadRollbackParams, + ThreadRollbackResponse, + ThreadStartParams, + ThreadStartResponse, + ThreadUnsubscribeParams, + ThreadUnsubscribeResponse, + TurnInterruptParams, + TurnInterruptResponse, + TurnStartParams, + TurnStartResponse, +}; + +export interface CodexClientRequestMap { + initialize: { params: InitializeRequestParams; result: InitializeResponse }; + "skills/list": { params: SkillsListParams; result: SkillsListResponse }; + "thread/start": { params: ThreadStartParams; result: ThreadStartResponse }; + "thread/resume": { params: ThreadResumeParams; result: ThreadResumeResponse }; + "thread/read": { params: ThreadReadParams; result: ThreadReadResponse }; + "thread/fork": { params: ThreadForkParams; result: ThreadForkResponse }; + "thread/unsubscribe": { params: ThreadUnsubscribeParams; result: ThreadUnsubscribeResponse }; + "thread/rollback": { params: ThreadRollbackParams; result: ThreadRollbackResponse }; + "thread/goal/set": { params: ThreadGoalSetParams; result: ThreadGoalSetResponse }; + "thread/goal/clear": { params: ThreadGoalClearParams; result: ThreadGoalClearResponse }; + "turn/start": { params: TurnStartParams; result: TurnStartResponse }; + "turn/interrupt": { params: TurnInterruptParams; result: TurnInterruptResponse }; + "config/mcpServer/reload": { params: undefined; result: McpServerRefreshResponse }; + "account/read": { params: GetAccountParams; result: GetAccountResponse }; + "model/list": { params: ModelListParams; result: ModelListResponse }; + "configRequirements/read": { params: undefined; result: ConfigRequirementsReadResponse }; +} diff --git a/src/supervisor/agents/codex/protocol/index.ts b/src/supervisor/agents/codex/protocol/index.ts new file mode 100644 index 000000000..396828aeb --- /dev/null +++ b/src/supervisor/agents/codex/protocol/index.ts @@ -0,0 +1,3 @@ +export type * from "./client"; +export type * from "./notifications"; +export type * from "./server"; diff --git a/src/supervisor/agents/codex/protocol/notifications.ts b/src/supervisor/agents/codex/protocol/notifications.ts new file mode 100644 index 000000000..ca1c87e00 --- /dev/null +++ b/src/supervisor/agents/codex/protocol/notifications.ts @@ -0,0 +1,67 @@ +import type { + AccountRateLimitsUpdatedNotification, + AgentMessageDeltaNotification, + CommandExecutionOutputDeltaNotification, + ErrorNotification, + FileChangeOutputDeltaNotification, + ItemCompletedNotification, + ItemStartedNotification, + PlanDeltaNotification, + ReasoningSummaryTextDeltaNotification, + ReasoningTextDeltaNotification, + ServerRequestResolvedNotification, + McpToolCallProgressNotification, + SkillsChangedNotification, + ThreadClosedNotification, + ThreadGoalClearedNotification, + ThreadGoalStatus, + ThreadGoalUpdatedNotification, + ThreadSettingsUpdatedNotification, + ThreadStartedNotification, + ThreadStatus, + ThreadStatusChangedNotification, + ThreadTokenUsage, + ThreadTokenUsageUpdatedNotification, + TurnCompletedNotification, + TurnError, + TurnPlanStep, + TurnPlanStepStatus, + TurnPlanUpdatedNotification, + TurnStartedNotification, +} from "@poracode/codex-protocol"; + +export type { + ErrorNotification, + ThreadGoalStatus, + ThreadStatus, + ThreadTokenUsage, + TurnError, + TurnPlanStep, + TurnPlanStepStatus, +}; + +export interface CodexServerNotificationMap { + error: ErrorNotification; + "thread/started": ThreadStartedNotification; + "thread/status/changed": ThreadStatusChangedNotification; + "thread/closed": ThreadClosedNotification; + "thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification; + "thread/settings/updated": ThreadSettingsUpdatedNotification; + "thread/goal/updated": ThreadGoalUpdatedNotification; + "thread/goal/cleared": ThreadGoalClearedNotification; + "turn/started": TurnStartedNotification; + "turn/completed": TurnCompletedNotification; + "turn/plan/updated": TurnPlanUpdatedNotification; + "item/started": ItemStartedNotification; + "item/completed": ItemCompletedNotification; + "item/agentMessage/delta": AgentMessageDeltaNotification; + "item/reasoning/textDelta": ReasoningTextDeltaNotification; + "item/reasoning/summaryTextDelta": ReasoningSummaryTextDeltaNotification; + "item/commandExecution/outputDelta": CommandExecutionOutputDeltaNotification; + "item/fileChange/outputDelta": FileChangeOutputDeltaNotification; + "item/plan/delta": PlanDeltaNotification; + "item/mcpToolCall/progress": McpToolCallProgressNotification; + "serverRequest/resolved": ServerRequestResolvedNotification; + "account/rateLimits/updated": AccountRateLimitsUpdatedNotification; + "skills/changed": SkillsChangedNotification; +} diff --git a/src/supervisor/agents/codex/protocol/server.ts b/src/supervisor/agents/codex/protocol/server.ts new file mode 100644 index 000000000..9a81fb5f9 --- /dev/null +++ b/src/supervisor/agents/codex/protocol/server.ts @@ -0,0 +1,79 @@ +import type { + ApplyPatchApprovalParams, + ApplyPatchApprovalResponse, + ChatgptAuthTokensRefreshParams, + ChatgptAuthTokensRefreshResponse, + CommandExecutionApprovalDecision, + CommandExecutionRequestApprovalParams, + CommandExecutionRequestApprovalResponse, + CurrentTimeReadParams, + CurrentTimeReadResponse, + ExecCommandApprovalParams, + ExecCommandApprovalResponse, + FileChangeRequestApprovalParams, + FileChangeRequestApprovalResponse, + PermissionsRequestApprovalParams, + PermissionsRequestApprovalResponse, + McpServerElicitationRequestParams, + McpServerElicitationRequestResponse, + ToolRequestUserInputParams, + ToolRequestUserInputResponse, +} from "@poracode/codex-protocol"; + +export type { + ApplyPatchApprovalParams, + ApplyPatchApprovalResponse, + ChatgptAuthTokensRefreshParams, + ChatgptAuthTokensRefreshResponse, + CommandExecutionApprovalDecision, + CommandExecutionRequestApprovalParams, + CommandExecutionRequestApprovalResponse, + CurrentTimeReadParams, + CurrentTimeReadResponse, + ExecCommandApprovalParams, + ExecCommandApprovalResponse, + FileChangeRequestApprovalParams, + FileChangeRequestApprovalResponse, + McpServerElicitationRequestParams, + McpServerElicitationRequestResponse, + PermissionsRequestApprovalParams, + PermissionsRequestApprovalResponse, + ToolRequestUserInputParams, + ToolRequestUserInputResponse, +}; + +export interface CodexServerRequestMap { + "item/commandExecution/requestApproval": { + params: CommandExecutionRequestApprovalParams; + result: CommandExecutionRequestApprovalResponse; + }; + "item/fileChange/requestApproval": { + params: FileChangeRequestApprovalParams; + result: FileChangeRequestApprovalResponse; + }; + "item/permissions/requestApproval": { + params: PermissionsRequestApprovalParams; + result: PermissionsRequestApprovalResponse; + }; + "item/tool/requestUserInput": { + params: ToolRequestUserInputParams; + result: ToolRequestUserInputResponse; + }; + "mcpServer/elicitation/request": { + params: McpServerElicitationRequestParams; + result: McpServerElicitationRequestResponse; + }; + "currentTime/read": { params: CurrentTimeReadParams; result: CurrentTimeReadResponse }; + "account/chatgptAuthTokens/refresh": { + params: ChatgptAuthTokensRefreshParams; + result: ChatgptAuthTokensRefreshResponse; + }; + applyPatchApproval: { + params: ApplyPatchApprovalParams; + result: ApplyPatchApprovalResponse; + }; + execCommandApproval: { + params: ExecCommandApprovalParams; + result: ExecCommandApprovalResponse; + }; +} diff --git a/tsconfig.json b/tsconfig.json index af3a0c646..b09f81e97 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ "src", "tests", "packages/agents-usage/src", + "packages/codex-protocol", "native/activity-bridge/src", "native/ssh-bridge/src", "vite.config.ts", From a30643db8a2d3f75bdddd090fb3baa99194ad8b4 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 18 Jul 2026 10:03:55 -0700 Subject: [PATCH 2/5] feat(codex): add app-server protocol typings and migrate rollback to fork - Generate app-server protocol typings from the pinned @openai/codex CLI (0.144.5) into a gitignored workspace package (@poracode/codex-protocol); add typed client/server/notification method maps at the provider boundary - Correct notification, approval, user-input, goal-status, and token-usage mappings against the official 0.144.5 protocol - Migrate deprecated thread/rollback to thread/fork; document why account/chatgptAuthTokens/refresh is intentionally rejected - Expand Codex integration tests Co-Authored-By: Claude Fable 5 --- package.json | 4 +- packages/codex-protocol/.gitignore | 1 + packages/codex-protocol/README.md | 13 ++ packages/codex-protocol/index.ts | 9 + packages/codex-protocol/package.json | 22 ++ packages/codex-protocol/scripts/generate.mjs | 48 ++++ pnpm-lock.yaml | 77 +++++++ pnpm-workspace.yaml | 3 + src/supervisor/agents/codex/acp.ts | 168 ++++++++++++-- src/supervisor/agents/codex/acpProtocol.ts | 15 +- .../agents/codex/acpQuestionAnswer.ts | 14 +- src/supervisor/agents/codex/acpTurn.ts | 11 +- .../agents/codex/appServerRpc.test.ts | 35 ++- src/supervisor/agents/codex/appServerRpc.ts | 33 ++- .../agents/codex/canonicalMapping.test.ts | 86 +++++++- .../agents/codex/canonicalMapping/dispatch.ts | 3 + .../agents/codex/canonicalMapping/goal.ts | 23 +- .../agents/codex/canonicalMapping/readers.ts | 29 ++- .../codex/canonicalMapping/serverRequest.ts | 94 ++++++-- .../agents/codex/canonicalMapping/usage.ts | 30 ++- src/supervisor/agents/codex/codex.test.ts | 208 +++++++++++++++++- .../agents/codex/protocol/README.md | 11 + .../agents/codex/protocol/client.ts | 90 ++++++++ src/supervisor/agents/codex/protocol/index.ts | 3 + .../agents/codex/protocol/notifications.ts | 67 ++++++ .../agents/codex/protocol/server.ts | 79 +++++++ tsconfig.json | 1 + 27 files changed, 1067 insertions(+), 110 deletions(-) create mode 100644 packages/codex-protocol/.gitignore create mode 100644 packages/codex-protocol/README.md create mode 100644 packages/codex-protocol/index.ts create mode 100644 packages/codex-protocol/package.json create mode 100644 packages/codex-protocol/scripts/generate.mjs create mode 100644 src/supervisor/agents/codex/protocol/README.md create mode 100644 src/supervisor/agents/codex/protocol/client.ts create mode 100644 src/supervisor/agents/codex/protocol/index.ts create mode 100644 src/supervisor/agents/codex/protocol/notifications.ts create mode 100644 src/supervisor/agents/codex/protocol/server.ts diff --git a/package.json b/package.json index d8d9ef8ed..8aefc53fc 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "type": "module", "main": "dist/main/main.cjs", "scripts": { - "postinstall": "electron-rebuild --only better-sqlite3 && node scripts/ensure-native-deps.mjs", + "postinstall": "electron-rebuild --only better-sqlite3 && node scripts/ensure-native-deps.mjs && pnpm run codex-protocol:gen", "setup:native": "electron-rebuild --only better-sqlite3 && node scripts/ensure-native-deps.mjs", "dev": "concurrently -k -n renderer,electron,app -c cyan,magenta,yellow \"pnpm run dev:renderer\" \"pnpm run dev:electron\" \"pnpm run dev:app\"", "devtools": "node scripts/dev-react-devtools.mjs", @@ -49,6 +49,7 @@ "prepare:mobile:ssh": "pnpm run build:electron && pnpm run prepare:package-assets && node scripts/build-mobile-ssh-runtime.mjs", "sentry:sourcemaps": "node scripts/sentry-sourcemaps.mjs", "clean:sourcemaps": "node scripts/clean-sourcemaps.mjs", + "codex-protocol:gen": "pnpm --filter @poracode/codex-protocol run generate", "dist": "node scripts/build-desktop-artifact.mjs", "dist:win": "node scripts/build-desktop-artifact.mjs --platform win --target nsis --arch x64", "dist:win:all": "node scripts/build-desktop-artifact.mjs --platform win", @@ -97,6 +98,7 @@ "@opencode-ai/sdk": "^1.18.3", "@poracode/activity-bridge": "file:native/activity-bridge", "@poracode/agents-usage": "workspace:*", + "@poracode/codex-protocol": "workspace:*", "@poracode/ssh-bridge": "file:native/ssh-bridge", "@sentry/electron": "^7.15.0", "@sentry/node": "10.63.0", diff --git a/packages/codex-protocol/.gitignore b/packages/codex-protocol/.gitignore new file mode 100644 index 000000000..9ab870da8 --- /dev/null +++ b/packages/codex-protocol/.gitignore @@ -0,0 +1 @@ +generated/ diff --git a/packages/codex-protocol/README.md b/packages/codex-protocol/README.md new file mode 100644 index 000000000..d684e46d3 --- /dev/null +++ b/packages/codex-protocol/README.md @@ -0,0 +1,13 @@ +# @poracode/codex-protocol + +TypeScript definitions generated during `pnpm install` from `@openai/codex`, pinned to `0.144.5`. + +The generated sources live in `generated/` and are intentionally gitignored. Regenerate them manually from the repository root with: + +```sh +pnpm codex-protocol:gen +``` + +If TypeScript reports that `./generated/index` cannot be found, run `pnpm install` or the generation command above. + +Bump the exact `@openai/codex` devDependency in `package.json` to update the generated protocol version. diff --git a/packages/codex-protocol/index.ts b/packages/codex-protocol/index.ts new file mode 100644 index 000000000..3a3705f61 --- /dev/null +++ b/packages/codex-protocol/index.ts @@ -0,0 +1,9 @@ +export type { + ApplyPatchApprovalParams, + ApplyPatchApprovalResponse, + ExecCommandApprovalParams, + ExecCommandApprovalResponse, + InitializeParams, + InitializeResponse, +} from "./generated/index"; +export * from "./generated/v2/index"; diff --git a/packages/codex-protocol/package.json b/packages/codex-protocol/package.json new file mode 100644 index 000000000..bf1a0015d --- /dev/null +++ b/packages/codex-protocol/package.json @@ -0,0 +1,22 @@ +{ + "name": "@poracode/codex-protocol", + "version": "0.1.0", + "private": true, + "description": "Install-time-generated TypeScript definitions for the Codex app-server protocol.", + "type": "module", + "sideEffects": false, + "main": "./index.ts", + "types": "./index.ts", + "exports": { + ".": { + "types": "./index.ts", + "default": "./index.ts" + } + }, + "scripts": { + "generate": "node ./scripts/generate.mjs" + }, + "devDependencies": { + "@openai/codex": "0.144.5" + } +} diff --git a/packages/codex-protocol/scripts/generate.mjs b/packages/codex-protocol/scripts/generate.mjs new file mode 100644 index 000000000..f2cc0ba06 --- /dev/null +++ b/packages/codex-protocol/scripts/generate.mjs @@ -0,0 +1,48 @@ +import { existsSync, rmSync } from "node:fs"; +import { delimiter, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const workspaceDir = resolve(packageDir, "../.."); +const executableName = process.platform === "win32" ? "codex.CMD" : "codex"; +const binDirectories = [ + resolve(packageDir, "node_modules/.bin"), + resolve(workspaceDir, "node_modules/.bin"), +]; +const codexBinDirectory = binDirectories.find((directory) => + existsSync(resolve(directory, executableName)), +); +const pnpmCli = process.env.npm_execpath; + +if (!codexBinDirectory || !pnpmCli) { + console.error( + "Cannot generate Codex protocol types: the pinned @openai/codex binary or pnpm CLI is missing. Run `pnpm install` from the repository root, then try again.", + ); + process.exit(1); +} + +rmSync(resolve(packageDir, "generated"), { recursive: true, force: true }); + +const result = spawnSync( + process.execPath, + [pnpmCli, "exec", "codex", "app-server", "generate-ts", "--experimental", "--out", "./generated"], + { + cwd: packageDir, + env: { + ...process.env, + PATH: `${codexBinDirectory}${delimiter}${process.env.PATH ?? ""}`, + }, + stdio: "inherit", + }, +); + +if (result.error) { + console.error(`Cannot generate Codex protocol types: ${result.error.message}`); + process.exit(1); +} + +if (result.status !== 0) { + console.error(`Codex protocol generation failed with exit code ${result.status ?? "unknown"}.`); + process.exit(result.status ?? 1); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da0da364f..ea3ca3e98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: '@poracode/agents-usage': specifier: workspace:* version: link:packages/agents-usage + '@poracode/codex-protocol': + specifier: workspace:* + version: link:packages/codex-protocol '@poracode/ssh-bridge': specifier: file:native/ssh-bridge version: file:native/ssh-bridge(@capacitor/core@8.4.1) @@ -380,6 +383,12 @@ importers: specifier: ^4.4.3 version: 4.4.3 + packages/codex-protocol: + devDependencies: + '@openai/codex': + specifier: 0.144.5 + version: 0.144.5 + website: dependencies: '@heroui/react': @@ -1481,6 +1490,47 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} + '@openai/codex@0.144.5': + resolution: {integrity: sha512-jjB+K+OMv572mKhS+2QuLxWXDJNdpwbPenf+V+8bdq7wg4Scqt3cn6WEekD8wPqDVZqck0HSX17K9rD9kbDJQA==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.144.5-darwin-arm64': + resolution: {integrity: sha512-zcT6NfBCqLFt+BReNSETTZW6v6PdbH0dzNtm9j7l7mDGqwPbKZDGJdnpkBao2389I0ZacyIKgSZoI0vez1d4Dw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.144.5-darwin-x64': + resolution: {integrity: sha512-//Mo0m1MwaoT6psu5xsmofXpKx4/0irIkeq10xJvk59+886EG355ibjA+ZmlRcKhE3bLjsKD7p81nTbAdRL/bw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.144.5-linux-arm64': + resolution: {integrity: sha512-zAHggxVwR2TBxKmybXY7ZMiB0G8DMonY2YPdwNNjwXcf+LOIqNGgswwNCDMbP/HEe6r8j+R9ZX/yYoo8f+n/RQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.144.5-linux-x64': + resolution: {integrity: sha512-FalLJlBQGFdK8Gc3kj9sa/ekNdgkHhUawLaKkvy5CtB18JaP2YxtTP/Pe1pD2iBiq8mMUliRnafpF6AdBdQMbg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.144.5-win32-arm64': + resolution: {integrity: sha512-0Pj7iqjEOEvPQPO3kFfCy9vGX4BTu76ChFFZHr2eNNIfVc3FOENAv/X98u4L+iIUtDOK9DbqmfUudW3DPapshg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.144.5-win32-x64': + resolution: {integrity: sha512-DnsSTlnnzleTxvLwIGnBitKInscxn2I7qASqosS8Fv+qysBygd+ZiBn/SQsRCgQ28PAlsNzmd3Gf3ZTecolAmg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@opencode-ai/sdk@1.18.3': resolution: {integrity: sha512-Mevo4e6kQwbvto9E+42KSIVMhp+JBu+SwQhC5AomAvrV6Xkio3U249T+xDILDCXhl5Z/Hi/DlAuVLzpGnuh0gg==} @@ -8346,6 +8396,33 @@ snapshots: '@noble/hashes@2.2.0': {} + '@openai/codex@0.144.5': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.144.5-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.144.5-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.144.5-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.144.5-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.144.5-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.144.5-win32-x64' + + '@openai/codex@0.144.5-darwin-arm64': + optional: true + + '@openai/codex@0.144.5-darwin-x64': + optional: true + + '@openai/codex@0.144.5-linux-arm64': + optional: true + + '@openai/codex@0.144.5-linux-x64': + optional: true + + '@openai/codex@0.144.5-win32-arm64': + optional: true + + '@openai/codex@0.144.5-win32-x64': + optional: true + '@opencode-ai/sdk@1.18.3': dependencies: cross-spawn: 7.0.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 53031bf08..9dbbdd450 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - "." - "website" - "packages/agents-usage" + - "packages/codex-protocol" # Share the virtual store across worktrees/checkouts via symlinks instead of # hardlinking every package into each node_modules/.pnpm (see pnpm.io/git-worktrees). @@ -199,6 +200,8 @@ minimumReleaseAgeExclude: - "yuku-ast@0.1.7" - "yuku-codegen@0.5.44" - "yuku-parser@0.5.44" + # The platform aliases are prerelease versions of this same package name. + - "@openai/codex" # Supply-chain guard: refuse to install any package version published less # than this many minutes ago. 7 days catches the typical window in which diff --git a/src/supervisor/agents/codex/acp.ts b/src/supervisor/agents/codex/acp.ts index 04e3f609a..f13611df0 100644 --- a/src/supervisor/agents/codex/acp.ts +++ b/src/supervisor/agents/codex/acp.ts @@ -48,7 +48,8 @@ import { parseCodexGoalCommand, type CodexGoalCommand, } from "./acpTurn"; -import { CodexAppServerRpc } from "./appServerRpc"; +import { CodexAppServerRpc, isUnsupportedCodexRequestError } from "./appServerRpc"; +import type { CodexClientRequestMap } from "./protocol"; import { CodexStdioTransport } from "./stdioTransport"; import { mapCodexSkillsToSlashCommands, @@ -60,6 +61,9 @@ import { CodexSubAgentRouter } from "./subAgentRouting"; export { deriveCodexStructuredState, parseCodexSocketMessage } from "./acpProtocol"; export type { CodexThreadStatus } from "./acpProtocol"; +type ThreadStartParams = CodexClientRequestMap["thread/start"]["params"]; +type TurnStartParams = CodexClientRequestMap["turn/start"]["params"]; + function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -68,13 +72,15 @@ function sleep(ms: number): Promise { // A `thread/status/changed → systemError` notification carries no message, so // we surface a generic fallback. But Codex often follows it with a specific -// error (a `turn/completed(failed)` or `thread/error` notification carrying the -// real reason, e.g. a usage-limit / "remote compact task" failure). Defer the -// generic fallback briefly so a specific error can preempt it — otherwise the +// error (a `turn/completed(failed)`, `error`, or legacy `thread/error` +// notification carrying the real reason, e.g. a usage-limit / "remote compact +// task" failure). Defer the generic fallback briefly so a specific error can +// preempt it — otherwise the // user sees the generic notice *plus* the real error. The delay only postpones // an error message; the error status icon updates synchronously. const CODEX_SYSTEM_ERROR_FALLBACK_DELAY_MS = 250; const CODEX_RESUME_STATUS_REPLAY_SUPPRESSION_MS = 500; +const CODEX_FORK_NOTIFICATION_BUFFER_LIMIT = 100; const CODEX_EVENT_DEBUG_ENV = "PORACODE_DEBUG_CODEX_EVENTS"; type CodexEventDebugDirection = @@ -191,7 +197,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { // Error messages already surfaced for the current turn. Codex can report one // underlying failure (e.g. a usage limit) through several channels — the // turn/start rejection, a `turn/completed(failed)` notification, and a - // `thread/error` notification — each carrying the same text. Deduping here + // `error` notification — each carrying the same text. Deduping here // keeps the user from seeing the same error two or three times. Cleared on // the next user turn. private readonly seenErrorMessages = new Set(); @@ -201,6 +207,13 @@ export class CodexStructuredSession implements StructuredSessionHandle { private pendingSystemErrorFallback: ReturnType | undefined; private mapperState: CodexMapperState | undefined; private subAgentRouter: CodexSubAgentRouter | undefined; + private forkNotificationBuffer: + | Array<{ + method: string; + params: Record | undefined; + threadId: string; + }> + | undefined; private readonly hasUserMcpServers: boolean; /** * Codex can report a plain `active` status while `thread/resume` is only @@ -481,11 +494,25 @@ export class CodexStructuredSession implements StructuredSessionHandle { async openThread(config: ThreadConfig, sessionRef?: SessionRef): Promise { // `mode` does not exist on `thread/start` or `thread/resume`; plan mode is // a per-turn override sent via `collaborationMode` on `turn/start`. - const threadOverrides = { + const threadOverrides: ThreadStartParams = { model: config.model, - ...(config.approvalPolicy ? { approvalPolicy: config.approvalPolicy } : {}), - ...(config.approvalsReviewer ? { approvalsReviewer: config.approvalsReviewer } : {}), - ...(config.sandboxMode ? { sandbox: config.sandboxMode } : {}), + ...(config.approvalPolicy + ? { + approvalPolicy: config.approvalPolicy as NonNullable< + ThreadStartParams["approvalPolicy"] + >, + } + : {}), + ...(config.approvalsReviewer + ? { + approvalsReviewer: config.approvalsReviewer as NonNullable< + ThreadStartParams["approvalsReviewer"] + >, + } + : {}), + ...(config.sandboxMode + ? { sandbox: config.sandboxMode as NonNullable } + : {}), config: { ...(config.effort ? { model_reasoning_effort: config.effort } : {}), model_reasoning_summary: "auto", @@ -680,7 +707,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { const collaborationMode = buildCodexCollaborationMode(config); try { if (this.hasUserMcpServers) { - await this.rpc.request("config/mcpServer/reload", null); + await this.rpc.request("config/mcpServer/reload", undefined); } const result = await this.rpc.request("turn/start", { threadId, @@ -688,9 +715,23 @@ export class CodexStructuredSession implements StructuredSessionHandle { model: config.model, ...(config.effort ? { effort: config.effort } : {}), summary: "auto", - ...(config.approvalPolicy ? { approvalPolicy: config.approvalPolicy } : {}), - ...(config.approvalsReviewer ? { approvalsReviewer: config.approvalsReviewer } : {}), - ...(sandboxPolicy ? { sandboxPolicy } : {}), + ...(config.approvalPolicy + ? { + approvalPolicy: config.approvalPolicy as NonNullable< + TurnStartParams["approvalPolicy"] + >, + } + : {}), + ...(config.approvalsReviewer + ? { + approvalsReviewer: config.approvalsReviewer as NonNullable< + TurnStartParams["approvalsReviewer"] + >, + } + : {}), + ...(sandboxPolicy + ? { sandboxPolicy: sandboxPolicy as NonNullable } + : {}), collaborationMode, // Fast toggle is authoritative and the server tier is sticky, so force it // every turn: "fast" selects the Fast lane, null clears it to the default. @@ -736,15 +777,84 @@ export class CodexStructuredSession implements StructuredSessionHandle { throw new Error(`rollbackThread: numTurns must be a positive integer (got ${numTurns}).`); } const threadId = await this.waitForRemoteThreadId(); - await this.rpc.request("thread/rollback", { + this.forkNotificationBuffer = undefined; + + const rollbackLegacy = async (reason: string): Promise => { + this.forkNotificationBuffer = undefined; + console.log(`[codex] ${reason}; falling back to thread/rollback.`); + try { + await this.rpc.request("thread/rollback", { + threadId, + numTurns, + }); + } catch (error) { + if (isUnsupportedCodexRequestError(error)) { + throw new Error( + "Codex cannot roll back this thread because neither thread/fork nor thread/rollback is supported.", + { cause: error }, + ); + } + throw error; + } + this.pendingTurnInterrupt = false; + this.activeTurnId = undefined; + await this.syncRemoteThreadState(threadId, toSessionRef(threadId)); + return { + providerSessionId: threadId, + messages: [], + }; + }; + + const readResult = await this.rpc.request("thread/read", { threadId, - numTurns, + includeTurns: true, + }); + const turns = readResult.thread?.turns; + if (!Array.isArray(turns)) { + return rollbackLegacy("thread/read omitted turn history"); + } + if (turns.length <= numTurns) { + return rollbackLegacy("thread/fork has no retained turn"); + } + + const retainedTurn = turns.at(turns.length - numTurns - 1); + if (!retainedTurn || typeof retainedTurn.id !== "string") { + return rollbackLegacy("thread/read omitted the retained turn id"); + } + + let forkResult: CodexClientRequestMap["thread/fork"]["result"]; + this.forkNotificationBuffer = []; + try { + forkResult = await this.rpc.request("thread/fork", { + threadId, + lastTurnId: retainedTurn.id, + }); + } catch (error) { + this.forkNotificationBuffer = undefined; + if (!isUnsupportedCodexRequestError(error)) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + return rollbackLegacy(`thread/fork is unsupported (${message})`); + } + + const newThreadId = forkResult.thread?.id; + if (!newThreadId) { + this.forkNotificationBuffer = undefined; + throw new Error("thread/fork response did not contain a thread id."); + } + + this.remoteThreadId = newThreadId; + this.launchOptions = { ...this.launchOptions, resumeThreadId: newThreadId }; + this.replayForkNotifications(newThreadId); + void this.rpc.request("thread/unsubscribe", { threadId }).catch((error) => { + console.log("[codex] failed to unsubscribe old thread after fork:", error); }); this.pendingTurnInterrupt = false; this.activeTurnId = undefined; - await this.syncRemoteThreadState(threadId, toSessionRef(threadId)); + await this.syncRemoteThreadState(newThreadId, toSessionRef(newThreadId)); return { - providerSessionId: threadId, + providerSessionId: newThreadId, messages: [], }; } @@ -793,6 +903,17 @@ export class CodexStructuredSession implements StructuredSessionHandle { return; } const notificationThreadId = readNotificationThreadId(params, this.remoteThreadId); + if ( + this.forkNotificationBuffer && + notificationThreadId && + this.remoteThreadId !== undefined && + notificationThreadId !== this.remoteThreadId + ) { + if (this.forkNotificationBuffer.length < CODEX_FORK_NOTIFICATION_BUFFER_LIMIT) { + this.forkNotificationBuffer.push({ method, params, threadId: notificationThreadId }); + } + return; + } const suppressResumeReplay = this.isResumeReplaySuppressed(notificationThreadId); const childEvents = this.ensureSubAgentRouter().routeChildNotification( method, @@ -857,7 +978,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { } const nextStatus = params.status as CodexThreadStatus; // A systemError status alone gives the renderer a red icon but no - // message. If Codex didn't already send a paired `thread/error` + // message. If Codex didn't already send a paired `error` // notification or a turn/start rejection (which set `errorSticky`), // surface a fallback runtime error event so `ThreadErrorDock` // renders something instead of leaving the user with an empty dock. @@ -903,6 +1024,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { return; } + // `turn/aborted` is retained only for older app-server compatibility. if (method === "turn/completed" || method === "turn/aborted") { if (suppressResumeReplay) { return; @@ -965,6 +1087,16 @@ export class CodexStructuredSession implements StructuredSessionHandle { return this.remoteThreadId === undefined || this.remoteThreadId === threadId; } + private replayForkNotifications(threadId: string): void { + const notifications = this.forkNotificationBuffer ?? []; + this.forkNotificationBuffer = undefined; + for (const notification of notifications) { + if (notification.threadId === threadId) { + this.handleNotification(notification.method, notification.params); + } + } + } + private async waitForRemoteThreadId(): Promise { for (let attempt = 0; attempt < 400; attempt += 1) { if (this.remoteThreadId) { diff --git a/src/supervisor/agents/codex/acpProtocol.ts b/src/supervisor/agents/codex/acpProtocol.ts index 0afa2b1ee..d9c5f520c 100644 --- a/src/supervisor/agents/codex/acpProtocol.ts +++ b/src/supervisor/agents/codex/acpProtocol.ts @@ -1,10 +1,11 @@ -import type { ThreadAttention, ThreadServerRequestId, ThreadStatus } from "@/shared/contracts"; +import type { + ThreadAttention, + ThreadServerRequestId, + ThreadStatus as CanonicalThreadStatus, +} from "@/shared/contracts"; +import type { ThreadStatus as CodexProtocolThreadStatus } from "./protocol"; -export type CodexThreadStatus = - | { type: "active"; activeFlags?: string[] } - | { type: "idle" } - | { type: "notLoaded" } - | { type: "systemError" }; +export type CodexThreadStatus = CodexProtocolThreadStatus; export type CodexSocketMessage = | { @@ -68,7 +69,7 @@ function extractObjectStringField( } export function deriveCodexStructuredState(status: CodexThreadStatus): { - status: ThreadStatus; + status: CanonicalThreadStatus; attention: ThreadAttention; } { if (status.type === "systemError") { diff --git a/src/supervisor/agents/codex/acpQuestionAnswer.ts b/src/supervisor/agents/codex/acpQuestionAnswer.ts index 97747cd4d..f0463c203 100644 --- a/src/supervisor/agents/codex/acpQuestionAnswer.ts +++ b/src/supervisor/agents/codex/acpQuestionAnswer.ts @@ -37,13 +37,17 @@ function codexQuestionSources( ? q.options.flatMap((opt) => { if (!opt || typeof opt !== "object") return []; const o = opt as Record; - if (typeof o.label !== "string" || o.label.length === 0) return []; - const optionId = - typeof o.optionId === "string" && o.optionId.length > 0 ? o.optionId : o.label; + const label = + typeof o.label === "string" && o.label.length > 0 + ? o.label + : typeof o.optionId === "string" && o.optionId.length > 0 + ? o.optionId + : undefined; + if (!label) return []; return [ { - optionId, - label: o.label, + optionId: label, + label, ...(typeof o.description === "string" && o.description.length > 0 ? { description: o.description } : {}), diff --git a/src/supervisor/agents/codex/acpTurn.ts b/src/supervisor/agents/codex/acpTurn.ts index be550044d..ec8021b4f 100644 --- a/src/supervisor/agents/codex/acpTurn.ts +++ b/src/supervisor/agents/codex/acpTurn.ts @@ -1,4 +1,7 @@ import type { PromptSegment, ThreadConfig } from "@/shared/contracts"; +import type { CodexClientRequestMap } from "./protocol"; + +type TurnStartParams = CodexClientRequestMap["turn/start"]["params"]; // Codex's `turn/start` requires a non-empty `developer_instructions` string // inside `collaborationMode.settings`. We send these on every turn so that @@ -34,8 +37,8 @@ export function buildCodexTurnInput( prompt: string, segments: PromptSegment[] | undefined, inlineInstructions?: string, -): Record[] { - const input: Record[] = []; +): TurnStartParams["input"] { + const input: TurnStartParams["input"] = []; const hasSkillSegment = segments?.some((segment) => segment.kind === "skill") === true; for (const seg of segments ?? []) { @@ -83,7 +86,9 @@ export function buildCodexTurnInput( return input; } -export function buildCodexCollaborationMode(config: ThreadConfig): Record { +export function buildCodexCollaborationMode( + config: ThreadConfig, +): NonNullable { return { mode: config.mode === "plan" ? "plan" : "default", settings: { diff --git a/src/supervisor/agents/codex/appServerRpc.test.ts b/src/supervisor/agents/codex/appServerRpc.test.ts index 746d76724..cf7011416 100644 --- a/src/supervisor/agents/codex/appServerRpc.test.ts +++ b/src/supervisor/agents/codex/appServerRpc.test.ts @@ -115,7 +115,7 @@ describe("CodexAppServerRpc", () => { it("rejects error responses with the app-server message", async () => { const { listener, rpc } = createRpcHarness(); - const pending = rpc.request("turn/start", { threadId: "provider-thread" }); + const pending = rpc.request("turn/start", { threadId: "provider-thread", input: [] }); listener().onMessage({ jsonrpc: "2.0", @@ -210,12 +210,25 @@ describe("CodexAppServerRpc", () => { id: "question-1", method: "item/tool/requestUserInput", params: { - questions: [{ id: "scope", header: "Scope", question: "Which scope?" }], + threadId: "provider-thread", + turnId: "turn-1", + itemId: "item-1", + autoResolutionMs: null, + questions: [ + { + id: "scope", + header: "Scope", + question: "Which scope?", + isOther: false, + isSecret: false, + options: [{ label: "Workspace", description: "Current workspace" }], + }, + ], }, }); runtimeEvents.splice(0); - const response = { answers: { scope: { answers: ["workspace"] } } }; + const response = { answers: { scope: { answers: ["Workspace"] } } }; rpc.resolveServerRequest("question-1", response); expect(writes.at(-1)).toEqual({ @@ -223,12 +236,24 @@ describe("CodexAppServerRpc", () => { result: response, }); expect(runtimeEvents).toEqual([ - expect.objectContaining({ type: "item.started", threadId: "local-thread" }), + expect.objectContaining({ + type: "item.started", + threadId: "local-thread", + payload: { + questions: [ + { + header: "Scope", + question: "Which scope?", + selected: [{ label: "Workspace", description: "Current workspace" }], + }, + ], + }, + }), expect.objectContaining({ type: "item.completed", threadId: "local-thread" }), ]); }); - it("answers unsupported inbound requests with method-not-found", () => { + it("returns method-not-found for unimplemented ChatGPT auth-token refresh", () => { const { listener, runtimeEvents, writes } = createRpcHarness(); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); diff --git a/src/supervisor/agents/codex/appServerRpc.ts b/src/supervisor/agents/codex/appServerRpc.ts index 4002571dd..0ab634e2e 100644 --- a/src/supervisor/agents/codex/appServerRpc.ts +++ b/src/supervisor/agents/codex/appServerRpc.ts @@ -2,6 +2,7 @@ import type { RuntimeEvent, ThreadServerRequestId } from "@/shared/contracts"; import { mapCodexServerRequest, translateCodexCanonicalResponse } from "./canonicalMapping"; import { parseCodexSocketMessage } from "./acpProtocol"; import { buildCodexQuestionAnswerEvents } from "./acpQuestionAnswer"; +import type { CodexClientRequestMap } from "./protocol"; import type { CodexStdioTransport } from "./stdioTransport"; export type CodexRpcDebugDirection = "codex->poracode" | "poracode->codex" | "transport"; @@ -34,7 +35,7 @@ type InboundRequest = { const SERVER_OVERLOADED_ERROR_CODE = -32001; const MAX_OVERLOAD_RETRIES = 2; -class CodexRpcResponseError extends Error { +export class CodexRpcResponseError extends Error { constructor( message: string, readonly code: number | undefined, @@ -43,6 +44,14 @@ class CodexRpcResponseError extends Error { } } +export function isUnsupportedCodexRequestError(error: unknown): boolean { + return ( + error instanceof CodexRpcResponseError && + (error.code === -32601 || + (error.code === -32602 && /invalid params|unknown (?:field|parameter)/iu.test(error.message))) + ); +} + /** Owns JSON-RPC correlation and server-request bookkeeping above stdio framing. */ export class CodexAppServerRpc { private listener: CodexAppServerRpcListener | undefined; @@ -64,7 +73,17 @@ export class CodexAppServerRpc { }); } - request( + request( + method: M, + params: CodexClientRequestMap[M]["params"], + timeoutMs = 30_000, + ): Promise { + return this.requestWithRetry(method, params, timeoutMs) as Promise< + CodexClientRequestMap[M]["result"] + >; + } + + requestUnmapped( method: string, params: Record | null, timeoutMs = 30_000, @@ -74,7 +93,7 @@ export class CodexAppServerRpc { private async requestWithRetry( method: string, - params: Record | null, + params: unknown, timeoutMs: number, ): Promise { for (let attempt = 0; ; attempt += 1) { @@ -95,11 +114,7 @@ export class CodexAppServerRpc { } } - private requestOnce( - method: string, - params: Record | null, - timeoutMs: number, - ): Promise { + private requestOnce(method: string, params: unknown, timeoutMs: number): Promise { const id = `poracode-${this.requestSequence++}`; const pending = new Promise((resolve, reject) => { @@ -214,6 +229,8 @@ export class CodexAppServerRpc { console.warn( `[codex] no canonical mapping for app-server request method "${message.method}"; replying method not found.`, ); + // Rejecting account/chatgptAuthTokens/refresh here is intentional: external-auth mode + // alone sends it, and Poracode never enables that mode (codex-rs app-server/src/external_auth.rs). this.write({ id: message.id, error: { diff --git a/src/supervisor/agents/codex/canonicalMapping.test.ts b/src/supervisor/agents/codex/canonicalMapping.test.ts index a795a7e4f..c1decccc5 100644 --- a/src/supervisor/agents/codex/canonicalMapping.test.ts +++ b/src/supervisor/agents/codex/canonicalMapping.test.ts @@ -49,7 +49,7 @@ describe("mapCodexNotification — turn lifecycle", () => { expect(state.currentTurnId).toBeUndefined(); }); - it("treats turn/aborted as turn.completed with state=interrupted", () => { + it("treats legacy turn/aborted as turn.completed with state=interrupted", () => { const state = createCodexMapperState("t-codex"); mapCodexNotification("turn/started", { turnId: "t-1", threadId: "x" }, state); const events = mapCodexNotification("turn/aborted", { threadId: "x" }, state); @@ -87,6 +87,14 @@ describe("mapCodexNotification — turn lifecycle", () => { ]); }); + it("falls back from whitespace-only Codex error messages", () => { + const state = createCodexMapperState("t-codex"); + + expect(mapCodexNotification("error", { error: { message: " " } }, state)).toEqual([ + { type: "error", threadId: "t-codex", message: "Codex thread error" }, + ]); + }); + it("maps turn usage into context usage when the app-server provides it", () => { const state = createCodexMapperState("t-codex"); mapCodexNotification("turn/started", { turnId: "t-1", threadId: "x" }, state); @@ -244,6 +252,36 @@ describe("mapCodexNotification — goals", () => { ]); }); + it.each([ + ["blocked", "paused"], + ["usageLimited", "budget_limited"], + ] as const)("maps Codex %s goals to canonical %s goals", (status, expectedStatus) => { + const state = createCodexMapperState("t-codex"); + const events = mapCodexNotification( + "thread/goal/updated", + { + threadId: "provider-thread", + turnId: null, + goal: { + threadId: "provider-thread", + objective: "finish protocol integration", + status, + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1778570000, + updatedAt: 1778570000, + }, + }, + state, + ); + + expect(events[0]).toMatchObject({ + type: "item.started", + payload: { status: expectedStatus }, + }); + }); + it("updates the existing Codex goal item when the goal is cleared", () => { const state = createCodexMapperState("t-codex"); const started = mapCodexNotification( @@ -1538,7 +1576,7 @@ describe("mapCodexNotification — streaming deltas", () => { }); }); - it("maps file-read approval requests", () => { + it("maps legacy file-read approval requests", () => { const event = mapCodexServerRequest("thread-1", "read-1", "item/fileRead/requestApproval", { path: "src/index.ts", reason: "Read outside workspace", @@ -1594,6 +1632,28 @@ describe("mapCodexServerRequest — approvals", () => { }); }); + it("skips structured command approval decisions when building options", () => { + const event = mapCodexServerRequest("thread-1", "0", "item/commandExecution/requestApproval", { + command: "pnpm test", + availableDecisions: [ + "accept", + { acceptWithExecpolicyAmendment: { execpolicy_amendment: {} } }, + { applyNetworkPolicyAmendment: { network_policy_amendment: {} } }, + "decline", + ], + }); + + expect(event).toMatchObject({ + type: "request.opened", + payload: { + options: [ + { optionId: "accept", label: "Allow" }, + { optionId: "decline", label: "Deny" }, + ], + }, + }); + }); + it("offers and translates session approval for file edits", () => { const event = mapCodexServerRequest("thread-1", "edit-1", "item/fileChange/requestApproval", { reason: "File changes need approval", @@ -1655,17 +1715,25 @@ describe("mapCodexServerRequest — approvals", () => { describe("mapCodexServerRequest — user input", () => { it("carries multi-question requestUserInput payloads as structured form details", () => { const event = mapCodexServerRequest("thread-1", "req-1", "item/tool/requestUserInput", { + threadId: "provider-thread", + turnId: "turn-1", + itemId: "item-1", + autoResolutionMs: null, questions: [ { id: "scope", header: "Scope", question: "Which scope?", + isOther: false, + isSecret: false, options: [{ label: "Scope A", description: "Minimal" }], }, { id: "validation", header: "Validation", question: "Which validation?", + isOther: false, + isSecret: false, options: [{ label: "After each phase", description: "Incremental" }], }, ], @@ -1684,11 +1752,25 @@ describe("mapCodexServerRequest — user input", () => { id: "scope", header: "Scope", question: "Which scope?", + options: [ + { + optionId: "Scope A", + label: "Scope A", + description: "Minimal", + }, + ], }, { id: "validation", header: "Validation", question: "Which validation?", + options: [ + { + optionId: "After each phase", + label: "After each phase", + description: "Incremental", + }, + ], }, ], }, diff --git a/src/supervisor/agents/codex/canonicalMapping/dispatch.ts b/src/supervisor/agents/codex/canonicalMapping/dispatch.ts index 7d094fd2f..a5d7c942f 100644 --- a/src/supervisor/agents/codex/canonicalMapping/dispatch.ts +++ b/src/supervisor/agents/codex/canonicalMapping/dispatch.ts @@ -53,6 +53,8 @@ export function mapCodexNotification( return [{ type: "turn.started", threadId, turnId }]; } + // `turn/aborted` is a legacy-only compatibility path; 0.144.5 reports + // interruption through `turn/completed` with `turn.status: "interrupted"`. if (method === "turn/completed" || method === "turn/aborted") { const events: RuntimeEvent[] = []; const usageEvent = createCodexContextUsageEvent(threadId, params); @@ -100,6 +102,7 @@ export function mapCodexNotification( return events; } + // `thread/error` is legacy-only; current app-server errors use `error`. if (method === "thread/error" || method === "error") { const message = readCodexErrorMessage(params) ?? "Codex thread error"; return [{ type: "error", threadId, message }]; diff --git a/src/supervisor/agents/codex/canonicalMapping/goal.ts b/src/supervisor/agents/codex/canonicalMapping/goal.ts index 216e6f34f..30c934a95 100644 --- a/src/supervisor/agents/codex/canonicalMapping/goal.ts +++ b/src/supervisor/agents/codex/canonicalMapping/goal.ts @@ -4,6 +4,16 @@ import type { ProviderGoalState } from "../../goalRuntime"; import type { CodexMapperState } from "../canonicalMappingState"; +import type { ThreadGoalStatus } from "../protocol"; + +const CODEX_GOAL_STATUS_MAP = { + active: "active", + paused: "paused", + blocked: "paused", + usageLimited: "budget_limited", + budgetLimited: "budget_limited", + complete: "complete", +} as const satisfies Record>; export function readCodexGoal( params: Record | undefined, @@ -30,16 +40,9 @@ export function readCodexGoal( } export function readCodexGoalStatus(status: unknown): ProviderGoalState["status"] | undefined { - switch (status) { - case "active": - case "paused": - case "complete": - return status; - case "budgetLimited": - return "budget_limited"; - default: - return undefined; - } + return typeof status === "string" && status in CODEX_GOAL_STATUS_MAP + ? CODEX_GOAL_STATUS_MAP[status as ThreadGoalStatus] + : undefined; } export function isNewCodexGoal(goal: ProviderGoalState, state: CodexMapperState): boolean { diff --git a/src/supervisor/agents/codex/canonicalMapping/readers.ts b/src/supervisor/agents/codex/canonicalMapping/readers.ts index 1eedf0d2a..1ad271670 100644 --- a/src/supervisor/agents/codex/canonicalMapping/readers.ts +++ b/src/supervisor/agents/codex/canonicalMapping/readers.ts @@ -7,6 +7,13 @@ */ import { readStringField } from "../../fileChangeSummary"; +import type { TurnPlanStepStatus } from "../protocol"; + +const CODEX_PLAN_STATUS_MAP = { + pending: "pending", + inProgress: "in_progress", + completed: "completed", +} as const satisfies Record; export interface CodexItemPayload { id?: string; @@ -97,6 +104,7 @@ export function readTurnState( method: string, params: Record | undefined, ): "completed" | "failed" | "interrupted" | "cancelled" { + // Legacy-only; current app-server sends `turn/completed` with an interrupted status. if (method === "turn/aborted") return "interrupted"; const turn = params?.turn; const status = turn && typeof turn === "object" ? (turn as Record).status : null; @@ -117,16 +125,20 @@ export function readCodexErrorMessage( ): string | undefined { const direct = readStringField(params, "message", "errorMessage"); if (direct) return direct; - const message = readStringField(params?.error, "message"); + const message = readTurnErrorMessage(params?.error); if (message) return message; const turn = params?.turn; if (turn && typeof turn === "object") { - const turnMessage = readStringField((turn as Record).error, "message"); + const turnMessage = readTurnErrorMessage((turn as Record).error); if (turnMessage) return turnMessage; } return undefined; } +function readTurnErrorMessage(value: unknown): string | undefined { + return readStringField(value, "message"); +} + export function readCodexPlanSteps( params: Record | undefined, ): Array<{ step: string; status: "pending" | "in_progress" | "completed" }> { @@ -147,15 +159,10 @@ export function readCodexPlanSteps( } function codexPlanStepStatus(raw: unknown): "pending" | "in_progress" | "completed" { - switch (raw) { - case "completed": - return "completed"; - case "inProgress": - case "in_progress": - return "in_progress"; - default: - return "pending"; - } + if (raw === "in_progress") return "in_progress"; + return typeof raw === "string" && raw in CODEX_PLAN_STATUS_MAP + ? CODEX_PLAN_STATUS_MAP[raw as TurnPlanStepStatus] + : "pending"; } export function readRecord(value: unknown): Record | undefined { diff --git a/src/supervisor/agents/codex/canonicalMapping/serverRequest.ts b/src/supervisor/agents/codex/canonicalMapping/serverRequest.ts index c9281085c..780b373d3 100644 --- a/src/supervisor/agents/codex/canonicalMapping/serverRequest.ts +++ b/src/supervisor/agents/codex/canonicalMapping/serverRequest.ts @@ -15,12 +15,24 @@ import type { UserInputOption, } from "@/shared/contracts"; import { readStringField } from "../../fileChangeSummary"; +import type { CommandExecutionApprovalDecision, ToolRequestUserInputParams } from "../protocol"; + +type StringCommandExecutionApprovalDecision = Extract; + +const DEFAULT_APPROVAL_DECISIONS = [ + "accept", + "acceptForSession", + "decline", + "cancel", +] as const satisfies readonly StringCommandExecutionApprovalDecision[]; const CODEX_APPROVAL_METHODS = new Set([ + // Legacy-only: this request is absent from the 0.144.5 protocol. "item/fileRead/requestApproval", "item/fileChange/requestApproval", "applyPatchApproval", "execCommandApproval", + // Legacy-only: this request is absent from the 0.144.5 protocol. "item/tool/requestApproval", "item/commandExecution/requestApproval", "item/permissions/requestApproval", @@ -28,7 +40,7 @@ const CODEX_APPROVAL_METHODS = new Set([ const CODEX_FORM_METHODS = new Set(["mcpServer/elicitation/request", "item/tool/requestUserInput"]); -function decisionLabel(decision: string): string { +function decisionLabel(decision: StringCommandExecutionApprovalDecision): string { switch (decision) { case "accept": return "Allow"; @@ -37,14 +49,17 @@ function decisionLabel(decision: string): string { case "decline": case "cancel": return "Deny"; - default: - return decision; } } -function codexDecisionOptions(decisions: readonly string[]): UserInputOption[] { - const hasDecline = decisions.includes("decline"); - return decisions +function codexDecisionOptions( + decisions: readonly CommandExecutionApprovalDecision[], +): UserInputOption[] { + const stringDecisions = decisions.filter( + (decision): decision is StringCommandExecutionApprovalDecision => typeof decision === "string", + ); + const hasDecline = stringDecisions.includes("decline"); + return stringDecisions .filter((decision) => decision !== "cancel" || !hasDecline) .map((decision) => ({ optionId: decision, @@ -54,13 +69,50 @@ function codexDecisionOptions(decisions: readonly string[]): UserInputOption[] { function readAvailableDecisions( params: Record | undefined, - fallback: readonly string[], -): string[] { + fallback: readonly StringCommandExecutionApprovalDecision[], +): CommandExecutionApprovalDecision[] { return Array.isArray(params?.availableDecisions) - ? (params.availableDecisions as unknown[]).filter((d): d is string => typeof d === "string") + ? (params.availableDecisions as unknown[]).filter(isCommandExecutionApprovalDecision) : [...fallback]; } +function isCommandExecutionApprovalDecision( + value: unknown, +): value is CommandExecutionApprovalDecision { + if (typeof value === "string") { + return DEFAULT_APPROVAL_DECISIONS.includes(value as StringCommandExecutionApprovalDecision); + } + return ( + value !== null && + typeof value === "object" && + ("acceptWithExecpolicyAmendment" in value || "applyNetworkPolicyAmendment" in value) + ); +} + +function normalizeCodexUserInputQuestions( + questions: ToolRequestUserInputParams["questions"], +): unknown[] { + return questions.map((question) => { + if (!question || typeof question !== "object") return question; + const record = question as Record; + if (!Array.isArray(record.options)) return question; + return { + ...record, + options: record.options.map((option) => { + if (!option || typeof option !== "object") return option; + const optionRecord = option as Record; + const label = + typeof optionRecord.label === "string" && optionRecord.label.length > 0 + ? optionRecord.label + : typeof optionRecord.optionId === "string" && optionRecord.optionId.length > 0 + ? optionRecord.optionId + : undefined; + return label ? { ...optionRecord, optionId: label, label } : option; + }), + }; + }); +} + function codexPermissionDetails(input: { toolName: string; displayName?: string; @@ -113,7 +165,11 @@ export function mapCodexServerRequest( } if (method === "item/tool/requestUserInput") { - const questions = Array.isArray(params?.questions) ? params.questions : []; + const questions = Array.isArray(params?.questions) + ? normalizeCodexUserInputQuestions( + params.questions as ToolRequestUserInputParams["questions"], + ) + : []; if (questions.length === 0) return undefined; return { type: "request.opened", @@ -159,12 +215,7 @@ export function mapCodexServerRequest( if (method === "item/commandExecution/requestApproval") { const command = readStringField(params, "command") ?? "command"; - const decisions = readAvailableDecisions(params, [ - "accept", - "acceptForSession", - "decline", - "cancel", - ]); + const decisions = readAvailableDecisions(params, DEFAULT_APPROVAL_DECISIONS); return { type: "request.opened", threadId, @@ -204,7 +255,7 @@ export function mapCodexServerRequest( ...(readStringField(params, "cwd") ? { cwd: readStringField(params, "cwd") } : {}), }, }), - options: codexDecisionOptions(["accept", "acceptForSession", "decline", "cancel"]), + options: codexDecisionOptions(DEFAULT_APPROVAL_DECISIONS), }, }; } @@ -232,12 +283,7 @@ export function mapCodexServerRequest( if (method === "item/fileChange/requestApproval" || method === "applyPatchApproval") { const summary = reason ?? "File changes need approval"; - const decisions = readAvailableDecisions(params, [ - "accept", - "acceptForSession", - "decline", - "cancel", - ]); + const decisions = readAvailableDecisions(params, DEFAULT_APPROVAL_DECISIONS); return { type: "request.opened", threadId, @@ -279,7 +325,7 @@ export function mapCodexServerRequest( ...(approvalToolName ? { displayName: approvalToolName } : {}), toolInput: params?.input, }), - options: codexDecisionOptions(["accept", "acceptForSession", "decline", "cancel"]), + options: codexDecisionOptions(DEFAULT_APPROVAL_DECISIONS), }, }; } diff --git a/src/supervisor/agents/codex/canonicalMapping/usage.ts b/src/supervisor/agents/codex/canonicalMapping/usage.ts index 4b0bc03ca..b39f90ff5 100644 --- a/src/supervisor/agents/codex/canonicalMapping/usage.ts +++ b/src/supervisor/agents/codex/canonicalMapping/usage.ts @@ -8,6 +8,7 @@ import { readNonNegativeInteger, usageFromTokenCounts, } from "../../contextUsage"; +import type { ThreadTokenUsage } from "../protocol"; import { readRecord } from "./readers"; export function createCodexContextUsageEvent( @@ -26,20 +27,29 @@ export function createCodexTokenUsageEvent( threadId: string, params: Record | undefined, ): RuntimeEvent | undefined { - const tokenUsage = readRecord(params?.tokenUsage) ?? readRecord(params?.token_usage); - if (tokenUsage) { + const currentTokenUsageRecord = readRecord(params?.tokenUsage); + const currentLastUsage = readRecord(currentTokenUsageRecord?.last); + if (currentTokenUsageRecord && currentLastUsage) { + const currentTokenUsage = currentTokenUsageRecord as ThreadTokenUsage; + return createCodexUsageEvent(threadId, currentLastUsage, { + maxTokens: readNonNegativeInteger(currentTokenUsage.modelContextWindow), + }); + } + + const legacyTokenUsage = readRecord(params?.token_usage) ?? currentTokenUsageRecord; + if (legacyTokenUsage) { const usage = - readRecord(tokenUsage.last) ?? - readRecord(tokenUsage.lastTokenUsage) ?? - readRecord(tokenUsage.last_token_usage) ?? - readRecord(tokenUsage.total) ?? - readRecord(tokenUsage.totalTokenUsage) ?? - readRecord(tokenUsage.total_token_usage); + readRecord(legacyTokenUsage.last) ?? + readRecord(legacyTokenUsage.lastTokenUsage) ?? + readRecord(legacyTokenUsage.last_token_usage) ?? + readRecord(legacyTokenUsage.total) ?? + readRecord(legacyTokenUsage.totalTokenUsage) ?? + readRecord(legacyTokenUsage.total_token_usage); if (!usage) return undefined; return createCodexUsageEvent(threadId, usage, { maxTokens: - readNonNegativeInteger(tokenUsage.modelContextWindow) ?? - readNonNegativeInteger(tokenUsage.model_context_window), + readNonNegativeInteger(legacyTokenUsage.modelContextWindow) ?? + readNonNegativeInteger(legacyTokenUsage.model_context_window), }); } diff --git a/src/supervisor/agents/codex/codex.test.ts b/src/supervisor/agents/codex/codex.test.ts index fb8852342..1ba2641aa 100644 --- a/src/supervisor/agents/codex/codex.test.ts +++ b/src/supervisor/agents/codex/codex.test.ts @@ -14,7 +14,8 @@ import { parseCodexLoginStatusOutput, } from "./detection"; import { CodexStructuredSession } from "./acp"; -import type { CodexAppServerRpcListener } from "./appServerRpc"; +import { CodexRpcResponseError, type CodexAppServerRpcListener } from "./appServerRpc"; +import type { CodexThreadStatus } from "./acpProtocol"; import type { OscNotification, OscTitle } from "@/shared/osc"; import type { RuntimeEvent, ToolCallPayload } from "@/shared/contracts"; import { codexIntentFor } from "./plugin/intentMap"; @@ -67,6 +68,18 @@ describe("deriveCodexStructuredState", () => { }); }); + it("ignores unknown active flags from newer app-server versions", () => { + const status = { + type: "active", + activeFlags: ["newerUnknownFlag"], + } as unknown as CodexThreadStatus; + + expect(deriveCodexStructuredState(status)).toEqual({ + status: "working", + attention: "working", + }); + }); + it("maps idle state to idle", () => { expect(deriveCodexStructuredState({ type: "idle" })).toEqual({ status: "idle", @@ -88,7 +101,11 @@ describe("deriveCodexStructuredState", () => { id: "req-1", method: "item/tool/requestUserInput", params: { + threadId: "provider-thread", + turnId: "turn-1", + itemId: "item-1", questions: [], + autoResolutionMs: null, }, }), ).toEqual({ @@ -96,7 +113,11 @@ describe("deriveCodexStructuredState", () => { id: "req-1", method: "item/tool/requestUserInput", params: { + threadId: "provider-thread", + turnId: "turn-1", + itemId: "item-1", questions: [], + autoResolutionMs: null, }, }); }); @@ -806,6 +827,7 @@ describe("CodexStructuredSession", () => { session["threadId"] = "local-thread"; session["remoteThreadId"] = "provider-thread"; + session["launchOptions"] = {}; session["bufferedRuntimeEvents"] = []; session["isDisposed"] = false; session["currentThreadStatus"] = { type: "idle" }; @@ -877,19 +899,193 @@ describe("CodexStructuredSession", () => { }); }); - it("rolls back Codex app-server threads with thread/rollback", async () => { + it("forks Codex app-server threads through the retained turn", async () => { const requests: Array<{ method: string; params: Record }> = []; const structuredSession = makeStructuredSession(requests); + const runtimeEvents: RuntimeEvent[] = []; + (structuredSession as unknown as Record)["listener"] = { + onRuntimeEvent: (event: RuntimeEvent) => runtimeEvents.push(event), + }; + (structuredSession as unknown as Record)["rpc"] = { + request: (method: string, params: Record) => { + requests.push({ method, params }); + if (method === "thread/read" && params.includeTurns === true) { + return Promise.resolve({ + thread: { + turns: ["turn-1", "turn-2", "turn-3", "turn-4"].map((id) => ({ id })), + }, + }); + } + if (method === "thread/fork") { + const response = Promise.resolve({ thread: { id: "forked-thread" } }); + dispatchNotification(structuredSession, { + jsonrpc: "2.0", + method: "thread/tokenUsage/updated", + params: { + threadId: "forked-thread", + turnId: "turn-2", + tokenUsage: { + total: { + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 20, + reasoningOutputTokens: 5, + totalTokens: 120, + }, + last: { + inputTokens: 80, + cachedInputTokens: 0, + outputTokens: 15, + reasoningOutputTokens: 5, + totalTokens: 100, + }, + modelContextWindow: 258_400, + }, + }, + }); + return response; + } + return Promise.resolve({ thread: { status: { type: "idle" } } }); + }, + }; + + const history = await structuredSession.rollbackThread(2); - await structuredSession.rollbackThread(2); + expect(requests.slice(0, 2)).toEqual([ + { + method: "thread/read", + params: { + threadId: "provider-thread", + includeTurns: true, + }, + }, + { + method: "thread/fork", + params: { + threadId: "provider-thread", + lastTurnId: "turn-2", + }, + }, + ]); + expect(requests[2]).toEqual({ + method: "thread/unsubscribe", + params: { threadId: "provider-thread" }, + }); + expect(history).toEqual({ providerSessionId: "forked-thread", messages: [] }); + expect((structuredSession as unknown as { remoteThreadId: string }).remoteThreadId).toBe( + "forked-thread", + ); + expect(structuredSession.launchOptions.resumeThreadId).toBe("forked-thread"); + expect(runtimeEvents).toContainEqual({ + type: "context.updated", + threadId: "local-thread", + usage: { + usedTokens: 100, + maxTokens: 258_400, + breakdown: [ + { id: "input", label: "Input", tokens: 80 }, + { id: "output", label: "Output", tokens: 15 }, + { id: "reasoning", label: "Reasoning", tokens: 5 }, + ], + }, + }); + }); - expect(requests[0]).toEqual({ + it("falls back to thread/rollback when thread/fork is unavailable", async () => { + const requests: Array<{ method: string; params: Record }> = []; + const structuredSession = makeStructuredSession(requests); + (structuredSession as unknown as Record)["rpc"] = { + request: async (method: string, params: Record) => { + requests.push({ method, params }); + if (method === "thread/read" && params.includeTurns === true) { + return { thread: { turns: [{ id: "turn-1" }, { id: "turn-2" }] } }; + } + if (method === "thread/fork") { + throw new CodexRpcResponseError("Method not found", -32601); + } + return { thread: { status: { type: "idle" } } }; + }, + }; + + const history = await structuredSession.rollbackThread(1); + + expect(requests.map((request) => request.method)).toEqual([ + "thread/read", + "thread/fork", + "thread/rollback", + "thread/read", + ]); + expect(requests[2]).toEqual({ method: "thread/rollback", params: { threadId: "provider-thread", - numTurns: 2, + numTurns: 1, }, }); + expect(requests.map((request) => request.method)).not.toContain("thread/unsubscribe"); + expect(history).toEqual({ providerSessionId: "provider-thread", messages: [] }); + }); + + it("clears buffered notifications when thread/fork fails", async () => { + const requests: Array<{ method: string; params: Record }> = []; + const structuredSession = makeStructuredSession(requests); + const runtimeEvents: RuntimeEvent[] = []; + (structuredSession as unknown as Record)["listener"] = { + onRuntimeEvent: (event: RuntimeEvent) => runtimeEvents.push(event), + }; + (structuredSession as unknown as Record)["rpc"] = { + request: async (method: string, params: Record) => { + requests.push({ method, params }); + if (method === "thread/read") { + return { thread: { turns: [{ id: "turn-1" }, { id: "turn-2" }] } }; + } + dispatchNotification(structuredSession, { + jsonrpc: "2.0", + method: "thread/tokenUsage/updated", + params: { + threadId: "failed-fork-thread", + turnId: "turn-1", + tokenUsage: { + total: {}, + last: { totalTokens: 25 }, + modelContextWindow: 258_400, + }, + }, + }); + throw new Error("fork failed"); + }, + }; + + await expect(structuredSession.rollbackThread(1)).rejects.toThrow("fork failed"); + + expect( + (structuredSession as unknown as { forkNotificationBuffer?: unknown }).forkNotificationBuffer, + ).toBeUndefined(); + expect(runtimeEvents).toEqual([]); + expect(requests.map((request) => request.method)).not.toContain("thread/unsubscribe"); + }); + + it("uses legacy rollback when dropping every turn leaves nothing to fork through", async () => { + const requests: Array<{ method: string; params: Record }> = []; + const structuredSession = makeStructuredSession(requests); + (structuredSession as unknown as Record)["rpc"] = { + request: async (method: string, params: Record) => { + requests.push({ method, params }); + if (method === "thread/read" && params.includeTurns === true) { + return { thread: { turns: [{ id: "turn-1" }, { id: "turn-2" }] } }; + } + return { thread: { status: { type: "idle" } } }; + }, + }; + + const history = await structuredSession.rollbackThread(2); + + expect(requests.map((request) => request.method)).toEqual([ + "thread/read", + "thread/rollback", + "thread/read", + ]); + expect(history).toEqual({ providerSessionId: "provider-thread", messages: [] }); }); it("requests Codex reasoning summaries for GUI turns", async () => { @@ -1163,7 +1359,7 @@ describe("CodexStructuredSession", () => { expect.objectContaining({ method: "thread/start" }), { method: "config/mcpServer/reload", - params: null, + params: undefined, }, expect.objectContaining({ method: "turn/start" }), ]); diff --git a/src/supervisor/agents/codex/protocol/README.md b/src/supervisor/agents/codex/protocol/README.md new file mode 100644 index 000000000..4b40dc012 --- /dev/null +++ b/src/supervisor/agents/codex/protocol/README.md @@ -0,0 +1,11 @@ +# Codex app-server protocol types + +The protocol façades import live generated definitions from `@poracode/codex-protocol`. Its generator uses the official `@openai/codex` package pinned to `0.144.5` and writes the app-server TypeScript output to `packages/codex-protocol/generated/` during `pnpm install`. + +The generated directory is gitignored. Regenerate it manually from the repository root with: + +```sh +pnpm codex-protocol:gen +``` + +If typecheck reports that `packages/codex-protocol/generated` is missing, run `pnpm install` or the command above. Bumping the exact `@openai/codex` devDependency in `packages/codex-protocol/package.json` updates the protocol typings on the next install or regeneration. diff --git a/src/supervisor/agents/codex/protocol/client.ts b/src/supervisor/agents/codex/protocol/client.ts new file mode 100644 index 000000000..30f279268 --- /dev/null +++ b/src/supervisor/agents/codex/protocol/client.ts @@ -0,0 +1,90 @@ +import type { + ConfigRequirementsReadResponse, + GetAccountParams, + GetAccountResponse, + InitializeParams, + InitializeResponse, + ModelListParams, + ModelListResponse, + SkillsListParams, + SkillsListResponse, + McpServerRefreshResponse, + ThreadForkParams, + ThreadForkResponse, + ThreadGoalClearParams, + ThreadGoalClearResponse, + ThreadGoalSetParams, + ThreadGoalSetResponse, + ThreadReadParams, + ThreadReadResponse, + ThreadResumeParams, + ThreadResumeResponse, + ThreadRollbackParams, + ThreadRollbackResponse, + ThreadStartParams, + ThreadStartResponse, + ThreadUnsubscribeParams, + ThreadUnsubscribeResponse, + TurnInterruptParams, + TurnInterruptResponse, + TurnStartParams, + TurnStartResponse, +} from "@poracode/codex-protocol"; + +type InitializeRequestParams = Omit & { + clientInfo: Omit & { + title?: InitializeParams["clientInfo"]["title"]; + }; +}; + +export type { + ConfigRequirementsReadResponse, + GetAccountParams, + GetAccountResponse, + InitializeParams, + InitializeResponse, + McpServerRefreshResponse, + ModelListParams, + ModelListResponse, + SkillsListParams, + SkillsListResponse, + ThreadForkParams, + ThreadForkResponse, + ThreadGoalClearParams, + ThreadGoalClearResponse, + ThreadGoalSetParams, + ThreadGoalSetResponse, + ThreadReadParams, + ThreadReadResponse, + ThreadResumeParams, + ThreadResumeResponse, + ThreadRollbackParams, + ThreadRollbackResponse, + ThreadStartParams, + ThreadStartResponse, + ThreadUnsubscribeParams, + ThreadUnsubscribeResponse, + TurnInterruptParams, + TurnInterruptResponse, + TurnStartParams, + TurnStartResponse, +}; + +export interface CodexClientRequestMap { + initialize: { params: InitializeRequestParams; result: InitializeResponse }; + "skills/list": { params: SkillsListParams; result: SkillsListResponse }; + "thread/start": { params: ThreadStartParams; result: ThreadStartResponse }; + "thread/resume": { params: ThreadResumeParams; result: ThreadResumeResponse }; + "thread/read": { params: ThreadReadParams; result: ThreadReadResponse }; + "thread/fork": { params: ThreadForkParams; result: ThreadForkResponse }; + "thread/unsubscribe": { params: ThreadUnsubscribeParams; result: ThreadUnsubscribeResponse }; + "thread/rollback": { params: ThreadRollbackParams; result: ThreadRollbackResponse }; + "thread/goal/set": { params: ThreadGoalSetParams; result: ThreadGoalSetResponse }; + "thread/goal/clear": { params: ThreadGoalClearParams; result: ThreadGoalClearResponse }; + "turn/start": { params: TurnStartParams; result: TurnStartResponse }; + "turn/interrupt": { params: TurnInterruptParams; result: TurnInterruptResponse }; + "config/mcpServer/reload": { params: undefined; result: McpServerRefreshResponse }; + "account/read": { params: GetAccountParams; result: GetAccountResponse }; + "model/list": { params: ModelListParams; result: ModelListResponse }; + "configRequirements/read": { params: undefined; result: ConfigRequirementsReadResponse }; +} diff --git a/src/supervisor/agents/codex/protocol/index.ts b/src/supervisor/agents/codex/protocol/index.ts new file mode 100644 index 000000000..396828aeb --- /dev/null +++ b/src/supervisor/agents/codex/protocol/index.ts @@ -0,0 +1,3 @@ +export type * from "./client"; +export type * from "./notifications"; +export type * from "./server"; diff --git a/src/supervisor/agents/codex/protocol/notifications.ts b/src/supervisor/agents/codex/protocol/notifications.ts new file mode 100644 index 000000000..ca1c87e00 --- /dev/null +++ b/src/supervisor/agents/codex/protocol/notifications.ts @@ -0,0 +1,67 @@ +import type { + AccountRateLimitsUpdatedNotification, + AgentMessageDeltaNotification, + CommandExecutionOutputDeltaNotification, + ErrorNotification, + FileChangeOutputDeltaNotification, + ItemCompletedNotification, + ItemStartedNotification, + PlanDeltaNotification, + ReasoningSummaryTextDeltaNotification, + ReasoningTextDeltaNotification, + ServerRequestResolvedNotification, + McpToolCallProgressNotification, + SkillsChangedNotification, + ThreadClosedNotification, + ThreadGoalClearedNotification, + ThreadGoalStatus, + ThreadGoalUpdatedNotification, + ThreadSettingsUpdatedNotification, + ThreadStartedNotification, + ThreadStatus, + ThreadStatusChangedNotification, + ThreadTokenUsage, + ThreadTokenUsageUpdatedNotification, + TurnCompletedNotification, + TurnError, + TurnPlanStep, + TurnPlanStepStatus, + TurnPlanUpdatedNotification, + TurnStartedNotification, +} from "@poracode/codex-protocol"; + +export type { + ErrorNotification, + ThreadGoalStatus, + ThreadStatus, + ThreadTokenUsage, + TurnError, + TurnPlanStep, + TurnPlanStepStatus, +}; + +export interface CodexServerNotificationMap { + error: ErrorNotification; + "thread/started": ThreadStartedNotification; + "thread/status/changed": ThreadStatusChangedNotification; + "thread/closed": ThreadClosedNotification; + "thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification; + "thread/settings/updated": ThreadSettingsUpdatedNotification; + "thread/goal/updated": ThreadGoalUpdatedNotification; + "thread/goal/cleared": ThreadGoalClearedNotification; + "turn/started": TurnStartedNotification; + "turn/completed": TurnCompletedNotification; + "turn/plan/updated": TurnPlanUpdatedNotification; + "item/started": ItemStartedNotification; + "item/completed": ItemCompletedNotification; + "item/agentMessage/delta": AgentMessageDeltaNotification; + "item/reasoning/textDelta": ReasoningTextDeltaNotification; + "item/reasoning/summaryTextDelta": ReasoningSummaryTextDeltaNotification; + "item/commandExecution/outputDelta": CommandExecutionOutputDeltaNotification; + "item/fileChange/outputDelta": FileChangeOutputDeltaNotification; + "item/plan/delta": PlanDeltaNotification; + "item/mcpToolCall/progress": McpToolCallProgressNotification; + "serverRequest/resolved": ServerRequestResolvedNotification; + "account/rateLimits/updated": AccountRateLimitsUpdatedNotification; + "skills/changed": SkillsChangedNotification; +} diff --git a/src/supervisor/agents/codex/protocol/server.ts b/src/supervisor/agents/codex/protocol/server.ts new file mode 100644 index 000000000..9a81fb5f9 --- /dev/null +++ b/src/supervisor/agents/codex/protocol/server.ts @@ -0,0 +1,79 @@ +import type { + ApplyPatchApprovalParams, + ApplyPatchApprovalResponse, + ChatgptAuthTokensRefreshParams, + ChatgptAuthTokensRefreshResponse, + CommandExecutionApprovalDecision, + CommandExecutionRequestApprovalParams, + CommandExecutionRequestApprovalResponse, + CurrentTimeReadParams, + CurrentTimeReadResponse, + ExecCommandApprovalParams, + ExecCommandApprovalResponse, + FileChangeRequestApprovalParams, + FileChangeRequestApprovalResponse, + PermissionsRequestApprovalParams, + PermissionsRequestApprovalResponse, + McpServerElicitationRequestParams, + McpServerElicitationRequestResponse, + ToolRequestUserInputParams, + ToolRequestUserInputResponse, +} from "@poracode/codex-protocol"; + +export type { + ApplyPatchApprovalParams, + ApplyPatchApprovalResponse, + ChatgptAuthTokensRefreshParams, + ChatgptAuthTokensRefreshResponse, + CommandExecutionApprovalDecision, + CommandExecutionRequestApprovalParams, + CommandExecutionRequestApprovalResponse, + CurrentTimeReadParams, + CurrentTimeReadResponse, + ExecCommandApprovalParams, + ExecCommandApprovalResponse, + FileChangeRequestApprovalParams, + FileChangeRequestApprovalResponse, + McpServerElicitationRequestParams, + McpServerElicitationRequestResponse, + PermissionsRequestApprovalParams, + PermissionsRequestApprovalResponse, + ToolRequestUserInputParams, + ToolRequestUserInputResponse, +}; + +export interface CodexServerRequestMap { + "item/commandExecution/requestApproval": { + params: CommandExecutionRequestApprovalParams; + result: CommandExecutionRequestApprovalResponse; + }; + "item/fileChange/requestApproval": { + params: FileChangeRequestApprovalParams; + result: FileChangeRequestApprovalResponse; + }; + "item/permissions/requestApproval": { + params: PermissionsRequestApprovalParams; + result: PermissionsRequestApprovalResponse; + }; + "item/tool/requestUserInput": { + params: ToolRequestUserInputParams; + result: ToolRequestUserInputResponse; + }; + "mcpServer/elicitation/request": { + params: McpServerElicitationRequestParams; + result: McpServerElicitationRequestResponse; + }; + "currentTime/read": { params: CurrentTimeReadParams; result: CurrentTimeReadResponse }; + "account/chatgptAuthTokens/refresh": { + params: ChatgptAuthTokensRefreshParams; + result: ChatgptAuthTokensRefreshResponse; + }; + applyPatchApproval: { + params: ApplyPatchApprovalParams; + result: ApplyPatchApprovalResponse; + }; + execCommandApproval: { + params: ExecCommandApprovalParams; + result: ExecCommandApprovalResponse; + }; +} diff --git a/tsconfig.json b/tsconfig.json index af3a0c646..b09f81e97 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ "src", "tests", "packages/agents-usage/src", + "packages/codex-protocol", "native/activity-bridge/src", "native/ssh-bridge/src", "vite.config.ts", From d5f0123661fd20b93f345d339cf19a299c9b4e5d Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 18 Jul 2026 12:21:11 -0700 Subject: [PATCH 3/5] fix(codex): apply live config on rollback fork and propagate forked session id - Pass the thread's current model/effort/approval/sandbox into thread/fork so a reverted thread never loads unsafe default settings (previously the fork loaded gpt-5.3-codex-spark / never / danger-full-access until the next turn); extract a shared buildCodexThreadOverrides for start/resume/fork - Thread the current composer config through the rollback IPC so a config change made before reverting is honored by the fork rather than a stale one - Emit thread-state on a provider-session-id change so the renderer adopts the forked session id immediately instead of only on the next turn Co-Authored-By: Claude Fable 5 --- src/main/remote/RemoteAccessServer.test.ts | 10 ++++- .../thread/ChatPane/ChatPane.test.tsx | 13 ++++++- .../components/thread/ChatPane/ChatPane.tsx | 1 + .../thread/ChatPane/parts/MessageList.tsx | 23 +++++++++-- src/renderer/state/remoteServersStore.test.ts | 10 +++++ src/renderer/state/remoteServersStore.ts | 2 + src/shared/contracts/thread.ts | 1 + src/supervisor/agents/base/types.ts | 2 +- src/supervisor/agents/codex/acp.ts | 39 +++++++------------ src/supervisor/agents/codex/codex.test.ts | 26 ++++++++++++- .../agents/codex/threadOverrides.ts | 33 ++++++++++++++++ src/supervisor/runtime.test.ts | 17 ++++++-- .../sessionRuntimeLifecycle.test.ts | 33 ++++++++++++++++ .../threadSession/sessionRuntimeLifecycle.ts | 4 +- .../runtime/threadSessionManager.ts | 4 +- 15 files changed, 178 insertions(+), 40 deletions(-) create mode 100644 src/supervisor/agents/codex/threadOverrides.ts diff --git a/src/main/remote/RemoteAccessServer.test.ts b/src/main/remote/RemoteAccessServer.test.ts index 9bd497f46..2bca9c173 100644 --- a/src/main/remote/RemoteAccessServer.test.ts +++ b/src/main/remote/RemoteAccessServer.test.ts @@ -3444,7 +3444,15 @@ describe("RemoteAccessServer", () => { const checkpointCalls = [ { procedure: "rollbackThreadConversation", - payload: { threadId: "thread-1", numTurns: 1 }, + payload: { + threadId: "thread-1", + numTurns: 1, + config: { + model: "gpt-5.6-terra", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + }, + }, }, { procedure: "listFileCheckpoints", diff --git a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx index 37eaebbf0..f646912c4 100644 --- a/src/renderer/components/thread/ChatPane/ChatPane.test.tsx +++ b/src/renderer/components/thread/ChatPane/ChatPane.test.tsx @@ -1581,7 +1581,16 @@ describe("ChatPane", () => { }); it("shows checkpoint buttons on later user messages and reverts to before that prompt", async () => { - const thread = { ...makeThread(), status: "idle" as const }; + const thread = { + ...makeThread(), + status: "idle" as const, + config: { + model: "gpt-5.6-terra", + effort: "high", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + }, + }; const rollbackThreadConversation = vi.fn<() => Promise>().mockResolvedValue(undefined); Object.assign(window, { poracode: { @@ -1616,6 +1625,7 @@ describe("ChatPane", () => { expect(rollbackThreadConversation).toHaveBeenCalledWith({ threadId: thread.id, numTurns: 1, + config: thread.config, }), ); await waitFor(() => expect(screen.queryByText("Follow-up prompt")).not.toBeInTheDocument()); @@ -1663,6 +1673,7 @@ describe("ChatPane", () => { expect(rollbackThreadConversation).toHaveBeenCalledWith({ threadId: thread.id, numTurns: 1, + config: thread.config, }), ); }); diff --git a/src/renderer/components/thread/ChatPane/ChatPane.tsx b/src/renderer/components/thread/ChatPane/ChatPane.tsx index 8e74bc32a..e7063f70c 100644 --- a/src/renderer/components/thread/ChatPane/ChatPane.tsx +++ b/src/renderer/components/thread/ChatPane/ChatPane.tsx @@ -328,6 +328,7 @@ export function ChatPane(props: ChatPaneProps) { ; + rollbackThreadConversation(input: { + threadId: string; + numTurns: number; + config?: ThreadConfig; + }): Promise; restoreFileCheckpoint(input: { threadId: string; checkpointItemId: string; @@ -57,6 +66,7 @@ export interface CheckpointRevertActions { interface MessageListProps { threadId: string; + threadConfig?: ThreadConfig; entries: readonly ChatTimelineEntry[]; isTurnActive?: boolean; setScrollContainer?: (element: HTMLDivElement | null) => void; @@ -108,6 +118,7 @@ const SKIP_REVERT_CONFIRM_PREF_KEY = "poracode-chat-checkpoint-revert-skip-confi // while moving the DOM, so the virtualizer must re-render to re-measure. export function MessageList({ threadId, + threadConfig, entries, isTurnActive = false, setScrollContainer, @@ -285,7 +296,11 @@ export function MessageList({ const revert = checkpointActions ?? readBridge(); if (rollbackTurns > 0) { try { - await revert.rollbackThreadConversation({ threadId, numTurns: rollbackTurns }); + await revert.rollbackThreadConversation({ + threadId, + numTurns: rollbackTurns, + ...(threadConfig ? { config: threadConfig } : {}), + }); } catch (error) { console.warn( "[checkpoint] provider rollback failed; continuing with local revert", @@ -304,7 +319,7 @@ export function MessageList({ await readBridge().dbTruncateThreadRuntimeAfter({ threadId, itemId }); parentActions?.onContentHeightChange(); }, - [checkpointActions, parentActions, projectLocation, threadId], + [checkpointActions, parentActions, projectLocation, threadConfig, threadId], ); const requestRevert = useCallback( diff --git a/src/renderer/state/remoteServersStore.test.ts b/src/renderer/state/remoteServersStore.test.ts index 419e9a1d2..8667029ca 100644 --- a/src/renderer/state/remoteServersStore.test.ts +++ b/src/renderer/state/remoteServersStore.test.ts @@ -484,6 +484,11 @@ describe("useRemoteServersStore", () => { desktopId: "d1", threadId: "thread-7", numTurns: 2, + config: { + model: "gpt-5.6-terra", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + }, }); await useRemoteServersStore.getState().restoreFileCheckpoint({ desktopId: "d1", @@ -495,6 +500,11 @@ describe("useRemoteServersStore", () => { expect(gitCall).toHaveBeenCalledWith("rollbackThreadConversation", { threadId: "thread-7", numTurns: 2, + config: { + model: "gpt-5.6-terra", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + }, }); expect(gitCall).toHaveBeenCalledWith("restoreFileCheckpoint", { threadId: "thread-7", diff --git a/src/renderer/state/remoteServersStore.ts b/src/renderer/state/remoteServersStore.ts index 0c6429475..828550177 100644 --- a/src/renderer/state/remoteServersStore.ts +++ b/src/renderer/state/remoteServersStore.ts @@ -380,6 +380,7 @@ interface RemoteServersState { readonly desktopId: string; readonly threadId: string; readonly numTurns: number; + readonly config?: ThreadConfig; }): Promise; /** Restore files on the remote server for a checkpoint revert. */ restoreFileCheckpoint(input: { @@ -874,6 +875,7 @@ export const useRemoteServersStore = create()( await requireClient(input.desktopId).gitCall("rollbackThreadConversation", { threadId: input.threadId, numTurns: input.numTurns, + ...(input.config ? { config: input.config } : {}), }); }, diff --git a/src/shared/contracts/thread.ts b/src/shared/contracts/thread.ts index 698efe89b..8fabab1f2 100644 --- a/src/shared/contracts/thread.ts +++ b/src/shared/contracts/thread.ts @@ -150,6 +150,7 @@ export type InterruptThreadPayload = z.infer; readThread?(): Promise; - rollbackThread?(numTurns: number): Promise; + rollbackThread?(numTurns: number, config?: ThreadConfig): Promise; setListener(listener: StructuredSessionListener): void; dispose(): Promise; } diff --git a/src/supervisor/agents/codex/acp.ts b/src/supervisor/agents/codex/acp.ts index f13611df0..ed23b6d28 100644 --- a/src/supervisor/agents/codex/acp.ts +++ b/src/supervisor/agents/codex/acp.ts @@ -51,6 +51,7 @@ import { import { CodexAppServerRpc, isUnsupportedCodexRequestError } from "./appServerRpc"; import type { CodexClientRequestMap } from "./protocol"; import { CodexStdioTransport } from "./stdioTransport"; +import { buildCodexThreadOverrides } from "./threadOverrides"; import { mapCodexSkillsToSlashCommands, mapCodexSlashCommands, @@ -61,7 +62,6 @@ import { CodexSubAgentRouter } from "./subAgentRouting"; export { deriveCodexStructuredState, parseCodexSocketMessage } from "./acpProtocol"; export type { CodexThreadStatus } from "./acpProtocol"; -type ThreadStartParams = CodexClientRequestMap["thread/start"]["params"]; type TurnStartParams = CodexClientRequestMap["turn/start"]["params"]; function sleep(ms: number): Promise { @@ -185,6 +185,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { private rolloutModelProvider: string | undefined; private wslDistro: string | undefined; private currentThreadStatus: CodexThreadStatus = { type: "idle" }; + private currentConfig: ThreadConfig | undefined; private activeTurnId: string | undefined; private currentSlashCommands: AgentSlashCommand[] | undefined; private currentBaseSlashCommands: AgentSlashCommand[] = []; @@ -494,30 +495,8 @@ export class CodexStructuredSession implements StructuredSessionHandle { async openThread(config: ThreadConfig, sessionRef?: SessionRef): Promise { // `mode` does not exist on `thread/start` or `thread/resume`; plan mode is // a per-turn override sent via `collaborationMode` on `turn/start`. - const threadOverrides: ThreadStartParams = { - model: config.model, - ...(config.approvalPolicy - ? { - approvalPolicy: config.approvalPolicy as NonNullable< - ThreadStartParams["approvalPolicy"] - >, - } - : {}), - ...(config.approvalsReviewer - ? { - approvalsReviewer: config.approvalsReviewer as NonNullable< - ThreadStartParams["approvalsReviewer"] - >, - } - : {}), - ...(config.sandboxMode - ? { sandbox: config.sandboxMode as NonNullable } - : {}), - config: { - ...(config.effort ? { model_reasoning_effort: config.effort } : {}), - model_reasoning_summary: "auto", - }, - }; + this.currentConfig = config; + const threadOverrides = buildCodexThreadOverrides(config); let threadId: string; @@ -643,6 +622,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { segments?: PromptSegment[], options?: StartTurnOptions, ): Promise { + this.currentConfig = config; // New user turn clears any sticky error from a previous failed turn, along // with the per-turn error dedupe state and any pending fallback timer. this.errorSticky = false; @@ -772,7 +752,7 @@ export class CodexStructuredSession implements StructuredSessionHandle { }); } - async rollbackThread(numTurns: number): Promise { + async rollbackThread(numTurns: number, config?: ThreadConfig): Promise { if (!Number.isInteger(numTurns) || numTurns <= 0) { throw new Error(`rollbackThread: numTurns must be a positive integer (got ${numTurns}).`); } @@ -823,9 +803,16 @@ export class CodexStructuredSession implements StructuredSessionHandle { } let forkResult: CodexClientRequestMap["thread/fork"]["result"]; + const rollbackConfig = config ?? this.currentConfig; + if (!rollbackConfig) { + throw new Error("Cannot roll back a Codex thread before its configuration is initialized."); + } + this.currentConfig = rollbackConfig; + const threadOverrides = buildCodexThreadOverrides(rollbackConfig); this.forkNotificationBuffer = []; try { forkResult = await this.rpc.request("thread/fork", { + ...threadOverrides, threadId, lastTurnId: retainedTurn.id, }); diff --git a/src/supervisor/agents/codex/codex.test.ts b/src/supervisor/agents/codex/codex.test.ts index 1ba2641aa..4b4727f27 100644 --- a/src/supervisor/agents/codex/codex.test.ts +++ b/src/supervisor/agents/codex/codex.test.ts @@ -831,6 +831,7 @@ describe("CodexStructuredSession", () => { session["bufferedRuntimeEvents"] = []; session["isDisposed"] = false; session["currentThreadStatus"] = { type: "idle" }; + session["currentConfig"] = { model: "gpt-5.4" }; session["seenErrorMessages"] = new Set(); session["resumeActiveStatusSuppressionUntil"] = new Map(); session["rpc"] = { @@ -899,9 +900,22 @@ describe("CodexStructuredSession", () => { }); }); - it("forks Codex app-server threads through the retained turn", async () => { + it("forks Codex app-server threads with the current rollback config", async () => { const requests: Array<{ method: string; params: Record }> = []; const structuredSession = makeStructuredSession(requests); + (structuredSession as unknown as Record)["currentConfig"] = { + model: "gpt-5.4", + effort: "low", + approvalPolicy: "never", + sandboxMode: "danger-full-access", + }; + const rollbackConfig = { + model: "gpt-5.6-terra", + effort: "high", + approvalPolicy: "on-request", + approvalsReviewer: "user", + sandboxMode: "workspace-write", + }; const runtimeEvents: RuntimeEvent[] = []; (structuredSession as unknown as Record)["listener"] = { onRuntimeEvent: (event: RuntimeEvent) => runtimeEvents.push(event), @@ -949,7 +963,7 @@ describe("CodexStructuredSession", () => { }, }; - const history = await structuredSession.rollbackThread(2); + const history = await structuredSession.rollbackThread(2, rollbackConfig); expect(requests.slice(0, 2)).toEqual([ { @@ -962,6 +976,14 @@ describe("CodexStructuredSession", () => { { method: "thread/fork", params: { + model: "gpt-5.6-terra", + approvalPolicy: "on-request", + approvalsReviewer: "user", + sandbox: "workspace-write", + config: { + model_reasoning_effort: "high", + model_reasoning_summary: "auto", + }, threadId: "provider-thread", lastTurnId: "turn-2", }, diff --git a/src/supervisor/agents/codex/threadOverrides.ts b/src/supervisor/agents/codex/threadOverrides.ts new file mode 100644 index 000000000..fa6259274 --- /dev/null +++ b/src/supervisor/agents/codex/threadOverrides.ts @@ -0,0 +1,33 @@ +import type { ThreadConfig } from "@/shared/contracts"; +import type { CodexClientRequestMap } from "./protocol"; + +type ThreadForkParams = CodexClientRequestMap["thread/fork"]["params"]; +type ThreadConfigOverrides = Pick< + ThreadForkParams, + "model" | "approvalPolicy" | "approvalsReviewer" | "sandbox" | "config" +>; + +export function buildCodexThreadOverrides(config: ThreadConfig): ThreadConfigOverrides { + return { + model: config.model, + ...(config.approvalPolicy + ? { + approvalPolicy: config.approvalPolicy as NonNullable, + } + : {}), + ...(config.approvalsReviewer + ? { + approvalsReviewer: config.approvalsReviewer as NonNullable< + ThreadForkParams["approvalsReviewer"] + >, + } + : {}), + ...(config.sandboxMode + ? { sandbox: config.sandboxMode as NonNullable } + : {}), + config: { + ...(config.effort ? { model_reasoning_effort: config.effort } : {}), + model_reasoning_summary: "auto", + }, + }; +} diff --git a/src/supervisor/runtime.test.ts b/src/supervisor/runtime.test.ts index 39858571c..a7668de76 100644 --- a/src/supervisor/runtime.test.ts +++ b/src/supervisor/runtime.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeEvent } from "@/shared/contracts"; +import type { RuntimeEvent, ThreadConfig } from "@/shared/contracts"; import type { SessionRuntime } from "./runtime/sessionTypes"; const taskkillSpawnSyncMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => unknown>()); @@ -331,7 +331,12 @@ describe("SupervisorRuntime thread input", () => { it("rolls back provider conversation through the structured session", async () => { const runtime = makeRuntime(() => undefined); const rollbackThread = vi - .fn<(numTurns: number) => Promise<{ providerSessionId: string; messages: [] }>>() + .fn< + ( + numTurns: number, + config?: ThreadConfig, + ) => Promise<{ providerSessionId: string; messages: [] }> + >() .mockResolvedValue({ providerSessionId: "provider-session-1", messages: [] }); const session = createRuntimeSession({ sessionRef: { providerSessionId: "provider-session-1" }, @@ -351,12 +356,18 @@ describe("SupervisorRuntime thread input", () => { session, ); + const config: ThreadConfig = { + model: "gpt-5.6-terra", + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + }; await runtime.threadSessionManager.rollbackThreadConversation({ threadId: session.threadId, numTurns: 2, + config, }); - expect(rollbackThread).toHaveBeenCalledWith(2); + expect(rollbackThread).toHaveBeenCalledWith(2, config); }); it("rejects checkpoint rollback when the provider does not support it", async () => { diff --git a/src/supervisor/runtime/threadSession/sessionRuntimeLifecycle.test.ts b/src/supervisor/runtime/threadSession/sessionRuntimeLifecycle.test.ts index 75f302738..98bdd733e 100644 --- a/src/supervisor/runtime/threadSession/sessionRuntimeLifecycle.test.ts +++ b/src/supervisor/runtime/threadSession/sessionRuntimeLifecycle.test.ts @@ -302,6 +302,39 @@ describe("SessionRuntimeLifecycle", () => { expect(harness.mocks.emitState).toHaveBeenCalledExactlyOnceWith(harness.session); }); + it("emits state when an idle structured session changes its provider session id", () => { + const harness = createHarness({ + session: { + status: "idle", + attention: "none", + sessionRef: { + providerSessionId: "old-session", + discoveredAt: "2026-01-01T00:00:00.000Z", + }, + }, + }); + harness.lifecycle.attach(harness.session); + harness.mocks.emitState.mockClear(); + const emittedSessionIds: Array = []; + harness.mocks.emitState.mockImplementation((session) => { + emittedSessionIds.push(session.sessionRef?.providerSessionId); + }); + + harness.structuredListener?.onUpdate({ + status: "idle", + attention: "none", + sessionRef: { + providerSessionId: "forked-session", + discoveredAt: "2026-01-02T00:00:00.000Z", + }, + }); + + expect(harness.session.sessionRef?.providerSessionId).toBe("forked-session"); + expect(harness.mocks.indexSessionRef).toHaveBeenCalledWith(harness.session, "old-session"); + expect(harness.mocks.emitState).toHaveBeenCalledExactlyOnceWith(harness.session); + expect(emittedSessionIds).toEqual(["forked-session"]); + }); + it("ignores structured events for stale and ignored sessions", () => { const harness = createHarness(); harness.lifecycle.attach(harness.session); diff --git a/src/supervisor/runtime/threadSession/sessionRuntimeLifecycle.ts b/src/supervisor/runtime/threadSession/sessionRuntimeLifecycle.ts index 5aa5f3c67..48931a3fe 100644 --- a/src/supervisor/runtime/threadSession/sessionRuntimeLifecycle.ts +++ b/src/supervisor/runtime/threadSession/sessionRuntimeLifecycle.ts @@ -92,8 +92,10 @@ export class SessionRuntimeLifecycle { const context = this.context; const wasWorking = session.status === "working"; const hadInterruptRequest = session.structuredTurnInterruptRequested === true; + let sessionRefChanged = false; if (update.sessionRef) { const prevId = session.sessionRef?.providerSessionId; + sessionRefChanged = prevId !== update.sessionRef.providerSessionId; session.sessionRef = update.sessionRef; session.canResumeWithConfig = true; context.indexSessionRef(session, prevId); @@ -157,7 +159,7 @@ export class SessionRuntimeLifecycle { context.steerCoordinator.maybeDrainPendingSteer(session); } if ( - (configChanged || slashCommandsChanged) && + (sessionRefChanged || configChanged || slashCommandsChanged) && !stateChanged && update.errorMessage === undefined ) { diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts index 3f5a9a509..2e19a61ba 100644 --- a/src/supervisor/runtime/threadSessionManager.ts +++ b/src/supervisor/runtime/threadSessionManager.ts @@ -626,7 +626,9 @@ export class ThreadSessionManager { } const previousSessionId = session.sessionRef?.providerSessionId; - const history = await session.structuredSession.rollbackThread(payload.numTurns); + const history = payload.config + ? await session.structuredSession.rollbackThread(payload.numTurns, payload.config) + : await session.structuredSession.rollbackThread(payload.numTurns); if ( history.providerSessionId && history.providerSessionId !== session.sessionRef?.providerSessionId From 43c4520e9265268a3dc3813f3d8ad508912648e7 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 18 Jul 2026 12:36:52 -0700 Subject: [PATCH 4/5] fix(ci): generate Codex protocol before typecheck --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32b80cd7d..adf587b9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --ignore-scripts + - name: Generate Codex protocol types + run: pnpm run codex-protocol:gen + - name: Typecheck run: pnpm run typecheck From 09faa71c574e8dea013ca74336924ccda696a5bf Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 18 Jul 2026 13:02:48 -0700 Subject: [PATCH 5/5] fix(codex): handle retryable errors and rollback validation --- .../agents/codex/appServerRpc.test.ts | 18 +++++++++++++++ src/supervisor/agents/codex/appServerRpc.ts | 2 +- .../agents/codex/canonicalMapping.test.ts | 12 ++++++++++ .../agents/codex/canonicalMapping/dispatch.ts | 4 +++- src/supervisor/agents/codex/codex.test.ts | 23 +++++++++++++++++++ 5 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/supervisor/agents/codex/appServerRpc.test.ts b/src/supervisor/agents/codex/appServerRpc.test.ts index cf7011416..be671faaa 100644 --- a/src/supervisor/agents/codex/appServerRpc.test.ts +++ b/src/supervisor/agents/codex/appServerRpc.test.ts @@ -2,6 +2,8 @@ import type { RuntimeEvent } from "@/shared/contracts"; import { describe, expect, it, vi } from "vitest"; import { CodexAppServerRpc, + CodexRpcResponseError, + isUnsupportedCodexRequestError, type CodexAppServerRpcTransport, type CodexRpcDebugDirection, } from "./appServerRpc"; @@ -126,6 +128,22 @@ describe("CodexAppServerRpc", () => { await expect(pending).rejects.toThrow("turn rejected"); }); + it("only treats missing methods and unknown parameters as unsupported", () => { + expect( + isUnsupportedCodexRequestError(new CodexRpcResponseError("Method not found", -32601)), + ).toBe(true); + expect( + isUnsupportedCodexRequestError( + new CodexRpcResponseError("Invalid params: unknown field `lastTurnId`", -32602), + ), + ).toBe(true); + expect( + isUnsupportedCodexRequestError( + new CodexRpcResponseError("Invalid params: lastTurnId is in progress", -32602), + ), + ).toBe(false); + }); + it("retries overloaded requests with exponential backoff", async () => { vi.useFakeTimers(); const random = vi.spyOn(Math, "random").mockReturnValue(0); diff --git a/src/supervisor/agents/codex/appServerRpc.ts b/src/supervisor/agents/codex/appServerRpc.ts index 0ab634e2e..143ff9a7a 100644 --- a/src/supervisor/agents/codex/appServerRpc.ts +++ b/src/supervisor/agents/codex/appServerRpc.ts @@ -48,7 +48,7 @@ export function isUnsupportedCodexRequestError(error: unknown): boolean { return ( error instanceof CodexRpcResponseError && (error.code === -32601 || - (error.code === -32602 && /invalid params|unknown (?:field|parameter)/iu.test(error.message))) + (error.code === -32602 && /unknown (?:field|parameter)/iu.test(error.message))) ); } diff --git a/src/supervisor/agents/codex/canonicalMapping.test.ts b/src/supervisor/agents/codex/canonicalMapping.test.ts index c1decccc5..9b0582d74 100644 --- a/src/supervisor/agents/codex/canonicalMapping.test.ts +++ b/src/supervisor/agents/codex/canonicalMapping.test.ts @@ -1531,6 +1531,18 @@ describe("mapCodexNotification — streaming deltas", () => { state, ), ).toEqual([{ type: "error", threadId: "t-codex", message: "Tool failed" }]); + expect( + mapCodexNotification( + "error", + { + threadId: "x", + turnId: "turn-1", + error: { message: "Stream disconnected" }, + willRetry: true, + }, + state, + ), + ).toEqual([{ type: "warning", threadId: "t-codex", message: "Stream disconnected" }]); expect( mapCodexNotification("serverRequest/resolved", { threadId: "x", requestId: 42 }, state), ).toEqual([ diff --git a/src/supervisor/agents/codex/canonicalMapping/dispatch.ts b/src/supervisor/agents/codex/canonicalMapping/dispatch.ts index a5d7c942f..f66382d1d 100644 --- a/src/supervisor/agents/codex/canonicalMapping/dispatch.ts +++ b/src/supervisor/agents/codex/canonicalMapping/dispatch.ts @@ -105,7 +105,9 @@ export function mapCodexNotification( // `thread/error` is legacy-only; current app-server errors use `error`. if (method === "thread/error" || method === "error") { const message = readCodexErrorMessage(params) ?? "Codex thread error"; - return [{ type: "error", threadId, message }]; + return method === "error" && params?.willRetry === true + ? [{ type: "warning", threadId, message }] + : [{ type: "error", threadId, message }]; } if (method === "serverRequest/resolved") { diff --git a/src/supervisor/agents/codex/codex.test.ts b/src/supervisor/agents/codex/codex.test.ts index 4b4727f27..558be6083 100644 --- a/src/supervisor/agents/codex/codex.test.ts +++ b/src/supervisor/agents/codex/codex.test.ts @@ -1048,6 +1048,29 @@ describe("CodexStructuredSession", () => { expect(history).toEqual({ providerSessionId: "provider-thread", messages: [] }); }); + it("does not fall back to thread/rollback when thread/fork rejects its parameters", async () => { + const requests: Array<{ method: string; params: Record }> = []; + const structuredSession = makeStructuredSession(requests); + (structuredSession as unknown as Record)["rpc"] = { + request: async (method: string, params: Record) => { + requests.push({ method, params }); + if (method === "thread/read") { + return { thread: { turns: [{ id: "turn-1" }, { id: "turn-2" }] } }; + } + if (method === "thread/fork") { + throw new CodexRpcResponseError("Invalid params: lastTurnId is in progress", -32602); + } + return { thread: { status: { type: "idle" } } }; + }, + }; + + await expect(structuredSession.rollbackThread(1)).rejects.toThrow( + "Invalid params: lastTurnId is in progress", + ); + + expect(requests.map((request) => request.method)).toEqual(["thread/read", "thread/fork"]); + }); + it("clears buffered notifications when thread/fork fails", async () => { const requests: Array<{ method: string; params: Record }> = []; const structuredSession = makeStructuredSession(requests);