Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
65 changes: 65 additions & 0 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,64 @@ export async function importExternalAgentSession(cwd, options = {}) {
});
}

const MODEL_LIST_TIMEOUT_MS = 3000;

async function validateReasoningEffortForModel(client, resolvedModel, effort, onProgress) {
const skip = (reason) => {
emitProgress(
onProgress,
`Warning: skipping reasoning-effort validation (${reason}); dispatching effort "${effort}" as requested.`,
"starting"
);
};

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;
let response = null;
try {
response = await Promise.race([
requestPromise,
new Promise((resolve) => {
timer = setTimeout(() => resolve(null), MODEL_LIST_TIMEOUT_MS);
})
]);
} catch {
response = null;
} finally {
clearTimeout(timer);
}

const entries = Array.isArray(response?.data) ? response.data : null;
if (!entries) {
skip("model catalog unavailable or malformed");
return;
}
if (!resolvedModel) {
skip("app-server did not report a resolved model");
return;
}
const entry = entries.find((item) => item && (item.id === resolvedModel || item.model === resolvedModel));
if (!entry) {
skip(`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) {
skip(`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 +1158,7 @@ export async function runAppServerTurn(cwd, options = {}) {

return withAppServer(cwd, async (client) => {
let threadId;
let resolvedModel = null;

if (options.resumeThreadId) {
emitProgress(options.onProgress, `Resuming thread ${options.resumeThreadId}.`, "starting");
Expand All @@ -1109,6 +1168,7 @@ export async function runAppServerTurn(cwd, options = {}) {
ephemeral: false
});
threadId = response.thread.id;
resolvedModel = response.model ?? null;
} else {
emitProgress(options.onProgress, "Starting Codex task thread.", "starting");
const response = await startThread(client, cwd, {
Expand All @@ -1118,6 +1178,7 @@ export async function runAppServerTurn(cwd, options = {}) {
threadName: options.persistThread ? options.threadName : options.threadName ?? null
});
threadId = response.thread.id;
resolvedModel = response.model ?? null;
}

emitProgress(options.onProgress, `Thread ready (${threadId}).`, "starting", {
Expand All @@ -1129,6 +1190,10 @@ export async function runAppServerTurn(cwd, options = {}) {
throw new Error("A prompt is required for this Codex run.");
}

if (options.effort) {
await validateReasoningEffortForModel(client, resolvedModel, options.effort, options.onProgress);
}

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 Avoid recording failed pre-turn threads as resumable

For an unsupported model/effort pair, this validation runs after startThread has emitted Thread ready, so the progress updater records a threadId; when the validation throws before any turn/start, runTrackedJob stores a failed task with that threadId, and findLatestResumableTaskJob treats any non-running task with a threadId as resumable. After this failure, the next --resume/rescue continuation can resume the empty validation-failed thread instead of the last useful task; validate before creating the persistent thread or avoid marking pre-turn validation failures as resumable.

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 — findLatestResumableTaskJob only requires threadId + non-active status, so a validation-failed job with a recorded threadId would have hijacked the next --resume. Fixed in ef1d739 by moving the check before thread creation on the fresh path: effort-only dispatches resolve the target from the catalog's isDefault entry, so a rejected pair now creates no thread at all (also no orphan ephemeral thread server-side), and the rejection tests assert threads.length === 0 in the fake state. Resume dispatches validate the resumed thread's reported model against the same single model/list fetch — a failure there leaves the pre-existing thread as the resume candidate, which is the correct target for that path.


const turnState = await captureTurn(
client,
threadId,
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`.
- `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
4 changes: 2 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,7 @@ 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, /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
150 changes: 150 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,156 @@ 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);
}
});

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);
}
});

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