Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
33 changes: 24 additions & 9 deletions plugins/codex/scripts/app-server-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 ?? {});
Expand All @@ -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.
}
}
});
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
98 changes: 98 additions & 0 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")}.`
);
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,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);

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-only resumes against the resumed model

For task --resume/--resume-last --effort ultra without --model, shouldValidateEffort is false, so the resume path skips the catalog check even though thread/resume returns the resolved model before turn/start. Resuming an older gpt-5.4/mini thread with max or ultra will therefore send an unsupported effort to turn/start, reintroducing the same pre-turn hang/error this validation was added to avoid; the fresh-dispatch config-default case needs special handling, but the resumed model is available here.

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.

Checked against the live app-server (codex-cli 0.142.4) before acting, because this hinges on what thread/resume actually reports. Probe: started a thread on gpt-5.4-mini (config default is a 5.6-family model), ran one turn, then thread/resume with model: null:

RESUME model:null -> response.model = "gpt-5.6-sol"   // config default
(thread ran on gpt-5.4-mini)

thread/resume reports the config default, not the model the thread runs on — the test fixture models this correctly. Validating an effort-only resume against response.model therefore falsely rejects the headline flow (dispatch --model gpt-5.6-sol --effort ultra, then --resume-last --effort ultra fails with Model "gpt-5.4" does not support… whenever the config default is older) — that exact regression was reproduced end-to-end during review of ef1d739 and is pinned by a test.

Since the protocol does not expose the resumed thread's own model pre-turn, there is nothing correct to validate against here; the residual risk (resuming an old small-model thread with max/ultra) is unchanged from pre-PR behavior and belongs to the server-side validation gap tracked in openai/codex#31552. Leaving the resume path as explicit-pair-only.

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,
Expand All @@ -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,
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. `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:
Expand Down
6 changes: 4 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,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);
Expand Down
62 changes: 62 additions & 0 deletions tests/fake-codex-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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" } });
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions tests/helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down
Loading