From 4926532e3d8739088fe5b8e8aeb47cd6db545e03 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Sat, 25 Jul 2026 08:45:13 -0700 Subject: [PATCH] feat(models): add claude-opus-5 + fix interactive /effort on Anthropic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register `claude-opus-5` and make `--effort` / `/effort` actually work on the interactive path, so `clawcodex --model claude-opus-5 --effort xhigh` runs at xhigh on a Claude subscription. ## claude-opus-5 registration `_model_supports_extended_thinking` already matched opus-5 by regex, so without a registration the loop fell through to the `budget_tokens` branch — REMOVED on Opus 5, i.e. a 400 on every request. Two quieter consequences: `--effort` was dropped (opus-5 was off the effort allowlist) and the context window resolved to the 200K default instead of 1M, the early-compaction handicap #730 removed for opus-4-8. * MODEL_CONFIGS row (1M context, 5/25 per MTok, 32K first-attempt wire max_tokens per the existing convention) + pricing tier and family prefix + both provider available-model lists. * The three query.py gates: adaptive thinking, effort, xhigh. Wire-probed 2026-07-25 over subscription OAuth: `claude-opus-5` with adaptive thinking accepts `effort: xhigh` AND `max` (200, end_turn), so the xhigh allowlist entry is measured, not documentation-derived. The comments that said "unprobed" now say so only of fable-5. ## Interactive /effort was broken on Anthropic Two independent defects, both invisible at runtime: 1. `_do_set_effort` validated against a hardcoded `(minimal|low|medium|high)`, so `/effort xhigh` and `/effort max` were REJECTED in the TUI while the same values worked via `--effort`, `/effort` on the other surfaces, and settings.effort. `minimal` was accepted despite being a GPT-5 level absent from every other surface — and actively harmful here: it is not in VALID_THINKING_EFFORT_LEVELS, so resolve_thinking_effort treats it as "nothing requested" and substitutes settings.effort, i.e. `/effort minimal` could emit `max` while the TUI echoed "minimal". The ladder is now VALID_EFFORT_VALUES. 2. Every provider got `extra_body={"reasoning_effort": …}`. That is the OpenAI-compat shape; the Anthropic wire rejects it outright (probed: `400 — reasoning_effort: Extra inputs are not permitted`), so a single `/effort` broke every following request in an interactive Anthropic session. New `_turn_effort_routing` sends Anthropic down `thinking_effort` → `output_config.effort` (with the per-model gating, so xhigh still clamps to high where unsupported) and keeps `_EffortProvider` for OpenAI-compat. Not wrapping Anthropic providers also un-breaks the `isinstance(provider, AnthropicProvider)` checks that agent_loop_compat worked around and advisor.py did not. ## --effort now applies interactively The flag was plumbed only into HeadlessOptions, so the interactive path parsed and discarded it. It now rides cli/launcher → backend argv → AgentServerConfig → the session's /effort level, on all four entry paths (`clawcodex`, `clawcodex tui`, `--print-connect`, `agent-server --stdio`). Validated and lowercased at the seed as well as via argparse `choices=`, because --stdio callers reach the config without a parser. ## Also * `is_anthropic_wire()` in src/providers replaces a predicate that was copy-pasted at three call sites and had to agree at all of them. * `_EffortProvider.__getattr__` guards `_inner`: `copy.copy` built an uninitialized instance and recursed to RecursionError, which two live callers swallowed as `except Exception` — silently running subagents on the session model instead of their override. * `/effort` and `/thinking off` now each warn that the other discards effort (it rides inside the thinking block on this wire). * The TUI advertised `[minimal|low|medium|high|auto|ultracode]`; it now advertises the real ladder, and the effort badge next to the model name has a producer for the first time (backend emits `reasoning_effort`). Tests: 8827 passed. Every hop of the flag chain and both halves of the routing split are individually revert-sensitive (verified by deleting each line and confirming exactly the intended test fails). Co-Authored-By: Claude Opus 5 --- eval/harbor/README.md | 53 ++++-- eval/harbor/clawcodex_agent.py | 9 +- src/cli.py | 24 ++- src/command_system/effort_command.py | 3 +- src/entrypoints/agent_server_cli.py | 9 + src/entrypoints/tui_launcher.py | 12 ++ src/models/configs.py | 34 ++++ src/providers/__init__.py | 33 +++- src/providers/anthropic_provider.py | 5 +- src/query/query.py | 50 +++-- src/server/agent_server.py | 177 +++++++++++++++++- src/services/pricing.py | 4 +- src/settings/constants.py | 2 + src/utils/advisor.py | 5 +- tests/server/test_agent_server_workflows.py | 197 ++++++++++++++++++++ tests/server/test_tui_launcher.py | 146 +++++++++++++++ tests/test_model_system.py | 41 +++- tests/test_query_extended_thinking.py | 38 +++- ui-tui/src/__tests__/gatewayClient.test.ts | 5 +- ui-tui/src/gatewayClient.ts | 27 ++- 20 files changed, 814 insertions(+), 60 deletions(-) diff --git a/eval/harbor/README.md b/eval/harbor/README.md index 841f57f08..2f77b7344 100644 --- a/eval/harbor/README.md +++ b/eval/harbor/README.md @@ -54,7 +54,7 @@ PYTHONPATH=$PWD/eval/harbor harbor run \ NOTE: hub datasets namespace task names — filters must match the full name: `-i 'terminal-bench/fix-git'` (or use a glob: `-i '*fix-git*'`). -## Evaluate with claude-opus-4-8 on a Claude subscription +## Evaluate with claude-opus-5 on a Claude subscription Uses your Claude Pro/Max subscription (OAuth) instead of an API key. One-time prerequisite on the host: `clawcodex login` (writes @@ -70,9 +70,10 @@ covers only the main loop). PYTHONPATH=$PWD/eval/harbor harbor run \ --dataset terminal-bench/terminal-bench-2-1 \ --agent clawcodex_agent:Clawcodex \ - --model anthropic/claude-opus-4-8 \ + --model anthropic/claude-opus-5 \ --ak subscription=true \ --ak effort=high \ + --ak source=git+https://github.com/agentforce314/clawcodex@main \ --jobs-dir eval/harbor/jobs \ --n-concurrent 2 ``` @@ -82,10 +83,21 @@ Notes: resources. Keep Harbor's default timeout policy for leaderboard-comparable runs; use timeout multipliers only for explicitly labeled diagnostics. - `effort=high` maps to `clawcodex --effort high` → - `output_config.effort` on effort-capable models (Opus 4.6/4.8, + `output_config.effort` on effort-capable models (Opus 5, Opus 4.6/4.8, Sonnet 4.6, Fable 5). Requires clawcodex > 1.2.1 in the container — - until the next PyPI release, add - `--ak source=git+https://github.com/agentforce314/clawcodex@main`. + until the next PyPI release, keep the + `--ak source=git+https://github.com/agentforce314/clawcodex@main` above. +- **claude-opus-5 needs a clawcodex that registers it** (the model tables + in `src/models/configs.py`, `src/services/pricing.py`, and the three + gates in `src/query/query.py`). On a build without it, opus-5 falls off + the adaptive-thinking allowlist and the request carries + `thinking={"type": "enabled", "budget_tokens": …}` — removed on Opus 5, + so **every request 400s** — while `--effort` is silently dropped and the + context window is assumed to be 200K instead of 1M (early compaction, + the handicap #730 removed for opus-4-8). PyPI 1.2.1 predates this, so + `source=` is required; verify the branch/SHA you point it at actually + carries the registration before starting a full run, and prefer a + commit SHA over `@main` for anything you plan to compare later. - Subscription rate limits are shared with your interactive Claude usage — keep `--n-concurrent` low (2-4) and consider `--max-retries 2 --retry-include ApiRateLimitError`. @@ -105,7 +117,7 @@ token), so multi-hour jobs work without a manually exported token: PYTHONPATH=$PWD/eval/harbor harbor run \ --dataset terminal-bench/terminal-bench-2-1 \ --agent claude_code_subscription:ClaudeCodeSubscription \ - --model anthropic/claude-opus-4-8 \ + --model anthropic/claude-opus-5 \ --ak reasoning_effort=high \ --jobs-dir eval/harbor/jobs \ --n-concurrent 2 @@ -114,8 +126,16 @@ PYTHONPATH=$PWD/eval/harbor harbor run \ Notes: - Effort uses the parent agent's kwarg name: `--ak reasoning_effort=` (low|medium|high|xhigh|max). +- The model is forwarded as the `ANTHROPIC_MODEL` env var with the + provider prefix stripped (`harbor/agents/installed/claude_code.py` + :1372-1386 — so don't expect a `--model` on the exec line when + debugging), and effort as the `--effort` CLI flag. Neither is gated on + a model table anywhere in the adapter, and the bootstrap installs the + latest official CLI in every container, so moving this arm to a new + model is the model string alone — no adapter or clawcodex change. - Pin the CLI to a leaderboard row's version with `--ak version=2.1.205` - (default: latest). + (default: latest). A pin predating a model's support will fail on that + model, so re-check the pin when changing `--model`. - `CLAUDE_FORCE_OAUTH` is set by the wrapper, so a host `ANTHROPIC_API_KEY` can never silently take over and bill the API. - `--ak subprocess_env_scrub=true` enables the CLI's subprocess-env scrub @@ -158,9 +178,19 @@ Notes: - Subscription auth is env-only (`CLAUDE_CODE_OAUTH_TOKEN` access token, no refresh token, no credential file in the container); the same 30-min-runway host refresh as the clawcodex adapter applies. -- This openclaude snapshot's effort ladder is low|medium|high|max (no - xhigh), and its model metadata predates claude-opus-4-8, so it assumes - a conservative 128k context for compaction purposes. +- This openclaude snapshot (0.24.0) knows claude-opus-4-8 but **not + claude-opus-5**, and on opus-5 it does not merely degrade — it breaks. + `modelSupportsAdaptiveThinking` (`typescript/src/utils/thinking.ts:159`) + allowlists only opus-4-8/4-7/4-6 and sonnet-4-6, then excludes anything + else matching `opus`, so opus-5 falls to the `budget_tokens` branch at + `typescript/src/services/api/claude.ts:1731` — and `budget_tokens` is + removed on Opus 5, i.e. **HTTP 400 on every request** (the same failure + the clawcodex adapter's `source=` note describes). Model metadata is + missing too, so the context window falls back to the 200K default + (`typescript/src/utils/context.ts:17`). Keep this arm on + `anthropic/claude-opus-4-8` until `typescript/` is refreshed to a + snapshot carrying opus-5. +- Its effort ladder is low|medium|high|xhigh|max as of 0.24.0. ## Evaluate ALL terminal-bench 2.0 tasks @@ -193,7 +223,7 @@ aggregate accuracy; each trial dir has the agent's stream-json log under # Agent kwargs --ak max_turns=100 # clawcodex --max-turns (default 300) --ak effort=high # clawcodex --effort (low|medium|high|xhigh|max) - # xhigh is model-dependent (opus-4-8 yes, + # xhigh is model-dependent (opus-5/opus-4-8 yes, # sonnet-4-6/opus-4-6 no → degraded to high) --ak version=1.2.1 # pin the clawcodex-cli PyPI version --ak source=git+https://github.com/agentforce314/clawcodex@main @@ -205,6 +235,7 @@ aggregate accuracy; each trial dir has the agent's stream-json log under # Other models/providers (Harbor convention: provider/model) --model deepseek/deepseek-v4-pro + --model anthropic/claude-opus-4-8 # the previous tb2.1 baseline --model anthropic/claude-opus-4-5 # needs ANTHROPIC_API_KEY ``` diff --git a/eval/harbor/clawcodex_agent.py b/eval/harbor/clawcodex_agent.py index 3ec28787a..b9f360ab0 100644 --- a/eval/harbor/clawcodex_agent.py +++ b/eval/harbor/clawcodex_agent.py @@ -39,9 +39,12 @@ own default of 50 is too low for terminal-bench tasks). The ``CLAWCODEX_MAX_TURNS`` host env var works as a fallback. * ``effort`` — clawcodex ``--effort`` (low|medium|high|xhigh|max) for - models that support ``output_config.effort`` (Opus 4.6/4.8, Sonnet 4.6, - Fable 5). ``xhigh`` is model-dependent (opus-4-8 yes; sonnet-4-6/ - opus-4-6 no) — clawcodex degrades it to ``high`` where rejected. + models that support ``output_config.effort`` (Opus 5, Opus 4.6/4.8, + Sonnet 4.6, Fable 5). ``xhigh`` is model-dependent (opus-5/opus-4-8 yes; + sonnet-4-6/opus-4-6 no) — clawcodex degrades it to ``high`` where + rejected. A model missing from clawcodex's effort allowlist drops the + flag SILENTLY, so a new model needs a clawcodex build that registers it + (see ``source`` below) before an effort number means anything. ``CLAWCODEX_EFFORT`` host env var works as a fallback. * ``version`` — pin a ``clawcodex-cli`` PyPI version (default: latest). * ``source`` — full pip-installable spec overriding the PyPI package, e.g. diff --git a/src/cli.py b/src/cli.py index 0549b4d16..936390426 100644 --- a/src/cli.py +++ b/src/cli.py @@ -227,6 +227,9 @@ def main(): return launch_ink_tui( provider=args.provider, model=args.model, + # --effort applies interactively too: forwarded to the agent-server + # child, which seeds the session's /effort level from it. + effort=args.effort, permission_mode=args._resolved_permission_mode, is_bypass_available=args._resolved_is_bypass_available, workspace=(worktree_session.worktree_path if worktree_session else None), @@ -474,15 +477,18 @@ def _build_parser() -> argparse.ArgumentParser: default=None, help='Override the provider (anthropic, openai, zai, minimax, openrouter, deepseek, meta)', ) - # Mirrors TS ``--effort `` (main.tsx:995). TS registers it - # session-wide; the interactive TUI equivalent here is the persisted - # ``/effort`` setting, so the flag is exposed on the print path where - # no dialog exists. None = auto (settings.effort, else the parameter is - # omitted and the API applies its model default). xhigh acceptance is - # model-dependent — resolve_thinking_effort clamps it to high on models - # that reject it. KNOWN LIMIT: the flag governs the MAIN loop only; - # subagents (Agent tool) resolve from settings.effort — persist - # ``/effort`` (or seed settings) for session-wide coverage. + # Mirrors TS ``--effort `` (main.tsx:995), and like TS it is + # session-wide: the print path reads it via HeadlessOptions, and the + # interactive path forwards it to the agent-server child, which seeds + # the session's ``/effort`` level from it (a later ``/effort`` in the + # TUI overrides; ``/effort auto`` clears back to settings.effort). + # Registered under the noninteractive group for backwards-compatible + # ``--help`` grouping only. None = auto (settings.effort, else the + # parameter is omitted and the API applies its model default). xhigh + # acceptance is model-dependent — resolve_thinking_effort clamps it to + # high on models that reject it. KNOWN LIMIT: the flag governs the MAIN + # loop only; subagents (Agent tool) resolve from settings.effort — + # persist ``/effort`` (or seed settings) for session-wide coverage. noninteractive.add_argument( '--effort', choices=('low', 'medium', 'high', 'xhigh', 'max'), diff --git a/src/command_system/effort_command.py b/src/command_system/effort_command.py index 1fae23f17..c773ea94d 100644 --- a/src/command_system/effort_command.py +++ b/src/command_system/effort_command.py @@ -102,7 +102,8 @@ def _invalid_msg(raw: str) -> str: # TS effort.tsx:179 help text. xhigh/max availability is model-dependent; # resolve_thinking_effort clamps xhigh to high on models that reject it -# (wire-probed 2026-07-18: opus-4-8 accepts xhigh; sonnet-4-6/opus-4-6 400). +# (wire-probed 2026-07-18: opus-4-8 accepts xhigh; sonnet-4-6/opus-4-6 400. +# 2026-07-25: opus-5 accepts xhigh and max. fable-5 unprobed.) _USAGE = ( "Usage: /effort [low|medium|high|xhigh|max|auto]\n\n" "Effort levels:\n" diff --git a/src/entrypoints/agent_server_cli.py b/src/entrypoints/agent_server_cli.py index d4307c035..31385e2fc 100644 --- a/src/entrypoints/agent_server_cli.py +++ b/src/entrypoints/agent_server_cli.py @@ -130,6 +130,14 @@ def run_agent_server_subcommand(argv: list[str]) -> int: help="Optional bearer token required on POST /sessions.") parser.add_argument("--provider", default=None, help="Provider name override.") parser.add_argument("--model", default=None, help="Model override.") + parser.add_argument( + "--effort", default=None, + choices=("low", "medium", "high", "xhigh", "max"), + help="Reasoning effort for the session. Seeds /effort; routed to " + "output_config.effort on Anthropic and reasoning_effort on " + "OpenAI-compatible providers. (_build_runtime re-validates, " + "because --stdio callers bypass this parser.)", + ) parser.add_argument( "--fallback-model", default=None, dest="fallback_model", help="Model to switch to after repeated overloaded (529) errors " @@ -233,6 +241,7 @@ def run_agent_server_subcommand(argv: list[str]) -> int: agent_config = AgentServerConfig( provider_name=args.provider, model=args.model, + effort=args.effort, fallback_model=args.fallback_model, permission_mode=args.permission_mode, is_bypass_available=is_bypass_available, diff --git a/src/entrypoints/tui_launcher.py b/src/entrypoints/tui_launcher.py index 1f734447e..61450bf78 100644 --- a/src/entrypoints/tui_launcher.py +++ b/src/entrypoints/tui_launcher.py @@ -51,6 +51,12 @@ def run_tui_launcher(argv: list[str]) -> int: ) parser.add_argument("--provider", default=None) parser.add_argument("--model", default=None) + # choices= so a bad level is a clean argparse error rather than a value + # that silently resolves to settings.effort downstream (the server also + # validates the seed, since it can be driven without a parser). + parser.add_argument("--effort", default=None, + choices=("low", "medium", "high", "xhigh", "max"), + help="Reasoning effort — seeds the session's /effort level") parser.add_argument("--permission-mode", default="default", dest="permission_mode") parser.add_argument( "--dangerously-skip-permissions", action="store_true", @@ -151,6 +157,7 @@ def run_tui_launcher(argv: list[str]) -> int: return launch_ink_tui( provider=args.provider, model=args.model, + effort=args.effort, permission_mode=mode, is_bypass_available=is_bypass_available, workspace=args.workspace, @@ -163,6 +170,7 @@ def launch_ink_tui( *, provider: str | None = None, model: str | None = None, + effort: str | None = None, permission_mode: str = "default", is_bypass_available: bool = False, workspace: str | None = None, @@ -186,6 +194,7 @@ def launch_ink_tui( args = SimpleNamespace( provider=provider, model=model, + effort=effort, permission_mode=permission_mode, is_bypass_available=is_bypass_available, workspace=workspace, @@ -256,6 +265,8 @@ def _agent_server_cmd(args) -> list[str]: cmd += ["--provider", args.provider] if args.model: cmd += ["--model", args.model] + if getattr(args, "effort", None): + cmd += ["--effort", args.effort] return cmd @@ -307,6 +318,7 @@ def _print_connect(args) -> int: if getattr(args, "is_bypass_available", False) else []), *(["--provider", args.provider] if args.provider else []), *(["--model", args.model] if args.model else []), + *(["--effort", args.effort] if getattr(args, "effort", None) else []), "--workspace", workspace, ]) diff --git a/src/models/configs.py b/src/models/configs.py index fdf59a0fa..b83d89f6b 100644 --- a/src/models/configs.py +++ b/src/models/configs.py @@ -88,6 +88,40 @@ class ModelConfig: cost_cache_create_per_mtok=12.50, cost_cache_read_per_mtok=1.00, ), + # Claude Opus 5 — the current Opus tier, at Opus 4.8's price (5/25 per + # MTok) and shape: 1M context (default AND maximum), 128K true output + # cap, same 32_000 first-attempt wire ``max_tokens`` convention as the + # rows above. Registered LAST among the opus rows because its prefix + # base is the family-wide ``claude-opus`` (``key.rsplit("-", 1)[0]``). + # Every 4.x id still matches the earlier ``claude-opus-4`` base first, + # so their 200K/legacy resolution is unchanged, but two other strings + # now land here: an unregistered future ``claude-opus-``, AND the + # literal ``claude-opus`` — a live MODEL_ALIASES key (aliases.py:15) + # that resolves to claude-opus-4-20250514, so ``display_name()`` on the + # UNRESOLVED alias now reads "Claude Opus 5". Callers that canonicalize + # first (the normal path) are unaffected; test_model_system pins both. + # This inverts the "under-estimate is the safe direction" note above + # for unknown opus ids, and over-estimating is the worse failure — + # auto-compact never fires and the request eventually exceeds the real + # window. Accepted because every Opus since 4.6 ships 1M, and it + # matches ``claude-fable-5``, whose base is likewise family-wide. + # (``src/services/pricing.py`` deliberately went the other way: its + # bare ``claude-opus-4`` prefix was dropped so unknown ids price as + # None rather than wrong. A wrong cost is silent; a wrong window is + # not — but the directions genuinely differ, so don't "harmonize" + # them without re-reading both rationales.) + "claude-opus-5": ModelConfig( + model_id="claude-opus-5", + display_name="Claude Opus 5", + context_window=1_000_000, + max_output_tokens=32_000, + supports_thinking=True, + supports_computer_use=True, + cost_input_per_mtok=5.0, + cost_output_per_mtok=25.0, + cost_cache_create_per_mtok=6.25, + cost_cache_read_per_mtok=0.50, + ), # Claude 3.7 series "claude-3-7-sonnet-20250219": ModelConfig( diff --git a/src/providers/__init__.py b/src/providers/__init__.py index e6dee7529..7c39e9749 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -23,7 +23,10 @@ class ProviderInfo(TypedDict): "available_models": [ # Frontier (above Opus tier) "claude-fable-5", - # Claude 4 series (latest) + # Claude 5 series (Opus tier; sonnet-5 not registered yet — + # it needs the same model-table entry opus-5 got) + "claude-opus-5", + # Claude 4 series "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-sonnet-4-5-20250929", @@ -294,6 +297,33 @@ def provider_env_vars(provider_name: str) -> tuple[str, ...]: return spec.env_vars if spec else () +def is_anthropic_wire(provider: Any) -> bool: + """Whether ``provider`` speaks the ANTHROPIC request shape. + + The single source of truth for a decision several call sites have to + agree on, because the two families take the same concepts as different + parameters and sending the wrong one is a hard 400: + + * reasoning effort — ``output_config.effort`` here vs a top-level + ``reasoning_effort`` body field on OpenAI-compatible providers, + * the system prompt — a ``system`` kwarg here vs a prepended + ``{"role": "system"}`` message there. + + Minimax is included because it speaks the Anthropic shape. A custom + ``base_url`` / third-party gateway is a FLAG on AnthropicProvider + (``has_custom_endpoint``), never a subclass, so an isinstance test + cannot be fooled by one. Takes a provider INSTANCE, not a name — the + class is what carries the wire contract. + + Imports are local: this module is imported at startup by the fast-path + CLI, and the provider modules pull in their SDKs. + """ + from .anthropic_provider import AnthropicProvider + from .minimax_provider import MinimaxProvider + + return isinstance(provider, (AnthropicProvider, MinimaxProvider)) + + def provider_requires_api_key(provider_name: str) -> bool: """Whether a provider needs an API key. @@ -377,6 +407,7 @@ def resolve_api_key( "ChatResponse", "get_provider_class", "get_provider_info", + "is_anthropic_wire", "canonical_provider_name", "provider_env_vars", "provider_has_credentials", diff --git a/src/providers/anthropic_provider.py b/src/providers/anthropic_provider.py index fdbc02f3a..289c6306a 100644 --- a/src/providers/anthropic_provider.py +++ b/src/providers/anthropic_provider.py @@ -921,7 +921,10 @@ def get_available_models(self) -> list[str]: return [ # Frontier (above Opus tier) "claude-fable-5", - # Claude 4 series (latest) + # Claude 5 series (Opus tier; sonnet-5 not registered yet — + # it needs the same model-table entry opus-5 got) + "claude-opus-5", + # Claude 4 series "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-sonnet-4-5-20250929", diff --git a/src/query/query.py b/src/query/query.py index bd37808e4..99a2cd87b 100644 --- a/src/query/query.py +++ b/src/query/query.py @@ -466,11 +466,15 @@ def _model_supports_adaptive_thinking(model: str | None) -> bool: adaptive to a non-adaptive model is rejected with HTTP 400 "adaptive thinking is not supported on this model". Allowlist ported from TS ``modelSupportsAdaptiveThinking`` (thinking.ts:152-169): Opus 4.6/4.7 - and Sonnet 4.6; extended with Opus 4.8 and Fable 5, where adaptive is - the ONLY accepted thinking config — the budget fallback below would be - a hard 400 on them (``budget_tokens`` is removed on 4.7+; Fable 5 also - rejects ``{"type": "disabled"}``, and accepts adaptive or an omitted - param). Substring match mirrors the reference's ``.includes()`` so + and Sonnet 4.6; extended with Opus 4.8, Opus 5 and Fable 5, where + adaptive is the ONLY accepted thinking config — the budget fallback + below would be a hard 400 on them (``budget_tokens`` is removed on + 4.7+; Fable 5 also rejects ``{"type": "disabled"}``, and accepts + adaptive or an omitted param). On Opus 5 thinking is ON by default, so + ``{"type": "adaptive"}`` is equivalent to omitting the param; the one + combination it rejects is ``{"type": "disabled"}`` at effort xhigh/max, + which this code never emits (it sends adaptive or budget, never + disabled). Substring match mirrors the reference's ``.includes()`` so dated snapshots (``claude-sonnet-4-6-20250929``) match. """ if not model: @@ -478,6 +482,7 @@ def _model_supports_adaptive_thinking(model: str | None) -> bool: m = model.lower() return ( "fable-5" in m + or "opus-5" in m or "opus-4-8" in m or "opus-4-7" in m or "opus-4-6" in m @@ -488,17 +493,22 @@ def _model_supports_adaptive_thinking(model: str | None) -> bool: def _model_supports_effort(model: str | None) -> bool: """True iff the model accepts ``output_config={"effort": ...}``. - Narrower than thinking support — Opus 4.6/4.8, Sonnet 4.6, and Fable 5 - (TS ``modelSupportsEffort``, effort.ts:32-51, plus the 4.8/Fable - additions where effort is GA). Sending effort to a model that doesn't - support it is rejected, so the caller gates on this independently of - the thinking type. + Narrower than thinking support — Opus 4.6/4.8, Opus 5, Sonnet 4.6, and + Fable 5 (TS ``modelSupportsEffort``, effort.ts:32-51, plus the + 4.8/Fable/Opus-5 additions where effort is GA). Sending effort to a + model that doesn't support it is rejected, so the caller gates on this + independently of the thinking type. Being absent here is silent, not + fatal: the request succeeds with the API's own default effort and a + requested ``--effort`` is dropped on the floor — which is why a new + effort-capable model has to be added here, not just to the thinking + allowlist. """ if not model: return False m = model.lower() return ( "fable-5" in m + or "opus-5" in m or "opus-4-8" in m or "opus-4-6" in m or "sonnet-4-6" in m @@ -517,11 +527,25 @@ def _model_supports_xhigh_effort(model: str | None) -> bool: snapshot's opus-4-6-only ``modelSupportsMaxEffort`` predates this). Fable 5 is included by analogy with opus-4-8 (Claude Code exposes the full ladder on it; unprobed — subscription plans don't carry it). + Opus 5 carries the full ladder (low|medium|high|xhigh|max), with xhigh + the recommended setting for coding/agentic work — wire-probed + 2026-07-25 over subscription OAuth: ``claude-opus-5`` + adaptive + thinking accepts both ``xhigh`` and ``max`` (200, ``stop_reason: + end_turn``). + + Note which direction is safe: an entry MISSING from this allowlist is + harmless to the REQUEST (resolve_thinking_effort clamps xhigh to high) + though a requested xhigh is then silently downgraded — the same quiet + failure the effort allowlist above warns about. A WRONG entry is + outright fatal: being listed here sends xhigh to the wire, and a 400 + on the effort level is not retried or downgraded anywhere, so every + request fails. Add a model here on documentation or a probe, never on + the theory that a bad guess degrades gracefully. """ if not model: return False m = model.lower() - return "opus-4-8" in m or "fable-5" in m + return "opus-5" in m or "opus-4-8" in m or "fable-5" in m # Kept in sync with settings.constants.VALID_EFFORT_VALUES (minus the empty @@ -933,10 +957,10 @@ async def _call_model_sync( # reject the advisor beta, and 1P-with-force-client doesn't # need it because the advisor schema is a regular tool here. + from ..providers import is_anthropic_wire from ..providers.anthropic_provider import AnthropicProvider - from ..providers.minimax_provider import MinimaxProvider - is_anthropic = isinstance(provider, (AnthropicProvider, MinimaxProvider)) + is_anthropic = is_anthropic_wire(provider) if deferred_tool_names and isinstance(provider, AnthropicProvider): has_custom_endpoint = getattr(provider, "has_custom_endpoint", None) if not callable(has_custom_endpoint) or not has_custom_endpoint(): diff --git a/src/server/agent_server.py b/src/server/agent_server.py index 4b5ccb7b8..095f447dd 100644 --- a/src/server/agent_server.py +++ b/src/server/agent_server.py @@ -97,6 +97,12 @@ class AgentServerConfig: # ch04 round-4 GAP B — capacity-relief model after repeated 529s # (`--fallback-model`; session-sticky, never persisted). fallback_model: str | None = None + # Launch-time reasoning effort (``--effort``), seeding each session's + # ``/effort`` level so the flag works on the interactive path and not + # just headless ``-p``. Routed per provider family by + # ``_AgentSession._turn_effort_routing``; ``None`` = whatever + # ``settings.effort`` resolves to at the wire boundary. + effort: str | None = None permission_mode: str = "default" # bypassPermissions AVAILABILITY, decoupled from the launch mode: True when # the user passed --dangerously-skip-permissions / --allow-dangerously- @@ -170,7 +176,11 @@ class _AgentSession: init_error: str | None = None _session_name: str | None = None # user-set label (/rename) shown in /resume _mcp_runtime: Any = None # McpRuntime (connected MCP servers) when configured - _effort: str | None = None # /effort reasoning level, injected via extra_body when set + # /effort reasoning level. Routed TWO different ways by provider family + # (see _turn_effort_routing): Anthropic takes ``output_config.effort`` + # via the query loop's ``thinking_effort``; OpenAI-compatible providers + # take ``reasoning_effort`` in the request body via _EffortProvider. + _effort: str | None = None _knowledge: Any = None # KnowledgeGraph (lazy-loaded), populated at each turn end _knowledge_enabled: bool = True # the original's knowledgeGraphEnabled (default on) _knowledge_semantic: bool = False # opt-in model-based extraction (vs heuristic) @@ -256,6 +266,11 @@ def emit_init(self) -> None: "cwd": self.cwd, "tools": tools, "permission_mode": _current_mode(self.tool_context, self.config.permission_mode), + # The client renders this next to the model name + # (appLayout.tsx modelReasoningEffort). Nothing produced it + # before, so the badge was permanently blank even with + # --effort set; None keeps it hidden. + "reasoning_effort": self._effort, "apiKeySource": "config", }) if self.init_error is not None: @@ -1282,7 +1297,17 @@ def _do_set_thinking(self, request_id: object, action: object) -> None: self._thinking = False else: self._thinking = not bool(self._thinking) - self._reply(request_id, {"ok": True, "thinking": bool(self._thinking)}) + reply = {"ok": True, "thinking": bool(self._thinking)} + # Reciprocal of the note in _do_set_effort: on the Anthropic path + # effort rides inside the thinking block, so turning thinking OFF + # discards a level the user already set. Whichever order the two + # commands arrive in, say it once. + if self._thinking is False and self._effort: + reply["note"] = ( + f"Effort {self._effort} is discarded while extended thinking " + "is off." + ) + self._reply(request_id, reply) def _do_set_language(self, request_id: object, language: object) -> None: """Set the preferred response language (LanguagePicker, §6) and recompose @@ -1417,6 +1442,45 @@ def _do_bgtask(self, request_id: object, subtype: str, command: object, tid: obj # replacements the Ink TUI drives. All are gated on is_workflows_enabled() # (workflow-engine §4.8: the surfaces disappear when workflows are off). + def _turn_effort_routing(self) -> tuple[Any, str | None]: + """Return ``(provider_for_this_turn, thinking_effort)`` for ``/effort``. + + The two provider families take reasoning effort as DIFFERENT wire + parameters, and sending one family's shape to the other is a hard + 400 — so the level has to be routed, not injected uniformly: + + * **Anthropic** (incl. Minimax, which speaks the Anthropic shape): + ``output_config={"effort": …}``. Returned as ``thinking_effort`` + so ``resolve_thinking_effort`` applies it at the wire boundary + with the per-model gating (unsupported ``xhigh`` clamps to high). + * **OpenAI-compatible**: ``reasoning_effort`` as a top-level body + field, which is what :class:`_EffortProvider` injects. + + Before this split, every provider got the ``extra_body`` injection. + On the Anthropic wire that is rejected — probed 2026-07-25 against + claude-opus-5: ``400 invalid_request_error — reasoning_effort: + Extra inputs are not permitted`` — so a ``/effort`` in an + interactive Anthropic session broke every following request in that + session, while the headless ``--effort`` path (which always went + through ``thinking_effort``) worked. + + ``is_anthropic_wire`` is the shared predicate (``src/providers``), + the same one ``query.py`` uses to decide whether ``output_config`` + is emitted at all — they have to agree or this bug comes back. + Deliberately NOT wrapped in a try/except: the only failure mode + would be an unimportable provider module, in which case the + provider could not be an instance of it anyway, and falling back to + the ``_EffortProvider`` branch on an Anthropic session would pick + the guaranteed-400 path over a merely-omitted effort. + """ + if not self._effort: + return self.provider, None + from src.providers import is_anthropic_wire + + if is_anthropic_wire(self.provider): + return self.provider, self._effort + return _EffortProvider(self.provider, self._effort), None + def _do_set_effort(self, request_id: object, effort: object) -> None: """``/effort`` backend: reasoning levels plus the ``ultracode`` workflow auto-orchestration mode (mirrors ``effort_command.py``). @@ -1438,10 +1502,30 @@ def _do_set_effort(self, request_id: object, effort: object) -> None: "ultracode": on, }) return + # The accepted ladder IS the settings source of truth + # (low|medium|high|xhigh|max) — the same set ``--effort``, + # ``/effort`` on the other surfaces, and settings.effort accept. + # Before this, the list was hardcoded to + # (minimal|low|medium|high): ``/effort xhigh`` and + # ``/effort max`` were REJECTED in the interactive TUI despite + # both being valid Claude levels (xhigh + max probed on + # claude-opus-5 2026-07-25), while ``minimal`` — which is a + # GPT-5 level, not a Claude one, and is absent from every other + # surface — was accepted. Keeping ``minimal`` here would be + # actively harmful on the Anthropic path: it is not in + # VALID_THINKING_EFFORT_LEVELS, so resolve_thinking_effort + # treats it as "no explicit value" and silently substitutes + # settings.effort — i.e. ``/effort minimal`` could emit `max` + # while the TUI echoed "minimal". OpenAI-compat users take + # ``low``. + from src.settings.constants import VALID_EFFORT_VALUES + + levels = tuple(v for v in VALID_EFFORT_VALUES if v) + choices = "|".join((*levels, "auto", "ultracode")) if not isinstance(effort, str): self._reply(request_id, { "ok": False, - "error": f"invalid effort '{effort}' (minimal|low|medium|high|auto|ultracode)", + "error": f"invalid effort '{effort}' ({choices})", }) return a = effort.strip().lower() @@ -1459,14 +1543,28 @@ def _do_set_effort(self, request_id: object, effort: object) -> None: set_ultracode_session(False) self._reply(request_id, {"ok": True, "effort": "default", "ultracode": False}) return - if a in ("minimal", "low", "medium", "high"): + if a in levels: self._effort = a set_ultracode_session(False) # a real level exits ultracode mode - self._reply(request_id, {"ok": True, "effort": a, "ultracode": False}) + reply = {"ok": True, "effort": a, "ultracode": False} + # Effort rides INSIDE the extended-thinking block on the + # Anthropic path (query.py gates the whole thing on + # ``extended_thinking is not False``), so /thinking off + # silently discards it. Say so rather than reporting a + # level that will not reach the wire — doubly worth saying + # on Opus 5, where thinking is on by DEFAULT, so + # /thinking off doesn't stop the model reasoning, it only + # throws the effort setting away. + if self._thinking is False: + reply["note"] = ( + "Extended thinking is off, which discards effort — " + "run /thinking on for it to take effect." + ) + self._reply(request_id, reply) return self._reply(request_id, { "ok": False, - "error": f"invalid effort '{effort.strip()}' (minimal|low|medium|high|auto|ultracode)", + "error": f"invalid effort '{effort.strip()}' ({choices})", }) except Exception as exc: # noqa: BLE001 logger.exception("[agent-server] set_effort failed") @@ -3457,9 +3555,9 @@ def on_message(message: Any) -> None: if not internal: self._fire_session_start_once() - # /effort: wrap the provider to inject reasoning_effort (default off ⇒ - # the real provider is used unchanged). - turn_provider = _EffortProvider(self.provider, self._effort) if self._effort else self.provider + # /effort: route the level to whichever parameter this provider + # family actually accepts (default off ⇒ real provider, no effort). + turn_provider, turn_thinking_effort = self._turn_effort_routing() # ch05 round-4 GAP A — the production compaction pipeline. The # tracking is session-scoped (circuit breaker survives turns); the # config is rebuilt per turn so it always carries the CURRENT @@ -3483,6 +3581,11 @@ def on_message(message: Any) -> None: on_message=on_message, abort_controller=abort, extended_thinking=self._thinking, # None = model default; True/False = ThinkingToggle + # /effort on the Anthropic path: resolved at the wire + # boundary into ``output_config.effort`` with the per-model + # gating (xhigh clamps to high where unsupported). None for + # OpenAI-compat providers, which got _EffortProvider instead. + thinking_effort=turn_thinking_effort, fallback_model=self.config.fallback_model, pipeline_config=pipeline_config, query_source="repl_main_thread", @@ -3854,6 +3957,42 @@ def _build_runtime(sess: _AgentSession, perm_mode: str | None) -> None: profile_checkpoint("agent_server_build_runtime_start") + # ``--effort`` seeds the session's /effort level, so the launch flag + # reaches the INTERACTIVE path too (it used to be plumbed only into + # HeadlessOptions, so `clawcodex --model X --effort xhigh` without + # -p parsed the flag and silently discarded it). A later /effort + # overrides this; /effort auto clears it back to settings.effort. + # + # Validated and lowercased HERE, not just in argparse: this server + # is also driven programmatically (--stdio from the vscode + # extension, --print-connect), so a bad value can arrive without + # passing a parser. Seeding it verbatim would resurrect the exact + # trap _do_set_effort rejects — an off-ladder level like "minimal" + # is not in VALID_THINKING_EFFORT_LEVELS, so resolve_thinking_effort + # treats it as "nothing requested" and silently substitutes + # settings.effort, while the init frame's badge displays the value + # the user asked for. Unnormalized case has the same shape on the + # OpenAI-compat side, where _EffortProvider injects it verbatim. + # ``isinstance`` rather than ``or ""``: a non-str effort from a + # programmatic caller would raise inside this try block, and + # _build_runtime converts any raise into init_error — killing the + # whole session over a cosmetic setting. Truthiness is checked + # before membership because VALID_EFFORT_VALUES includes the "" + # auto sentinel, which must not seed a level. + raw_effort = sess.config.effort + seed = raw_effort.strip().lower() if isinstance(raw_effort, str) else "" + if not sess._effort: + from src.settings.constants import VALID_EFFORT_VALUES + + if seed and seed in VALID_EFFORT_VALUES: + sess._effort = seed + elif raw_effort: + logger.warning( + "[agent-server] ignoring --effort %r: not one of %s", + raw_effort, + [v for v in VALID_EFFORT_VALUES if v], + ) + # ch02 round-4 WI-1 — one persisted-trust verdict for this # session's cwd, evaluated BEFORE any config read: get_merged's # untrusted-tier strip now gates per-cwd on this same source, and @@ -4423,13 +4562,31 @@ def _fmt_rule(rule: Any) -> str: class _EffortProvider: """Wraps a provider to inject ``reasoning_effort`` via ``extra_body`` on chat calls (the original's /effort). Used only when /effort is set; delegates - everything else to the inner provider, so the default path is untouched.""" + everything else to the inner provider, so the default path is untouched. + + OpenAI-compatible providers ONLY — ``reasoning_effort`` is their body + field. The Anthropic wire rejects it (``400 … Extra inputs are not + permitted``, probed 2026-07-25) and takes ``output_config.effort`` + instead, so :meth:`AgentSession._turn_effort_routing` sends Anthropic + sessions down the ``thinking_effort`` path and never wraps them here. + """ def __init__(self, inner: Any, effort: str) -> None: self._inner = inner self._effort = effort def __getattr__(self, name: str) -> Any: # model, get_available_models, … + # Guard the delegate itself: without this, an instance created + # WITHOUT __init__ (copy.copy / copy.deepcopy build one that way, + # then probe for __setstate__/__deepcopy__) recurses forever — + # __getattr__ looks up self._inner, which is missing, which calls + # __getattr__ … until RecursionError. Two live sites copy the + # session provider (src/agent/run_agent.py's per-subagent model + # override and src/permissions/yolo_classifier.py), and both + # swallow Exception — which RecursionError is — so the failure was + # silent. + if name == "_inner": + raise AttributeError(name) return getattr(self._inner, name) def _inject(self, kwargs: dict) -> dict: diff --git a/src/services/pricing.py b/src/services/pricing.py index 0585ad877..7cb9ab0b3 100644 --- a/src/services/pricing.py +++ b/src/services/pricing.py @@ -154,7 +154,8 @@ "claude-3-5-sonnet-20240620": _TIER_3_15, # Fable family — frontier tier above Opus (10/50) "claude-fable-5": _TIER_10_50, - # Opus family — 4.5+ on the 5/25 tier, 4.0/4.1 on 15/75 + # Opus family — 5 and 4.5+ on the 5/25 tier, 4.0/4.1 on 15/75 + "claude-opus-5": _TIER_5_25, "claude-opus-4-8": _TIER_5_25, "claude-opus-4-7": _TIER_5_25, "claude-opus-4-6": _TIER_5_25, @@ -192,6 +193,7 @@ ("claude-haiku-4", _TIER_HAIKU_45), ("claude-3-5-haiku", _TIER_HAIKU_45), ("claude-3-haiku", _TIER_HAIKU_3), + ("claude-opus-5", _TIER_5_25), ("claude-opus-4-8", _TIER_5_25), ("claude-opus-4-7", _TIER_5_25), ("claude-opus-4-6", _TIER_5_25), diff --git a/src/settings/constants.py b/src/settings/constants.py index b840788a1..3c71437b8 100644 --- a/src/settings/constants.py +++ b/src/settings/constants.py @@ -50,6 +50,8 @@ # resolve_thinking_effort (src/query/query.py) clamps it to "high" on # models that reject it (probed 2026-07-18: opus-4-8 accepts; sonnet-4-6 # and opus-4-6 return 400 "does not support effort level 'xhigh'"). +# opus-5 carries the full ladder (probed 2026-07-25: xhigh and max both +# accepted). fable-5 is documentation-derived, still unprobed. VALID_EFFORT_VALUES = ("", "low", "medium", "high", "xhigh", "max") # Known valid output styles diff --git a/src/utils/advisor.py b/src/utils/advisor.py index 54068e999..a1c0e7c89 100644 --- a/src/utils/advisor.py +++ b/src/utils/advisor.py @@ -756,10 +756,9 @@ def execute_client_advisor( # Detect the provider type to send the right shape — sending both # forms blindly would either be ignored (best case) or fail # validation (worst case, on Anthropic). - from src.providers.anthropic_provider import AnthropicProvider - from src.providers.minimax_provider import MinimaxProvider + from src.providers import is_anthropic_wire - is_anthropic_shape = isinstance(provider, (AnthropicProvider, MinimaxProvider)) + is_anthropic_shape = is_anthropic_wire(provider) call_kwargs: dict[str, Any] = { "tools": [], diff --git a/tests/server/test_agent_server_workflows.py b/tests/server/test_agent_server_workflows.py index f952aaad3..d831f4533 100644 --- a/tests/server/test_agent_server_workflows.py +++ b/tests/server/test_agent_server_workflows.py @@ -162,6 +162,203 @@ async def test_set_effort_levels_and_ultracode_roundtrip(tmp_path): assert r["ok"] is False and "invalid effort" in r["error"] +async def test_set_effort_accepts_the_full_claude_ladder(tmp_path): + """``xhigh``/``max`` are real Claude levels and must be settable here. + + They were rejected by a hardcoded ``(minimal|low|medium|high)`` list, + so ``/effort xhigh`` failed in the interactive TUI while the same value + worked via ``--effort``, ``/effort`` on the other surfaces, and + settings.effort. Both probed on claude-opus-5 2026-07-25. The ladder is + now exactly VALID_EFFORT_VALUES — see + test_set_effort_rejects_minimal_on_the_claude_ladder for the one value + deliberately NOT carried over. + """ + async with _spawned(tmp_path, _TextProvider) as (handle, gen): + sess = _session_of(handle) + for i, level in enumerate(("xhigh", "max", "low", "medium", "high")): + r = await _control( + handle, gen, f"l{i}", {"subtype": "set_effort", "effort": level} + ) + assert r == {"ok": True, "effort": level, "ultracode": False}, level + assert sess._effort == level, level + + # The error text must enumerate what is actually accepted, so a + # rejected value tells the user the real ladder. + r = await _control(handle, gen, "bad", {"subtype": "set_effort", "effort": "bogus"}) + assert r["ok"] is False + for level in ("low", "medium", "high", "xhigh", "max"): + assert level in r["error"], f"{level} missing from {r['error']!r}" + + +async def test_effort_routing_matches_the_provider_wire_shape(tmp_path): + """Anthropic takes ``output_config.effort``; OpenAI-compat takes a body field. + + Sending the OpenAI shape to Anthropic is a hard 400 (probed 2026-07-25: + ``reasoning_effort: Extra inputs are not permitted``), which used to + break every request after a ``/effort`` in an interactive Anthropic + session. Pin both directions of the split. + """ + from unittest.mock import MagicMock + + from src.providers.anthropic_provider import AnthropicProvider + from src.server.agent_server import _EffortProvider + + async with _spawned(tmp_path, _TextProvider) as (handle, gen): + sess = _session_of(handle) + + # No effort set → untouched provider, no thinking_effort. + sess._effort = None + assert sess._turn_effort_routing() == (sess.provider, None) + + # Anthropic → the real provider plus output_config.effort. The + # provider must NOT be wrapped: wrapping is what injected the + # rejected body field. + sess.provider = AnthropicProvider(api_key="sk-test", model="claude-opus-5") + sess._effort = "xhigh" + provider, thinking_effort = sess._turn_effort_routing() + assert provider is sess.provider + assert not isinstance(provider, _EffortProvider) + assert thinking_effort == "xhigh" + + # OpenAI-compatible → wrapped, and effort stays out of the query + # loop's Anthropic-only parameter. + sess.provider = MagicMock(name="openai-compat") + provider, thinking_effort = sess._turn_effort_routing() + assert isinstance(provider, _EffortProvider) + assert thinking_effort is None + injected = provider._inject({}) + assert injected["extra_body"]["reasoning_effort"] == "xhigh" + + +async def test_effort_reaches_the_query_loop_kwarg(tmp_path, monkeypatch): + """The seam that actually delivers interactive effort to the wire. + + ``_turn_effort_routing`` returning ``"xhigh"`` is inert unless the turn + passes it to ``run_query_as_agent_loop`` as ``thinking_effort`` — that + single kwarg is what ``resolve_thinking_effort`` turns into + ``output_config.effort``. Deleting it leaves every other effort test + green, so spy on the call itself. + + Two harness details this test exists to encode: the spy must be a + coroutine function (the worker invokes the loop via ``asyncio.run(...)``, + which rejects an async generator with ``ValueError: a coroutine was + expected``), and it must be patched at its SOURCE module — the worker + imports it locally inside ``_run_turn`` (agent_server.py:3393), so + ``src.server.agent_server.run_query_as_agent_loop`` does not exist as a + module attribute to patch. + """ + seen: dict = {} + + from src.providers.anthropic_provider import AnthropicProvider + from src.utils.abort_controller import AbortError + + class _AnthropicShaped(AnthropicProvider): + """Real Anthropic class (so is_anthropic_wire is True), never called + over the network — the spy replaces the whole query loop.""" + + def __init__(self, *args, **kwargs): + super().__init__(api_key="sk-test", model="claude-opus-5") + + async def _spy(*args, **kwargs): + seen.update(kwargs) + raise AbortError() # unwind the turn cleanly, no envelope assertions + + async with _spawned(tmp_path, _AnthropicShaped) as (handle, gen): + sess = _session_of(handle) + r = await _control(handle, gen, "e1", {"subtype": "set_effort", "effort": "xhigh"}) + assert r["ok"] is True + + monkeypatch.setattr( + "src.query.agent_loop_compat.run_query_as_agent_loop", _spy + ) + await handle.send_to_agent( + {"type": "user", "message": {"role": "user", "content": "hi"}} + ) + assert await _wait_for(lambda: "thinking_effort" in seen), f"loop not called: {seen!r}" + + assert seen["thinking_effort"] == "xhigh", ( + "interactive /effort must arrive as thinking_effort — without it the " + "level never becomes output_config.effort" + ) + + +async def test_launch_effort_flag_seeds_the_session(tmp_path): + """``--effort`` must apply interactively, not only on headless ``-p``. + + The flag was plumbed solely into HeadlessOptions, so + ``clawcodex --model claude-opus-5 --effort xhigh`` (no ``-p``) parsed it + and silently discarded it. It now rides AgentServerConfig into the + session's ``/effort`` level. + """ + config = AgentServerConfig(effort="xhigh") + async with _spawned(tmp_path, _TextProvider, config) as (handle, gen): + sess = _session_of(handle) + assert sess._effort == "xhigh" + # _TextProvider is not Anthropic-shaped, so the level routes down + # the OpenAI-compat branch — the point here is only that the launch + # flag SEEDED a level at all. Per-family routing is pinned by + # test_effort_routing_matches_the_provider_wire_shape. + provider, thinking_effort = sess._turn_effort_routing() + assert provider is not sess.provider and thinking_effort is None + assert provider._inject({})["extra_body"]["reasoning_effort"] == "xhigh" + + # A later /effort still wins over the launch flag, and auto clears. + r = await _control(handle, gen, "e1", {"subtype": "set_effort", "effort": "low"}) + assert r["effort"] == "low" and sess._effort == "low" + r = await _control(handle, gen, "e2", {"subtype": "set_effort", "effort": "auto"}) + assert r["effort"] == "default" and sess._effort is None + + +@pytest.mark.parametrize("seed", ["minimal", "bogus", "", " ", 5, True]) +async def test_launch_effort_flag_ignores_off_ladder_values(tmp_path, seed): + """An off-ladder ``--effort`` must be dropped, not seeded verbatim. + + Seeding it would resurrect the trap ``_do_set_effort`` rejects: a value + outside VALID_THINKING_EFFORT_LEVELS makes resolve_thinking_effort fall + back to settings.effort, so ``--effort minimal`` could put ``max`` on + the wire while the init frame's badge showed "minimal". Validated at the + seed rather than only in argparse because --stdio/--print-connect + callers reach AgentServerConfig without passing a parser — which is also + why non-str values are covered: a ``.strip()`` on an int would raise + inside _build_runtime, and that turns into init_error, failing the whole + session over a cosmetic setting. + """ + async with _spawned(tmp_path, _TextProvider, AgentServerConfig(effort=seed)) as ( + handle, + gen, + ): + assert _session_of(handle)._effort is None + + +async def test_launch_effort_flag_is_normalized(tmp_path): + """Case is normalized at the seed, matching /effort's ``.lower()``. + + ``_EffortProvider`` injects the level verbatim into the request body, so + an unnormalized "MAX" would go out on the OpenAI-compat wire as-is. + """ + async with _spawned(tmp_path, _TextProvider, AgentServerConfig(effort=" MAX ")) as ( + handle, + gen, + ): + assert _session_of(handle)._effort == "max" + + +async def test_set_effort_rejects_minimal_on_the_claude_ladder(tmp_path): + """``minimal`` is a GPT-5 level, and accepting it here was a trap. + + It is absent from VALID_THINKING_EFFORT_LEVELS, so on the Anthropic path + ``resolve_thinking_effort`` would treat it as "nothing requested" and + silently substitute ``settings.effort`` — ``/effort minimal`` could emit + ``max`` while the TUI echoed "minimal". + """ + async with _spawned(tmp_path, _TextProvider) as (handle, gen): + sess = _session_of(handle) + r = await _control(handle, gen, "m1", {"subtype": "set_effort", "effort": "minimal"}) + assert r["ok"] is False + assert "minimal" not in r["error"].split("(")[-1], r["error"] + assert sess._effort is None + + async def test_set_effort_ultracode_gated_when_workflows_disabled(tmp_path, monkeypatch): monkeypatch.setenv("CLAUDE_CODE_DISABLE_WORKFLOWS", "1") async with _spawned(tmp_path, _TextProvider) as (handle, gen): diff --git a/tests/server/test_tui_launcher.py b/tests/server/test_tui_launcher.py index 2481698e1..214e8243a 100644 --- a/tests/server/test_tui_launcher.py +++ b/tests/server/test_tui_launcher.py @@ -45,6 +45,109 @@ class _Args: assert "--allow-dangerously-skip-permissions" not in cmd +def test_agent_server_cmd_forwards_effort(): + """``--effort`` must reach the backend the client spawns. + + The flag used to be plumbed only into HeadlessOptions, so + ``clawcodex --model claude-opus-5 --effort xhigh`` (interactive, no + ``-p``) parsed it and silently ran at the API default. The chain is + cli/launcher → this command → agent_server_cli → AgentServerConfig.effort + → the session's /effort level; every hop is load-bearing, and a dropped + hop is invisible at runtime (no error, just the wrong effort). + """ + class _Args: + permission_mode = "default" + provider = "anthropic" + model = "claude-opus-5" + effort = "xhigh" + + cmd = _agent_server_cmd(_Args()) + i = cmd.index("--effort") + assert cmd[i + 1] == "xhigh" + + class _NoEffort: + permission_mode = "default" + provider = None + model = None + effort = None + + assert "--effort" not in _agent_server_cmd(_NoEffort()) + + +def test_print_connect_forwards_effort(): + """The ``--print-connect`` route runs the server in-process, so it needs + the flag on its own argv list — a separate hop from _agent_server_cmd.""" + from types import SimpleNamespace + from unittest.mock import patch + + seen: dict = {} + + with patch( + "src.entrypoints.tui_launcher.run_agent_server_subcommand", + lambda argv: seen.setdefault("argv", argv) and 0 or 0, + ): + from src.entrypoints.tui_launcher import _print_connect + + _print_connect(SimpleNamespace( + workspace=None, permission_mode="default", is_bypass_available=False, + provider="anthropic", model="claude-opus-5", effort="xhigh", + )) + + argv = seen["argv"] + assert argv[argv.index("--effort") + 1] == "xhigh" + + +def test_interactive_entry_forwards_effort_to_launch_ink_tui(monkeypatch, tmp_path): + """``clawcodex --model X --effort xhigh`` (no -p) must reach the TUI. + + Pins the cli.py hop specifically: the flag lives in the argparse + "noninteractive" group for --help grouping, which is exactly how it came + to be forwarded only to HeadlessOptions. + """ + import src.cli as cli + + seen: dict = {} + monkeypatch.setattr( + "src.entrypoints.tui_launcher.launch_ink_tui", + lambda **kw: seen.update(kw) or 0, + ) + monkeypatch.setattr(cli, "run_pre_action", lambda args: None, raising=False) + # HOME is a temp dir, so there are no credentials — the startup provider + # gate would SystemExit(2) before reaching the launch call under test. + monkeypatch.setattr( + "src.entrypoints.provider_validation.validate_provider_at_startup", + lambda *a, **k: None, + ) + monkeypatch.setattr(sys, "argv", [ + "clawcodex", "--model", "claude-opus-5", "--effort", "xhigh", + ]) + monkeypatch.setenv("HOME", str(tmp_path)) + + assert cli.main() == 0 + assert seen.get("effort") == "xhigh", f"effort dropped by cli.main: {seen!r}" + assert seen.get("model") == "claude-opus-5" + + +def test_launcher_forwards_effort_to_the_ink_tui(tmp_path, monkeypatch): + """``clawcodex tui --effort`` must not parse-and-drop the flag. + + Regression: the standalone launcher grew the argparse entry while its + ``launch_ink_tui`` call still omitted ``effort=``, which parses cleanly + and silently loses the level. + """ + seen: dict = {} + + def _fake_launch(**kwargs): + seen.update(kwargs) + return 0 + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr("src.entrypoints.tui_launcher.launch_ink_tui", _fake_launch) + rc = run_tui_launcher(["--workspace", str(tmp_path), "--effort", "xhigh"]) + assert rc == 0 + assert seen.get("effort") == "xhigh", f"effort dropped: {seen!r}" + + def test_agent_server_cmd_forwards_bypass_availability(): """Resolved availability rides to the backend as --allow-dangerously-…, so Shift+Tab / /mode can reach bypassPermissions at runtime.""" @@ -78,6 +181,49 @@ def _flag_probe_stub(tmp_path, monkeypatch, expect: str) -> None: monkeypatch.setenv("CLAWCODEX_TUI_CMD", f"{sys.executable} {child}") +def test_launcher_end_to_end_puts_effort_on_the_backend_cmd(tmp_path, monkeypatch): + """Cross the launcher→SimpleNamespace→argv hops with NO mock between them. + + The kwarg-level tests around this one stop at ``launch_ink_tui`` and the + argv-level test starts after it, so the ``effort=effort`` entry in the + SimpleNamespace that ``launch_ink_tui`` builds sat in the seam between + two mocks — deletable with the whole suite green. That is the hop that + actually broke once during this work (parser entry added, call site not), + so it gets an unmocked probe: this asserts on the real + CLAWCODEX_AGENT_SERVER_CMD the launcher hands the client. + """ + monkeypatch.setenv("HOME", str(tmp_path)) + _flag_probe_stub(tmp_path, monkeypatch, "--effort xhigh") + rc = run_tui_launcher(["--workspace", str(tmp_path), "--effort", "xhigh"]) + assert rc == 0, "expected --effort xhigh in the spawned backend cmd" + + +def test_agent_server_cli_maps_effort_onto_the_config(monkeypatch, tmp_path): + """``--effort`` must land on AgentServerConfig, the last hop before the seed. + + Dropping this mapping makes the backend parse the flag and discard it on + EVERY launch path — interactive, ``clawcodex tui``, ``--print-connect``, + and the vscode ``--stdio`` route — with no error anywhere. + """ + from src.entrypoints import agent_server_cli + + seen: dict = {} + + def _capture(workspace, agent_config): + seen["effort"] = agent_config.effort + return 0 + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(agent_server_cli, "_serve_stdio", lambda w, c: _capture(w, c)) + monkeypatch.setattr( + "asyncio.run", lambda coro, **k: coro if isinstance(coro, int) else 0 + ) + agent_server_cli.run_agent_server_subcommand( + ["--stdio", "--effort", "xhigh", "--workspace", str(tmp_path)] + ) + assert seen.get("effort") == "xhigh", f"effort dropped before the config: {seen!r}" + + def test_launcher_dsp_flag_starts_backend_in_bypass(tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) _flag_probe_stub( diff --git a/tests/test_model_system.py b/tests/test_model_system.py index 06d57767b..6ae237eff 100644 --- a/tests/test_model_system.py +++ b/tests/test_model_system.py @@ -66,9 +66,46 @@ def test_unknown_returns_none(self): assert get_model_config("totally-unknown-model") is None def test_prefix_match(self): + # The fallback exists for date/suffix variants of a registered id. cfg = get_model_config("claude-sonnet-4-20250514-v2") - # Should match on prefix - assert cfg is not None or cfg is None # Prefix may or may not match depending on format + assert cfg is not None + assert cfg.model_id == "claude-sonnet-4-20250514" + + def test_prefix_match_is_insertion_ordered_for_opus(self): + """Pin who wins the ``claude-opus*`` fallback, in order. + + ``get_model_config`` walks MODEL_CONFIGS in insertion order and + matches on ``key.rsplit("-", 1)[0]``. The opus rows therefore have + two different bases — ``claude-opus-4`` (from the legacy dated key) + and the family-wide ``claude-opus`` (from ``claude-opus-5``) — and + the legacy one is registered first. Unregistered 4.x ids must keep + resolving to the legacy 200K row (under-estimating the window is + the safe direction), while opus-5 itself gets 1M. + """ + assert get_model_config("claude-opus-5").context_window == 1_000_000 + assert get_model_config("claude-opus-5-20260701").model_id == "claude-opus-5" + # Registered exactly — not via any prefix. + assert get_model_config("claude-opus-4-8").context_window == 1_000_000 + # Unregistered 4.x → legacy row, NOT the opus-5 row. + for unregistered in ("claude-opus-4-7", "claude-opus-4-5", "claude-opus-4-9"): + cfg = get_model_config(unregistered) + assert cfg.model_id == "claude-opus-4-20250514", unregistered + assert cfg.context_window == 200_000, unregistered + + def test_family_base_captures_bare_opus_strings(self): + """The accepted cost of registering ``claude-opus-5``. + + Its base is the family-wide ``claude-opus``, so the bare alias + string and any future ``claude-opus-`` now resolve to the + opus-5 row rather than falling through to None/200K. Pinned + because it is a deliberate trade, not an accident: if a future + Opus ships a different window, this test is where it bites. + ``claude-opus`` is a MODEL_ALIASES key resolving to Opus 4, so + display of the UNRESOLVED alias reads "Claude Opus 5" — callers + are expected to canonicalize first. + """ + assert get_model_config("claude-opus").model_id == "claude-opus-5" + assert get_model_config("claude-opus-6").model_id == "claude-opus-5" def test_deprecated_model_flag(self): cfg = get_model_config("claude-3-5-sonnet-20240620") diff --git a/tests/test_query_extended_thinking.py b/tests/test_query_extended_thinking.py index fe4b84cd9..5d953099c 100644 --- a/tests/test_query_extended_thinking.py +++ b/tests/test_query_extended_thinking.py @@ -105,6 +105,8 @@ class TestThinkingAllowlists(unittest.TestCase): ("claude-opus-4-6", True, True, True), ("claude-opus-4-8", True, True, True), ("claude-fable-5", True, True, True), + ("claude-opus-5", True, True, True), + ("claude-opus-5-20260701", True, True, True), # dated snapshot ("claude-opus-4-7", True, True, False), # adaptive but NOT effort ("claude-sonnet-4-5", True, False, False), ("claude-haiku-4-5", True, False, False), @@ -121,12 +123,29 @@ def test_capability_matrix(self): def test_xhigh_effort_allowlist(self): # Wire-probed 2026-07-18: opus-4-8 accepts xhigh; sonnet-4-6 and - # opus-4-6 400 on it (fable-5 by analogy with opus-4-8). + # opus-4-6 400 on it (fable-5 by analogy with opus-4-8; opus-5 + # ships the full low..max ladder). self.assertTrue(_model_supports_xhigh_effort("claude-opus-4-8")) self.assertTrue(_model_supports_xhigh_effort("claude-fable-5")) + self.assertTrue(_model_supports_xhigh_effort("claude-opus-5")) for model in ("claude-opus-4-6", "claude-sonnet-4-6", None): self.assertFalse(_model_supports_xhigh_effort(model), model) + def test_opus_5_is_thinking_eligible_so_adaptive_is_load_bearing(self): + """Why opus-5 MUST be on the adaptive allowlist. + + The regex-based ``_model_supports_extended_thinking`` already says + True for opus-5 (it needs no allowlist entry), so the loop will + always emit *some* thinking config. The adaptive allowlist is the + only thing deciding whether that config is ``{"type": "adaptive"}`` + or the ``budget_tokens`` form — and ``budget_tokens`` is REMOVED on + Opus 5, i.e. a 400 on every request. The emitted dict itself is + asserted in TestExtendedThinkingInjection. + """ + for model in ("claude-opus-5", "anthropic/claude-opus-5"): + self.assertTrue(_model_supports_extended_thinking(model), model) + self.assertTrue(_model_supports_adaptive_thinking(model), model) + class TestResolveThinkingEffort(unittest.TestCase): """Precedence (explicit > settings > default) + max clamping.""" @@ -272,6 +291,23 @@ def test_anthropic_opus_47_adaptive_but_no_effort(self): self.assertEqual(kw.get("thinking"), {"type": "adaptive"}) self.assertNotIn("output_config", kw) + def test_anthropic_opus_5_adaptive_plus_requested_effort(self): + # The terminal-bench configuration: opus-5 at effort=high must put + # adaptive thinking on the wire (budget_tokens is REMOVED on Opus 5 + # — the fallback path would 400 every request) AND carry the + # requested effort (dropping it silently would mean an "effort=high" + # eval run that never asked for high). + provider = _make_anthropic_mock("claude-opus-5") + kw = self._drive_one_turn(provider, thinking_effort="high") + self.assertEqual(kw.get("thinking"), {"type": "adaptive"}) + self.assertEqual(kw.get("output_config"), {"effort": "high"}) + + def test_anthropic_opus_5_keeps_xhigh(self): + # opus-5 carries the full ladder, so xhigh must NOT be clamped. + provider = _make_anthropic_mock("claude-opus-5") + kw = self._drive_one_turn(provider, thinking_effort="xhigh") + self.assertEqual(kw.get("output_config"), {"effort": "xhigh"}) + def test_anthropic_legacy_3x_does_NOT_get_thinking(self): # 3.x models reject the parameter at the API layer — the helper # must withhold it. diff --git a/ui-tui/src/__tests__/gatewayClient.test.ts b/ui-tui/src/__tests__/gatewayClient.test.ts index 8ab78a80d..c4ca73b81 100644 --- a/ui-tui/src/__tests__/gatewayClient.test.ts +++ b/ui-tui/src/__tests__/gatewayClient.test.ts @@ -514,7 +514,10 @@ describe('GatewayClient NDJSON adapter', () => { await replyToControl('list_workflow_commands', { commands: [], ok: true }) const r = await p const effort = r.items.find(i => i.text === '/effort') - expect(effort?.hint).toBe('[minimal|low|medium|high|auto|ultracode]') + // Must track the backend ladder (VALID_EFFORT_VALUES): xhigh/max are the + // levels Claude Opus 5 wants, and `minimal` is a GPT-5 level the backend + // rejects — advertising it sent users at a guaranteed error. + expect(effort?.hint).toBe('[low|medium|high|xhigh|max|auto|ultracode]') }) it('passes workflow argument_hint through to completion items', async () => { diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index c1af997b1..763726029 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -360,7 +360,11 @@ const SLASHES: ReadonlyArray<{ desc: string; hint?: string; name: string }> = [ { desc: 'Toggle extended thinking', hint: '[on|off|toggle]', name: '/thinking' }, { desc: 'Set reasoning effort (or "ultracode" workflow mode)', - hint: '[minimal|low|medium|high|auto|ultracode]', + // The real ladder is VALID_EFFORT_VALUES (agent_server _do_set_effort). + // This used to advertise `minimal` — a GPT-5 level the backend now + // rejects — while omitting xhigh and max, the two levels Claude Opus 5 + // actually wants for coding/agentic work. + hint: '[low|medium|high|xhigh|max|auto|ultracode]', name: '/effort' }, { desc: 'Switch the provider', hint: '[]', name: '/provider' }, @@ -1139,7 +1143,18 @@ export class GatewayClient extends EventEmitter { return out('Ultracode on: workflow auto-orchestration for this session (reset with /effort high).') } - return out(`Effort: ${r?.effort ?? arg ?? '(unchanged)'}.`) + // Keep the model-line badge in step with the new level (the backend + // only sends reasoning_effort on the init frame). + if (this.sessionInfo && typeof r?.effort === 'string') { + this.sessionInfo.reasoning_effort = r.effort === 'default' ? undefined : r.effort + this.publish({ payload: this.sessionInfo, session_id: this.sessionId, type: 'session.info' }) + } + + // `note` carries a caveat the level alone doesn't convey — today: + // extended thinking is off, which discards effort entirely. + const note = typeof r?.note === 'string' && r.note ? ` ${r.note}` : '' + + return out(`Effort: ${r?.effort ?? arg ?? '(unchanged)'}.${note}`) } case 'mode': { @@ -1207,8 +1222,11 @@ export class GatewayClient extends EventEmitter { case 'thinking': { const r = (await this.controlQuery('set_thinking', { action: arg ?? 'toggle' })) as any + // `note` warns when turning thinking off discards an effort level + // the user already set (effort rides inside the thinking block). + const note = typeof r?.note === 'string' && r.note ? ` ${r.note}` : '' - return out(`Thinking ${r?.thinking ? 'on' : 'off'}.`) + return out(`Thinking ${r?.thinking ? 'on' : 'off'}.${note}`) } case 'bg': { @@ -1983,6 +2001,9 @@ export class GatewayClient extends EventEmitter { model: String(init.model ?? ''), permission_mode: typeof init.permission_mode === 'string' ? init.permission_mode : undefined, profile_name: init.provider ? String(init.provider) : undefined, + // Rendered beside the model name; the backend sends the session's + // /effort level (seeded from --effort at launch), null when unset. + reasoning_effort: typeof init.reasoning_effort === 'string' ? init.reasoning_effort : undefined, skills: {}, tools: { '': toolNames }, // The app gates "ready" on info.version (useSessionLifecycle:227) and the