diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 56de9555..2d610e7b 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" allowed-tools: Bash(node:*), AskUserQuestion, Agent --- diff --git a/plugins/codex/scripts/app-server-broker.mjs b/plugins/codex/scripts/app-server-broker.mjs index 1954274f..4dce6e0c 100644 --- a/plugins/codex/scripts/app-server-broker.mjs +++ b/plugins/codex/scripts/app-server-broker.mjs @@ -70,8 +70,26 @@ async function main() { let activeStreamSocket = null; let activeStreamThreadIds = null; const sockets = new Set(); + // Requests on one socket can overlap (a client may stop waiting for a slow request + // and issue the next one), so request ownership must be refcounted: releasing the + // slot on the FIRST completion would drop notifications for the still-running + // request and let another socket bypass serialization mid-flight. + const inflightRequests = new Map(); + + function releaseRequestSlot(socket) { + const remaining = (inflightRequests.get(socket) ?? 1) - 1; + if (remaining > 0) { + inflightRequests.set(socket, remaining); + return; + } + inflightRequests.delete(socket); + if (activeRequestSocket === socket) { + activeRequestSocket = null; + } + } function clearSocketOwnership(socket) { + inflightRequests.delete(socket); if (activeRequestSocket === socket) { activeRequestSocket = null; } @@ -196,6 +214,7 @@ async function main() { const isStreaming = STREAMING_METHODS.has(message.method); activeRequestSocket = socket; + inflightRequests.set(socket, (inflightRequests.get(socket) ?? 0) + 1); try { const result = await appClient.request(message.method, message.params ?? {}); @@ -204,20 +223,16 @@ async function main() { activeStreamSocket = socket; activeStreamThreadIds = buildStreamThreadIds(message.method, message.params ?? {}, result); } - if (activeRequestSocket === socket) { - activeRequestSocket = null; - } + releaseRequestSlot(socket); } catch (error) { send(socket, { id: message.id, error: buildJsonRpcError(error.rpcCode ?? -32000, error.message) }); - if (activeRequestSocket === socket) { - activeRequestSocket = null; - } - if (activeStreamSocket === socket && !isStreaming) { - activeStreamSocket = null; - } + releaseRequestSlot(socket); + // Deliberately keep activeStreamSocket: a failed NON-streaming request must not + // strip stream ownership from an in-flight turn on the same socket, or its + // turn/completed notifications are dropped and the client hangs forever. } } }); diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468a..4dbd4a91 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -68,7 +68,7 @@ const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); const REVIEW_SCHEMA = path.join(ROOT_DIR, "schemas", "review-output.schema.json"); const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000; const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; -const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); +const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]); const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; @@ -79,7 +79,7 @@ function printUsage() { " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", @@ -121,7 +121,7 @@ function normalizeReasoningEffort(effort) { } if (!VALID_REASONING_EFFORTS.has(normalized)) { throw new Error( - `Unsupported reasoning effort "${effort}". Use one of: none, minimal, low, medium, high, xhigh.` + `Unsupported reasoning effort "${effort}". Use one of: ${[...VALID_REASONING_EFFORTS].join(", ")}.` ); } return normalized; diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc..d5e64961 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -1092,6 +1092,88 @@ export async function importExternalAgentSession(cwd, options = {}) { }); } +const MODEL_LIST_TIMEOUT_MS = 3000; +const MODEL_LIST_MAX_PAGES = 5; + +function emitEffortValidationSkip(onProgress, effort, reason) { + emitProgress( + onProgress, + `Warning: skipping reasoning-effort validation (${reason}); dispatching effort "${effort}" as requested.`, + "starting" + ); +} + +async function fetchModelCatalog(client) { + // includeHidden: the default model/list only returns picker-visible models, so a + // hidden model passed via --model would otherwise dodge validation. The catalog is + // paginated; follow nextCursor under ONE shared deadline. A partial catalog can only + // cause a fail-open skip for models on unfetched pages — never a false rejection. + const deadline = Date.now() + MODEL_LIST_TIMEOUT_MS; + const entries = []; + let cursor = null; + for (let page = 0; page < MODEL_LIST_MAX_PAGES; page += 1) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + break; + } + const requestPromise = client.request("model/list", cursor ? { includeHidden: true, cursor } : { includeHidden: true }); + requestPromise.catch(() => {}); + let timer; + let response = null; + try { + response = await Promise.race([ + requestPromise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), remaining); + }) + ]); + } catch { + response = null; + } finally { + clearTimeout(timer); + } + if (!response || !Array.isArray(response.data)) { + break; + } + entries.push(...response.data); + cursor = response.nextCursor ?? null; + if (!cursor) { + break; + } + } + return entries.length > 0 ? entries : null; +} + +function assertCatalogSupportsEffort(entries, resolvedModel, effort, onProgress) { + if (!entries) { + emitEffortValidationSkip(onProgress, effort, "model catalog unavailable or malformed"); + return; + } + if (!resolvedModel) { + emitEffortValidationSkip(onProgress, effort, "could not resolve the target model"); + return; + } + const entry = entries.find((item) => item && (item.id === resolvedModel || item.model === resolvedModel)); + if (!entry) { + emitEffortValidationSkip(onProgress, effort, `model "${resolvedModel}" is not in the model catalog`); + return; + } + const supported = Array.isArray(entry.supportedReasoningEfforts) + ? entry.supportedReasoningEfforts + .map((item) => item?.reasoningEffort) + .filter((value) => typeof value === "string" && value) + : []; + if (supported.length === 0) { + emitEffortValidationSkip(onProgress, effort, `model "${resolvedModel}" reports no reasoning-effort catalog`); + return; + } + if (!supported.includes(effort)) { + throw new Error( + `Model "${resolvedModel}" does not support reasoning effort "${effort}". Supported: ${supported.join(", ")}.` + ); + } +} + export async function runAppServerTurn(cwd, options = {}) { const availability = getCodexAvailability(cwd); if (!availability.available) { @@ -1100,8 +1182,21 @@ export async function runAppServerTurn(cwd, options = {}) { return withAppServer(cwd, async (client) => { let threadId; + // Validate only an explicit --model/--effort pair. Without --model the target is + // whatever the server resolves (the config.toml default on fresh dispatches, the + // thread's own model on resume) — the catalog's global isDefault entry is NOT that + // model, so guessing here falsely rejects documented flows. + const shouldValidateEffort = Boolean(options.effort) && Boolean(options.model); + const catalogEntries = shouldValidateEffort ? await fetchModelCatalog(client) : null; if (options.resumeThreadId) { + // Validate the model that turn/start will actually receive, before thread/resume + // has any side effect. The resume response's model is NOT that model (it reports + // the config default / prior thread state), so checking it can both falsely reject + // a valid requested pair and approve an unsupported one. + if (shouldValidateEffort) { + assertCatalogSupportsEffort(catalogEntries, options.model, options.effort, options.onProgress); + } emitProgress(options.onProgress, `Resuming thread ${options.resumeThreadId}.`, "starting"); const response = await resumeThread(client, options.resumeThreadId, cwd, { model: options.model, @@ -1110,6 +1205,9 @@ export async function runAppServerTurn(cwd, options = {}) { }); threadId = response.thread.id; } else { + if (shouldValidateEffort) { + assertCatalogSupportsEffort(catalogEntries, options.model, options.effort, options.onProgress); + } emitProgress(options.onProgress, "Starting Codex task thread.", "starting"); const response = await startThread(client, cwd, { model: options.model, diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 0e91bfb5..9e5e32a7 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -32,7 +32,7 @@ Command selection: - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. - `--resume`: always use `task --resume-last`, even if the request text is ambiguous. - `--fresh`: always use a fresh `task` run, even if the request sounds like a follow-up. -- `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. +- `--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Supported tiers vary by model (for example `max`/`ultra` are 5.6-family tiers); a pair the live model catalog rejects fails before dispatch with that model's supported list. `none` and `minimal` are legacy values that current catalogs do not list for any model — combined with an explicit `--model` they will be rejected the same way. - `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run. Safety rules: diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b0605..92466848 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -104,7 +104,7 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /--background\|--wait/); assert.match(rescue, /--resume\|--fresh/); assert.match(rescue, /--model /); - assert.match(rescue, /--effort /); + assert.match(rescue, /--effort /); assert.match(rescue, /task-resume-candidate --json/); assert.match(rescue, /AskUserQuestion/); assert.match(rescue, /Continue current Codex thread/); @@ -150,7 +150,9 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /Map `spark` to `--model gpt-5\.3-codex-spark`/i); assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); assert.match(runtimeSkill, /Strip it before calling `task`/i); - assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); + assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`/i); + assert.match(runtimeSkill, /Supported tiers vary by model/i); + assert.match(runtimeSkill, /`none` and `minimal` are legacy values/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); assert.match(readme, /`codex:codex-rescue` subagent/i); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0..9a5c403e 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -341,6 +341,12 @@ rl.on("line", (line) => { } case "thread/resume": { + if (BEHAVIOR === "resume-reports-different-model") { + const resumed = ensureThread(state, message.params.threadId); + const reported = message.params.model === "gpt-5.4-mini" ? "gpt-5.6-sol" : "gpt-5.4-mini"; + send({ id: message.id, result: { thread: buildThread(resumed), model: reported, modelProvider: "openai", serviceTier: null, cwd: resumed.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); + break; + } if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) { throw new Error("thread/resume.persistFullHistory requires experimentalApi capability"); } @@ -351,6 +357,43 @@ rl.on("line", (line) => { break; } + case "model/list": { + if (BEHAVIOR === "model-list-unsupported") { + send({ id: message.id, error: { code: -32601, message: "Unsupported method: model/list" } }); + break; + } + if (BEHAVIOR === "model-list-slow-error" || BEHAVIOR === "model-list-slow-error-during-request") { + setTimeout(() => { + send({ id: message.id, error: { code: -32000, message: "model catalog backend timed out" } }); + }, 3500); + break; + } + if (BEHAVIOR === "model-list-malformed") { + send({ id: message.id, result: { data: "not-a-catalog" } }); + break; + } + if (BEHAVIOR === "model-list-paginated") { + const pageEffort = (reasoningEffort) => ({ reasoningEffort, description: reasoningEffort }); + if (message.params && message.params.cursor === "page-2") { + send({ id: message.id, result: { data: [ + { id: "gpt-5.4-mini", model: "gpt-5.4-mini", hidden: true, isDefault: false, defaultReasoningEffort: "medium", supportedReasoningEfforts: ["low", "medium", "high", "xhigh"].map(pageEffort) } + ], nextCursor: null } }); + break; + } + send({ id: message.id, result: { data: [ + { id: "gpt-5.6-sol", model: "gpt-5.6-sol", isDefault: true, defaultReasoningEffort: "medium", supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"].map(pageEffort) } + ], nextCursor: "page-2" } }); + break; + } + const catalogEffort = (reasoningEffort) => ({ reasoningEffort, description: reasoningEffort }); + send({ id: message.id, result: { data: [ + { id: "gpt-5.6-sol", model: "gpt-5.6-sol", isDefault: false, defaultReasoningEffort: "medium", supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"].map(catalogEffort) }, + { id: "gpt-5.4", model: "gpt-5.4", isDefault: true, defaultReasoningEffort: "medium", supportedReasoningEfforts: ["low", "medium", "high", "xhigh"].map(catalogEffort) }, + { id: "gpt-5.4-mini", model: "gpt-5.4-mini", isDefault: false, defaultReasoningEffort: "medium", supportedReasoningEfforts: ["low", "medium", "high", "xhigh"].map(catalogEffort) } + ] } }); + break; + } + case "externalAgentConfig/import": { if (BEHAVIOR === "external-import-unsupported") { send({ id: message.id, error: { code: -32601, message: "Unsupported method: externalAgentConfig/import" } }); @@ -452,6 +495,22 @@ rl.on("line", (line) => { prompt }; saveState(state); + if (BEHAVIOR === "model-list-slow-error-during-request") { + // Keep turn/start pending long enough for the delayed model/list error to land + // mid-flight, then flush the response and every turn event as ONE stdout write + // (one chunk), so the notifications are processed before the microtask that + // resumes the broker's awaited turn/start request. + setTimeout(() => { + const burst = [ + { id: message.id, result: { turn: buildTurn(turnId) } }, + { method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }, + { method: "item/completed", params: { threadId: thread.id, turnId, item: { type: "agentMessage", id: "msg_" + turnId, text: taskPayload(prompt, false), phase: "final_answer" } } }, + { method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } } + ]; + process.stdout.write(burst.map((entry) => JSON.stringify(entry)).join("\\n") + "\\n"); + }, 800); + break; + } send({ id: message.id, result: { turn: buildTurn(turnId) } }); const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict @@ -602,6 +661,9 @@ rl.on("line", (line) => { interruptibleTurns.set(turnId, { threadId: thread.id, timer }); } else if (BEHAVIOR === "slow-task") { emitTurnCompletedLater(thread.id, turnId, items, 400); + } else if (BEHAVIOR === "model-list-slow-error") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + emitTurnCompletedLater(thread.id, turnId, items, 1800); } else { emitTurnCompleted(thread.id, turnId, items); } diff --git a/tests/helpers.mjs b/tests/helpers.mjs index d6981197..41ced54d 100644 --- a/tests/helpers.mjs +++ b/tests/helpers.mjs @@ -18,6 +18,7 @@ export function run(command, args, options = {}) { env: options.env, encoding: "utf8", input: options.input, + timeout: options.timeout, shell: options.shell ?? (process.platform === "win32" && !path.isAbsolute(command)), windowsHide: true }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835..5a91c8a6 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -784,6 +784,290 @@ test("task forwards model selection and reasoning effort to app-server turn/star assert.equal(fakeState.lastTurnStart.effort, "low"); }); +test("task accepts max and ultra reasoning efforts and forwards them to turn/start", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + for (const effort of ["max", "ultra"]) { + const result = run("node", [SCRIPT, "task", "--fresh", "--model", "gpt-5.6-sol", "--effort", effort, "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.model, "gpt-5.6-sol"); + assert.equal(fakeState.lastTurnStart.effort, effort); + } +}); + +test("task rejects an unknown reasoning effort before starting a job", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + + const result = run("node", [SCRIPT, "task", "--effort", "hyperdrive", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Unsupported reasoning effort "hyperdrive"/); + assert.match(result.stderr, /max, ultra/); +}); + +test("task rejects an effort the resolved model does not support before starting a turn", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "--model", "gpt-5.4-mini", "--effort", "ultra", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Model "gpt-5\.4-mini" does not support reasoning effort "ultra"/); + assert.match(result.stderr, /low, medium, high, xhigh/); + if (fs.existsSync(statePath)) { + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart ?? null, null); + assert.equal((fakeState.threads ?? []).length, 0, "no thread may be created for a rejected pair"); + } +}); + +test("task forwards an effort-only dispatch without validating against a guessed default model", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "--fresh", "--effort", "ultra", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.effort, "ultra"); + assert.equal(fakeState.lastTurnStart.model, null, "no guessed model may be injected into turn/start"); +}); + +test("task fails open with a warning when the model catalog is unavailable", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir, "model-list-unsupported"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "--model", "gpt-5.4-mini", "--effort", "ultra", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout + result.stderr, /skipping reasoning-effort validation/); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.effort, "ultra"); +}); + +test("task fails open with a warning when the model catalog is malformed", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir, "model-list-malformed"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "--model", "gpt-5.4-mini", "--effort", "ultra", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout + result.stderr, /skipping reasoning-effort validation/); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.effort, "ultra"); +}); + +test("task --resume-last keeps an explicit effort without validating against the config default", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const firstRun = run("node", [SCRIPT, "task", "--fresh", "--model", "gpt-5.6-sol", "--effort", "ultra", "start the migration"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const resume = run("node", [SCRIPT, "task", "--resume-last", "--effort", "ultra", "keep going"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(resume.status, 0, resume.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.effort, "ultra"); +}); + +test("resume validates the requested model, not the model reported by thread/resume", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir, "resume-reports-different-model"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const firstRun = run("node", [SCRIPT, "task", "--fresh", "--model", "gpt-5.6-sol", "--effort", "ultra", "start the migration"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + // thread/resume reports gpt-5.6-sol here; the requested gpt-5.4-mini is what + // turn/start would receive, so the pair must be rejected before any resume. + const reject = run("node", [SCRIPT, "task", "--resume-last", "--model", "gpt-5.4-mini", "--effort", "ultra", "keep going"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.notEqual(reject.status, 0); + assert.match(reject.stderr, /Model "gpt-5\.4-mini" does not support reasoning effort "ultra"/); + let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.match(fakeState.lastTurnStart.prompt, /start the migration/, "rejected resume must not start a turn"); + + // thread/resume reports gpt-5.4-mini here; the requested gpt-5.6-sol supports + // ultra, so the resume must proceed instead of being falsely rejected. + const approve = run("node", [SCRIPT, "task", "--resume-last", "--model", "gpt-5.6-sol", "--effort", "ultra", "keep going"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(approve.status, 0, approve.stderr); + fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.model, "gpt-5.6-sol"); + assert.equal(fakeState.lastTurnStart.effort, "ultra"); +}); + +test("effort validation finds models on later catalog pages before dispatching", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir, "model-list-paginated"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + // gpt-5.4-mini only appears on the second catalog page; skipping pagination would + // fail open and dispatch the unsupported pair instead of rejecting it. + const result = run("node", [SCRIPT, "task", "--model", "gpt-5.4-mini", "--effort", "ultra", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Model "gpt-5\.4-mini" does not support reasoning effort "ultra"/); + if (fs.existsSync(statePath)) { + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart ?? null, null); + assert.equal((fakeState.threads ?? []).length, 0, "no thread may be created for a rejected pair"); + } +}); + +test("a model/list settling during an in-flight request does not drop turn events or broker serialization", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir, "model-list-slow-error-during-request"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "--fresh", "--model", "gpt-5.6-sol", "--effort", "ultra", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 30000 + }); + + assert.equal(result.status, 0, `companion did not complete (request slot lost?): ${result.stderr}`); + assert.match(result.stdout + result.stderr, /skipping reasoning-effort validation/); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.effort, "ultra"); +}); + +test("a slow-failing model/list does not kill the turn's event stream on the broker transport", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir, "model-list-slow-error"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "--fresh", "--model", "gpt-5.6-sol", "--effort", "ultra", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 30000 + }); + + assert.equal(result.status, 0, `companion did not complete (stream ownership lost?): ${result.stderr}`); + assert.match(result.stdout + result.stderr, /skipping reasoning-effort validation/); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.effort, "ultra"); +}); + +test("task warns and proceeds when the resolved model is not in the catalog", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "--model", "spark", "--effort", "ultra", "diagnose the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout + result.stderr, /is not in the model catalog/); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark"); + assert.equal(fakeState.lastTurnStart.effort, "ultra"); +}); + test("task logs reasoning summaries and assistant messages to the job log", () => { const repo = makeTempDir(); const binDir = makeTempDir();