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..4aa975666 100644 --- a/packages/sc-codex/CHANGELOG.md +++ b/packages/sc-codex/CHANGELOG.md @@ -3,6 +3,37 @@ 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 (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 +- 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. + `max` is now only an effort level. + +### Notes +- Versions 0.8.0–0.13.0 were released without changelog entries. + ## [0.7.0] - 2026-01-20 ### Added - Task Tool-compatible Codex runner with hook emulation. diff --git a/packages/sc-codex/README.md b/packages/sc-codex/README.md index 937b6ad61..a88084d62 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,44 @@ 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 | + +`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`, + `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/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/commands/sc-codex.md b/packages/sc-codex/commands/sc-codex.md index 23e9e7850..3468f08e8 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,50 @@ 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 | + +`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. + ## 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` 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. @@ -53,8 +84,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 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): @@ -78,7 +113,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/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. diff --git a/packages/sc-codex/schemas/task_tool.schema.json b/packages/sc-codex/schemas/task_tool.schema.json index ef091a582..196063396 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", @@ -17,37 +21,67 @@ "description": "The type of specialized agent to use for this task" }, "model": { - "type": "string", + "type": [ + "string", + "null" + ], "enum": [ "sonnet", "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", + 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", + "null" + ], + "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", + null ], - "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", + "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 b068545ef..409b93187 100644 --- a/packages/sc-codex/scripts/ai_cli/README.md +++ b/packages/sc-codex/scripts/ai_cli/README.md @@ -20,8 +20,9 @@ Background output (run_in_background: true): { "description": "Summarize issue", "prompt": "Summarize the latest GitHub issue thread", - "subagent_type": "issue-summary", - "model": "haiku", + "subagent_type": "sc-codex", + "model": "sol", + "reasoning_effort": "low", "run_in_background": false, "max_turns": 10 } @@ -67,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: @@ -82,22 +84,50 @@ 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 | + +`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): +- 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..7e45d991f 100644 --- a/packages/sc-codex/scripts/ai_cli/cli.py +++ b/packages/sc-codex/scripts/ai_cli/cli.py @@ -11,6 +11,9 @@ 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, run_task, @@ -42,9 +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) 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: @@ -113,6 +118,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..404252462 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,58 @@ 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 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. + + 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 +211,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 +270,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 +434,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 +460,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 +473,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 +531,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 +540,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 +557,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 +577,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 +590,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 +600,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 +608,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 +623,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..bded5f374 100644 --- a/packages/sc-codex/scripts/sc_codex_task.py +++ b/packages/sc-codex/scripts/sc_codex_task.py @@ -11,8 +11,18 @@ 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, + apply_cli_overrides, + 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: @@ -22,17 +32,18 @@ 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: 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 +61,20 @@ def main() -> int: data = _load_json(raw) if "subagent_type" not in data: data["subagent_type"] = "sc-codex" - payload = TaskToolInput.model_validate(data) else: - payload = _build_payload(raw, "sc-codex") + data = _build_payload(raw, "sc-codex") runner = resolve_runner("codex") - model = resolve_model(runner, args.model or payload.model) + try: + # 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: 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..1abe3f5bb 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,48 @@ 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 | + +`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. +- 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`. +- 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 -- 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_luna.yaml similarity index 70% rename from test-packages/fixtures/sc-codex/tests/test_parallel_minis.yaml rename to test-packages/fixtures/sc-codex/tests/test_parallel_luna.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_luna.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..b4d867ea8 100644 --- a/tests/test_ai_cli_task_runner.py +++ b/tests/test_ai_cli_task_runner.py @@ -9,11 +9,71 @@ 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"]) - {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() + 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) + + # 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(): + assert set(efforts) <= set(task_runner.REASONING_EFFORTS) def test_resolve_model_claude_defaults() -> None: @@ -23,7 +83,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 +91,225 @@ 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_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"]) + + +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" - 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" + # The child process re-reads the payload and passes effort through to run_sync. + seen = {} + + 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 +396,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) @@ -240,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 == {}