diff --git a/README.md b/README.md index 937a3037b..98c5bd2a5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ they already have. - `/codex:review` for a normal read-only Codex review - `/codex:adversarial-review` for a steerable challenge review -- `/codex:rescue`, `/codex:transfer`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work, hand off sessions, and manage background jobs +- `/codex:rescue`, `/codex:expert`, `/codex:transfer`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work, request a user-approved expert pass, hand off sessions, and manage background jobs ## Requirements @@ -158,10 +158,16 @@ Ask Codex to redesign the database connection to be more resilient. **Notes:** -- if you do not pass `--model` or `--effort`, Codex chooses its own defaults. +- for a fresh rescue task, if you do not pass `--model` or `--effort`, the companion explicitly starts `gpt-5.6-luna` at `max` effort; resumed tasks preserve their existing thread routing. - if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark` - follow-up rescue requests can continue the latest Codex task in the repo +### `/codex:expert` + +The active model can invoke this command when it needs a stronger bounded pass, reaches the Luna Max effort ceiling, or encounters an ambiguous/high-risk decision. The command always asks the user to choose the expert model and reasoning effort before it starts anything; the default is Sol at High effort. + +The selected expert runs in a separate persistent, named Codex thread. Its final answer and session ID are returned to the active conversation so the model can continue with the expert's evidence. + ### `/codex:transfer` Creates a persistent Codex thread from the current Claude Code session and prints a `codex resume ` command. diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 7009ec86a..60e6b3379 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -26,8 +26,9 @@ Forwarding rules: - Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work beyond shaping the forwarded prompt text. - 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. - Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`. This subagent only forwards to `task`. -- Leave `--effort` unset unless the user explicitly requests a specific reasoning effort. -- Leave model unset by default. Only add `--model` when the user explicitly asks for a specific model. +- Leave `--effort` unset unless the user explicitly requests a specific reasoning effort; the runtime defaults fresh bounded tasks to Luna at Max effort. +- Leave model unset unless the user explicitly asks for a specific model; the runtime defaults fresh bounded tasks to `gpt-5.6-luna`. +- Do not add these defaults when continuing a previous task; the runtime preserves the existing thread's routing on resume. - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. diff --git a/plugins/codex/commands/expert.md b/plugins/codex/commands/expert.md new file mode 100644 index 000000000..f7b7ce32b --- /dev/null +++ b/plugins/codex/commands/expert.md @@ -0,0 +1,29 @@ +--- +description: Automatically ask a user-selected named Codex expert when the current model needs a stronger bounded pass +argument-hint: "[--name ] [--write] [handoff]" +allowed-tools: Bash(node:*), AskUserQuestion +--- + +Use the companion expert broker for a deliberate, user-approved escalation when the current model has reached its supported effort ceiling, has failed a bounded task, or identifies an ambiguous/high-risk decision that needs a stronger pass. This command is safe for model invocation because it always pauses for the user's model-and-effort choice before starting an expert, even when raw arguments contain routing flags. + +Raw handoff request: +`$ARGUMENTS` + +Use `AskUserQuestion` exactly once with these two questions: + +1. Which expert model should handle the handoff? + - `Sol (Recommended)` — map to `gpt-5.6-sol` and use it as the default expert. + - `Terra` — map to `gpt-5.6-terra` for a balanced expert pass. + - `Luna` — map to `gpt-5.6-luna` for a lower-cost expert pass. +2. How much reasoning effort should the expert use? + - `High (Recommended)` — the default Sol setting. + - `XHigh` — spend more effort on a difficult bounded problem. + - `Max` — use the maximum effort supported by this harness. + +After the choice, invoke exactly one foreground command, adding the selected `--model` and `--effort` values after the raw handoff arguments so the user's choice is authoritative: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" expert "$ARGUMENTS" --model --effort +``` + +Return the command's stdout verbatim. The selected model and effort are authoritative even if raw arguments contained routing flags. Do not silently change the selected model or effort, create a second expert, or modify the handoff text. The command creates a persistent named Codex thread and returns its session ID with the expert's final answer. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index 56de9555d..618587c3f 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 --- @@ -42,8 +42,9 @@ Operating rules: - Return the Codex companion stdout verbatim to the user. - Do not paraphrase, summarize, rewrite, or add commentary before or after it. - Do not ask the subagent to inspect files, monitor progress, poll `/codex:status`, fetch `/codex:result`, call `/codex:cancel`, summarize output, or do follow-up work of its own. -- Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort. -- Leave the model unset unless the user explicitly asks for one. If they ask for `spark`, map it to `gpt-5.3-codex-spark`. +- For a fresh bounded task, leave model and effort unset unless the user explicitly chooses them; the runtime then passes `--model gpt-5.6-luna --effort max` explicitly. +- When continuing a previous task, leave model and effort unset so the runtime preserves that thread's routing. +- If the user asks for `spark`, pass `--model gpt-5.3-codex-spark` to the runtime. - Leave `--resume` and `--fresh` in the forwarded request. The subagent handles that routing when it builds the `task` command. - If the helper reports that Codex is missing or unauthenticated, stop and tell the user to run `/codex:setup`. - If the user did not supply a request, ask what Codex should investigate or fix. diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..d30241942 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { parseArgs, splitRawArgumentString } from "./lib/args.mjs"; import { + buildExpertSelectionOffer, buildPersistentTaskThreadName, DEFAULT_CONTINUE_PROMPT, findLatestTaskThread, @@ -19,6 +20,7 @@ import { parseStructuredOutput, readOutputSchema, runAppServerReview, + runExpertHandoff, runAppServerTurn } from "./lib/codex.mjs"; import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs"; @@ -58,6 +60,8 @@ import { renderReviewResult, renderStoredJobResult, renderCancelReport, + renderExpertResult, + renderExpertSelectionOffer, renderJobStatusReport, renderSetupReport, renderStatusReport, @@ -68,7 +72,9 @@ 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 DEFAULT_TASK_MODEL = "gpt-5.6-luna"; +const DEFAULT_TASK_EFFORT = "max"; +const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]); 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 +85,8 @@ 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 expert [--name ] [--write] [--model ] [--effort ] [handoff]", " 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,21 +128,37 @@ 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: none, minimal, low, medium, high, xhigh, max.` ); } return normalized; } +function resolveTaskRouting(options, resumeLast) { + const model = normalizeRequestedModel(options.model); + const effort = normalizeReasoningEffort(options.effort); + + if (resumeLast) { + return { model, effort }; + } + + return { + model: model ?? DEFAULT_TASK_MODEL, + effort: effort ?? DEFAULT_TASK_EFFORT + }; +} + function normalizeArgv(argv) { - if (argv.length === 1) { - const [raw] = argv; - if (!raw || !raw.trim()) { - return []; - } - return splitRawArgumentString(raw); + if (argv.length === 0) { + return []; + } + + const [first, ...rest] = argv; + if (!first || !first.trim()) { + return rest; } - return argv; + + return [...splitRawArgumentString(first), ...rest]; } function parseCommandInput(argv, config = {}) { @@ -148,6 +171,60 @@ function parseCommandInput(argv, config = {}) { }); } +function parseExpertRawHandoff(rawHandoff) { + const tokens = splitRawArgumentString(rawHandoff); + const options = {}; + let index = 0; + + while (index < tokens.length) { + const token = tokens[index]; + if (token === "--json" || token === "--write") { + options[token.slice(2)] = true; + index += 1; + continue; + } + if (token === "--name" || token.startsWith("--name=")) { + const value = token.startsWith("--name=") ? token.slice("--name=".length) : tokens[index + 1]; + if (value === undefined) { + throw new Error("Missing value for --name"); + } + options.name = value; + index += token === "--name" ? 2 : 1; + continue; + } + if (token === "--model" || token === "--effort" || token.startsWith("--model=") || token.startsWith("--effort=")) { + if (!token.includes("=") && tokens[index + 1] === undefined) { + throw new Error(`Missing value for ${token}`); + } + index += token.includes("=") ? 1 : 2; + continue; + } + break; + } + + return { options, positionals: tokens.slice(index) }; +} + +function parseExpertInput(argv, config = {}) { + const [rawHandoff = "", ...selectedRouting] = argv; + if (argv.length > 1 && rawHandoff.startsWith("-") && !/\s/.test(rawHandoff)) { + return parseCommandInput(argv, config); + } + + const raw = parseExpertRawHandoff(rawHandoff); + const selected = parseCommandInput(selectedRouting, config); + + return { + options: { + ...raw.options, + ...selected.options, + model: selected.options.model, + effort: selected.options.effort + }, + positionals: [...raw.positionals, ...selected.positionals] + }; +} + function resolveCommandCwd(options = {}) { return options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd(); } @@ -759,6 +836,64 @@ async function handleReview(argv) { }); } +async function handleExpert(argv) { + const { options, positionals } = parseExpertInput(argv, { + valueOptions: ["model", "effort", "cwd", "prompt-file", "name"], + booleanOptions: ["json", "write"], + aliasMap: { + m: "model" + } + }); + + const cwd = resolveCommandCwd(options); + const prompt = readTaskPrompt(cwd, options, positionals); + requireTaskRequest(prompt, false); + + const model = normalizeRequestedModel(options.model); + const effort = normalizeReasoningEffort(options.effort); + const expertName = options.name === undefined ? "Expert" : String(options.name).trim(); + if (!expertName) { + throw new Error("Provide a non-empty expert name with --name."); + } + + if (!model || !effort) { + const offer = buildExpertSelectionOffer({ + expertName, + model, + effort, + prompt + }); + outputCommandResult(offer, renderExpertSelectionOffer(offer), options.json); + return; + } + + ensureCodexAvailable(cwd); + const result = await runExpertHandoff(cwd, { + expertName, + model, + effort, + prompt, + sandbox: options.write ? "workspace-write" : "read-only" + }); + const payload = { + status: result.status === 0 ? "completed" : "failed", + expert: { + name: result.expertName, + model: result.model, + effort: result.effort, + threadId: result.threadId, + turnId: result.turnId + }, + finalMessage: result.finalMessage, + reasoningSummary: result.reasoningSummary, + error: result.error?.message ?? result.error ?? result.stderr ?? null + }; + outputCommandResult(payload, renderExpertResult(payload), options.json); + if (result.status !== 0) { + process.exitCode = result.status; + } +} + async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["model", "effort", "cwd", "prompt-file"], @@ -770,8 +905,6 @@ async function handleTask(argv) { const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); - const model = normalizeRequestedModel(options.model); - const effort = normalizeReasoningEffort(options.effort); const prompt = readTaskPrompt(cwd, options, positionals); const resumeLast = Boolean(options["resume-last"] || options.resume); @@ -779,6 +912,7 @@ async function handleTask(argv) { if (resumeLast && fresh) { throw new Error("Choose either --resume/--resume-last or --fresh."); } + const { model, effort } = resolveTaskRouting(options, resumeLast); const write = Boolean(options.write); const taskMetadata = buildTaskRunMetadata({ prompt, @@ -1043,6 +1177,9 @@ async function main() { case "task": await handleTask(argv); break; + case "expert": + await handleExpert(argv); + break; case "transfer": await handleTransfer(argv); break; diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..6a361166a 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -51,6 +51,38 @@ const DEFAULT_CONTINUE_PROMPT = const EXTERNAL_AGENT_IMPORT_COMPLETED = "externalAgentConfig/import/completed"; const EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000; +export const EXPERT_DEFAULT_SELECTION = Object.freeze({ + model: "gpt-5.6-sol", + effort: "high" +}); + +export const EXPERT_MODEL_OPTIONS = Object.freeze([ + Object.freeze({ id: "gpt-5.6-sol", label: "Sol", description: "Highest-capability expert" }), + Object.freeze({ id: "gpt-5.6-terra", label: "Terra", description: "Balanced expert" }), + Object.freeze({ id: "gpt-5.6-luna", label: "Luna", description: "Fast, lower-cost expert" }) +]); + +export const EXPERT_EFFORT_OPTIONS = Object.freeze(["none", "minimal", "low", "medium", "high", "xhigh", "max"]); + +export function buildExpertSelectionOffer(options = {}) { + return { + status: "selection_required", + reason: "user_selection_required", + message: "Choose the expert model and reasoning effort before starting the handoff.", + defaultSelection: { ...EXPERT_DEFAULT_SELECTION }, + modelOptions: EXPERT_MODEL_OPTIONS.map((option) => ({ ...option })), + effortOptions: [...EXPERT_EFFORT_OPTIONS], + selected: { + model: options.model ?? null, + effort: options.effort ?? null + }, + request: { + expertName: options.expertName ?? "Expert", + handoff: options.prompt ?? "" + } + }; +} + function cleanCodexStderr(stderr) { return stderr .split(/\r?\n/) @@ -1159,6 +1191,68 @@ export async function runAppServerTurn(cwd, options = {}) { }); } +export async function runExpertHandoff(cwd, options = {}) { + const expertName = String(options.expertName ?? "Expert").trim(); + const prompt = String(options.prompt ?? "").trim(); + const model = String(options.model ?? "").trim(); + const effort = String(options.effort ?? "").trim().toLowerCase(); + + if (!expertName) { + throw new Error("An expert name is required."); + } + if (!prompt) { + throw new Error("A compact handoff prompt is required."); + } + if (!model || !effort) { + throw new Error("An expert model and reasoning effort must be selected before starting the handoff."); + } + if (!EXPERT_EFFORT_OPTIONS.includes(effort)) { + throw new Error(`Unsupported expert reasoning effort \"${effort}\". Use one of: ${EXPERT_EFFORT_OPTIONS.join(", ")}.`); + } + + return withDirectAppServer(cwd, async (client) => { + emitProgress(options.onProgress, `Starting named expert thread \"${expertName}\".`, "starting"); + const threadResponse = await startThread(client, cwd, { + model, + sandbox: options.sandbox ?? "read-only", + ephemeral: false, + threadName: expertName + }); + const threadId = threadResponse.thread.id; + emitProgress(options.onProgress, `Expert thread ready (${threadId}).`, "starting", { + threadId + }); + + const turnState = await captureTurn( + client, + threadId, + () => + client.request("turn/start", { + threadId, + input: buildTurnInput(prompt), + model, + effort, + outputSchema: options.outputSchema ?? null + }), + { onProgress: options.onProgress } + ); + + return { + status: buildResultStatus(turnState), + expertName, + model, + effort, + threadId, + turnId: turnState.turnId, + finalMessage: turnState.lastAgentMessage, + reasoningSummary: turnState.reasoningSummary, + turn: turnState.finalTurn, + error: turnState.error, + stderr: cleanCodexStderr(client.stderr) + }; + }); +} + export async function findLatestTaskThread(cwd) { const availability = getCodexAvailability(cwd); if (!availability.available) { diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2ec185236..5e23f8aac 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -322,6 +322,45 @@ export function renderTaskResult(parsedResult, meta) { return `${message}\n`; } +export function renderExpertSelectionOffer(offer) { + const defaultSelection = offer.defaultSelection ?? {}; + const lines = [ + "# Codex Expert Handoff", + "", + offer.message ?? "Choose the expert model and reasoning effort before starting the handoff.", + "", + `Default: ${defaultSelection.model ?? "unknown"} + ${defaultSelection.effort ?? "unknown"}`, + "", + "Model options:", + ...(offer.modelOptions ?? []).map((option) => `- ${option.label} (${option.id}): ${option.description}`), + "", + `Effort options: ${(offer.effortOptions ?? []).join(", ")}` + ]; + return `${lines.join("\n").trimEnd()}\n`; +} + +export function renderExpertResult(result) { + const lines = [ + "# Codex Expert Handoff", + "", + `Expert: ${result.expert?.name ?? "Expert"}`, + `Model: ${result.expert?.model ?? "unknown"}`, + `Effort: ${result.expert?.effort ?? "unknown"}`, + `Codex session ID: ${result.expert?.threadId ?? "unknown"}`, + "" + ]; + + if (result.finalMessage) { + lines.push(result.finalMessage); + } else if (result.error) { + lines.push(`Expert handoff failed: ${result.error}`); + } else { + lines.push("The expert did not return a final message."); + } + + return `${lines.join("\n").trimEnd()}\n`; +} + export function renderStatusReport(report) { const lines = [ "# Codex Status", diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 0e91bfb50..82a52bf6b 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -18,8 +18,8 @@ Execution rules: - Use `task` for every rescue request, including diagnosis, planning, research, and explicit fix requests. - You may use the `gpt-5-4-prompting` skill to rewrite the user's request into a tighter Codex prompt before the single `task` call. - That prompt drafting is the only Claude-side work allowed. Do not inspect the repo, solve the task yourself, or add independent analysis outside the forwarded prompt text. -- Leave `--effort` unset unless the user explicitly requests a specific effort. -- Leave model unset by default. Add `--model` only when the user explicitly asks for one. +- Fresh bounded tasks default to `gpt-5.6-luna` at `max` effort when these flags are omitted. Pass explicit flags when the user chooses another route. +- Do not add defaults when continuing a previous task; the runtime preserves the existing thread's routing on resume. - Map `spark` to `--model gpt-5.3-codex-spark`. - Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. @@ -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`. - `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 c34b06059..0fe0c4cc4 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -75,6 +75,7 @@ test("continue is not exposed as a user-facing command", () => { assert.deepEqual(commandFiles, [ "adversarial-review.md", "cancel.md", + "expert.md", "rescue.md", "result.md", "review.md", @@ -84,6 +85,20 @@ test("continue is not exposed as a user-facing command", () => { ]); }); +test("expert command asks for model and effort before invoking the handoff", () => { + const source = read("commands/expert.md"); + assert.match(source, /user-approved escalation/i); + assert.doesNotMatch(source, /disable-model-invocation:\s*true/); + assert.match(source, /AskUserQuestion` exactly once/i); + assert.match(source, /always pauses for the user's model-and-effort choice/i); + assert.match(source, /Sol \(Recommended\)/); + assert.match(source, /gpt-5\.6-sol/); + assert.match(source, /High \(Recommended\)/); + assert.match(source, /--model --effort /); + assert.match(source, /Do not silently change the selected model or effort/i); + assert.match(source, /persistent named Codex thread/i); +}); + test("rescue command absorbs continue semantics", () => { const rescue = read("commands/rescue.md"); const agent = read("agents/codex-rescue.md"); @@ -104,7 +119,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/); @@ -113,8 +128,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(rescue, /default to foreground/i); assert.match(rescue, /Do not forward them to `task`/i); assert.match(rescue, /`--model` and `--effort` are runtime-selection flags/i); - assert.match(rescue, /Leave `--effort` unset unless the user explicitly asks for a specific reasoning effort/i); - assert.match(rescue, /If they ask for `spark`, map it to `gpt-5\.3-codex-spark`/i); + assert.match(rescue, /fresh bounded task.*runtime then passes `--model gpt-5\.6-luna --effort max` explicitly/i); + assert.match(rescue, /If the user asks for `spark`, pass `--model gpt-5\.3-codex-spark`/i); assert.match(rescue, /If the request includes `--resume`, do not ask whether to continue/i); assert.match(rescue, /If the request includes `--fresh`, do not ask whether to continue/i); assert.match(rescue, /If the user chooses continue, add `--resume`/i); @@ -132,8 +147,9 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /Use exactly one `Bash` call/i); assert.match(agent, /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(agent, /Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`/i); - assert.match(agent, /Leave `--effort` unset unless the user explicitly requests a specific reasoning effort/i); - assert.match(agent, /Leave model unset by default/i); + assert.match(agent, /runtime defaults fresh bounded tasks to Luna at Max effort/i); + assert.match(agent, /runtime defaults fresh bounded tasks to `gpt-5\.6-luna`/i); + assert.match(agent, /preserves the existing thread's routing on resume/i); assert.match(agent, /If the user asks for `spark`, map that to `--model gpt-5\.3-codex-spark`/i); assert.match(agent, /If the user asks for a concrete model name such as `gpt-5\.4-mini`, pass it through with `--model`/i); assert.match(agent, /Return the stdout of the `codex-companion` command exactly as-is/i); @@ -145,16 +161,16 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /Do not call `setup`, `review`, `adversarial-review`, `status`, `result`, or `cancel`/i); assert.match(runtimeSkill, /use the `gpt-5-4-prompting` skill to rewrite the user's request into a tighter Codex prompt/i); assert.match(runtimeSkill, /That prompt drafting is the only Claude-side work allowed/i); - assert.match(runtimeSkill, /Leave `--effort` unset unless the user explicitly requests a specific effort/i); - assert.match(runtimeSkill, /Leave model unset by default/i); + assert.match(runtimeSkill, /Fresh bounded tasks default to `gpt-5\.6-luna` at `max` effort/i); + assert.match(runtimeSkill, /preserves the existing thread's routing on resume/i); 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`/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); - assert.match(readme, /if you do not pass `--model` or `--effort`, Codex chooses its own defaults/i); + assert.match(readme, /fresh rescue task.*explicitly starts `gpt-5\.6-luna` at `max` effort/i); assert.match(readme, /--model gpt-5\.4-mini --effort medium/i); assert.match(readme, /`spark`, the plugin maps that to `gpt-5\.3-codex-spark`/i); assert.match(readme, /continue a previous Codex task/i); diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..6cc03a672 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -19,7 +19,7 @@ const readline = require("node:readline"); function loadState() { if (!fs.existsSync(STATE_PATH)) { - return { nextThreadId: 1, nextTurnId: 1, appServerStarts: 0, threads: [], capabilities: null, lastInterrupt: null }; + return { nextThreadId: 1, nextTurnId: 1, appServerStarts: 0, threads: [], capabilities: null, lastInterrupt: null, lastThreadStart: null }; } return JSON.parse(fs.readFileSync(STATE_PATH, "utf8")); } @@ -312,6 +312,12 @@ rl.on("line", (line) => { if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) { throw new Error("thread/start.persistFullHistory requires experimentalApi capability"); } + state.lastThreadStart = { + cwd: message.params.cwd ?? null, + model: message.params.model ?? null, + sandbox: message.params.sandbox ?? null, + ephemeral: message.params.ephemeral ?? null + }; const thread = nextThread(state, message.params.cwd, message.params.ephemeral); send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); send({ method: "thread/started", params: { thread: { id: thread.id } } }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..69866e295 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -763,7 +763,7 @@ test("task --fresh is treated as routing control and does not leak into the prom assert.equal(fakeState.lastTurnStart.prompt, "diagnose the flaky test"); }); -test("task forwards model selection and reasoning effort to app-server turn/start", () => { +test("task forwards model selection and max reasoning effort to app-server turn/start", () => { const repo = makeTempDir(); const binDir = makeTempDir(); const statePath = path.join(binDir, "fake-codex-state.json"); @@ -773,7 +773,7 @@ test("task forwards model selection and reasoning effort to app-server turn/star run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const result = run("node", [SCRIPT, "task", "--model", "spark", "--effort", "low", "diagnose the failing test"], { + const result = run("node", [SCRIPT, "task", "--model", "spark", "--effort", "max", "diagnose the failing test"], { cwd: repo, env: buildEnv(binDir) }); @@ -781,7 +781,270 @@ test("task forwards model selection and reasoning effort to app-server turn/star assert.equal(result.status, 0, result.stderr); const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark"); - assert.equal(fakeState.lastTurnStart.effort, "low"); + assert.equal(fakeState.lastTurnStart.effort, "max"); +}); + +test("fresh tasks explicitly default to Luna at max effort", () => { + 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", "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.lastThreadStart.model, "gpt-5.6-luna"); + assert.equal(fakeState.lastTurnStart.model, "gpt-5.6-luna"); + assert.equal(fakeState.lastTurnStart.effort, "max"); +}); + +test("expert handoff returns a Sol-high offer until the user selects routing", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + 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, "expert", "--json", "--name", "architecture-expert", "challenge this design"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "selection_required"); + assert.deepEqual(payload.defaultSelection, { + model: "gpt-5.6-sol", + effort: "high" + }); + assert.deepEqual(payload.selected, { model: null, effort: null }); + assert.equal(payload.request.expertName, "architecture-expert"); + assert.equal(payload.request.handoff, "challenge this design"); +}); + +test("expert handoff requires selection when raw input includes routing flags", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + 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, + "expert", + "--json --model gpt-5.6-sol --effort max investigate this design" + ], + { + cwd: repo, + env: buildEnv(binDir) + } + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "selection_required"); + assert.deepEqual(payload.selected, { model: null, effort: null }); + assert.equal(payload.request.handoff, "investigate this design"); +}); + +test("expert handoff keeps hidden flags in raw handoff text", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + 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, "expert", "--json --prompt-file README.md investigate this design"], + { + cwd: repo, + env: buildEnv(binDir) + } + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "selection_required"); + assert.equal(payload.request.handoff, "--prompt-file README.md investigate this design"); +}); + +test("expert handoff accepts value flags before the handoff", () => { + 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, + "expert", + "--json", + "--name", + "architecture-expert", + "--model", + "gpt-5.6-sol", + "--effort", + "high", + "challenge this design" + ], + { + cwd: repo, + env: buildEnv(binDir) + } + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(payload.status, "completed"); + assert.equal(payload.expert.name, "architecture-expert"); + assert.equal(fakeState.lastTurnStart.prompt, "challenge this design"); +}); + +test("expert handoff creates and names a persistent thread with selected routing", () => { + 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, + "expert", + "--json", + "--name", + "architecture-expert", + "--model", + "gpt-5.6-sol", + "--effort", + "high", + "challenge this design" + ], + { + cwd: repo, + env: buildEnv(binDir) + } + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(payload.status, "completed"); + assert.equal(payload.expert.name, "architecture-expert"); + assert.equal(payload.expert.model, "gpt-5.6-sol"); + assert.equal(payload.expert.effort, "high"); + assert.match(payload.finalMessage, /Handled the requested task/); + assert.equal(fakeState.lastThreadStart.model, "gpt-5.6-sol"); + assert.equal(fakeState.lastThreadStart.ephemeral, false); + assert.equal(fakeState.threads[0].name, "architecture-expert"); + assert.equal(fakeState.lastTurnStart.model, "gpt-5.6-sol"); + assert.equal(fakeState.lastTurnStart.effort, "high"); + assert.equal(fakeState.lastTurnStart.prompt, "challenge this design"); +}); + +test("expert handoff preserves raw name and write flags when selected routing is appended", () => { + 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, + "expert", + "--json --name architecture-expert --write challenge this design", + "--model", + "gpt-5.6-sol", + "--effort", + "high" + ], + { + cwd: repo, + env: buildEnv(binDir) + } + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(payload.status, "completed"); + assert.equal(payload.expert.name, "architecture-expert"); + assert.equal(payload.expert.model, "gpt-5.6-sol"); + assert.equal(payload.expert.effort, "high"); + assert.equal(fakeState.threads[0].name, "architecture-expert"); + assert.equal(fakeState.lastThreadStart.sandbox, "workspace-write"); + assert.equal(fakeState.lastTurnStart.prompt, "challenge this design"); +}); + +test("expert handoff keeps appended routing active after a raw passthrough marker", () => { + 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, + "expert", + "--json --name architecture-expert --write run npm test -- --watch", + "--model", + "gpt-5.6-sol", + "--effort", + "high" + ], + { + cwd: repo, + env: buildEnv(binDir) + } + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(payload.status, "completed"); + assert.equal(payload.expert.name, "architecture-expert"); + assert.equal(payload.expert.model, "gpt-5.6-sol"); + assert.equal(payload.expert.effort, "high"); + assert.equal(fakeState.threads[0].name, "architecture-expert"); + assert.equal(fakeState.lastThreadStart.sandbox, "workspace-write"); + assert.equal(fakeState.lastTurnStart.prompt, "run npm test -- --watch"); }); test("task logs reasoning summaries and assistant messages to the job log", () => { @@ -938,6 +1201,11 @@ test("task --background enqueues a detached worker and exposes per-job status", const launchPayload = JSON.parse(launched.stdout); assert.equal(launchPayload.status, "queued"); assert.match(launchPayload.jobId, /^task-/); + const queuedJob = JSON.parse( + fs.readFileSync(path.join(resolveStateDir(repo), "jobs", `${launchPayload.jobId}.json`), "utf8") + ); + assert.equal(queuedJob.request.model, "gpt-5.6-luna"); + assert.equal(queuedJob.request.effort, "max"); const waitedStatus = run( "node",