Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/codex/commands/rescue.md
Original file line number Diff line number Diff line change
@@ -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 <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [what Codex should investigate, solve, or continue]"
argument-hint: "[--background|--wait] [--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh|max|ultra>] [what Codex should investigate, solve, or continue]"
allowed-tools: Bash(node:*), AskUserQuestion, Agent
---

Expand Down
6 changes: 3 additions & 3 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.";

Expand All @@ -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 <ref>] [--scope <auto|working-tree|branch>]",
" node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [focus text]",
" node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
" node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh|max|ultra>] [prompt]",
" node scripts/codex-companion.mjs transfer [--source <claude-jsonl>] [--json]",
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
" node scripts/codex-companion.mjs result [job-id] [--json]",
Expand Down Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,70 @@ export async function importExternalAgentSession(cwd, options = {}) {
});
}

const MODEL_LIST_TIMEOUT_MS = 3000;

function emitEffortValidationSkip(onProgress, effort, reason) {
emitProgress(
onProgress,
`Warning: skipping reasoning-effort validation (${reason}); dispatching effort "${effort}" as requested.`,
"starting"
);
}

async function fetchModelCatalog(client) {
const requestPromise = client.request("model/list", {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fetch the full model catalog before validating efforts

When validating an explicit --model/--effort pair, this calls model/list with default params. The Codex app-server docs show model/list returns picker-visible models by default and says to set includeHidden: true for the full list; the response is also paginated with nextCursor (https://developers.openai.com/codex/app-server). For a user who passes a hidden or later-page model with an unsupported effort, we skip validation as “not in the model catalog” and still send turn/start, which reintroduces the unsupported-pair hang this change is trying to prevent. Please request the full catalog and page until the requested model is found or the catalog is exhausted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed against the live app-server before changing anything: default model/list returns 6 of 7 models (the hidden codex-auto-review entry is absent), includeHidden: true returns all 7, and nextCursor is null in every case today (pageSize hints are ignored) — so the hidden-model hole is real now and pagination is future-proofing. Fixed in the latest commit: fetchModelCatalog sends includeHidden: true and follows nextCursor up to 5 pages under the same single 3s deadline; a partial catalog can only fail open (skip + warn) for models on unfetched pages, never falsely reject. Regression test drives a paginated fixture catalog with the target model on page two — red on the parent commit, green now. Suite 103/103.

requestPromise.catch(() => {});
let timer;
try {
const response = await Promise.race([
requestPromise,
new Promise((resolve) => {
timer = setTimeout(() => resolve(null), MODEL_LIST_TIMEOUT_MS);
})
]);
return Array.isArray(response?.data) ? response.data : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}

function catalogDefaultModel(entries) {
const entry = Array.isArray(entries) ? entries.find((item) => item && item.isDefault) : null;
return entry ? entry.id ?? entry.model ?? null : 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(", ")}.`
);
Comment on lines +1170 to +1173

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve documented non-catalog effort values

When model/list is available, this catalog check rejects --effort none and --effort minimal for the current OpenAI model entries because their supportedReasoningEfforts list starts at low, while this same change still accepts and advertises none/minimal in normalizeReasoningEffort and the task usage. Those values were valid plugin inputs before this commit, so users selecting a documented effort now fail before turn/start; either exempt these legacy values from the per-model catalog check or remove them from the accepted set/docs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ef1d739, though not via either suggested option — both have a sharper edge than the mismatch they fix:

What changed: the values stay in the syntactic allowlist, the catalog check still applies to them (a rejected pair fails with that model's real tier list, which is strictly more actionable than the pre-PR dispatch-and-hang), and the runtime-skill doc now states that supported tiers vary by model and catalog-rejected pairs fail before dispatch.

}
}

export async function runAppServerTurn(cwd, options = {}) {
const availability = getCodexAvailability(cwd);
if (!availability.available) {
Expand All @@ -1100,6 +1164,7 @@ export async function runAppServerTurn(cwd, options = {}) {

return withAppServer(cwd, async (client) => {
let threadId;
const catalogEntries = options.effort ? await fetchModelCatalog(client) : null;

if (options.resumeThreadId) {
emitProgress(options.onProgress, `Resuming thread ${options.resumeThreadId}.`, "starting");
Expand All @@ -1109,7 +1174,14 @@ export async function runAppServerTurn(cwd, options = {}) {
ephemeral: false
});
threadId = response.thread.id;
if (options.effort) {
assertCatalogSupportsEffort(catalogEntries, response.model ?? null, options.effort, options.onProgress);
}
} else {
if (options.effort) {
const targetModel = options.model ?? catalogDefaultModel(catalogEntries);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate effort against the configured default model

When a user relies on a user/project config.toml default model and supplies only --effort, this chooses the catalog's global isDefault entry instead of the model that thread/start would resolve from config (the start request still passes model: null). For a configured default such as a 5.6-family model that supports ultra, task --effort ultra is rejected against the global default before Codex can apply the config, breaking the documented config-default flow; start the thread first and validate the returned model, or read the configured default before this check.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — the catalog's global isDefault entry is not the model thread/start resolves from config.toml, so effort-only dispatches could be falsely rejected. Fixed in f7f47ee by narrowing scope instead of adding a second resolution mechanism: validation now applies only when BOTH --model and --effort are explicit. Effort-only dispatches pass through unvalidated — exactly the pre-PR behavior for that path — and catalogDefaultModel() is removed. The same explicit-pair rule covers resume (resuming without --model continues on the thread's own model, which the config default also misrepresents).

Independent review of the previous commit also surfaced a broker-transport hazard armed by the 3s validation timeout: a model/list that fails after the client stopped waiting hit the broker's generic catch, which cleared activeStreamSocket for the in-flight turn on the same socket — its turn/completed was then dropped and the client hung indefinitely, for fully supported pairs. Same commit removes that clearing for non-streaming failures (streaming-failure behavior unchanged) and adds a model-list-slow-error fixture + no-hang regression test.

Three regression tests were verified red on ef1d739 before the fixes; suite is 100/100.

assertCatalogSupportsEffort(catalogEntries, targetModel, options.effort, options.onProgress);
}
emitProgress(options.onProgress, "Starting Codex task thread.", "starting");
const response = await startThread(client, cwd, {
model: options.model,
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/skills/codex-cli-runtime/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- `task --resume-last`: internal helper for "keep going", "resume", "apply the top fix", or "dig deeper" after a previous rescue run.

Safety rules:
Expand Down
5 changes: 3 additions & 2 deletions tests/commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ test("rescue command absorbs continue semantics", () => {
assert.match(rescue, /--background\|--wait/);
assert.match(rescue, /--resume\|--fresh/);
assert.match(rescue, /--model <model\|spark>/);
assert.match(rescue, /--effort <none\|minimal\|low\|medium\|high\|xhigh>/);
assert.match(rescue, /--effort <none\|minimal\|low\|medium\|high\|xhigh\|max\|ultra>/);
assert.match(rescue, /task-resume-candidate --json/);
assert.match(rescue, /AskUserQuestion/);
assert.match(rescue, /Continue current Codex thread/);
Expand Down Expand Up @@ -150,7 +150,8 @@ 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, /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);
Expand Down
18 changes: 18 additions & 0 deletions tests/fake-codex-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,24 @@ 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-malformed") {
send({ id: message.id, result: { data: "not-a-catalog" } });
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" } });
Expand Down
152 changes: 152 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,158 @@ 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 validates an effort-only dispatch against the resolved 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", "--effort", "ultra", "diagnose the failing test"], {
cwd: repo,
env: buildEnv(binDir)
});

assert.notEqual(result.status, 0);
assert.match(result.stderr, /Model "gpt-5\.4" 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("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 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();
Expand Down