diff --git a/agents/Codex.md b/agents/Codex.md index 2ff4e19..c0b10c6 100644 --- a/agents/Codex.md +++ b/agents/Codex.md @@ -4,4 +4,5 @@ - Default command: `npx @zed-industries/codex-acp` - Upstream: https://github.com/zed-industries/codex-acp - Runtime config options exposed by current codex-acp releases include `mode`, `model`, and `reasoning_effort`. +- `acpx --model codex ...` applies the requested model after session creation via `session/set_config_option`. - `acpx codex set thought_level ` is supported as a compatibility alias and is translated to codex-acp `reasoning_effort`. diff --git a/skills/acpx/SKILL.md b/skills/acpx/SKILL.md index beaab92..843b778 100644 --- a/skills/acpx/SKILL.md +++ b/skills/acpx/SKILL.md @@ -153,6 +153,7 @@ Behavior: - `set-mode`: calls ACP `session/set_mode`. - `set-mode` mode ids are adapter-defined; unsupported values are rejected by the adapter (often `Invalid params`). - `set`: calls ACP `session/set_config_option`. +- For codex, `--model ` is applied after session creation via `session/set_config_option`. - For codex, `thought_level` is accepted as a compatibility alias for codex-acp `reasoning_effort`. - `set-mode`/`set` route through queue-owner IPC when active, otherwise reconnect directly. diff --git a/src/cli-core.ts b/src/cli-core.ts index 227201f..c38ec58 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -29,6 +29,7 @@ import { } from "./cli/flags.js"; import { emitJsonResult } from "./cli/json-output.js"; import { registerStatusCommand } from "./cli/status-command.js"; +import { isCodexInvocation } from "./codex-compat.js"; import { loadResolvedConfig, type ResolvedAcpxConfig } from "./config.js"; import { exitCodeForOutputErrorCode, @@ -154,23 +155,15 @@ function applyPermissionExitCode(result: { } } -function isCodexAgentInvocation(agent: { agentName: string; agentCommand: string }): boolean { - if (agent.agentName === "codex") { - return true; - } - return /\bcodex-acp\b/.test(agent.agentCommand); -} - function resolveCompatibleConfigId( agent: { agentName: string; agentCommand: string }, configId: string, ): string { - if (isCodexAgentInvocation(agent) && configId === "thought_level") { + if (isCodexInvocation(agent.agentName, agent.agentCommand) && configId === "thought_level") { return "reasoning_effort"; } return configId; } - export { parseAllowedTools, parseMaxTurns, parseTtlSeconds }; export { formatPromptSessionBannerLine } from "./cli/output-render.js"; diff --git a/src/client.ts b/src/client.ts index e78fc3d..5307625 100644 --- a/src/client.ts +++ b/src/client.ts @@ -30,6 +30,7 @@ import { } from "@agentclientprotocol/sdk"; import { extractAcpError } from "./acp-error-shapes.js"; import { isSessionUpdateNotification } from "./acp-jsonrpc.js"; +import { isCodexAcpCommand } from "./codex-compat.js"; import { AgentSpawnError, AuthPolicyError, @@ -1170,6 +1171,7 @@ export class AcpClient { const connection = this.getConnection(); const { command, args } = splitCommandLine(this.options.agentCommand); const claudeAcp = isClaudeAcpCommand(command, args); + const codexAcp = isCodexAcpCommand(command, args); let result: Awaited>; try { @@ -1192,6 +1194,18 @@ export class AcpClient { } this.loadedSessionId = result.sessionId; + if ( + codexAcp && + typeof this.options.sessionOptions?.model === "string" && + this.options.sessionOptions.model.trim().length > 0 + ) { + await this.setSessionConfigOption( + result.sessionId, + "model", + this.options.sessionOptions.model, + ); + } + return { sessionId: result.sessionId, agentSessionId: extractRuntimeSessionId(result._meta), diff --git a/src/codex-compat.ts b/src/codex-compat.ts new file mode 100644 index 0000000..44bd3bf --- /dev/null +++ b/src/codex-compat.ts @@ -0,0 +1,24 @@ +import path from "node:path"; + +function basenameToken(value: string): string { + return path + .basename(value) + .toLowerCase() + .replace(/\.(cmd|exe|bat)$/u, ""); +} + +export function isCodexAcpCommand(command: string, args: readonly string[]): boolean { + const commandToken = basenameToken(command); + if (commandToken === "codex-acp") { + return true; + } + return args.some((arg) => arg.includes("codex-acp")); +} + +export function isCodexInvocation(agentName: string, agentCommand: string): boolean { + if (agentName === "codex") { + return true; + } + + return /\bcodex-acp\b/u.test(agentCommand); +} diff --git a/test/cli.test.ts b/test/cli.test.ts index b214109..0608581 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -834,6 +834,59 @@ test("codex thought_level aliases to reasoning_effort", async () => { }); }); +test("codex set model passes the requested model through unchanged", async () => { + await withTempHome(async (homeDir) => { + const cwd = path.join(homeDir, "workspace"); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true }); + await fs.writeFile( + path.join(homeDir, ".acpx", "config.json"), + `${JSON.stringify( + { + agents: { + codex: { + command: MOCK_AGENT_COMMAND, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const sessionId = "codex-model-alias"; + await writeSessionRecord(homeDir, { + acpxRecordId: sessionId, + acpSessionId: sessionId, + agentCommand: MOCK_AGENT_COMMAND, + cwd, + createdAt: "2026-01-01T00:00:00.000Z", + lastUsedAt: "2026-01-01T00:00:00.000Z", + closed: false, + }); + + const result = await runCli( + ["--cwd", cwd, "--format", "json", "codex", "set", "model", "GPT-5-2"], + homeDir, + ); + assert.equal(result.code, 0, result.stderr); + + const payload = JSON.parse(result.stdout.trim()) as { + action?: string; + configId?: string; + value?: string; + configOptions?: Array<{ id?: string; currentValue?: string; category?: string }>; + }; + assert.equal(payload.action, "config_set"); + assert.equal(payload.configId, "model"); + assert.equal(payload.value, "GPT-5-2"); + const model = payload.configOptions?.find((option) => option.id === "model"); + assert.equal(model?.currentValue, "GPT-5-2"); + assert.equal(model?.category, "model"); + }); +}); + test("set-mode load fallback failure does not persist the fresh session id to disk", async () => { await withTempHome(async (homeDir) => { const cwd = path.join(homeDir, "workspace"); diff --git a/test/client.test.ts b/test/client.test.ts index b8ca8a6..74e2af6 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -408,6 +408,57 @@ test("AcpClient createSession forwards claudeCode options in _meta", async () => }); }); +test("AcpClient createSession applies codex model via session/set_config_option", async () => { + const client = makeClient({ + agentCommand: "npx @zed-industries/codex-acp", + sessionOptions: { + model: "GPT-5-2", + }, + }); + + let capturedNewSessionParams: Record | undefined; + let capturedSetConfigParams: + | { + sessionId: string; + configId: string; + value: string; + } + | undefined; + asInternals(client).connection = { + newSession: async (params: Record) => { + capturedNewSessionParams = params; + return { sessionId: "session-456" }; + }, + setSessionConfigOption: async (params: { + sessionId: string; + configId: string; + value: string; + }) => { + capturedSetConfigParams = params; + return { configOptions: [] }; + }, + }; + + const result = await client.createSession("/tmp/acpx-client-codex-model"); + assert.equal(result.sessionId, "session-456"); + assert.deepEqual(capturedNewSessionParams, { + cwd: "/tmp/acpx-client-codex-model", + mcpServers: [], + _meta: { + claudeCode: { + options: { + model: "GPT-5-2", + }, + }, + }, + }); + assert.deepEqual(capturedSetConfigParams, { + sessionId: "session-456", + configId: "model", + value: "GPT-5-2", + }); +}); + test("AcpClient session update handling drains queued callbacks and swallows handler failures", async () => { const notifications: string[] = []; const client = makeClient({ diff --git a/test/mock-agent.ts b/test/mock-agent.ts index 8896532..3c909e8 100644 --- a/test/mock-agent.ts +++ b/test/mock-agent.ts @@ -406,6 +406,8 @@ function buildConfigOptions(state: SessionState): SetSessionConfigOptionResponse typeof state.configValues.reasoning_effort === "string" ? state.configValues.reasoning_effort : "medium"; + const modelId = + typeof state.configValues.model === "string" ? state.configValues.model : "default"; return [ { @@ -422,6 +424,18 @@ function buildConfigOptions(state: SessionState): SetSessionConfigOptionResponse { value: "default", name: "Default" }, ], }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: modelId, + options: [ + { value: "default", name: "Default" }, + { value: "gpt-5.4", name: "gpt-5.4" }, + { value: "gpt-5.2", name: "gpt-5.2" }, + ], + }, { id: "reasoning_effort", name: "Reasoning Effort", diff --git a/test/prompt-runner.test.ts b/test/prompt-runner.test.ts index 5413841..d120452 100644 --- a/test/prompt-runner.test.ts +++ b/test/prompt-runner.test.ts @@ -121,6 +121,27 @@ test("runSessionSetConfigOptionDirect falls back to createSession and returns up }, ], }, + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "default", + options: [ + { + value: "default", + name: "Default", + }, + { + value: "gpt-5.4", + name: "gpt-5.4", + }, + { + value: "gpt-5.2", + name: "gpt-5.2", + }, + ], + }, { id: "reasoning_effort", name: "Reasoning Effort",