From 9a658238fe8380efc47326d2ecb26665d8e74811 Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Sun, 27 Sep 2026 20:45:21 -0700 Subject: [PATCH 1/3] feat(sc-codex): add gpt-6 model aliases and reasoning effort option - New model catalog (sol/astra/luna/terra/codex aliases, gpt-6 and gpt-5.6 slugs, gpt-5.5); default model is gpt-6-astra. - --effort {low,medium,high,xhigh,max,ultra} on sc_codex_task.py and ai_cli run, plus optional reasoning_effort payload/schema field, validated per model and passed as -c model_reasoning_effort="...". Omitted when unset so ~/.codex/config.toml applies. - CLI flags override the payload; effort survives background runs and is logged as a top-level field. - ChatGPT-account fallback now retries once on gpt-5.5 with effort clamped to xhigh. BREAKING CHANGE: legacy aliases gpt-5.2-codex, codex-max/max, codex-mini/mini, gpt-5, gpt-5.2 and gtp-5 are removed. Co-Authored-By: Claude Opus 5.5 --- packages/sc-codex/README.md | 43 ++- packages/sc-codex/commands/sc-codex.md | 44 ++- .../sc-codex/schemas/task_tool.schema.json | 39 ++- packages/sc-codex/scripts/ai_cli/README.md | 43 ++- packages/sc-codex/scripts/ai_cli/cli.py | 5 + .../sc-codex/scripts/ai_cli/task_runner.py | 161 ++++++++--- packages/sc-codex/scripts/ai_cli/task_tool.py | 77 +++-- packages/sc-codex/scripts/sc_codex_task.py | 28 +- packages/sc-codex/skills/codex-agent/SKILL.md | 39 ++- .../sc-codex/tests/test_parallel_minis.yaml | 10 +- .../fixtures/sc-codex/tests/test_resume.yaml | 4 +- tests/test_ai_cli_task_runner.py | 271 ++++++++++++++++-- 12 files changed, 629 insertions(+), 135 deletions(-) diff --git a/packages/sc-codex/README.md b/packages/sc-codex/README.md index 937b6ad61..298b7c833 100644 --- a/packages/sc-codex/README.md +++ b/packages/sc-codex/README.md @@ -1,12 +1,11 @@ -# sc-codex (v0.7.0) +# sc-codex Run Codex agents via a Task Tool-compatible runner with hook emulation and background execution support. Provides a CLI wrapper plus JSON schemas for input/output validation. ## Badges -- Status: pre-release (0.7.0) -- Compatibility: Synaptic Canvas 0.7.x +- Status: pre-release (see `manifest.yaml` for the current version) - Safety: hook commands are explicit and logged ## Installation @@ -16,15 +15,41 @@ input/output validation. ## Quick Start ```bash -# Run a task via the runner -python3 packages/sc-codex/scripts/sc_codex_task.py \ - --runner codex \ - --file /path/to/input.json +# Run a prompt (background by default; returns agentId + output_file) +python3 packages/sc-codex/scripts/sc_codex_task.py "write a haiku about rain" -# Use ai_cli directly -PYTHONPATH=packages/sc-codex/scripts python3 -m ai_cli run --file /path/to/input.json +# Pick model and reasoning effort, blocking mode +python3 packages/sc-codex/scripts/sc_codex_task.py --model sol --effort low --no-background \ + "write a haiku about rain" + +# Task Tool JSON input +python3 packages/sc-codex/scripts/sc_codex_task.py --json \ + '{"description":"Compose haiku","prompt":"compose a haiku","model":"luna","reasoning_effort":"medium"}' + +# Use ai_cli directly (blocking by default) +PYTHONPATH=packages/sc-codex/scripts python3 -m ai_cli run --runner codex --file /path/to/input.json ``` +## Configuration + +Models (`--model` or JSON `model`; default `gpt-6-astra`): + +| Alias | Model slug | Supported `--effort` levels | +|-------|------------|-----------------------------| +| `sol` | `gpt-6-sol` | low, medium, high, xhigh, max, ultra | +| `astra` (default; also `codex`) | `gpt-6-astra` | low, medium, high, xhigh, max, ultra | +| `luna` | `gpt-6-luna` | low, medium, high, xhigh, max | +| — | `gpt-5.6-sol` | low, medium, high, xhigh, max, ultra | +| `terra` | `gpt-5.6-terra` | low, medium, high, xhigh, max, ultra | +| — | `gpt-5.6-luna` | low, medium, high, xhigh, max | +| — | `gpt-5.5` | low, medium, high, xhigh | + +- Reasoning effort: `--effort` or JSON `reasoning_effort`. When omitted, no override is passed and + your `~/.codex/config.toml` applies. Unsupported model/effort combinations are rejected. +- Command-line flags override the JSON payload. +- Breaking in 0.14.0: legacy aliases (`gpt-5.2-codex`, `codex-max`/`max`, `codex-mini`/`mini`, + `gpt-5`, `gpt-5.2`, `gtp-5`) were removed. + ## Schemas - Input schema: `packages/sc-codex/schemas/task_tool.schema.json` - Output schema: `packages/sc-codex/schemas/task_tool.output.schema.json` diff --git a/packages/sc-codex/commands/sc-codex.md b/packages/sc-codex/commands/sc-codex.md index 23e9e7850..f8fd311a9 100644 --- a/packages/sc-codex/commands/sc-codex.md +++ b/packages/sc-codex/commands/sc-codex.md @@ -2,13 +2,18 @@ allowed-tools: Bash(python3 .claude/scripts/sc_codex_task.py*) name: sc-codex description: Run Codex tasks via the ai_cli runner (supports JSON input, background runs, and model selection). -version: 0.13.0 +version: 0.14.0 options: - name: --model args: - name: model - description: Codex model or alias (e.g., codex, max, mini, gpt-5). - description: Select the Codex model. + description: Codex model alias (sol, astra, luna, terra, codex) or full slug (e.g., gpt-6-sol, gpt-5.5). + description: Select the Codex model (default gpt-6-astra). + - name: --effort + args: + - name: effort + description: One of low, medium, high, xhigh, max, ultra (supported levels vary by model). + description: Set Codex reasoning effort. When omitted, the Codex CLI config (~/.codex/config.toml) applies. - name: --background description: Run in background mode (returns output_file). Default if not disabled. - name: --no-background @@ -25,24 +30,44 @@ Run a Codex task using the Task Tool-compatible runner. Usage: - `/sc-codex write a haiku about rain` -- `/sc-codex --model mini write a haiku about rain` +- `/sc-codex --model luna write a haiku about rain` +- `/sc-codex --model sol --effort low write a haiku about rain` - `/sc-codex --background write a haiku about rain` - `/sc-codex --json {"description":"Compose haiku","prompt":"compose a haiku","subagent_type":"sc-codex"}` Flags: - `--help` (show usage) -- `--model ` (codex, codex-max/max, codex-mini/mini, gpt-5, or full model name) +- `--model ` (sol, astra, luna, terra, codex, or a full slug; default `gpt-6-astra`) +- `--effort ` (low, medium, high, xhigh, max, ultra; omitted = use `~/.codex/config.toml`) - `--background` (force background mode) - `--no-background` (force blocking mode) - `--json ` (Task Tool JSON input) +Models and effort: + +| Alias | Model slug | Supported `--effort` levels | +|-------|------------|-----------------------------| +| `sol` | `gpt-6-sol` | low, medium, high, xhigh, max, ultra | +| `astra` (default; also `codex`) | `gpt-6-astra` | low, medium, high, xhigh, max, ultra | +| `luna` | `gpt-6-luna` | low, medium, high, xhigh, max | +| — | `gpt-5.6-sol` | low, medium, high, xhigh, max, ultra | +| `terra` | `gpt-5.6-terra` | low, medium, high, xhigh, max, ultra | +| — | `gpt-5.6-luna` | low, medium, high, xhigh, max | +| — | `gpt-5.5` | low, medium, high, xhigh | + +Legacy aliases (`gpt-5.2-codex`, `codex-max`/`max`, `codex-mini`/`mini`, `gpt-5`, `gpt-5.2`, `gtp-5`) +were removed in 0.14.0 and are rejected with an error listing the valid names. +An effort level the chosen model does not support is also rejected. + ## Context Use the `codex-agent` skill to launch a Codex task **in background mode**. Interpret arguments as follows: - `--help`: print usage (with flags) and stop. -- `--model `: pass the model to the codex-agent runner. +- `--model `: set `model` in the Task Tool JSON (or pass `--model` before `--json`). +- `--effort `: set `reasoning_effort` in the Task Tool JSON (or pass `--effort` before `--json`). + Natural language such as "use sc-codex with sol low" means `--model sol --effort low`. - `--background`: force background mode (default for this command). - `--no-background`: force blocking mode (explicit override). - `--json `: treat the remaining arguments as Task Tool JSON and pass through unchanged. @@ -53,8 +78,12 @@ After the child task completes, preserve any caller-specified post-processing in When running: 1) Build Task Tool JSON with `description`, `prompt`, `subagent_type: "sc-codex"` (unless user provided one). Default `run_in_background` to true unless `--no-background` is provided. + Include `"model"` and/or `"reasoning_effort"` when the user asked for them, e.g. + `{"description":"Compose haiku","prompt":"compose a haiku","subagent_type":"sc-codex","model":"sol","reasoning_effort":"low"}`. + Omit `reasoning_effort` when the user did not ask for one. 2) Call the codex-agent runner as a Bash tool call using `--json`. Always use `python3 .claude/scripts/sc_codex_task.py --json '{...}'` (do not call other runner scripts). + `--model`/`--effort` flags placed before `--json` override the matching JSON fields. Do not invent flags like `--run_in_background`, `--description`, `--prompt`, or `--subagent_type`. 3) Poll the `output_file` for up to 8 seconds using Python (avoid `tail -f` and avoid `timeout`, which may be missing on macOS): @@ -78,7 +107,8 @@ PY ``` If the task is still running, return `agentId` and `output_file`. -4) If the child run fails because a Codex-native model is unsupported for the current account, retry once with `gpt-5`/`gpt-5.2` compatibility mode before surfacing the error. +4) If Codex rejects the model for a ChatGPT-account login, the runner already retries once on `gpt-5.5` + (clamping effort to at most `xhigh`). Do not retry manually; surface any remaining error. 5) Return the Codex output and the `agentId`. If the task is still running at timeout, return `agentId` and `output_file` and tell the user how to check status. 6) If a caller provides `resume` but the prior transcript cannot be reopened or does not contain usable assistant output, return a concise error and stop. Do not fabricate prior context or a synthetic refinement result. diff --git a/packages/sc-codex/schemas/task_tool.schema.json b/packages/sc-codex/schemas/task_tool.schema.json index ef091a582..de8136c97 100644 --- a/packages/sc-codex/schemas/task_tool.schema.json +++ b/packages/sc-codex/schemas/task_tool.schema.json @@ -2,7 +2,11 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["description", "prompt", "subagent_type"], + "required": [ + "description", + "prompt", + "subagent_type" + ], "properties": { "description": { "type": "string", @@ -23,18 +27,31 @@ "opus", "haiku", "codex", - "gpt-5.2-codex", - "codex-max", + "sol", + "astra", + "luna", + "terra", + "gpt-6-sol", + "gpt-6-astra", + "gpt-6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5" + ], + "description": "Optional model to use for this agent. If not specified, inherits from parent. Claude models: sonnet, opus, haiku. Codex aliases: sol, astra, luna, terra, codex (default, gpt-6-astra). Codex slugs: gpt-6-sol, gpt-6-astra, gpt-6-luna, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5." + }, + "reasoning_effort": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "xhigh", "max", - "gpt-5.1-codex-max", - "codex-mini", - "mini", - "gpt-5.1-codex-mini", - "gpt-5", - "gpt-5.2", - "gtp-5" + "ultra" ], - "description": "Optional model to use for this agent. If not specified, inherits from parent. Claude models: sonnet, opus, haiku. Codex models: codex, max/codex-max, mini/codex-mini, gpt-5, or full model names." + "description": "Optional Codex reasoning effort: low, medium, high, xhigh, max, ultra (supported levels vary by model). When omitted, no effort override is passed and the Codex CLI config (~/.codex/config.toml) applies. Ignored by the Claude runner." }, "run_in_background": { "type": "boolean", diff --git a/packages/sc-codex/scripts/ai_cli/README.md b/packages/sc-codex/scripts/ai_cli/README.md index b068545ef..e1d800054 100644 --- a/packages/sc-codex/scripts/ai_cli/README.md +++ b/packages/sc-codex/scripts/ai_cli/README.md @@ -21,7 +21,8 @@ Background output (run_in_background: true): "description": "Summarize issue", "prompt": "Summarize the latest GitHub issue thread", "subagent_type": "issue-summary", - "model": "haiku", + "model": "sol", + "reasoning_effort": "low", "run_in_background": false, "max_turns": 10 } @@ -82,22 +83,48 @@ PYTHONPATH=packages/sc-codex/scripts python3 -m ai_cli run --runner codex --back Background outputs default to `.sc/sessions` (gitignored). For Codex, if `CODEX_HOME` is set, the default becomes `$CODEX_HOME/sessions`. Use `--output-dir` to override. +Override model and reasoning effort from the command line (flags override the JSON payload): + +```bash +PYTHONPATH=packages/sc-codex/scripts python3 -m ai_cli run --runner codex --model sol --effort low --file /path/to/input.json +# -> codex exec --yolo --model gpt-6-sol -c model_reasoning_effort="low" +``` + Model defaults: - Claude defaults to `sonnet` -- Codex defaults to `gpt-5.2-codex` +- Codex defaults to `gpt-6-astra` + +Codex models (the catalog is defined once in `ai_cli/task_tool.py`; `CODEX_MODEL_ALIASES`, +`CODEX_MODEL_EFFORTS`, `REASONING_EFFORTS`): + +| Alias | Model slug | Supported `--effort` levels | +|-------|------------|-----------------------------| +| `sol` | `gpt-6-sol` | low, medium, high, xhigh, max, ultra | +| `astra` (default; also `codex`) | `gpt-6-astra` | low, medium, high, xhigh, max, ultra | +| `luna` | `gpt-6-luna` | low, medium, high, xhigh, max | +| — | `gpt-5.6-sol` | low, medium, high, xhigh, max, ultra | +| `terra` | `gpt-5.6-terra` | low, medium, high, xhigh, max, ultra | +| — | `gpt-5.6-luna` | low, medium, high, xhigh, max | +| — | `gpt-5.5` | low, medium, high, xhigh | + +Full slugs are accepted as-is; unknown names raise an error listing valid aliases and slugs. + +Reasoning effort (`reasoning_effort` in JSON, `--effort` on the CLI): +- Levels: `low`, `medium`, `high`, `xhigh`, `max`, `ultra`; unsupported levels for the model are rejected. +- When unset, no `-c model_reasoning_effort` override is passed, so `~/.codex/config.toml` applies. +- Carried in the payload, so background runs keep it. +- Ignored by the Claude runner (`claude --print` has no equivalent). -Codex model aliases: -- `codex` -> `gpt-5.2-codex` -- `codex-max` or `max` -> `gpt-5.1-codex-max` -- `codex-mini` or `mini` -> `gpt-5.1-codex-mini` -- `gpt-5` or `gtp-5` -> `gpt-5.2` +ChatGPT-account fallback: if Codex reports the model is "not supported when using Codex with a +ChatGPT account", the runner retries once with `gpt-5.5` (effort clamped to `xhigh` if higher) +and logs a `model_fallback` event. ## Logs Errors and schema validation failures are logged to: - `.claude/state/logs//` (derived from the runner script path) -Task start/end events are also logged with `agentId`, `runner`, `model`, and parameters. +Task start/end events are also logged with `agentId`, `runner`, `model`, `reasoning_effort`, and parameters. Logs include `prompt_preview` and `duration_ms`. ## Hook emulation diff --git a/packages/sc-codex/scripts/ai_cli/cli.py b/packages/sc-codex/scripts/ai_cli/cli.py index af74caa76..276ded131 100644 --- a/packages/sc-codex/scripts/ai_cli/cli.py +++ b/packages/sc-codex/scripts/ai_cli/cli.py @@ -11,6 +11,8 @@ from ai_cli.logging import write_log from ai_cli.task_runner import ( + REASONING_EFFORTS, + effort_help, run_background_child_with_payload, resolve_runner, run_task, @@ -43,6 +45,8 @@ def cmd_validate(args: argparse.Namespace) -> int: def cmd_run(args: argparse.Namespace) -> int: data = _read_json(args.file) payload = TaskToolInput.model_validate(data) + if args.effort: + payload = payload.model_copy(update={"reasoning_effort": args.effort}) runner = resolve_runner(args.runner) model = resolve_model(runner, args.model or payload.model) if args.background is None: @@ -113,6 +117,7 @@ def main() -> int: p_run.add_argument("--file", help="Path to JSON file (defaults to stdin)") p_run.add_argument("--runner", choices=["claude", "codex"], help="Override runner selection") p_run.add_argument("--model", help="Override model (default derived from runner or input)") + p_run.add_argument("--effort", choices=REASONING_EFFORTS, help=effort_help()) bg_group = p_run.add_mutually_exclusive_group() bg_group.add_argument("--background", dest="background", action="store_true", help="Force background mode") bg_group.add_argument("--no-background", dest="background", action="store_false", help="Force blocking mode") diff --git a/packages/sc-codex/scripts/ai_cli/task_runner.py b/packages/sc-codex/scripts/ai_cli/task_runner.py index 3907631a6..cfa60a167 100644 --- a/packages/sc-codex/scripts/ai_cli/task_runner.py +++ b/packages/sc-codex/scripts/ai_cli/task_runner.py @@ -24,31 +24,42 @@ import yaml from ai_cli.logging import write_log -from ai_cli.task_tool import TaskToolInput, TaskToolOutputBackground, TaskToolOutputForeground +from ai_cli.task_tool import ( # noqa: F401 (catalog names re-exported) + CLAUDE_MODELS, + CODEX_DEFAULT_MODEL, + CODEX_MODEL_ALIASES, + CODEX_MODEL_EFFORTS, + CODEX_MODEL_NAMES, + CODEX_MODEL_SLUGS, + REASONING_EFFORTS, + TaskToolInput, + TaskToolOutputBackground, + TaskToolOutputForeground, +) RunnerType = Literal["claude", "codex"] -_CODEX_MODEL_MAP = { - "codex": "gpt-5.2-codex", - "gpt-5.2-codex": "gpt-5.2-codex", - "codex-max": "gpt-5.1-codex-max", - "max": "gpt-5.1-codex-max", - "gpt-5.1-codex-max": "gpt-5.1-codex-max", - "codex-mini": "gpt-5.1-codex-mini", - "mini": "gpt-5.1-codex-mini", - "gpt-5.1-codex-mini": "gpt-5.1-codex-mini", - "gpt-5": "gpt-5.2", - "gpt-5.2": "gpt-5.2", - "gtp-5": "gpt-5.2", -} - -_CLAUDE_MODELS = {"haiku", "sonnet", "opus"} -_CODEX_ACCOUNT_FALLBACK = "gpt-5.2" -_CODEX_NATIVE_MODELS = { - "gpt-5.2-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", -} +# Model/effort catalog is defined once in ai_cli.task_tool (see note there); +# these names are re-exported for callers and tests. +_CLAUDE_MODELS = set(CLAUDE_MODELS) + +#: Fallback model when Codex rejects the requested model for a ChatGPT +#: account. Only triggered by that specific CLI error message. +_CODEX_ACCOUNT_FALLBACK = "gpt-5.5" +_CHATGPT_ACCOUNT_ERROR = "not supported when using codex with a chatgpt account" + + +def codex_model_help() -> str: + aliases = ", ".join(f"{k}={v}" for k, v in CODEX_MODEL_ALIASES.items()) + return f"Codex model alias ({aliases}) or full slug ({', '.join(CODEX_MODEL_SLUGS)})" + + +def effort_help() -> str: + per_model = "; ".join(f"{m}: {e[0]}..{e[-1]}" for m, e in CODEX_MODEL_EFFORTS.items()) + return ( + "Codex reasoning effort. When omitted, no override is passed and ~/.codex/config.toml " + f"applies. Supported per model: {per_model}. Ignored by the Claude runner." + ) @lru_cache(maxsize=None) @@ -82,13 +93,40 @@ def resolve_model(runner: RunnerType, model: Optional[str]) -> str: raise ValueError(f"Unsupported Claude model: {model}") return model if model is None: - return "gpt-5.2-codex" - resolved = _CODEX_MODEL_MAP.get(model) + return CODEX_DEFAULT_MODEL + if model in CODEX_MODEL_EFFORTS: + return model + resolved = CODEX_MODEL_ALIASES.get(model) if resolved is None: - raise ValueError(f"Unsupported Codex model: {model}") + raise ValueError( + f"Unsupported Codex model: {model!r}. " + f"Valid aliases: {', '.join(CODEX_MODEL_ALIASES)}. " + f"Valid slugs: {', '.join(CODEX_MODEL_SLUGS)}." + ) return resolved +def resolve_reasoning_effort(model: str, effort: Optional[str]) -> Optional[str]: + """Validate ``effort`` for a resolved Codex model slug. + + Returns ``None`` when no effort was requested, so the caller omits the + ``-c model_reasoning_effort`` override and the user's Codex config applies. + """ + if effort is None: + return None + if effort not in REASONING_EFFORTS: + raise ValueError( + f"Unsupported reasoning effort: {effort!r}. Valid levels: {', '.join(REASONING_EFFORTS)}." + ) + supported = CODEX_MODEL_EFFORTS.get(model, REASONING_EFFORTS) + if effort not in supported: + raise ValueError( + f"Reasoning effort {effort!r} is not supported by {model}. " + f"Supported levels: {', '.join(supported)}." + ) + return effort + + def _resume_context(payload: TaskToolInput, runner: RunnerType) -> Optional[str]: if not payload.resume: return None @@ -155,19 +193,49 @@ def _preview(text: str, limit: int = 200) -> str: return cleaned[: limit - 1] + "…" -def run_sync(runner: RunnerType, model: str, prompt: str) -> str: +def _codex_cmd(model: str, effort: Optional[str], prompt: str) -> list[str]: + cmd = ["codex", "exec", "--yolo", "--model", model] + if effort is not None: + cmd.extend(["-c", f'model_reasoning_effort="{effort}"']) + cmd.append(prompt) + return cmd + + +def run_sync( + runner: RunnerType, + model: str, + prompt: str, + reasoning_effort: Optional[str] = None, +) -> str: + """Run a single prompt synchronously. + + ``reasoning_effort`` applies to the Codex runner only; the Claude runner + ignores it (claude --print has no equivalent flag). + """ _check_runner_available(runner) attempted_model = model if runner == "codex": + effort = resolve_reasoning_effort(attempted_model, reasoning_effort) res = subprocess.run( - ["codex", "exec", "--yolo", "--model", attempted_model, prompt], + _codex_cmd(attempted_model, effort, prompt), text=True, capture_output=True, ) if res.returncode != 0 and _should_fallback_codex_model(attempted_model, res.stderr): attempted_model = _CODEX_ACCOUNT_FALLBACK + effort = _fallback_effort(effort) + write_log( + { + "component": "ai_cli", + "event": "model_fallback", + "runner": runner, + "model": model, + "fallback_model": attempted_model, + "reasoning_effort": effort, + } + ) res = subprocess.run( - ["codex", "exec", "--yolo", "--model", attempted_model, prompt], + _codex_cmd(attempted_model, effort, prompt), text=True, capture_output=True, ) @@ -184,10 +252,22 @@ def run_sync(runner: RunnerType, model: str, prompt: str) -> str: def _should_fallback_codex_model(model: str, stderr: str) -> bool: - if model not in _CODEX_NATIVE_MODELS: + """True when Codex rejected ``model`` for a ChatGPT-account login. + + Applies to any model except the fallback itself (so gpt-6/gpt-5.6 models + are retried once on gpt-5.5, and gpt-5.5 never loops). + """ + if model == _CODEX_ACCOUNT_FALLBACK: return False - lowered = (stderr or "").lower() - return "not supported when using codex with a chatgpt account" in lowered + return _CHATGPT_ACCOUNT_ERROR in (stderr or "").lower() + + +def _fallback_effort(effort: Optional[str]) -> Optional[str]: + """Clamp ``effort`` to the highest level the fallback model supports.""" + supported = CODEX_MODEL_EFFORTS[_CODEX_ACCOUNT_FALLBACK] + if effort is None or effort in supported: + return effort + return supported[-1] def _timestamp() -> str: @@ -336,6 +416,11 @@ def run_pretool_hooks(agent_path: Optional[Path], payload: TaskToolInput) -> Non ) +def _effort_for(payload: TaskToolInput, runner: RunnerType) -> Optional[str]: + """Effort that applies to this run (None for the Claude runner, which ignores it).""" + return payload.reasoning_effort if runner == "codex" else None + + def run_background(payload: TaskToolInput, runner: RunnerType, model: str, output_dir: Path) -> TaskToolOutputBackground: _check_runner_available(runner) agent_id = str(uuid.uuid4()) @@ -357,6 +442,7 @@ def run_background(payload: TaskToolInput, runner: RunnerType, model: str, outpu "mode": "background", "runner": runner, "model": model, + "reasoning_effort": _effort_for(payload, runner), "agentId": agent_id, "error": str(exc), } @@ -369,6 +455,7 @@ def run_background(payload: TaskToolInput, runner: RunnerType, model: str, outpu "mode": "background", "runner": runner, "model": model, + "reasoning_effort": _effort_for(payload, runner), "agentId": agent_id, "output_file": str(output_file), "prompt_preview": _preview(payload.prompt), @@ -426,7 +513,7 @@ def run_background_child_with_payload( try: agent_path = resolve_agent_path(payload.subagent_type, runner) run_pretool_hooks(agent_path, payload) - output = run_sync(runner, model, build_prompt(payload, runner)) + output = run_sync(runner, model, build_prompt(payload, runner), _effort_for(payload, runner)) assistant_entry = _jsonl_entry(agent_id, "assistant", output, None) write_log( { @@ -435,6 +522,7 @@ def run_background_child_with_payload( "mode": "background", "runner": runner, "model": model, + "reasoning_effort": _effort_for(payload, runner), "agentId": agent_id, "output_file": str(output_file), "status": "success", @@ -451,6 +539,7 @@ def run_background_child_with_payload( "mode": "background", "runner": runner, "model": model, + "reasoning_effort": _effort_for(payload, runner), "agentId": agent_id, "output_file": str(output_file), "status": "error", @@ -470,6 +559,9 @@ def run_task( output_dir: Optional[Path] = None, raise_on_error: bool = True, ) -> TaskToolOutputForeground | TaskToolOutputBackground: + if runner == "codex": + # Validate up front so bad model/effort combos fail before any launch. + resolve_reasoning_effort(model, payload.reasoning_effort) if run_in_background: return run_background(payload, runner, model, output_dir or _default_output_dir(runner)) agent_id = str(uuid.uuid4()) @@ -480,6 +572,7 @@ def run_task( "mode": "blocking", "runner": runner, "model": model, + "reasoning_effort": _effort_for(payload, runner), "agentId": agent_id, "prompt_preview": _preview(payload.prompt), "params": payload.model_dump(), @@ -489,7 +582,7 @@ def run_task( try: agent_path = resolve_agent_path(payload.subagent_type, runner) run_pretool_hooks(agent_path, payload) - output = run_sync(runner, model, build_prompt(payload, runner)) + output = run_sync(runner, model, build_prompt(payload, runner), _effort_for(payload, runner)) write_log( { "component": "ai_cli", @@ -497,6 +590,7 @@ def run_task( "mode": "blocking", "runner": runner, "model": model, + "reasoning_effort": _effort_for(payload, runner), "agentId": agent_id, "status": "success", "duration_ms": int((time.monotonic() - start) * 1000), @@ -511,6 +605,7 @@ def run_task( "mode": "blocking", "runner": runner, "model": model, + "reasoning_effort": _effort_for(payload, runner), "agentId": agent_id, "status": "error", "error": str(exc), diff --git a/packages/sc-codex/scripts/ai_cli/task_tool.py b/packages/sc-codex/scripts/ai_cli/task_tool.py index 367da9b45..e5e5f914e 100644 --- a/packages/sc-codex/scripts/ai_cli/task_tool.py +++ b/packages/sc-codex/scripts/ai_cli/task_tool.py @@ -15,20 +15,56 @@ from pydantic import BaseModel, Field -CodexModel = Literal[ - "codex", - "gpt-5.2-codex", - "codex-max", - "max", - "gpt-5.1-codex-max", - "codex-mini", - "mini", - "gpt-5.1-codex-mini", - "gpt-5", - "gpt-5.2", - "gtp-5", -] -ClaudeModel = Literal["sonnet", "opus", "haiku"] +# --------------------------------------------------------------------------- +# Model / effort catalog -- the single source of truth. +# +# These constants live here (rather than in task_runner.py) because +# task_runner imports this module; defining them here avoids a circular +# import. task_runner re-exports them, and argparse help, pydantic Literals +# and resolution logic are all derived from them. tests/ assert that +# schemas/task_tool.schema.json stays in sync. +# --------------------------------------------------------------------------- + +#: Reasoning effort levels, lowest to highest. +REASONING_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "xhigh", "max", "ultra") + + +def _efforts_up_to(highest: str) -> tuple[str, ...]: + return REASONING_EFFORTS[: REASONING_EFFORTS.index(highest) + 1] + + +#: Full Codex model slugs -> supported reasoning effort levels. +CODEX_MODEL_EFFORTS: dict[str, tuple[str, ...]] = { + "gpt-6-sol": _efforts_up_to("ultra"), + "gpt-6-astra": _efforts_up_to("ultra"), + "gpt-6-luna": _efforts_up_to("max"), + "gpt-5.6-sol": _efforts_up_to("ultra"), + "gpt-5.6-terra": _efforts_up_to("ultra"), + "gpt-5.6-luna": _efforts_up_to("max"), + "gpt-5.5": _efforts_up_to("xhigh"), +} +CODEX_MODEL_SLUGS: tuple[str, ...] = tuple(CODEX_MODEL_EFFORTS) + +#: Model used when no model is given (or the `codex` alias is used). +CODEX_DEFAULT_MODEL = "gpt-6-astra" + +#: Short aliases -> full slugs. Full slugs are also accepted as-is. +CODEX_MODEL_ALIASES: dict[str, str] = { + "codex": CODEX_DEFAULT_MODEL, + "sol": "gpt-6-sol", + "astra": "gpt-6-astra", + "luna": "gpt-6-luna", + "terra": "gpt-5.6-terra", +} + +#: Every accepted Codex model name (aliases first, then slugs). +CODEX_MODEL_NAMES: tuple[str, ...] = tuple(CODEX_MODEL_ALIASES) + CODEX_MODEL_SLUGS + +CLAUDE_MODELS: tuple[str, ...] = ("sonnet", "opus", "haiku") + +CodexModel = Literal[CODEX_MODEL_NAMES] # type: ignore[valid-type] +ClaudeModel = Literal[CLAUDE_MODELS] # type: ignore[valid-type] +ReasoningEffort = Literal[REASONING_EFFORTS] # type: ignore[valid-type] ModelName = Union[CodexModel, ClaudeModel] @@ -49,8 +85,17 @@ class TaskToolInput(BaseModel): default=None, description=( "Optional model to use for this agent. If not specified, inherits from parent. " - "Claude models: sonnet, opus, haiku. Codex models: codex, max/codex-max, " - "mini/codex-mini, gpt-5, or full model names." + "Claude models: sonnet, opus, haiku. Codex aliases: sol, astra, luna, terra, " + "codex (default, gpt-6-astra). Codex slugs: gpt-6-sol, gpt-6-astra, gpt-6-luna, " + "gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5." + ), + ) + reasoning_effort: Optional[ReasoningEffort] = Field( + default=None, + description=( + "Optional Codex reasoning effort: low, medium, high, xhigh, max, ultra " + "(supported levels vary by model). When omitted, no effort override is passed " + "and the Codex CLI config (~/.codex/config.toml) applies. Ignored by the Claude runner." ), ) run_in_background: Optional[bool] = Field( diff --git a/packages/sc-codex/scripts/sc_codex_task.py b/packages/sc-codex/scripts/sc_codex_task.py index decc5dd94..52f051831 100644 --- a/packages/sc-codex/scripts/sc_codex_task.py +++ b/packages/sc-codex/scripts/sc_codex_task.py @@ -11,8 +11,17 @@ if str(SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(SCRIPT_DIR)) -from ai_cli.task_runner import resolve_model, resolve_runner, run_task # noqa: E402 +from ai_cli.task_runner import ( # noqa: E402 + REASONING_EFFORTS, + codex_model_help, + effort_help, + resolve_model, + resolve_reasoning_effort, + resolve_runner, + run_task, +) from ai_cli.task_tool import TaskToolInput # noqa: E402 +from pydantic import ValidationError # noqa: E402 def _load_json(text: str) -> dict: @@ -32,7 +41,8 @@ def _build_payload(prompt: str, subagent_type: str) -> TaskToolInput: def main() -> int: ap = argparse.ArgumentParser(description="Run Codex tasks via ai_cli", add_help=True) - ap.add_argument("--model", help="Codex model or alias (codex, max, mini, gpt-5)") + ap.add_argument("--model", help=codex_model_help()) + ap.add_argument("--effort", choices=REASONING_EFFORTS, help=effort_help()) bg_group = ap.add_mutually_exclusive_group() bg_group.add_argument("--background", dest="background", action="store_true", help="Run in background mode") bg_group.add_argument("--no-background", dest="background", action="store_false", help="Run in blocking mode") @@ -50,12 +60,22 @@ def main() -> int: data = _load_json(raw) if "subagent_type" not in data: data["subagent_type"] = "sc-codex" - payload = TaskToolInput.model_validate(data) + try: + payload = TaskToolInput.model_validate(data) + except ValidationError as exc: + raise SystemExit(f"Invalid Task Tool input: {exc}") from exc else: payload = _build_payload(raw, "sc-codex") + if args.effort: + payload = payload.model_copy(update={"reasoning_effort": args.effort}) + runner = resolve_runner("codex") - model = resolve_model(runner, args.model or payload.model) + try: + model = resolve_model(runner, args.model or payload.model) + resolve_reasoning_effort(model, payload.reasoning_effort) + except ValueError as exc: + raise SystemExit(str(exc)) from exc if args.background is None: run_in_background = True if payload.run_in_background is None else bool(payload.run_in_background) else: diff --git a/packages/sc-codex/skills/codex-agent/SKILL.md b/packages/sc-codex/skills/codex-agent/SKILL.md index 6249f3b1f..cfdb579e5 100644 --- a/packages/sc-codex/skills/codex-agent/SKILL.md +++ b/packages/sc-codex/skills/codex-agent/SKILL.md @@ -1,7 +1,7 @@ --- name: codex-agent description: Run Codex tasks via the ai_cli Task Tool runner. -version: 0.13.0 +version: 0.14.0 --- # Codex Agent @@ -27,21 +27,42 @@ Provide Task Tool input JSON with: - `description` - `prompt` - `subagent_type` (defaults to `sc-codex` if not provided by the caller) +- `model` (optional; alias or slug, see below) +- `reasoning_effort` (optional; see below) +- `run_in_background` (optional; defaults to true for `sc_codex_task.py`) + +## Models and effort + +| Alias | Model slug | Supported `--effort` levels | +|-------|------------|-----------------------------| +| `sol` | `gpt-6-sol` | low, medium, high, xhigh, max, ultra | +| `astra` (default; also `codex`) | `gpt-6-astra` | low, medium, high, xhigh, max, ultra | +| `luna` | `gpt-6-luna` | low, medium, high, xhigh, max | +| — | `gpt-5.6-sol` | low, medium, high, xhigh, max, ultra | +| `terra` | `gpt-5.6-terra` | low, medium, high, xhigh, max, ultra | +| — | `gpt-5.6-luna` | low, medium, high, xhigh, max | +| — | `gpt-5.5` | low, medium, high, xhigh | + +- Default model (no `model`, or `codex`): `gpt-6-astra`. +- Full slugs are accepted as-is. Unknown models (including the removed legacy aliases + `gpt-5.2-codex`, `codex-max`/`max`, `codex-mini`/`mini`, `gpt-5`, `gpt-5.2`, `gtp-5`) are rejected. +- When `reasoning_effort` / `--effort` is omitted, no override is passed and the user's + `~/.codex/config.toml` applies. Unsupported model/effort combinations are rejected before launch. +- Map natural-language requests to flags or JSON fields: "use sc-codex with sol low" means + `--model sol --effort low` (or `"model":"sol","reasoning_effort":"low"` in the JSON payload); + "luna high" means `--model luna --effort high`. +- `--model` / `--effort` flags given before `--json` override the JSON fields. ## Notes -- Model flags (aliases and full names are accepted): - - `--model codex` (default: gpt-5.2-codex) - - `--model codex-max` or `--model max` (maps to gpt-5.1-codex-max) - - `--model codex-mini` or `--model mini` (maps to gpt-5.1-codex-mini) - - `--model gpt-5` (maps to gpt-5.2) - Background mode: - - Default is background unless `--no-background` is provided. + - Default is background unless `--no-background` is provided (or the JSON sets `"run_in_background": false`). - Add `--background` to force background explicitly. - Add `--no-background` to force blocking mode. - The JSON output includes `output_file` (JSONL transcript path) and `agentId`. - Poll `output_file` via a short Python loop (avoid `tail -f` and avoid `timeout`, which may be missing on macOS). -- Blocking mode (default without `--background`) returns `{ "output", "agentId" }`. +- Blocking mode (`--no-background` or `"run_in_background": false`) returns `{ "output", "agentId" }`. - The runner enforces schema validation and logs to `.claude/state/logs//`. -- If a Codex-native model is rejected for the current account, retry once with `gpt-5.2` compatibility mode before failing. +- If Codex rejects the model for a ChatGPT-account login, the runner retries once on `gpt-5.5` + automatically (effort clamped to at most `xhigh`); do not retry manually. - If `resume` is provided but the prior transcript cannot be reopened or does not contain usable assistant output, return a concise error instead of inventing missing context. diff --git a/test-packages/fixtures/sc-codex/tests/test_parallel_minis.yaml b/test-packages/fixtures/sc-codex/tests/test_parallel_minis.yaml index 58a85ff05..b8a4d9323 100644 --- a/test-packages/fixtures/sc-codex/tests/test_parallel_minis.yaml +++ b/test-packages/fixtures/sc-codex/tests/test_parallel_minis.yaml @@ -1,10 +1,10 @@ test_id: sc-codex-002 -test_name: Two mini runs in one prompt -description: Launch two codex-mini runs back-to-back and grade both results. +test_name: Two luna runs in one prompt +description: Launch two Codex luna (--model luna) runs back-to-back and grade both results. execution: prompt: | - /sc-codex Launch two background Codex mini runs: (1) write a short haiku about sunrise, (2) write a short haiku about the ocean. + /sc-codex Launch two background Codex runs with --model luna: (1) write a short haiku about sunrise, (2) write a short haiku about the ocean. Start both runs before waiting. Wait up to 8 seconds for each to finish. If they complete, return both haikus and two lines `Grade: N` (one per haiku) where N is a numeric score from 1-10. If either is still running after 8 seconds, reply only with `STILL RUNNING` and both output_file paths. @@ -15,14 +15,14 @@ execution: expectations: - id: exp-001 - description: Runs codex mini for sunrise + description: Runs codex luna for sunrise type: tool_call expected: tool: Bash pattern: "sc_codex_task.py.*sunrise" - id: exp-002 - description: Runs codex mini for ocean + description: Runs codex luna for ocean type: tool_call expected: tool: Bash diff --git a/test-packages/fixtures/sc-codex/tests/test_resume.yaml b/test-packages/fixtures/sc-codex/tests/test_resume.yaml index 0b831a504..5dcb922d5 100644 --- a/test-packages/fixtures/sc-codex/tests/test_resume.yaml +++ b/test-packages/fixtures/sc-codex/tests/test_resume.yaml @@ -8,9 +8,9 @@ description: | execution: prompt: | - /sc-codex --background --model mini write a short haiku about rain. Capture the agentId and output_file, but do not tail the log. + /sc-codex --background --model luna write a short haiku about rain. Capture the agentId and output_file, but do not tail the log. Verify the output_file exists, then derive from the output_file filename (basename without .jsonl). - Immediately run /sc-codex --json {"description":"Refine haiku","prompt":"Refine the previous haiku and improve imagery.","subagent_type":"sc-codex","model":"mini","resume":"","run_in_background":false}. + Immediately run /sc-codex --json {"description":"Refine haiku","prompt":"Refine the previous haiku and improve imagery.","subagent_type":"sc-codex","model":"luna","resume":"","run_in_background":false}. Replace with the derived value, and return the refined haiku with a line `Grade: N` (N is 1-10). Do not run any other Codex tasks. If the resume call fails, report the error and stop. model: haiku diff --git a/tests/test_ai_cli_task_runner.py b/tests/test_ai_cli_task_runner.py index c4a3ed18e..962e75b0a 100644 --- a/tests/test_ai_cli_task_runner.py +++ b/tests/test_ai_cli_task_runner.py @@ -9,11 +9,52 @@ def test_resolve_model_codex_aliases() -> None: - assert task_runner.resolve_model("codex", None) == "gpt-5.2-codex" - assert task_runner.resolve_model("codex", "codex") == "gpt-5.2-codex" - assert task_runner.resolve_model("codex", "max") == "gpt-5.1-codex-max" - assert task_runner.resolve_model("codex", "codex-mini") == "gpt-5.1-codex-mini" - assert task_runner.resolve_model("codex", "gpt-5") == "gpt-5.2" + assert task_runner.resolve_model("codex", None) == "gpt-6-astra" + assert task_runner.resolve_model("codex", "codex") == "gpt-6-astra" + assert task_runner.resolve_model("codex", "sol") == "gpt-6-sol" + assert task_runner.resolve_model("codex", "astra") == "gpt-6-astra" + assert task_runner.resolve_model("codex", "luna") == "gpt-6-luna" + assert task_runner.resolve_model("codex", "terra") == "gpt-5.6-terra" + + +@pytest.mark.parametrize( + "slug", + ["gpt-6-sol", "gpt-6-astra", "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"], +) +def test_resolve_model_codex_full_slugs(slug: str) -> None: + assert task_runner.resolve_model("codex", slug) == slug + + +@pytest.mark.parametrize( + "legacy", ["gpt-5.2-codex", "codex-max", "max", "codex-mini", "mini", "gpt-5", "gpt-5.2", "gtp-5"] +) +def test_resolve_model_codex_legacy_aliases_removed(legacy: str) -> None: + with pytest.raises(ValueError, match="Valid aliases: codex, sol, astra, luna, terra"): + task_runner.resolve_model("codex", legacy) + + +def test_model_catalog_consistent_with_schema_and_pydantic() -> None: + schema_path = ( + Path(__file__).resolve().parents[1] / "packages" / "sc-codex" / "schemas" / "task_tool.schema.json" + ) + schema = json.loads(schema_path.read_text(encoding="utf-8")) + props = schema["properties"] + expected_models = set(task_runner.CLAUDE_MODELS) | set(task_runner.CODEX_MODEL_NAMES) + assert set(props["model"]["enum"]) == expected_models + assert props["reasoning_effort"]["enum"] == list(task_runner.REASONING_EFFORTS) + + generated = task_runner.TaskToolInput.model_json_schema()["properties"] + generated_models = set() + for option in generated["model"]["anyOf"]: + generated_models.update(option.get("enum", [])) + assert generated_models == expected_models + effort_enum = [o for o in generated["reasoning_effort"]["anyOf"] if "enum" in o][0]["enum"] + assert effort_enum == list(task_runner.REASONING_EFFORTS) + + # Every alias targets a known slug; every slug has an effort table. + assert set(task_runner.CODEX_MODEL_ALIASES.values()) <= set(task_runner.CODEX_MODEL_SLUGS) + for efforts in task_runner.CODEX_MODEL_EFFORTS.values(): + assert set(efforts) <= set(task_runner.REASONING_EFFORTS) def test_resolve_model_claude_defaults() -> None: @@ -23,7 +64,7 @@ def test_resolve_model_claude_defaults() -> None: def test_resolve_model_invalid_claude() -> None: with pytest.raises(ValueError): - task_runner.resolve_model("claude", "gpt-5") + task_runner.resolve_model("claude", "gpt-6-sol") def test_resolve_model_invalid_codex() -> None: @@ -31,41 +72,209 @@ def test_resolve_model_invalid_codex() -> None: task_runner.resolve_model("codex", "sonnet") +class _Result: + def __init__(self, returncode: int, stdout: str = "", stderr: str = ""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +_ACCOUNT_ERR = "The '{m}' model is not supported when using Codex with a ChatGPT account." + + +def _capture_codex(monkeypatch: pytest.MonkeyPatch, results=None) -> list: + calls: list = [] + + def fake_run(cmd, text, capture_output): + calls.append(cmd) + if results: + return results[len(calls) - 1] + return _Result(0, stdout="ok") + + monkeypatch.setattr(task_runner, "_check_runner_available", lambda runner: f"{runner} 1.0") + monkeypatch.setattr(task_runner.subprocess, "run", fake_run) + monkeypatch.setattr(task_runner, "write_log", lambda event: None) + return calls + + def test_codex_account_fallback_detection() -> None: + assert task_runner._should_fallback_codex_model("gpt-6-sol", _ACCOUNT_ERR.format(m="gpt-6-sol")) is True assert ( - task_runner._should_fallback_codex_model( - "gpt-5.2-codex", - "The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", - ) - is True + task_runner._should_fallback_codex_model("gpt-5.6-terra", _ACCOUNT_ERR.format(m="gpt-5.6-terra")) is True ) - assert task_runner._should_fallback_codex_model("gpt-5.2", "unsupported") is False + # Never falls back from the fallback model itself, nor on unrelated errors. + assert task_runner._should_fallback_codex_model("gpt-5.5", _ACCOUNT_ERR.format(m="gpt-5.5")) is False + assert task_runner._should_fallback_codex_model("gpt-6-sol", "unsupported") is False def test_run_sync_retries_codex_with_general_model(monkeypatch: pytest.MonkeyPatch) -> None: - calls = [] + calls = _capture_codex( + monkeypatch, [_Result(1, stderr=_ACCOUNT_ERR.format(m="gpt-6-sol")), _Result(0, stdout="ok")] + ) + assert task_runner.run_sync("codex", "gpt-6-sol", "hello", "ultra") == "ok" + assert calls[0][4] == "gpt-6-sol" + assert calls[0][5:7] == ["-c", 'model_reasoning_effort="ultra"'] + assert calls[1][4] == "gpt-5.5" + # ultra is not supported by gpt-5.5, so the retry clamps to its highest level. + assert calls[1][5:7] == ["-c", 'model_reasoning_effort="xhigh"'] + + +def test_codex_cmd_sol_low() -> None: + model = task_runner.resolve_model("codex", "sol") + effort = task_runner.resolve_reasoning_effort(model, "low") + assert task_runner._codex_cmd(model, effort, "hi") == [ + "codex", + "exec", + "--yolo", + "--model", + "gpt-6-sol", + "-c", + 'model_reasoning_effort="low"', + "hi", + ] + + +def test_run_sync_passes_effort_when_set(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _capture_codex(monkeypatch) + task_runner.run_sync("codex", "gpt-6-luna", "hi", "medium") + assert calls[0] == ["codex", "exec", "--yolo", "--model", "gpt-6-luna", "-c", 'model_reasoning_effort="medium"', "hi"] + + +def test_run_sync_omits_effort_when_unset(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _capture_codex(monkeypatch) + task_runner.run_sync("codex", "gpt-6-astra", "hi") + assert calls[0] == ["codex", "exec", "--yolo", "--model", "gpt-6-astra", "hi"] + assert "-c" not in calls[0] + + +def test_run_sync_claude_ignores_effort(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _capture_codex(monkeypatch) + task_runner.run_sync("claude", "sonnet", "hi", "ultra") + assert calls[0] == ["claude", "--model", "sonnet", "--print", "hi"] + + +@pytest.mark.parametrize( + "model,effort", + [("gpt-6-luna", "ultra"), ("gpt-5.6-luna", "ultra"), ("gpt-5.5", "max"), ("gpt-5.5", "ultra")], +) +def test_unsupported_effort_for_model_rejected(model: str, effort: str) -> None: + with pytest.raises(ValueError, match="not supported by"): + task_runner.resolve_reasoning_effort(model, effort) + + +@pytest.mark.parametrize( + "model,effort", + [("gpt-6-sol", "ultra"), ("gpt-6-astra", "ultra"), ("gpt-6-luna", "max"), ("gpt-5.5", "xhigh")], +) +def test_supported_effort_for_model_accepted(model: str, effort: str) -> None: + assert task_runner.resolve_reasoning_effort(model, effort) == effort + assert task_runner.resolve_reasoning_effort(model, None) is None + + +def test_run_task_rejects_unsupported_effort_before_launch(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _capture_codex(monkeypatch) + payload = task_runner.TaskToolInput( + description="Test", prompt="x", subagent_type="test", reasoning_effort="ultra" + ) + with pytest.raises(ValueError, match="not supported by gpt-5.5"): + task_runner.run_task(payload, runner="codex", model="gpt-5.5", run_in_background=True) + assert calls == [] - class Result: - def __init__(self, returncode: int, stdout: str = "", stderr: str = ""): - self.returncode = returncode - self.stdout = stdout - self.stderr = stderr - def fake_run(cmd, text, capture_output): - calls.append(cmd) - if len(calls) == 1: - return Result( - 1, - stderr="The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.", - ) - return Result(0, stdout="ok") +def test_payload_rejects_unknown_effort() -> None: + with pytest.raises(Exception): + task_runner.TaskToolInput(description="T", prompt="x", subagent_type="t", reasoning_effort="extreme") + + +def _run_sc_codex_task(monkeypatch: pytest.MonkeyPatch, argv: list[str]) -> dict: + import importlib + import sys + + sc_codex_task = importlib.import_module("sc_codex_task") + captured: dict = {} + + def fake_run_task(payload, runner, model, run_in_background, raise_on_error=True, output_dir=None): + captured.update(payload=payload, runner=runner, model=model, background=run_in_background) + return task_runner.TaskToolOutputForeground(output="ok", agentId="a") + + monkeypatch.setattr(sc_codex_task, "resolve_runner", lambda preferred: "codex") + monkeypatch.setattr(sc_codex_task, "run_task", fake_run_task) + monkeypatch.setattr(sys, "argv", ["sc_codex_task.py", *argv]) + assert sc_codex_task.main() == 0 + return captured + + +def test_sc_codex_task_cli_model_and_effort(monkeypatch: pytest.MonkeyPatch) -> None: + got = _run_sc_codex_task(monkeypatch, ["--model", "sol", "--effort", "low", "hello"]) + assert got["model"] == "gpt-6-sol" + assert got["payload"].reasoning_effort == "low" + # Default for sc_codex_task.py is background mode. + assert got["background"] is True + + +def test_sc_codex_task_cli_overrides_payload(monkeypatch: pytest.MonkeyPatch) -> None: + payload = json.dumps( + {"description": "d", "prompt": "p", "model": "luna", "reasoning_effort": "high"} + ) + got = _run_sc_codex_task(monkeypatch, ["--model", "astra", "--effort", "xhigh", "--json", payload]) + assert got["model"] == "gpt-6-astra" + assert got["payload"].reasoning_effort == "xhigh" + + +def test_sc_codex_task_payload_effort_used_without_flag(monkeypatch: pytest.MonkeyPatch) -> None: + payload = json.dumps({"description": "d", "prompt": "p", "model": "terra", "reasoning_effort": "max"}) + got = _run_sc_codex_task(monkeypatch, ["--json", payload]) + assert got["model"] == "gpt-5.6-terra" + assert got["payload"].reasoning_effort == "max" + + +def test_sc_codex_task_rejects_unsupported_effort(monkeypatch: pytest.MonkeyPatch) -> None: + with pytest.raises(SystemExit, match="not supported by gpt-6-luna"): + _run_sc_codex_task(monkeypatch, ["--model", "luna", "--effort", "ultra", "hello"]) + + +def test_background_payload_carries_effort(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + popen_cmds = [] + logs = [] + + class FakeProc: + def poll(self): + return None + + def fake_popen(cmd, stdout, stderr, env): + popen_cmds.append(cmd) + return FakeProc() monkeypatch.setattr(task_runner, "_check_runner_available", lambda runner: f"{runner} 1.0") - monkeypatch.setattr(task_runner.subprocess, "run", fake_run) + monkeypatch.setattr(task_runner, "resolve_agent_path", lambda subagent_type, runner: None) + monkeypatch.setattr(task_runner.subprocess, "Popen", fake_popen) + monkeypatch.setattr(task_runner, "write_log", lambda event: logs.append(event)) + + payload = task_runner.TaskToolInput( + description="Test", prompt="bg", subagent_type="test", reasoning_effort="high" + ) + result = task_runner.run_task( + payload, runner="codex", model="gpt-6-sol", run_in_background=True, output_dir=tmp_path + ) + payload_file = tmp_path / f"{result.agentId}.input.json" + stored = task_runner.TaskToolInput.model_validate_json(payload_file.read_text(encoding="utf-8")) + assert stored.reasoning_effort == "high" + assert "--input-file" in popen_cmds[0] + start = [e for e in logs if e.get("event") == "task_start"][0] + assert start["reasoning_effort"] == "high" + + # The child process re-reads the payload and passes effort through to run_sync. + seen = {} - assert task_runner.run_sync("codex", "gpt-5.2-codex", "hello") == "ok" - assert calls[0][4] == "gpt-5.2-codex" - assert calls[1][4] == "gpt-5.2" + def fake_run_sync(runner, model, prompt, reasoning_effort=None): + seen.update(model=model, effort=reasoning_effort) + return "done" + + monkeypatch.setattr(task_runner, "run_sync", fake_run_sync) + task_runner.run_background_child_with_payload( + stored, "codex", "gpt-6-sol", tmp_path / f"{result.agentId}.jsonl", result.agentId + ) + assert seen == {"model": "gpt-6-sol", "effort": "high"} def test_resolve_runner_prefers_claude(monkeypatch: pytest.MonkeyPatch) -> None: @@ -152,7 +361,7 @@ def test_run_task_logs_error(monkeypatch: pytest.MonkeyPatch) -> None: def fake_log(event): calls.append(event) - def fake_run_sync(runner, model, prompt): + def fake_run_sync(runner, model, prompt, reasoning_effort=None): raise RuntimeError("boom") monkeypatch.setattr(task_runner, "write_log", fake_log) From fb1d60fb2038427945921341f204150cbe9e8658 Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Sun, 27 Sep 2026 20:45:21 -0700 Subject: [PATCH 2/3] chore(sc-codex): release 0.14.0 Bump version via set-package-version.py, add CHANGELOG entry, and register the sc-codex agent in .claude/agents/registry.yaml. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/registry.json | 46 ++++++++++---------- .claude/agents/registry.yaml | 3 ++ docs/registries/nuget/registry.json | 44 +++++++++---------- packages/sc-codex/.claude-plugin/plugin.json | 2 +- packages/sc-codex/CHANGELOG.md | 29 ++++++++++++ packages/sc-codex/agents/sc-codex.md | 2 +- packages/sc-codex/manifest.yaml | 2 +- 8 files changed, 81 insertions(+), 49 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e9df88f53..2791e4cec 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -53,7 +53,7 @@ "name": "sc-codex", "source": "./packages/sc-codex", "description": "Run Codex agents via a Task Tool-compatible runner, with hooks emulation and background execution support.\n", - "version": "0.13.0", + "version": "0.14.0", "author": { "name": "synaptic-canvas" }, diff --git a/.claude-plugin/registry.json b/.claude-plugin/registry.json index e15f40ced..c53bd7505 100644 --- a/.claude-plugin/registry.json +++ b/.claude-plugin/registry.json @@ -21,7 +21,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.118783+00:00" + "lastUpdated": "2026-09-28T03:32:40.641476+00:00" }, { "name": "sc-ci-automation", @@ -38,11 +38,11 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.119819+00:00" + "lastUpdated": "2026-09-28T03:32:40.642386+00:00" }, { "name": "sc-codex", - "version": "0.13.0", + "version": "0.14.0", "description": "Run Codex agents via a Task Tool-compatible runner, with hooks emulation and background execution support.\n", "author": "synaptic-canvas", "license": "MIT", @@ -55,7 +55,7 @@ "scripts": 2, "schemas": 2 }, - "lastUpdated": "2026-09-23T06:05:07.120619+00:00" + "lastUpdated": "2026-09-28T03:32:40.643120+00:00" }, { "name": "sc-coding-agent-hardening", @@ -72,7 +72,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.121081+00:00" + "lastUpdated": "2026-09-28T03:32:40.643539+00:00" }, { "name": "sc-commit-push-pr", @@ -89,7 +89,7 @@ "scripts": 9, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.121745+00:00" + "lastUpdated": "2026-09-28T03:32:40.644173+00:00" }, { "name": "sc-delay-tasks", @@ -106,7 +106,7 @@ "scripts": 2, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.122357+00:00" + "lastUpdated": "2026-09-28T03:32:40.644761+00:00" }, { "name": "sc-docling-pdf", @@ -123,7 +123,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.123165+00:00" + "lastUpdated": "2026-09-28T03:32:40.645518+00:00" }, { "name": "sc-gh-stack", @@ -140,7 +140,7 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.124081+00:00" + "lastUpdated": "2026-09-28T03:32:40.646414+00:00" }, { "name": "sc-git-worktree", @@ -157,7 +157,7 @@ "scripts": 7, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.125067+00:00" + "lastUpdated": "2026-09-28T03:32:40.647308+00:00" }, { "name": "sc-github-issue", @@ -174,7 +174,7 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.126099+00:00" + "lastUpdated": "2026-09-28T03:32:40.648300+00:00" }, { "name": "sc-just", @@ -191,7 +191,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.127102+00:00" + "lastUpdated": "2026-09-28T03:32:40.649212+00:00" }, { "name": "sc-kanban", @@ -208,7 +208,7 @@ "scripts": 5, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.127868+00:00" + "lastUpdated": "2026-09-28T03:32:40.649896+00:00" }, { "name": "sc-launch-term", @@ -225,7 +225,7 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.128494+00:00" + "lastUpdated": "2026-09-28T03:32:40.650534+00:00" }, { "name": "sc-launchpad", @@ -242,7 +242,7 @@ "scripts": 2, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.129165+00:00" + "lastUpdated": "2026-09-28T03:32:40.651213+00:00" }, { "name": "sc-manage", @@ -259,7 +259,7 @@ "scripts": 8, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.129845+00:00" + "lastUpdated": "2026-09-28T03:32:40.651883+00:00" }, { "name": "sc-refactory", @@ -276,7 +276,7 @@ "scripts": 10, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.130913+00:00" + "lastUpdated": "2026-09-28T03:32:40.652907+00:00" }, { "name": "sc-repomix-nuget", @@ -293,7 +293,7 @@ "scripts": 3, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.131556+00:00" + "lastUpdated": "2026-09-28T03:32:40.653573+00:00" }, { "name": "sc-roslyn-diff", @@ -310,7 +310,7 @@ "scripts": 6, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.132277+00:00" + "lastUpdated": "2026-09-28T03:32:40.654274+00:00" }, { "name": "sc-rust", @@ -327,7 +327,7 @@ "scripts": 0, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.133451+00:00" + "lastUpdated": "2026-09-28T03:32:40.655335+00:00" }, { "name": "sc-startup", @@ -344,7 +344,7 @@ "scripts": 1, "schemas": 0 }, - "lastUpdated": "2026-09-23T06:05:07.134183+00:00" + "lastUpdated": "2026-09-28T03:32:40.656028+00:00" } ], "metadata": { @@ -355,6 +355,6 @@ "totalScripts": 63, "totalSchemas": 2 }, - "generated": "2026-09-23T06:05:07.134194+00:00", - "lastUpdated": "2026-09-23T06:05:07.134195+00:00" + "generated": "2026-09-28T03:32:40.656038+00:00", + "lastUpdated": "2026-09-28T03:32:40.656039+00:00" } diff --git a/.claude/agents/registry.yaml b/.claude/agents/registry.yaml index 73d4aa5e2..528eec39c 100644 --- a/.claude/agents/registry.yaml +++ b/.claude/agents/registry.yaml @@ -92,6 +92,9 @@ agents: rust-service-hardening-agent: path: packages/sc-rust/agents/rust-service-hardening-agent.md version: 0.13.0 + sc-codex: + path: packages/sc-codex/agents/sc-codex.md + version: 0.14.0 skills: ci-automation: depends_on: diff --git a/docs/registries/nuget/registry.json b/docs/registries/nuget/registry.json index 3c15c145a..4690a63f7 100644 --- a/docs/registries/nuget/registry.json +++ b/docs/registries/nuget/registry.json @@ -1,7 +1,7 @@ { "$schema": "https://yourcompany.github.io/schemas/package-registry.schema.json", "version": "2.0.0", - "generated": "2026-09-23T06:05:07Z", + "generated": "2026-09-28T03:32:40Z", "repo": "randlee/synaptic-canvas", "marketplace": { "name": "Synaptic Canvas", @@ -45,12 +45,12 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-ci-automation/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-codex": { "name": "sc-codex", - "version": "0.13.0", + "version": "0.14.0", "status": "beta", "tier": 0, "description": "Run Codex agents via a Task Tool-compatible runner, with hooks emulation and background execution support.\n", @@ -82,7 +82,7 @@ "pyyaml" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-codex/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-delay-tasks": { @@ -119,7 +119,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-delay-tasks/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-docling-pdf": { @@ -162,7 +162,7 @@ "transformers < 5.5" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-docling-pdf/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-git-worktree": { @@ -206,7 +206,7 @@ "git >= 2.20" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-git-worktree/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [ "sc-github-issue" ] @@ -246,7 +246,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-github-issue/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-launch-term": { @@ -289,7 +289,7 @@ "Python 3 launcher on PATH (`python3`, `py -3`, or `python`)" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-launch-term/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-kanban": { @@ -328,7 +328,7 @@ "sc-git-worktree>=0.5.2" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-kanban/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-launchpad": { @@ -369,7 +369,7 @@ "gemini", "pydantic" ], - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [], "readme": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-launchpad/README.md", "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-launchpad/CHANGELOG.md" @@ -407,7 +407,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-manage/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-repomix-nuget": { @@ -450,7 +450,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-repomix-nuget/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-roslyn-diff": { @@ -491,7 +491,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-roslyn-diff/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-startup": { @@ -530,7 +530,7 @@ "pydantic" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-startup/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-commit-push-pr": { @@ -569,7 +569,7 @@ "gh" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-commit-push-pr/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-rust": { @@ -610,7 +610,7 @@ "sc-compose" ], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-rust/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-ai-cli": { @@ -645,7 +645,7 @@ }, "dependencies": [], "changelog": "https://raw.githubusercontent.com/randlee/synaptic-canvas/main/packages/sc-ai-cli/CHANGELOG.md", - "lastUpdated": "2026-09-23", + "lastUpdated": "2026-09-28", "dependents": [] }, "sc-coding-agent-hardening": { @@ -680,7 +680,7 @@ "schemas": 0 }, "dependencies": [], - "lastUpdated": "2026-09-23" + "lastUpdated": "2026-09-28" }, "sc-just": { "name": "sc-just", @@ -716,7 +716,7 @@ "just >= 1.0", "python3 >= 3.11" ], - "lastUpdated": "2026-09-23" + "lastUpdated": "2026-09-28" }, "sc-gh-stack": { "name": "sc-gh-stack", @@ -756,7 +756,7 @@ "github/gh-stack >= 0.1.0", "jq" ], - "lastUpdated": "2026-09-23" + "lastUpdated": "2026-09-28" }, "sc-refactory": { "name": "sc-refactory", @@ -797,7 +797,7 @@ "PyYAML", "pydantic" ], - "lastUpdated": "2026-09-23" + "lastUpdated": "2026-09-28" } }, "metadata": { diff --git a/packages/sc-codex/.claude-plugin/plugin.json b/packages/sc-codex/.claude-plugin/plugin.json index f2178336a..7ba2e5865 100644 --- a/packages/sc-codex/.claude-plugin/plugin.json +++ b/packages/sc-codex/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "sc-codex", "description": "Run Codex agents via a Task Tool-compatible runner, with hooks emulation and background execution support.", - "version": "0.13.0", + "version": "0.14.0", "author": { "name": "synaptic-canvas" }, diff --git a/packages/sc-codex/CHANGELOG.md b/packages/sc-codex/CHANGELOG.md index 0fe8b521f..d546bea77 100644 --- a/packages/sc-codex/CHANGELOG.md +++ b/packages/sc-codex/CHANGELOG.md @@ -3,6 +3,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.14.0] - 2026-09-27 +### Added +- gpt-6 model support. Aliases: `sol` -> `gpt-6-sol`, `astra` -> `gpt-6-astra`, + `luna` -> `gpt-6-luna`, `terra` -> `gpt-5.6-terra`, `codex` -> default. Full slugs + `gpt-6-sol`, `gpt-6-astra`, `gpt-6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, + `gpt-5.5` are accepted as-is. +- `--effort {low,medium,high,xhigh,max,ultra}` on `sc_codex_task.py` and `ai_cli run`, and an + optional `reasoning_effort` payload/schema field. Passed to Codex as + `-c model_reasoning_effort=""`; omitted when unset so `~/.codex/config.toml` applies. + Levels are validated per model. Effort is carried in the payload (background runs keep it) + and logged as a top-level `reasoning_effort` field. Ignored by the Claude runner. +- Command-line `--model` / `--effort` override the JSON payload. +- `sc-codex` agent added to `.claude/agents/registry.yaml`. + +### Changed +- Default Codex model is now `gpt-6-astra` (was `gpt-5.2-codex`). +- ChatGPT-account fallback now retries once on `gpt-5.5` for any other model (was + `gpt-5.2` for codex-native models only), clamping effort to `xhigh`, and logs a + `model_fallback` event. +- Docs: README Quick Start uses real flags; command/skill docs describe models, effort and the + actual background default (background for `sc_codex_task.py`, blocking for `ai_cli run`). + +### Removed (breaking) +- Legacy model aliases `gpt-5.2-codex`, `codex-max`/`max`, `codex-mini`/`mini`, `gpt-5`, + `gpt-5.2`, `gtp-5`. Unknown models now fail with an error listing valid aliases and slugs. + +## [0.8.0 - 0.13.0] +- Intermediate versions (repo-wide version bumps) were not recorded in this changelog. + ## [0.7.0] - 2026-01-20 ### Added - Task Tool-compatible Codex runner with hook emulation. diff --git a/packages/sc-codex/agents/sc-codex.md b/packages/sc-codex/agents/sc-codex.md index 15ea75c11..384fa79c6 100644 --- a/packages/sc-codex/agents/sc-codex.md +++ b/packages/sc-codex/agents/sc-codex.md @@ -1,6 +1,6 @@ --- name: sc-codex -version: 0.13.0 +version: 0.14.0 description: Codex agent placeholder for Task Tool execution and hook resolution. model: codex color: blue diff --git a/packages/sc-codex/manifest.yaml b/packages/sc-codex/manifest.yaml index f9bcbe4ca..8ed589930 100644 --- a/packages/sc-codex/manifest.yaml +++ b/packages/sc-codex/manifest.yaml @@ -1,5 +1,5 @@ name: sc-codex -version: 0.13.0 +version: 0.14.0 description: > Run Codex agents via a Task Tool-compatible runner, with hooks emulation and background execution support. From 25344772d97008410e44d51bb8e1906a7eeea373 Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Sun, 27 Sep 2026 20:52:38 -0700 Subject: [PATCH 3/3] fix(sc-codex): address review feedback - Apply --model/--effort to the raw payload before validation in sc_codex_task.py and ai_cli run, persisting the resolved model so background payloads and logs reflect what ran. - Add cmd_run tests (effort override, unsupported effort, override of invalid payload values) and schema nullability parity checks. - Allow null for optional fields in task_tool.schema.json. - Docs: effort-vs-model disambiguation, "alongside --json" wording, minimal effort note, --runner codex in ai_cli examples, argv-accurate -c quoting; CHANGELOG note replaces range heading. - Rename fixture test_parallel_minis.yaml -> test_parallel_luna.yaml. Co-Authored-By: Claude Opus 5.5 --- packages/sc-codex/CHANGELOG.md | 8 +- packages/sc-codex/README.md | 5 +- packages/sc-codex/commands/sc-codex.md | 12 +- .../sc-codex/schemas/task_tool.schema.json | 31 ++++-- packages/sc-codex/scripts/ai_cli/README.md | 11 +- packages/sc-codex/scripts/ai_cli/cli.py | 9 +- .../sc-codex/scripts/ai_cli/task_runner.py | 18 +++ packages/sc-codex/scripts/sc_codex_task.py | 29 +++-- packages/sc-codex/skills/codex-agent/SKILL.md | 8 +- ...lel_minis.yaml => test_parallel_luna.yaml} | 0 tests/test_ai_cli_task_runner.py | 105 +++++++++++++++++- 11 files changed, 196 insertions(+), 40 deletions(-) rename test-packages/fixtures/sc-codex/tests/{test_parallel_minis.yaml => test_parallel_luna.yaml} (100%) diff --git a/packages/sc-codex/CHANGELOG.md b/packages/sc-codex/CHANGELOG.md index d546bea77..4aa975666 100644 --- a/packages/sc-codex/CHANGELOG.md +++ b/packages/sc-codex/CHANGELOG.md @@ -14,7 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). `-c model_reasoning_effort=""`; omitted when unset so `~/.codex/config.toml` applies. Levels are validated per model. Effort is carried in the payload (background runs keep it) and logged as a top-level `reasoning_effort` field. Ignored by the Claude runner. -- Command-line `--model` / `--effort` override the JSON payload. +- Command-line `--model` / `--effort` override the JSON payload (applied before validation, and + the resolved model is persisted in the payload for background runs). - `sc-codex` agent added to `.claude/agents/registry.yaml`. ### Changed @@ -28,9 +29,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Removed (breaking) - Legacy model aliases `gpt-5.2-codex`, `codex-max`/`max`, `codex-mini`/`mini`, `gpt-5`, `gpt-5.2`, `gtp-5`. Unknown models now fail with an error listing valid aliases and slugs. + `max` is now only an effort level. -## [0.8.0 - 0.13.0] -- Intermediate versions (repo-wide version bumps) were not recorded in this changelog. +### Notes +- Versions 0.8.0–0.13.0 were released without changelog entries. ## [0.7.0] - 2026-01-20 ### Added diff --git a/packages/sc-codex/README.md b/packages/sc-codex/README.md index 298b7c833..a88084d62 100644 --- a/packages/sc-codex/README.md +++ b/packages/sc-codex/README.md @@ -44,7 +44,10 @@ Models (`--model` or JSON `model`; default `gpt-6-astra`): | — | `gpt-5.6-luna` | low, medium, high, xhigh, max | | — | `gpt-5.5` | low, medium, high, xhigh | -- Reasoning effort: `--effort` or JSON `reasoning_effort`. When omitted, no override is passed and +`minimal` effort is not supported: no current model accepts it (all start at `low`), so it is rejected. + +- Reasoning effort: `--effort` or JSON `reasoning_effort`, passed to Codex as + `-c 'model_reasoning_effort=""'`. When omitted, no override is passed and your `~/.codex/config.toml` applies. Unsupported model/effort combinations are rejected. - Command-line flags override the JSON payload. - Breaking in 0.14.0: legacy aliases (`gpt-5.2-codex`, `codex-max`/`max`, `codex-mini`/`mini`, diff --git a/packages/sc-codex/commands/sc-codex.md b/packages/sc-codex/commands/sc-codex.md index f8fd311a9..3468f08e8 100644 --- a/packages/sc-codex/commands/sc-codex.md +++ b/packages/sc-codex/commands/sc-codex.md @@ -55,6 +55,8 @@ Models and effort: | — | `gpt-5.6-luna` | low, medium, high, xhigh, max | | — | `gpt-5.5` | low, medium, high, xhigh | +`minimal` effort is not supported: no current model accepts it (all start at `low`), so it is rejected. + Legacy aliases (`gpt-5.2-codex`, `codex-max`/`max`, `codex-mini`/`mini`, `gpt-5`, `gpt-5.2`, `gtp-5`) were removed in 0.14.0 and are rejected with an error listing the valid names. An effort level the chosen model does not support is also rejected. @@ -65,9 +67,13 @@ Use the `codex-agent` skill to launch a Codex task **in background mode**. Interpret arguments as follows: - `--help`: print usage (with flags) and stop. -- `--model `: set `model` in the Task Tool JSON (or pass `--model` before `--json`). -- `--effort `: set `reasoning_effort` in the Task Tool JSON (or pass `--effort` before `--json`). +- `--model `: set `model` in the Task Tool JSON (or pass `--model` alongside `--json`). +- `--effort `: set `reasoning_effort` in the Task Tool JSON (or pass `--effort` alongside `--json`). Natural language such as "use sc-codex with sol low" means `--model sol --effort low`. +- Disambiguation: `low`, `medium`, `high`, `xhigh`, `max`, `ultra` are always effort levels, never + models. `max` was a model alias before 0.14.0 and no longer is ("codex max" means + `--effort max` on the default model). A bare level with no model (e.g. "sc-codex high") means the + default model (`gpt-6-astra`) with that effort. - `--background`: force background mode (default for this command). - `--no-background`: force blocking mode (explicit override). - `--json `: treat the remaining arguments as Task Tool JSON and pass through unchanged. @@ -83,7 +89,7 @@ When running: Omit `reasoning_effort` when the user did not ask for one. 2) Call the codex-agent runner as a Bash tool call using `--json`. Always use `python3 .claude/scripts/sc_codex_task.py --json '{...}'` (do not call other runner scripts). - `--model`/`--effort` flags placed before `--json` override the matching JSON fields. + `--model`/`--effort` flags given alongside `--json` override payload fields. Do not invent flags like `--run_in_background`, `--description`, `--prompt`, or `--subagent_type`. 3) Poll the `output_file` for up to 8 seconds using Python (avoid `tail -f` and avoid `timeout`, which may be missing on macOS): diff --git a/packages/sc-codex/schemas/task_tool.schema.json b/packages/sc-codex/schemas/task_tool.schema.json index de8136c97..196063396 100644 --- a/packages/sc-codex/schemas/task_tool.schema.json +++ b/packages/sc-codex/schemas/task_tool.schema.json @@ -21,7 +21,10 @@ "description": "The type of specialized agent to use for this task" }, "model": { - "type": "string", + "type": [ + "string", + "null" + ], "enum": [ "sonnet", "opus", @@ -37,34 +40,48 @@ "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", - "gpt-5.5" + "gpt-5.5", + null ], "description": "Optional model to use for this agent. If not specified, inherits from parent. Claude models: sonnet, opus, haiku. Codex aliases: sol, astra, luna, terra, codex (default, gpt-6-astra). Codex slugs: gpt-6-sol, gpt-6-astra, gpt-6-luna, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5." }, "reasoning_effort": { - "type": "string", + "type": [ + "string", + "null" + ], "enum": [ "low", "medium", "high", "xhigh", "max", - "ultra" + "ultra", + null ], "description": "Optional Codex reasoning effort: low, medium, high, xhigh, max, ultra (supported levels vary by model). When omitted, no effort override is passed and the Codex CLI config (~/.codex/config.toml) applies. Ignored by the Claude runner." }, "run_in_background": { - "type": "boolean", + "type": [ + "boolean", + "null" + ], "description": "Set to true to run this agent in the background. The tool result will include an output_file path - use Read tool or Bash tail to check on output." }, "max_turns": { - "type": "integer", + "type": [ + "integer", + "null" + ], "exclusiveMinimum": 0, "maximum": 9007199254740991, "description": "Maximum number of agentic turns (API round-trips) before stopping. Used internally for warmup." }, "resume": { - "type": "string", + "type": [ + "string", + "null" + ], "description": "Optional agent ID to resume from. If provided, the agent will continue from the previous execution transcript." } }, diff --git a/packages/sc-codex/scripts/ai_cli/README.md b/packages/sc-codex/scripts/ai_cli/README.md index e1d800054..409b93187 100644 --- a/packages/sc-codex/scripts/ai_cli/README.md +++ b/packages/sc-codex/scripts/ai_cli/README.md @@ -20,7 +20,7 @@ Background output (run_in_background: true): { "description": "Summarize issue", "prompt": "Summarize the latest GitHub issue thread", - "subagent_type": "issue-summary", + "subagent_type": "sc-codex", "model": "sol", "reasoning_effort": "low", "run_in_background": false, @@ -68,10 +68,11 @@ Validate input from file: PYTHONPATH=packages/sc-codex/scripts python3 -m ai_cli validate --file /path/to/input.json ``` -Run a task (blocking by default, runner auto-selects based on availability): +Run a task (blocking by default). Without `--runner`, the runner auto-selects (Claude first if +installed), so pass `--runner codex` for Codex models such as `sol`: ```bash -PYTHONPATH=packages/sc-codex/scripts python3 -m ai_cli run --file /path/to/input.json +PYTHONPATH=packages/sc-codex/scripts python3 -m ai_cli run --runner codex --file /path/to/input.json ``` Run in background with Codex and custom output directory: @@ -87,7 +88,7 @@ Override model and reasoning effort from the command line (flags override the JS ```bash PYTHONPATH=packages/sc-codex/scripts python3 -m ai_cli run --runner codex --model sol --effort low --file /path/to/input.json -# -> codex exec --yolo --model gpt-6-sol -c model_reasoning_effort="low" +# -> codex exec --yolo --model gpt-6-sol -c 'model_reasoning_effort="low"' ``` Model defaults: @@ -107,6 +108,8 @@ Codex models (the catalog is defined once in `ai_cli/task_tool.py`; `CODEX_MODEL | — | `gpt-5.6-luna` | low, medium, high, xhigh, max | | — | `gpt-5.5` | low, medium, high, xhigh | +`minimal` effort is not supported: no current model accepts it (all start at `low`), so it is rejected. + Full slugs are accepted as-is; unknown names raise an error listing valid aliases and slugs. Reasoning effort (`reasoning_effort` in JSON, `--effort` on the CLI): diff --git a/packages/sc-codex/scripts/ai_cli/cli.py b/packages/sc-codex/scripts/ai_cli/cli.py index 276ded131..7e45d991f 100644 --- a/packages/sc-codex/scripts/ai_cli/cli.py +++ b/packages/sc-codex/scripts/ai_cli/cli.py @@ -12,6 +12,7 @@ from ai_cli.logging import write_log from ai_cli.task_runner import ( REASONING_EFFORTS, + apply_cli_overrides, effort_help, run_background_child_with_payload, resolve_runner, @@ -44,11 +45,11 @@ def cmd_validate(args: argparse.Namespace) -> int: def cmd_run(args: argparse.Namespace) -> int: data = _read_json(args.file) - payload = TaskToolInput.model_validate(data) - if args.effort: - payload = payload.model_copy(update={"reasoning_effort": args.effort}) runner = resolve_runner(args.runner) - model = resolve_model(runner, args.model or payload.model) + # CLI flags override payload fields before validation. + data = apply_cli_overrides(data, runner, args.model, args.effort) + payload = TaskToolInput.model_validate(data) + model = resolve_model(runner, payload.model) if args.background is None: run_in_background = bool(payload.run_in_background) else: diff --git a/packages/sc-codex/scripts/ai_cli/task_runner.py b/packages/sc-codex/scripts/ai_cli/task_runner.py index cfa60a167..404252462 100644 --- a/packages/sc-codex/scripts/ai_cli/task_runner.py +++ b/packages/sc-codex/scripts/ai_cli/task_runner.py @@ -106,6 +106,24 @@ def resolve_model(runner: RunnerType, model: Optional[str]) -> str: return resolved +def apply_cli_overrides( + data: dict, runner: RunnerType, model: Optional[str] = None, effort: Optional[str] = None +) -> dict: + """Apply command-line ``--model`` / ``--effort`` to a raw payload dict. + + Runs *before* pydantic validation so a CLI override wins even when the + payload's own value is invalid (e.g. a removed legacy alias). The model is + stored resolved (full slug), so the persisted/background payload and logs + reflect what actually runs. + """ + data = dict(data) + if model is not None: + data["model"] = resolve_model(runner, model) + if effort is not None: + data["reasoning_effort"] = effort + return data + + def resolve_reasoning_effort(model: str, effort: Optional[str]) -> Optional[str]: """Validate ``effort`` for a resolved Codex model slug. diff --git a/packages/sc-codex/scripts/sc_codex_task.py b/packages/sc-codex/scripts/sc_codex_task.py index 52f051831..bded5f374 100644 --- a/packages/sc-codex/scripts/sc_codex_task.py +++ b/packages/sc-codex/scripts/sc_codex_task.py @@ -15,6 +15,7 @@ REASONING_EFFORTS, codex_model_help, effort_help, + apply_cli_overrides, resolve_model, resolve_reasoning_effort, resolve_runner, @@ -31,12 +32,12 @@ def _load_json(text: str) -> dict: raise SystemExit(f"Invalid JSON: {exc}") from exc -def _build_payload(prompt: str, subagent_type: str) -> TaskToolInput: - return TaskToolInput( - description="Codex task", - prompt=prompt, - subagent_type=subagent_type, - ) +def _build_payload(prompt: str, subagent_type: str) -> dict: + return { + "description": "Codex task", + "prompt": prompt, + "subagent_type": subagent_type, + } def main() -> int: @@ -60,20 +61,18 @@ def main() -> int: data = _load_json(raw) if "subagent_type" not in data: data["subagent_type"] = "sc-codex" - try: - payload = TaskToolInput.model_validate(data) - except ValidationError as exc: - raise SystemExit(f"Invalid Task Tool input: {exc}") from exc else: - payload = _build_payload(raw, "sc-codex") - - if args.effort: - payload = payload.model_copy(update={"reasoning_effort": args.effort}) + data = _build_payload(raw, "sc-codex") runner = resolve_runner("codex") try: - model = resolve_model(runner, args.model or payload.model) + # CLI flags override payload fields before validation. + data = apply_cli_overrides(data, runner, args.model, args.effort) + payload = TaskToolInput.model_validate(data) + model = resolve_model(runner, payload.model) resolve_reasoning_effort(model, payload.reasoning_effort) + except ValidationError as exc: + raise SystemExit(f"Invalid Task Tool input: {exc}") from exc except ValueError as exc: raise SystemExit(str(exc)) from exc if args.background is None: diff --git a/packages/sc-codex/skills/codex-agent/SKILL.md b/packages/sc-codex/skills/codex-agent/SKILL.md index cfdb579e5..1abe3f5bb 100644 --- a/packages/sc-codex/skills/codex-agent/SKILL.md +++ b/packages/sc-codex/skills/codex-agent/SKILL.md @@ -43,6 +43,8 @@ Provide Task Tool input JSON with: | — | `gpt-5.6-luna` | low, medium, high, xhigh, max | | — | `gpt-5.5` | low, medium, high, xhigh | +`minimal` effort is not supported: no current model accepts it (all start at `low`), so it is rejected. + - Default model (no `model`, or `codex`): `gpt-6-astra`. - Full slugs are accepted as-is. Unknown models (including the removed legacy aliases `gpt-5.2-codex`, `codex-max`/`max`, `codex-mini`/`mini`, `gpt-5`, `gpt-5.2`, `gtp-5`) are rejected. @@ -51,7 +53,11 @@ Provide Task Tool input JSON with: - Map natural-language requests to flags or JSON fields: "use sc-codex with sol low" means `--model sol --effort low` (or `"model":"sol","reasoning_effort":"low"` in the JSON payload); "luna high" means `--model luna --effort high`. -- `--model` / `--effort` flags given before `--json` override the JSON fields. +- Disambiguation: `low`, `medium`, `high`, `xhigh`, `max`, `ultra` are always effort levels, never + models. `max` was a model alias before 0.14.0 and no longer is ("codex max" means + `--effort max` on the default model). A bare level with no model (e.g. "sc-codex high") means the + default model (`gpt-6-astra`) with that effort. +- `--model` / `--effort` flags given alongside `--json` override payload fields. ## Notes diff --git a/test-packages/fixtures/sc-codex/tests/test_parallel_minis.yaml b/test-packages/fixtures/sc-codex/tests/test_parallel_luna.yaml similarity index 100% rename from test-packages/fixtures/sc-codex/tests/test_parallel_minis.yaml rename to test-packages/fixtures/sc-codex/tests/test_parallel_luna.yaml diff --git a/tests/test_ai_cli_task_runner.py b/tests/test_ai_cli_task_runner.py index 962e75b0a..b4d867ea8 100644 --- a/tests/test_ai_cli_task_runner.py +++ b/tests/test_ai_cli_task_runner.py @@ -40,8 +40,8 @@ def test_model_catalog_consistent_with_schema_and_pydantic() -> None: schema = json.loads(schema_path.read_text(encoding="utf-8")) props = schema["properties"] expected_models = set(task_runner.CLAUDE_MODELS) | set(task_runner.CODEX_MODEL_NAMES) - assert set(props["model"]["enum"]) == expected_models - assert props["reasoning_effort"]["enum"] == list(task_runner.REASONING_EFFORTS) + assert set(props["model"]["enum"]) - {None} == expected_models + assert [e for e in props["reasoning_effort"]["enum"] if e is not None] == list(task_runner.REASONING_EFFORTS) generated = task_runner.TaskToolInput.model_json_schema()["properties"] generated_models = set() @@ -51,6 +51,25 @@ def test_model_catalog_consistent_with_schema_and_pydantic() -> None: effort_enum = [o for o in generated["reasoning_effort"]["anyOf"] if "enum" in o][0]["enum"] assert effort_enum == list(task_runner.REASONING_EFFORTS) + # Nullability parity: optional pydantic fields that accept None must accept null in the JSON schema. + for name, field in task_runner.TaskToolInput.model_fields.items(): + if field.is_required(): + continue + pyd_nullable = any(o.get("type") == "null" for o in generated[name].get("anyOf", [])) + types = props[name]["type"] + json_nullable = "null" in (types if isinstance(types, list) else [types]) + assert pyd_nullable == json_nullable, name + if json_nullable and "enum" in props[name]: + assert None in props[name]["enum"], name + + base = {"description": "d", "prompt": "p", "subagent_type": "s"} + for extra in ({"reasoning_effort": None}, {"reasoning_effort": "low"}, {"model": None}, {"model": "sol"}): + instance = {**base, **extra} + task_runner.TaskToolInput.model_validate(instance) + validate_schema(instance=instance, schema=schema) + with pytest.raises(SchemaValidationError): + validate_schema(instance={**base, "reasoning_effort": "minimal"}, schema=schema) + # Every alias targets a known slug; every slug has an effort table. assert set(task_runner.CODEX_MODEL_ALIASES.values()) <= set(task_runner.CODEX_MODEL_SLUGS) for efforts in task_runner.CODEX_MODEL_EFFORTS.values(): @@ -228,6 +247,22 @@ def test_sc_codex_task_payload_effort_used_without_flag(monkeypatch: pytest.Monk assert got["payload"].reasoning_effort == "max" +def test_sc_codex_task_cli_overrides_invalid_payload_values(monkeypatch: pytest.MonkeyPatch) -> None: + payload = json.dumps( + {"description": "d", "prompt": "p", "model": "mini", "reasoning_effort": "extreme"} + ) + got = _run_sc_codex_task(monkeypatch, ["--model", "sol", "--effort", "low", "--json", payload]) + assert got["model"] == "gpt-6-sol" + # Persisted payload carries the resolved override, so background runs/logs reflect it. + assert got["payload"].model == "gpt-6-sol" + assert got["payload"].reasoning_effort == "low" + + +def test_sc_codex_task_rejects_unknown_cli_model(monkeypatch: pytest.MonkeyPatch) -> None: + with pytest.raises(SystemExit, match="Valid aliases"): + _run_sc_codex_task(monkeypatch, ["--model", "mini", "hello"]) + + def test_sc_codex_task_rejects_unsupported_effort(monkeypatch: pytest.MonkeyPatch) -> None: with pytest.raises(SystemExit, match="not supported by gpt-6-luna"): _run_sc_codex_task(monkeypatch, ["--model", "luna", "--effort", "ultra", "hello"]) @@ -449,3 +484,69 @@ def test_run_pretool_hooks_failure(tmp_path: Path) -> None: ) with pytest.raises(RuntimeError): task_runner.run_pretool_hooks(agent_file, payload) + + +def _run_ai_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, payload: dict, argv: list[str]): + import sys + + from ai_cli import cli + + input_file = tmp_path / "input.json" + input_file.write_text(json.dumps(payload), encoding="utf-8") + captured: dict = {} + + def fake_run_task(payload, runner, model, run_in_background, output_dir=None, raise_on_error=True): + # Exercise real validation, but never launch a process. + if runner == "codex": + task_runner.resolve_reasoning_effort(model, payload.reasoning_effort) + captured.update(payload=payload, runner=runner, model=model, background=run_in_background) + return task_runner.TaskToolOutputForeground(output="ok", agentId="a") + + monkeypatch.setattr(cli, "resolve_runner", lambda preferred: preferred or "codex") + monkeypatch.setattr(cli, "run_task", fake_run_task) + monkeypatch.setattr(cli, "write_log", lambda event: None) + monkeypatch.setattr(sys, "argv", ["ai_cli", "run", "--runner", "codex", "--file", str(input_file), *argv]) + return cli.main(), captured + + +_BASE_PAYLOAD = {"description": "d", "prompt": "p", "subagent_type": "sc-codex"} + + +def test_ai_cli_run_effort_override(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + rc, got = _run_ai_cli( + monkeypatch, tmp_path, {**_BASE_PAYLOAD, "reasoning_effort": "high"}, ["--model", "sol", "--effort", "low"] + ) + assert rc == 0 + assert got["model"] == "gpt-6-sol" + assert got["payload"].model == "gpt-6-sol" + assert got["payload"].reasoning_effort == "low" + assert got["background"] is False # ai_cli run defaults to blocking + + +def test_ai_cli_run_rejects_unsupported_effort( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture +) -> None: + rc, got = _run_ai_cli(monkeypatch, tmp_path, _BASE_PAYLOAD, ["--model", "luna", "--effort", "ultra"]) + assert rc != 0 + assert got == {} + assert "not supported by gpt-6-luna" in capsys.readouterr().err + + +def test_ai_cli_run_overrides_invalid_payload_values(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + rc, got = _run_ai_cli( + monkeypatch, + tmp_path, + {**_BASE_PAYLOAD, "model": "mini", "reasoning_effort": "extreme"}, + ["--model", "sol", "--effort", "low"], + ) + assert rc == 0 + assert got["payload"].model == "gpt-6-sol" + assert got["payload"].reasoning_effort == "low" + + +def test_ai_cli_run_invalid_payload_without_override_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + rc, got = _run_ai_cli(monkeypatch, tmp_path, {**_BASE_PAYLOAD, "model": "mini"}, []) + assert rc != 0 + assert got == {}