diff --git a/src/backend/codex-stdio.ts b/src/backend/codex-stdio.ts index 5a81331..860fd15 100644 --- a/src/backend/codex-stdio.ts +++ b/src/backend/codex-stdio.ts @@ -311,13 +311,13 @@ export async function runCodexStdioTurn(options: CodexStdioTurnOptions): Promise const threadMethod = options.threadId ? "thread/resume" : "thread/start"; const threadParams: JsonRecord = options.threadId - ? { threadId: options.threadId, model: options.model ?? "default", cwd: options.cwd, approvalPolicy: options.approvalPolicy, sandbox: options.sandbox } - : { model: options.model ?? "default", cwd: options.cwd, approvalPolicy: options.approvalPolicy, sandbox: options.sandbox, serviceName: "agentrun" }; + ? withOptionalModel({ threadId: options.threadId, cwd: options.cwd, approvalPolicy: options.approvalPolicy, sandbox: options.sandbox }, options.model) + : withOptionalModel({ cwd: options.cwd, approvalPolicy: options.approvalPolicy, sandbox: options.sandbox, serviceName: "agentrun" }, options.model); const threadResponse = requireResponseRecord(await client.request(threadMethod, threadParams, requestTimeoutMs), threadMethod); threadId = requireNestedId(threadResponse, threadMethod, "thread"); events.push({ type: "backend_status", payload: { phase: `${threadMethod}:completed`, threadId } }); - const turnResponse = requireResponseRecord(await client.request("turn/start", { threadId, input: textInput(options.prompt), cwd: options.cwd, approvalPolicy: options.approvalPolicy, model: options.model ?? "default" }, requestTimeoutMs), "turn/start"); + const turnResponse = requireResponseRecord(await client.request("turn/start", withOptionalModel({ threadId, input: textInput(options.prompt), cwd: options.cwd, approvalPolicy: options.approvalPolicy }, options.model), requestTimeoutMs), "turn/start"); turnId = requireNestedId(turnResponse, "turn/start", "turn"); events.push({ type: "backend_status", payload: { phase: "turn/start:completed", turnId } }); @@ -450,6 +450,12 @@ function terminalStatusFromValue(value: unknown): TerminalStatus { return "failed"; } +function withOptionalModel(params: JsonRecord, model: string | undefined): JsonRecord { + const value = typeof model === "string" ? model.trim() : ""; + if (!value) return params; + return { ...params, model: value }; +} + function childEnv(options: CodexStdioTurnOptions, codexHome: string): NodeJS.ProcessEnv { return { ...process.env, diff --git a/src/selftest/cases/30-codex-stdio.ts b/src/selftest/cases/30-codex-stdio.ts index 9e678dd..d759d6b 100644 --- a/src/selftest/cases/30-codex-stdio.ts +++ b/src/selftest/cases/30-codex-stdio.ts @@ -33,6 +33,15 @@ const selfTest: SelfTestCase = async (context) => { await access(path.join(projectedHome, "auth.json")); await access(path.join(projectedHome, "config.toml")); + const configModel = await createRunWithCommand(client, context, "hello config model", "selftest-config-model", 15_000); + const configModelResult = await runOnce({ managerUrl: server.baseUrl, runId: configModel.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_FAKE_CODEX_MODE: "reject-unexpected-model" } }); + assert.equal(configModelResult.terminalStatus, "completed", "unspecified model should be omitted so Codex config.toml remains authoritative"); + + const explicitModel = await createRunWithCommand(client, context, "hello explicit model placeholder", "selftest-explicit-model-placeholder", 15_000); + const explicitCommand = await client.post(`/api/v1/runs/${explicitModel.runId}/commands`, { type: "turn", payload: { prompt: "hello explicit model", model: "gpt-5.5" }, idempotencyKey: "selftest-explicit-model-command" }) as { id: string }; + const explicitModelResult = await runOnce({ managerUrl: server.baseUrl, runId: explicitModel.runId, commandId: explicitCommand.id, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_FAKE_CODEX_MODE: "require-explicit-model" } }); + assert.equal(explicitModelResult.terminalStatus, "completed", "explicit command payload model should still be forwarded"); + await runFailureCase({ client, managerUrl: server.baseUrl, context, mode: "missing-turn-result", expectedStatus: "failed", expectedFailureKind: "backend-response-invalid" }); await runFailureCase({ client, managerUrl: server.baseUrl, context, mode: "provider-503-rpc-error", expectedStatus: "failed", expectedFailureKind: "provider-unavailable" }); await runFailureCase({ client, managerUrl: server.baseUrl, context, mode: "provider-503-terminal", expectedStatus: "failed", expectedFailureKind: "provider-unavailable" }); @@ -41,7 +50,7 @@ const selfTest: SelfTestCase = async (context) => { await runFailureCase({ client, managerUrl: server.baseUrl, context, mode: "missing-terminal", expectedStatus: "failed", expectedFailureKind: "backend-timeout", timeoutMs: 500 }); await runSpawnFailureCase({ client, managerUrl: server.baseUrl, context }); - return { name: "codex-stdio", tests: ["runner-lease-heartbeat", "codex-stdio-fake-turn", "codex-stdio-projected-writable-home", "codex-stdio-missing-turn-result", "codex-stdio-provider-503-rpc-error", "codex-stdio-provider-503-terminal", "codex-stdio-provider-503-retry-event", "codex-stdio-invalid-json", "codex-stdio-timeout", "codex-stdio-spawn-failure"] }; + return { name: "codex-stdio", tests: ["runner-lease-heartbeat", "codex-stdio-fake-turn", "codex-stdio-projected-writable-home", "codex-stdio-config-model-authoritative", "codex-stdio-explicit-model-forwarded", "codex-stdio-missing-turn-result", "codex-stdio-provider-503-rpc-error", "codex-stdio-provider-503-terminal", "codex-stdio-provider-503-retry-event", "codex-stdio-invalid-json", "codex-stdio-timeout", "codex-stdio-spawn-failure"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } diff --git a/src/selftest/fake-codex-app-server.ts b/src/selftest/fake-codex-app-server.ts index 1156ae1..e5ef69d 100644 --- a/src/selftest/fake-codex-app-server.ts +++ b/src/selftest/fake-codex-app-server.ts @@ -4,6 +4,7 @@ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity const mode = process.env.AGENTRUN_FAKE_CODEX_MODE ?? "success"; let threadCounter = 0; let turnCounter = 0; +let observedThreadModel = false; for await (const line of rl) { const trimmed = String(line).trim(); @@ -18,6 +19,15 @@ for await (const line of rl) { continue; } if (message.method === "thread/start") { + observedThreadModel = Object.hasOwn(message.params ?? {}, "model"); + if (mode === "reject-unexpected-model" && observedThreadModel) { + respond(message.id, null, { code: -32000, message: "thread/start unexpectedly included model" }); + continue; + } + if (mode === "require-explicit-model" && message.params?.model !== "gpt-5.5") { + respond(message.id, null, { code: -32000, message: "thread/start did not include expected model" }); + continue; + } threadCounter += 1; const thread = { id: `thread_selftest_${threadCounter}` }; notify("thread/started", { thread }); @@ -25,12 +35,29 @@ for await (const line of rl) { continue; } if (message.method === "thread/resume") { + observedThreadModel = Object.hasOwn(message.params ?? {}, "model"); + if (mode === "reject-unexpected-model" && observedThreadModel) { + respond(message.id, null, { code: -32000, message: "thread/resume unexpectedly included model" }); + continue; + } + if (mode === "require-explicit-model" && message.params?.model !== "gpt-5.5") { + respond(message.id, null, { code: -32000, message: "thread/resume did not include expected model" }); + continue; + } const thread = { id: String(message.params?.threadId ?? "thread_selftest_resumed") }; notify("thread/started", { thread }); respond(message.id, { thread }); continue; } if (message.method === "turn/start") { + if (mode === "reject-unexpected-model" && (observedThreadModel || Object.hasOwn(message.params ?? {}, "model"))) { + respond(message.id, null, { code: -32000, message: "turn/start unexpectedly included model" }); + continue; + } + if (mode === "require-explicit-model" && message.params?.model !== "gpt-5.5") { + respond(message.id, null, { code: -32000, message: "turn/start did not include expected model" }); + continue; + } if (mode === "missing-turn-result") { respond(message.id, {}); continue;