feat(code): add reasoning effort selector (#4403)

Closes #3491

---

Adds a `/effort` slash command for configuring reasoning effort on the
current model. The command resolves provider/model-specific effort
levels, applies the selected effort through the existing per-session
model-params override path, and rejects unsupported levels for the
active model.

The bare `/effort` command opens an interactive selector matching the
existing modal selector UX. Users can also pass an effort level
directly, or clear/reset the per-session effort override. The active
effort is shown next to the model in the status label and updates when
the effort is set, cleared, or the model is switched.

This also fixes provider credential switching so gateway-provisioned
custom auth headers, such as `ANTHROPIC_CUSTOM_HEADERS`, are cleared
when returning to a provider-native key and endpoint.

## Release note

Users can now configure supported reasoning effort levels with `/effort`
and see the active effort displayed next to the current model. Switching
back to a provider-native credential now clears gateway custom-header
auth for supported providers.

## Video

https://github.com/user-attachments/assets/4a347939-b5e8-4fb6-bd0d-c50635017593

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: Mason Daugherty <github@mdrxy.com>
Co-authored-by: Mason Daugherty <mason@langchain.dev>
This commit is contained in:
Johannes du Plessis
2026-07-01 13:11:51 -07:00
committed by GitHub
parent ef637f4a3f
commit 6ee0ac4cca
13 changed files with 1939 additions and 27 deletions
+2 -1
View File
@@ -8,7 +8,7 @@ Regenerate this file with `make commands-catalog` after changing command names,
aliases, descriptions, visibility, or hidden-command metadata.
## Public (31)
## Public (32)
| Command | Aliases | Description |
| --- | --- | --- |
@@ -20,6 +20,7 @@ aliases, descriptions, visibility, or hidden-command metadata.
| `/copy` | | Copy the latest assistant message to clipboard |
| `/docs` | | Open the docs |
| `/editor` | | Open prompt in an external editor ($EDITOR) |
| `/effort` | | Set reasoning effort for the current model |
| `/feedback` | | Send feedback or report an issue |
| `/force-clear` | | Stop active work, clear the chat, and start a new thread |
| `/goal` | | Set a persistent objective by drafting acceptance criteria |
+220 -18
View File
@@ -1302,6 +1302,43 @@ class DeferredAction:
"""Async callable that performs the actual work."""
@dataclass(frozen=True, slots=True)
class _EffortContext:
"""The current model and the effort levels `/effort` can offer for it.
When the user runs `/effort`, they either pick a reasoning level from a
menu or type one. This holds what that requires: the active model and the
levels it supports so the menu lists the right choices and a typed level
that the model does not support is rejected instead of silently applied.
"""
spec: str
"""Active `provider:model` spec."""
efforts: tuple[str, ...]
"""Reasoning effort labels supported by `spec`."""
current: str | None
"""Effort from the per-session override, or `None` when unset."""
default: str | None
"""Provider default effort for `spec`, or `None` when unknown."""
@dataclass(frozen=True, slots=True)
class _EffortUnavailable:
"""Why `/effort` cannot proceed, as a user-facing message.
The failure arm of `_resolve_effort_context`, paired with `_EffortContext`.
Making it a distinct type rather than a bare `str` keeps it nominally
separate from any other string a caller handles, so the two arms can never
be confused by an unrelated `str` value.
"""
message: str
"""User-facing explanation to surface via `AppMessage`."""
@dataclass(frozen=True, slots=True)
class _ThreadHistoryPayload:
"""Data returned by `_fetch_thread_history_data`."""
@@ -2775,6 +2812,7 @@ class DeepAgentsApp(App):
self._chat_input = self.query_one("#input-area", ChatInput)
self._sync_status_connection()
self._sync_status_queued()
self._sync_status_model()
self._chat_input.set_cursor_blink(blink=self._cursor_blink_enabled)
# Apply any skill commands discovered before the widget was mounted
@@ -3772,18 +3810,7 @@ class DeepAgentsApp(App):
if self._status_bar is None:
logger.warning("Status bar not found during server ready transition")
else:
from deepagents_code.config import settings
provider = settings.model_provider or ""
model = settings.model_name or ""
if not provider or not model:
logger.warning(
"Settings missing model identity at server ready "
"(provider=%r, model=%r); status bar will render blank",
provider,
model,
)
self._status_bar.set_model(provider=provider, model=model)
self._sync_status_model()
if self._active_mcp_viewer is not None:
viewer = self._active_mcp_viewer
@@ -9100,7 +9127,7 @@ class DeepAgentsApp(App):
await self._mount_message(UserMessage(command))
help_body = (
"Commands: /quit, /agents, /auth, /clear, /force-clear, "
"/copy, /goal, /offload, /editor, "
"/copy, /goal, /offload, /editor, /effort, "
"/mcp, /model [--model-params JSON] [--default], "
"/notifications, /reload, /restart, /rubric, "
"/skill:<name>, /remember, "
@@ -9330,6 +9357,8 @@ class DeepAgentsApp(App):
await self._show_theme_selector()
elif cmd == "/notifications":
await self._show_notification_settings()
elif cmd == "/effort" or cmd.startswith("/effort "):
await self._handle_effort_command(command)
elif cmd == "/model" or cmd.startswith("/model "):
model_arg = None
set_default = False
@@ -9961,6 +9990,182 @@ class DeepAgentsApp(App):
return settings.model_provider or None
def _sync_status_model(self) -> None:
"""Update the status bar with the active model and reasoning effort."""
from deepagents_code.config import settings
from deepagents_code.reasoning_effort import (
current_effort_from_model_params,
default_effort_for_model,
)
if self._status_bar is None:
return
provider = settings.model_provider or ""
model = settings.model_name or ""
if not provider or not model:
logger.warning(
"Settings missing model identity at status sync "
"(provider=%r, model=%r); status bar will render blank",
provider,
model,
)
# Identity is blank, so render a uniformly-empty row rather than a
# blank model paired with a populated effort suffix.
self._status_bar.set_model(provider=provider, model=model, effort="")
return
spec = self._effective_model_spec()
effort = ""
if spec:
effort = (
current_effort_from_model_params(spec, self._model_params_override)
or default_effort_for_model(spec)
or ""
)
self._status_bar.set_model(provider=provider, model=model, effort=effort)
def _resolve_effort_context(self) -> _EffortContext | _EffortUnavailable:
"""Resolve the active model spec and its supported reasoning efforts.
Returns:
An `_EffortContext` on success, or an `_EffortUnavailable` carrying
a user-facing message when no model is configured or the model
does not support reasoning effort. The two arms are distinct
types, so callers discriminate with
`isinstance(..., _EffortUnavailable)`.
"""
from deepagents_code.reasoning_effort import (
current_effort_from_model_params,
default_effort_for_model,
supported_efforts_for_model,
)
spec = self._effective_model_spec()
if not spec:
return _EffortUnavailable(
"No model is configured yet. Run `/model` to choose one."
)
efforts = supported_efforts_for_model(spec)
if not efforts:
return _EffortUnavailable(
f"Reasoning effort is not configurable for {spec}."
)
return _EffortContext(
spec=spec,
efforts=efforts,
current=current_effort_from_model_params(spec, self._model_params_override),
default=default_effort_for_model(spec),
)
async def _handle_effort_command(self, command: str) -> None:
"""Set or select reasoning effort for the current model.
Args:
command: The raw `/effort` slash command.
"""
raw = command.strip()[len("/effort") :].strip().lower()
if not raw:
await self._show_effort_selector(command)
return
await self._mount_message(UserMessage(command))
await self._set_effort_override(raw)
async def _show_effort_selector(self, command: str) -> None:
"""Open the reasoning effort selector for the current model.
Args:
command: The raw `/effort` slash command.
"""
from deepagents_code.widgets.effort_selector import EffortSelectorScreen
context = self._resolve_effort_context()
if isinstance(context, _EffortUnavailable):
await self._mount_message(UserMessage(command))
await self._mount_message(AppMessage(context.message))
return
screen = EffortSelectorScreen(
model_spec=context.spec,
efforts=context.efforts,
current_effort=context.current,
default_effort=context.default,
)
async def apply_effort(effort: str) -> None:
try:
await self._set_effort_override(effort)
except Exception:
# The interactive path applies the effort in a background
# worker, so a failure would otherwise die silently there with
# no confirmation and no error for the user.
logger.exception("Failed to apply reasoning effort %r", effort)
await self._mount_message(
ErrorMessage(f"Failed to apply reasoning effort {effort!r}."),
)
def handle_result(result: str | None) -> None:
if result is not None:
self.run_worker(
apply_effort(result),
exclusive=False,
group="effort-selection",
)
if self._chat_input:
self._chat_input.focus_input()
self.push_screen(screen, handle_result)
async def _set_effort_override(self, effort: str) -> None:
"""Apply a reasoning effort override to the current model params.
Args:
effort: Effort label or clear/reset token.
"""
from deepagents_code.reasoning_effort import (
merge_effort_model_params,
model_params_for_effort,
without_effort_model_params,
)
context = self._resolve_effort_context()
if isinstance(context, _EffortUnavailable):
await self._mount_message(AppMessage(context.message))
return
spec = context.spec
if effort in {"clear", "--clear", "reset"}:
had_override = context.current is not None
self._model_params_override = without_effort_model_params(
self._model_params_override
)
self._sync_status_model()
message = (
f"Reasoning effort override cleared for {spec}."
if had_override
else f"No reasoning effort override was set for {spec}."
)
await self._mount_message(AppMessage(message))
return
params = model_params_for_effort(spec, effort)
if params is None:
supported = ", ".join(context.efforts)
await self._mount_message(
ErrorMessage(
f"Unsupported reasoning effort {effort!r} for {spec}. "
f"Supported efforts: {supported}",
),
)
return
self._model_params_override = merge_effort_model_params(
self._model_params_override, params
)
self._sync_status_model()
await self._mount_message(
AppMessage(f"Reasoning effort for {spec} set to {effort}."),
)
async def _run_agent_task(
self,
message: str,
@@ -15454,6 +15659,7 @@ class DeepAgentsApp(App):
# prior per-session override.
self._model_override = current
self._model_params_override = extra_kwargs
self._sync_status_model()
params_suffix = _format_model_params(extra_kwargs)
if announce_unchanged:
message = f"Already using {current}{params_suffix}"
@@ -15498,11 +15704,7 @@ class DeepAgentsApp(App):
self._model_override = display
self._model_params_override = extra_kwargs
if self._status_bar:
self._status_bar.set_model(
provider=settings.model_provider or "",
model=settings.model_name or "",
)
self._sync_status_model()
self._last_model_unchanged_message = None
params_suffix = _format_model_params(extra_kwargs)
@@ -118,6 +118,13 @@ COMMANDS: tuple[SlashCommand, ...] = (
description="Open prompt in an external editor ($EDITOR)",
bypass_tier=BypassTier.QUEUED,
),
SlashCommand(
name="/effort",
description="Set reasoning effort for the current model",
bypass_tier=BypassTier.QUEUED,
hidden_keywords="reasoning thinking level",
argument_hint="[none|low|medium|high|xhigh|max|clear]",
),
SlashCommand(
name="/mcp",
description="Manage MCP servers and authentication",
+51
View File
@@ -742,6 +742,9 @@ OPTIONAL_AUTH_ENV: dict[str, str] = {"ollama": "OLLAMA_API_KEY"}
PROVIDER_HOST_ENV: dict[str, str] = {"ollama": "OLLAMA_HOST"}
"""Provider-specific env vars that can point a local provider at a remote host."""
PROVIDER_CUSTOM_HEADERS_ENV: dict[str, str] = {"anthropic": "ANTHROPIC_CUSTOM_HEADERS"}
"""Provider SDK env vars that inject custom request headers (e.g. gateway auth)."""
OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434"
"""Default endpoint assumed when no `base_url` or `OLLAMA_HOST` is configured."""
@@ -2170,6 +2173,10 @@ def _apply_stored_base_url(provider: str, base_url: str | None) -> None:
clears every name when `base_url` is `None` (reset to the provider
default). See `apply_stored_credentials` for the pairing rationale.
When switching to a provider-native key (no `base_url`), also clears the
provider's custom-headers env var (e.g. `ANTHROPIC_CUSTOM_HEADERS`) so a
gateway-provisioned auth header isn't sent to the native endpoint.
Args:
provider: Provider name.
base_url: The stored endpoint, or `None` to reset to the default.
@@ -2183,12 +2190,56 @@ def _apply_stored_base_url(provider: str, base_url: str | None) -> None:
names.add(canonical)
if not names:
return
configured_base_url_survives = _configured_base_url_survives_env_clear(provider)
for name in names:
if base_url and name == canonical:
os.environ[name] = base_url
else:
os.environ.pop(name, None)
# A provider SDK's custom-header env var (e.g. `ANTHROPIC_CUSTOM_HEADERS`)
# injects headers into every request. A gateway-provisioned environment
# often sets it to `X-Api-Key: <gateway-key>`, which overrides the SDK's
# own `api_key`-derived header. When switching to a provider-native key
# (no stored `base_url`), that header must also be cleared — otherwise the
# gateway key is sent to the native endpoint and rejected.
custom_headers_env = PROVIDER_CUSTOM_HEADERS_ENV.get(provider)
if custom_headers_env and not base_url:
if not configured_base_url_survives:
if os.environ.pop(custom_headers_env, None) is not None:
# Log the env var name only — never its value, which carries
# auth headers. Surfaces the removal for the user who set a
# header deliberately for the native endpoint and later wonders
# where it went.
logger.info(
"Cleared %s while applying a provider-native %s key",
custom_headers_env,
provider,
)
elif os.environ.get(custom_headers_env) is not None:
# A provider base URL still routes (config or a prefixed env var),
# so the custom-header env is deliberately kept. Log the name only —
# never the value — so the retention is observable when a user later
# wonders why a gateway header is still in effect after applying a
# native key.
logger.debug(
"Kept %s: a %s base URL is still configured",
custom_headers_env,
provider,
)
def _configured_base_url_survives_env_clear(provider: str) -> bool:
"""Return whether endpoint config still routes after plain env cleanup."""
config = ModelConfig.load()
provider_cfg = config.providers.get(provider)
if provider_cfg and provider_cfg.get("base_url"):
return True
for env_var in get_base_url_env_vars(provider):
if os.environ.get(f"{_ENV_PREFIX}{env_var}"):
return True
return False
def warn_on_split_credential_source(provider: str) -> None:
"""Log when a provider's key and endpoint resolve from different env tiers.
@@ -0,0 +1,552 @@
"""Provider-specific reasoning effort support for `/effort`."""
from __future__ import annotations
import logging
import re
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeAlias, get_args
if TYPE_CHECKING:
from collections.abc import Callable
from deepagents_code.model_config import CODEX_PROVIDER, ModelSpec
logger = logging.getLogger(__name__)
EffortLabel: TypeAlias = Literal["none", "low", "medium", "high", "xhigh", "max"]
"""Closed vocabulary of effort labels across all supported providers.
Typing the per-provider tuples with this alias catches typos in the vocabulary
at check time. It does not express the deeper invariant that a label must be
supported by a *specific* model — that is enforced at runtime by
`supported_efforts_for_model`.
This vocabulary is also hand-duplicated as display text in the `/effort`
`argument_hint` (`command_registry.py`) and in `COMMANDS.md`; those are not
type-checked against this alias, so update them in lockstep when it changes.
"""
ReasoningProvider: TypeAlias = Literal[
"anthropic", "fireworks", "google_genai", "openai", "openai_codex"
]
"""Provider identifiers that support model-specific reasoning effort controls.
Values must stay byte-identical to the provider strings from `ModelSpec.parse`
used throughout `model_config.py` (e.g. `CODEX_PROVIDER`).
"""
class ReasoningProviderConfig(NamedTuple):
"""Provider-specific reasoning effort behavior."""
supported_efforts: Callable[[str], tuple[EffortLabel, ...]]
"""Return supported effort labels for a lowercased model name."""
default_effort: Callable[[str], EffortLabel | None]
"""Return the provider default effort for a lowercased model name, if known."""
model_params: Callable[[str], dict[str, Any]]
"""Translate an effort label into provider-specific model params."""
current_effort: Callable[[dict[str, Any]], str | None]
"""Read the configured effort label from provider-specific model params."""
OPENAI_EFFORTS: tuple[EffortLabel, ...] = ("none", "low", "medium", "high", "xhigh")
"""OpenAI GPT-5 effort labels for `reasoning.effort`.
See https://platform.openai.com/docs/guides/reasoning.
"""
ANTHROPIC_EFFORTS: tuple[EffortLabel, ...] = ("low", "medium", "high", "xhigh", "max")
"""Anthropic `output_config.effort` labels for Opus 4.7+ and Sonnet 5.
See https://platform.claude.com/docs/en/build-with-claude/effort.
"""
ANTHROPIC_EFFORTS_NO_XHIGH: tuple[EffortLabel, ...] = ("low", "medium", "high", "max")
"""Anthropic effort labels for Opus 4.6 and Sonnet 4.6.
These models predate `xhigh`; Sonnet 4.5 rejects `effort` entirely.
See https://platform.claude.com/docs/en/build-with-claude/effort.
"""
ANTHROPIC_EFFORTS_NO_MAX: tuple[EffortLabel, ...] = ("low", "medium", "high")
"""Anthropic effort labels for Opus 4.5.
Opus 4.5 predates both `max` (Opus 4.6+) and `xhigh` (Opus 4.7+).
See https://platform.claude.com/docs/en/build-with-claude/effort.
"""
GOOGLE_EFFORTS: tuple[EffortLabel, ...] = ("low", "medium", "high")
"""Gemini `thinking_level` labels.
Applied to every `gemini-3*` model (the gate in `_classify_reasoning_provider`),
including Gemini 3 Pro/Flash, 3.1 Pro, and 3.5 Flash — all accept
low/medium/high. `minimal` is Flash-Lite / original-Pro territory, neither of
which is offered here. See https://ai.google.dev/gemini-api/docs/thinking.
"""
FIREWORKS_REASONING_EFFORTS: tuple[EffortLabel, ...] = (
"none",
"low",
"medium",
"high",
"xhigh",
"max",
)
"""Fireworks `reasoning_effort` labels for DeepSeek V4 Pro.
See https://docs.fireworks.ai/guides/reasoning.
"""
FIREWORKS_KIMI_EFFORTS: tuple[EffortLabel, ...] = ("low", "medium", "high")
"""Fireworks `reasoning_effort` labels for Kimi K2 models.
See https://docs.fireworks.ai/guides/reasoning.
"""
FIREWORKS_GLM_EFFORTS: tuple[EffortLabel, ...] = ("none", "high", "max")
"""Fireworks `reasoning_effort` labels for GLM 5 models.
See https://docs.fireworks.ai/guides/reasoning.
"""
_REASONING_KEYS: frozenset[str] = frozenset(
{"effort", "reasoning", "reasoning_effort", "thinking", "thinking_level"}
)
"""Runtime config keys that may already carry provider reasoning settings."""
def _openai_supported_efforts(_model: str) -> tuple[EffortLabel, ...]:
"""Return OpenAI reasoning effort levels."""
return OPENAI_EFFORTS
def _openai_default_effort(model: str) -> EffortLabel | None:
"""Return the OpenAI default reasoning effort when known."""
# Only gpt-5.5 documents `medium` as its default; other/newer gpt-5 variants
# fall through to `None` until their default is confirmed against
# https://platform.openai.com/docs/guides/reasoning.
return "medium" if model.startswith("gpt-5.5") else None
def _openai_model_params(effort: str) -> dict[str, Any]:
"""Return OpenAI reasoning params for an effort label."""
if effort == "none":
return {"reasoning": {"effort": "none"}}
return {"reasoning": {"effort": effort, "summary": "auto"}}
def _openai_current_effort(model_params: dict[str, Any]) -> str | None:
"""Read the OpenAI reasoning effort from model params.
Returns:
The configured effort label, or `None` when unset.
"""
reasoning = model_params.get("reasoning")
if isinstance(reasoning, dict):
value = reasoning.get("effort")
if value is not None and not isinstance(value, str):
# Present but mistyped (e.g. a hand-edited config or bad
# `--model-params` JSON). Discard it, but log the *type* — never the
# value — so the drop is greppable instead of silently read as "no
# effort set" while the malformed param still ships on the wire.
logger.warning(
"Ignoring non-str OpenAI reasoning.effort of type %s",
type(value).__name__,
)
return value if isinstance(value, str) else None
if reasoning is not None:
logger.warning(
"Ignoring OpenAI reasoning params of unexpected type %s",
type(reasoning).__name__,
)
return None
def _has_version(model: str, token: str) -> bool:
"""Return whether `model` carries version `token` not followed by a digit.
A plain substring test would match a longer version by accident — e.g.
`"opus-4-1" in "claude-opus-4-16"` is true. Anchoring on a non-digit
boundary keeps `opus-4-1` from matching a future `opus-4-16` while still
matching a dated suffix like `opus-4-1-20250805`. `token` is always a
hardcoded constant, but `re.escape` keeps the match literal regardless.
"""
return re.search(rf"{re.escape(token)}(?!\d)", model) is not None
def _anthropic_supported_efforts(model: str) -> tuple[EffortLabel, ...]:
"""Return the effort levels an Anthropic model accepts.
Args:
model: Lowercased Anthropic model name (e.g. `claude-opus-4-8`).
Returns:
Supported effort labels, or an empty tuple when the model does not
accept `effort` (e.g. Sonnet 4.5).
"""
if model.startswith("claude-opus-"):
if _has_version(model, "opus-4-0") or _has_version(model, "opus-4-1"):
# Opus 4.0/4.1 predate reasoning effort entirely.
return ()
if _has_version(model, "opus-4-5"):
# Opus 4.5 predates both `max` (4.6+) and `xhigh` (4.7+).
return ANTHROPIC_EFFORTS_NO_MAX
# Opus 4.6 predates `xhigh`; 4.7+ (and newer, unrecognized versions)
# get the full range.
return (
ANTHROPIC_EFFORTS_NO_XHIGH
if _has_version(model, "opus-4-6")
else ANTHROPIC_EFFORTS
)
if model.startswith("claude-sonnet-"):
if (
_has_version(model, "sonnet-4-0")
or _has_version(model, "sonnet-4-1")
or _has_version(model, "sonnet-4-5")
):
# Sonnet 4.0/4.1 predate effort; Sonnet 4.5 rejects it.
return ()
# Sonnet 4.6 predates `xhigh`; Sonnet 5 (and newer) get the full range.
return (
ANTHROPIC_EFFORTS_NO_XHIGH
if _has_version(model, "sonnet-4-6")
else ANTHROPIC_EFFORTS
)
return ()
def _anthropic_default_effort(model: str) -> EffortLabel | None:
"""Return the Anthropic default reasoning effort when known."""
return "high" if _anthropic_supported_efforts(model) else None
def _anthropic_model_params(effort: str) -> dict[str, Any]:
"""Return Anthropic reasoning params for an effort label."""
return {
"thinking": {"type": "adaptive", "display": "summarized"},
"effort": effort,
}
def _anthropic_current_effort(model_params: dict[str, Any]) -> str | None:
"""Read the Anthropic reasoning effort from model params.
Returns:
The configured effort label, or `None` when unset.
"""
value = model_params.get("effort")
if value is not None and not isinstance(value, str):
logger.warning(
"Ignoring non-str Anthropic effort of type %s", type(value).__name__
)
return value if isinstance(value, str) else None
def _google_supported_efforts(_model: str) -> tuple[EffortLabel, ...]:
"""Return Gemini thinking levels."""
return GOOGLE_EFFORTS
def _google_default_effort(model: str) -> EffortLabel | None:
"""Return the Gemini default thinking level when known."""
if model.startswith("gemini-3.5-flash"):
return "medium"
if model.startswith(("gemini-3.1-pro", "gemini-3-flash", "gemini-3-pro")):
return "high"
return None
def _google_model_params(effort: str) -> dict[str, Any]:
"""Return Gemini thinking params for an effort label."""
return {"thinking_level": effort}
def _google_current_effort(model_params: dict[str, Any]) -> str | None:
"""Read the Gemini thinking level from model params.
Returns:
The configured effort label, or `None` when unset.
"""
value = model_params.get("thinking_level")
if value is not None and not isinstance(value, str):
logger.warning(
"Ignoring non-str Gemini thinking_level of type %s",
type(value).__name__,
)
return value if isinstance(value, str) else None
def _fireworks_supported_efforts(model: str) -> tuple[EffortLabel, ...]:
"""Return Fireworks reasoning effort levels for a model."""
if "kimi-k2" in model:
return FIREWORKS_KIMI_EFFORTS
if "glm-5" in model:
return FIREWORKS_GLM_EFFORTS
if "deepseek-v4-pro" in model:
return FIREWORKS_REASONING_EFFORTS
return ()
def _fireworks_default_effort(model: str) -> EffortLabel | None:
"""Return the Fireworks default reasoning effort when known."""
if "deepseek-v4-pro" in model:
return "high"
if "glm-5p2" in model:
return "max"
return None
def _fireworks_model_params(effort: str) -> dict[str, Any]:
"""Return Fireworks reasoning params for an effort label."""
return {"model_kwargs": {"reasoning_effort": effort}}
def _fireworks_current_effort(model_params: dict[str, Any]) -> str | None:
"""Read the Fireworks reasoning effort from model params.
Returns:
The configured effort label, or `None` when unset.
"""
kwargs = model_params.get("model_kwargs")
if isinstance(kwargs, dict):
value = kwargs.get("reasoning_effort")
if value is not None and not isinstance(value, str):
logger.warning(
"Ignoring non-str Fireworks reasoning_effort of type %s",
type(value).__name__,
)
return value if isinstance(value, str) else None
# A non-dict `model_kwargs` is a legitimate shape here (it may hold
# unrelated params, or be preserved verbatim by `without_effort_model_params`),
# so treat it as "no effort configured" without warning.
return None
_OPENAI_CONFIG = ReasoningProviderConfig(
supported_efforts=_openai_supported_efforts,
default_effort=_openai_default_effort,
model_params=_openai_model_params,
current_effort=_openai_current_effort,
)
"""Shared config for OpenAI-compatible GPT-5 reasoning providers.
`openai` and `openai_codex` use different provider names so model selection can
route to the right client, but `/effort` maps both to the same reasoning params.
"""
_PROVIDER_CONFIGS: dict[ReasoningProvider, ReasoningProviderConfig] = {
"openai": _OPENAI_CONFIG,
"openai_codex": _OPENAI_CONFIG,
"anthropic": ReasoningProviderConfig(
supported_efforts=_anthropic_supported_efforts,
default_effort=_anthropic_default_effort,
model_params=_anthropic_model_params,
current_effort=_anthropic_current_effort,
),
"google_genai": ReasoningProviderConfig(
supported_efforts=_google_supported_efforts,
default_effort=_google_default_effort,
model_params=_google_model_params,
current_effort=_google_current_effort,
),
"fireworks": ReasoningProviderConfig(
supported_efforts=_fireworks_supported_efforts,
default_effort=_fireworks_default_effort,
model_params=_fireworks_model_params,
current_effort=_fireworks_current_effort,
),
}
"""Provider-specific reasoning effort behavior keyed by `ModelSpec` provider."""
if set(_PROVIDER_CONFIGS) != set(get_args(ReasoningProvider)): # pragma: no cover
# `_classify_reasoning_provider` only ever returns members of the
# `ReasoningProvider` vocabulary, and `_reasoning_config` indexes
# `_PROVIDER_CONFIGS` with the result — so the two must stay in lockstep or
# that lookup raises `KeyError` at runtime. Fail loudly at import instead.
msg = "_PROVIDER_CONFIGS keys must match the ReasoningProvider vocabulary"
raise RuntimeError(msg)
def _classify_reasoning_provider(provider: str, model: str) -> ReasoningProvider | None:
"""Classify provider/model parts into a reasoning-capable provider.
Returns:
The registry key for supported reasoning models, or `None` otherwise.
"""
model_lower = model.lower()
if provider == "openai" and model_lower.startswith("gpt-5"):
return "openai"
if provider == CODEX_PROVIDER and model_lower.startswith("gpt-5"):
return "openai_codex"
if provider == "anthropic" and model_lower.startswith(
("claude-opus-", "claude-sonnet-")
):
return "anthropic"
if provider == "google_genai" and model_lower.startswith("gemini-3"):
return "google_genai"
if provider == "fireworks" and model_lower.startswith("accounts/fireworks/models/"):
return "fireworks"
return None
def _reasoning_config(model_spec: str) -> tuple[ReasoningProviderConfig, str] | None:
"""Return provider config and lowercased model when reasoning is supported."""
parsed = ModelSpec.try_parse(model_spec)
if parsed is None:
return None
provider = _classify_reasoning_provider(parsed.provider, parsed.model)
if provider is None:
return None
return _PROVIDER_CONFIGS[provider], parsed.model.lower()
def supported_efforts_for_model(model_spec: str | None) -> tuple[str, ...]:
"""Return reasoning efforts supported by `model_spec`.
Returns plain `str` labels rather than `EffortLabel`: this is the public
boundary where the label vocabulary is intentionally dropped, since the
values flow straight to the UI.
Args:
model_spec: `provider:model` spec for the active model.
Returns:
Supported effort labels, or an empty tuple when the model is unsupported.
"""
if not model_spec:
return ()
context = _reasoning_config(model_spec)
if context is None:
return ()
config, model = context
efforts = config.supported_efforts(model)
if not efforts:
# A recognized reasoning provider that yields no configurable efforts
# usually means the model-version heuristics need updating for a newer
# release. Log at info so the maintenance gap is visible at default
# verbosity rather than silently reporting "not configurable".
logger.info("No configurable reasoning efforts for %s", model_spec)
return efforts
def default_effort_for_model(model_spec: str | None) -> str | None:
"""Return the documented default reasoning effort when known.
Returns a plain `str` rather than `EffortLabel`: like
`supported_efforts_for_model`, this is the public boundary where the label
vocabulary is intentionally dropped, since every caller treats the value as
display text.
Args:
model_spec: `provider:model` spec for the active model.
Returns:
The provider default effort label, or `None` when the default is unknown.
"""
if not model_spec:
return None
context = _reasoning_config(model_spec)
if context is None:
return None
config, model = context
return config.default_effort(model)
def model_params_for_effort(model_spec: str, effort: str) -> dict[str, Any] | None:
"""Translate an effort label into provider-specific model params.
Args:
model_spec: `provider:model` spec for the active model.
effort: Effort label accepted by `supported_efforts_for_model`.
Returns:
Model params to merge into the per-session override, or `None` when the
model/effort pair is unsupported.
"""
context = _reasoning_config(model_spec)
if context is None:
return None
config, model = context
if effort not in config.supported_efforts(model):
return None
return config.model_params(effort)
def current_effort_from_model_params(
model_spec: str | None, model_params: dict[str, Any] | None
) -> str | None:
"""Read the configured effort from model params when present.
Args:
model_spec: `provider:model` spec for the active model.
model_params: Per-session model params.
Returns:
The configured effort, or `None` when no recognized effort override is set.
"""
if not model_spec or not model_params:
return None
context = _reasoning_config(model_spec)
if context is None:
return None
config, _ = context
return config.current_effort(model_params)
def merge_effort_model_params(
existing: dict[str, Any] | None, effort_params: dict[str, Any]
) -> dict[str, Any]:
"""Merge effort params into existing per-session model params.
Args:
existing: Current per-session model params.
effort_params: Params returned by `model_params_for_effort`.
Returns:
A new merged dictionary preserving unrelated nested `model_kwargs`.
"""
merged = dict(existing) if existing else {}
for key, value in effort_params.items():
if key == "model_kwargs" and isinstance(value, dict):
current = merged.get("model_kwargs")
base = dict(current) if isinstance(current, dict) else {}
base.update(value)
merged["model_kwargs"] = base
else:
merged[key] = value
return merged
def without_effort_model_params(
existing: dict[str, Any] | None,
) -> dict[str, Any] | None:
"""Remove known effort params while preserving unrelated model params.
Args:
existing: Current per-session model params.
Returns:
A cleaned dictionary, or `None` when no params remain.
"""
if not existing:
return None
# Exclude `model_kwargs` from the comprehension and rebuild it below.
# Leaving it here would retain a stale `reasoning_effort` when the cleaned
# nested dict ends up empty — the empty-check would then skip the overwrite
# and the original (still-populated) copy would survive.
cleaned = {
key: (dict(value) if isinstance(value, dict) else value)
for key, value in existing.items()
if key not in _REASONING_KEYS and key != "model_kwargs"
}
kwargs = existing.get("model_kwargs")
if isinstance(kwargs, dict):
model_kwargs = {k: v for k, v in kwargs.items() if k != "reasoning_effort"}
if model_kwargs:
cleaned["model_kwargs"] = model_kwargs
elif kwargs is not None:
cleaned["model_kwargs"] = kwargs
return cleaned or None
@@ -0,0 +1,189 @@
"""Interactive reasoning effort selector for `/effort`."""
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from textual.binding import Binding, BindingType
from textual.containers import Vertical
from textual.content import Content
from textual.css.query import NoMatches
from textual.screen import ModalScreen
from textual.widgets import OptionList, Static
from textual.widgets.option_list import Option
if TYPE_CHECKING:
from textual.app import ComposeResult
from deepagents_code import theme
from deepagents_code.config import get_glyphs, is_ascii_mode
class EffortSelectorScreen(ModalScreen[str | None]):
"""Modal dialog for selecting a reasoning effort level."""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "cancel", "Cancel", show=False),
Binding("tab", "cursor_down", "Next", show=False, priority=True),
Binding("shift+tab", "cursor_up", "Previous", show=False, priority=True),
]
CSS = """
EffortSelectorScreen {
align: center middle;
background: transparent;
}
EffortSelectorScreen > Vertical {
width: 54;
max-width: 90%;
height: auto;
max-height: 80%;
background: $surface;
border: solid $primary;
padding: 1 2;
}
EffortSelectorScreen .effort-selector-title {
text-style: bold;
color: $primary;
text-align: center;
margin-bottom: 1;
}
EffortSelectorScreen .effort-selector-subtitle {
height: auto;
color: $text-muted;
text-align: center;
margin-bottom: 1;
}
EffortSelectorScreen OptionList {
height: auto;
max-height: 10;
background: $background;
}
EffortSelectorScreen .effort-selector-help {
height: auto;
color: $text-muted;
text-style: italic;
margin-top: 1;
text-align: center;
}
"""
def __init__(
self,
*,
model_spec: str,
efforts: tuple[str, ...],
current_effort: str | None = None,
default_effort: str | None = None,
) -> None:
"""Initialize the effort selector.
Args:
model_spec: Active `provider:model` spec.
efforts: Supported effort labels for `model_spec`.
current_effort: Current per-session effort override, if any.
default_effort: Provider default effort for `model_spec`, if known.
"""
super().__init__()
self._model_spec = model_spec
self._efforts = efforts
self._current_effort = current_effort
self._default_effort = default_effort
def compose(self) -> ComposeResult:
"""Compose the screen layout.
Yields:
Widgets for the effort selector UI.
"""
glyphs = get_glyphs()
options = [
Option(self._format_label(effort), id=effort) for effort in self._efforts
]
highlighted_effort = self._current_effort or self._default_effort
try:
highlighted = self._efforts.index(highlighted_effort)
except ValueError:
highlighted = 0
help_text = (
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab switch"
f" {glyphs.bullet} Enter select"
f" {glyphs.bullet} Esc cancel"
)
with Vertical():
yield Static("Select Reasoning Effort", classes="effort-selector-title")
yield Static(self._model_spec, classes="effort-selector-subtitle")
option_list = OptionList(*options, id="effort-options")
option_list.highlighted = highlighted
yield option_list
yield Static(help_text, classes="effort-selector-help")
def _format_label(self, effort: str) -> Content:
"""Render an effort label with a current marker.
Args:
effort: Effort label.
Returns:
Styled option label.
"""
markers = []
if effort == self._current_effort:
markers.append("current")
if effort == self._default_effort:
markers.append("default")
if markers:
suffix = ", ".join(markers)
return Content.from_markup(
"$effort [dim]($suffix)[/dim]", effort=effort, suffix=suffix
)
return Content.from_markup("$effort", effort=effort)
def on_mount(self) -> None:
"""Apply ASCII border if needed."""
if is_ascii_mode():
container = self.query_one(Vertical)
colors = theme.get_theme_colors(self)
container.styles.border = ("ascii", colors.success)
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
"""Dismiss with the selected effort.
Args:
event: The option selected event.
"""
effort = event.option.id
# Every option is built with a non-empty `id` (the effort label), so
# this is always truthy today. Guard anyway: a future id-less option
# (e.g. a separator) would otherwise dismiss with `None`, which reads
# as a cancel — a silent no-op rather than a clearly-impossible branch.
if effort is not None:
self.dismiss(effort)
def action_cancel(self) -> None:
"""Cancel without changing effort."""
self.dismiss(None)
def action_cursor_down(self) -> None:
"""Move the option list cursor down."""
option_list = self._option_list()
if option_list is not None:
option_list.action_cursor_down()
def action_cursor_up(self) -> None:
"""Move the option list cursor up."""
option_list = self._option_list()
if option_list is not None:
option_list.action_cursor_up()
def _option_list(self) -> OptionList | None:
"""Return the option list if it is mounted."""
try:
return self.query_one("#effort-options", OptionList)
except NoMatches:
return None
+33 -4
View File
@@ -48,10 +48,16 @@ class ModelLabel(Widget):
When the full `provider:model` text doesn't fit, the provider is dropped
first. If the bare model name still doesn't fit, it is left-truncated
with a leading ellipsis so the most distinctive tail stays visible.
When a reasoning effort is set, its label is appended to the model and
participates in the same ladder: the effort suffix is preserved (with the
model left-truncated to make room) and is only dropped once even the
left-truncated model plus effort cannot fit.
"""
provider: reactive[str] = reactive("", layout=True)
model: reactive[str] = reactive("", layout=True)
effort: reactive[str] = reactive("", layout=True)
def _clean_model(self) -> str:
"""Strip the provider's registered prefix so the status bar stays compact.
@@ -68,6 +74,19 @@ class ModelLabel(Widget):
return name[len(prefix) :]
return name
def _with_effort(self, text: str) -> str:
"""Append the reasoning effort label when one is set.
Args:
text: Base model display text.
Returns:
Model display text with the effort suffix (a per-session override or
the provider default) when one is present, else
`text` unchanged.
"""
return f"{text} {self.effort}" if self.effort else text
def get_content_width(self, container: Size, viewport: Size) -> int: # noqa: ARG002
"""Return the intrinsic width so `width: auto` works.
@@ -82,7 +101,7 @@ class ModelLabel(Widget):
return 0
model = self._clean_model()
full = f"{self.provider}:{model}" if self.provider else model
return len(full)
return len(self._with_effort(full))
def render(self) -> RenderResult:
"""Render the model label with width-aware truncation.
@@ -95,8 +114,15 @@ class ModelLabel(Widget):
return ""
model = self._clean_model()
full = f"{self.provider}:{model}" if self.provider else model
if len(full) <= width:
return Content(full)
full_with_effort = self._with_effort(full)
model_with_effort = self._with_effort(model)
if len(full_with_effort) <= width:
return Content(full_with_effort)
if len(model_with_effort) <= width:
return Content(model_with_effort)
if self.effort and width > len(self.effort) + 2:
model_width = width - len(self.effort) - 1
return Content(f"\u2026{model[-(model_width - 1) :]} {self.effort}")
if len(model) <= width:
return Content(model)
if width > 1:
@@ -585,13 +611,16 @@ class StatusBar(Horizontal):
except NoMatches:
return
def set_model(self, *, provider: str, model: str) -> None:
def set_model(self, *, provider: str, model: str, effort: str = "") -> None:
"""Update the model display text.
Args:
provider: Model provider name (e.g., `'anthropic'`).
model: Model name (e.g., `'claude-sonnet-4-5'`).
effort: Reasoning effort label to display (per-session override or
provider default), or empty when none applies.
"""
label = self.query_one("#model-display", ModelLabel)
label.provider = provider
label.model = model
label.effort = effort
@@ -48,6 +48,7 @@ _TIPS: dict[str, int] = {
"Use /mcp login <server> to authenticate MCP OAuth servers without leaving the TUI": 1, # noqa: E501
"Use /remember to save learnings from this conversation": 1,
"Use /model to switch models mid-conversation": 2,
"Use /effort high to change the current model's reasoning effort": 1,
"Press ctrl+x to compose prompts in your external editor": 1,
"Press ctrl+u to delete to the start of the line in the chat input": 1,
"Use /skill:<name> to invoke a skill directly": 1,
+37 -2
View File
@@ -248,6 +248,41 @@ class TestInitialPromptOnMount:
assert submitted == [("code-review", "review this diff", None)]
async def test_on_mount_infers_status_bar_default_effort(self) -> None:
"""Initial status sync should show default effort before server ready."""
from deepagents_code.widgets.status import StatusBar
app = DeepAgentsApp(thread_id="thread-123")
chat = MagicMock()
chat.styles = SimpleNamespace(scrollbar_size_vertical=None)
status_bar = MagicMock(spec=StatusBar)
chat_input = MagicMock(spec=ChatInput)
def query_one(selector: object, *_args: object) -> object:
if selector == "#chat":
return chat
if selector == "#status-bar":
return status_bar
if selector == "#input-area":
return chat_input
raise NoMatches(str(selector))
app.query_one = MagicMock(side_effect=query_one) # ty: ignore
app.call_after_refresh = MagicMock() # ty: ignore
app.run_worker = MagicMock(side_effect=_closing_run_worker_mock) # ty: ignore
with (
patch("deepagents_code.config.settings") as mock_settings,
patch("asyncio.create_task", side_effect=_closing_run_worker_mock),
):
mock_settings.model_provider = "openai"
mock_settings.model_name = "gpt-5.5"
await app.on_mount()
status_bar.set_model.assert_called_once_with(
provider="openai", model="gpt-5.5", effort="medium"
)
async def test_server_ready_refreshes_status_bar_model(self) -> None:
"""ServerReady should push current settings into the StatusBar model display.
@@ -280,7 +315,7 @@ class TestInitialPromptOnMount:
await asyncio.sleep(0)
status_bar.set_model.assert_called_once_with(
provider="anthropic", model="claude-opus-4-7"
provider="anthropic", model="claude-opus-4-7", effort="high"
)
async def test_server_ready_warns_when_status_bar_missing(
@@ -345,7 +380,7 @@ class TestInitialPromptOnMount:
# Still calls set_model with the falsy-coerced strings so the widget
# doesn't render stale state — but emits a warning so the misconfig
# isn't invisible.
status_bar.set_model.assert_called_once_with(provider="", model="")
status_bar.set_model.assert_called_once_with(provider="", model="", effort="")
assert any(
"Settings missing model identity" in record.message
for record in caplog.records
@@ -390,6 +390,146 @@ class TestStoredCredentials:
assert apply_stored_credentials("google_genai") is True
assert "GOOGLE_GEMINI_BASE_URL" not in os.environ
def test_apply_stored_credentials_blank_base_url_clears_anthropic_custom_headers(
self,
fake_state_dir: Path, # noqa: ARG002
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A stored Anthropic key with no base_url clears `ANTHROPIC_CUSTOM_HEADERS`.
The Anthropic SDK reads `ANTHROPIC_CUSTOM_HEADERS` and injects the
headers into every request. A gateway-provisioned environment sets
this to `X-Api-Key: <gateway-key>`, which overrides the SDK's own
`api_key`-derived header. When switching to a personal key, the
custom header must also be cleared or the gateway key is sent to
the provider's native endpoint and rejected.
"""
import os
from deepagents_code import auth_store
from deepagents_code.model_config import apply_stored_credentials
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv(
"ANTHROPIC_BASE_URL", "https://gateway.smith.langchain.com/anthropic"
)
monkeypatch.setenv("ANTHROPIC_CUSTOM_HEADERS", "X-Api-Key: lsv2_sk_gateway_key")
auth_store.set_stored_key("anthropic", "sk-ant-personal")
assert apply_stored_credentials("anthropic") is True
assert os.environ["ANTHROPIC_API_KEY"] == "sk-ant-personal"
assert "ANTHROPIC_BASE_URL" not in os.environ
assert "ANTHROPIC_API_URL" not in os.environ
assert "ANTHROPIC_CUSTOM_HEADERS" not in os.environ
def test_apply_stored_credentials_with_base_url_preserves_anthropic_custom_headers(
self,
fake_state_dir: Path, # noqa: ARG002
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A stored Anthropic key *with* a base_url preserves custom headers.
When the user stores a gateway endpoint in `/auth`, the custom
headers env var should be left in place — it carries the gateway
auth header that the gateway expects.
"""
import os
from deepagents_code import auth_store
from deepagents_code.model_config import apply_stored_credentials
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_CUSTOM_HEADERS", "X-Api-Key: lsv2_sk_gateway_key")
auth_store.set_stored_key(
"anthropic",
"lsv2_sk_gateway_key",
base_url="https://gateway.smith.langchain.com/anthropic",
)
assert apply_stored_credentials("anthropic") is True
assert (
os.environ["ANTHROPIC_BASE_URL"]
== "https://gateway.smith.langchain.com/anthropic"
)
assert (
os.environ["ANTHROPIC_CUSTOM_HEADERS"] == "X-Api-Key: lsv2_sk_gateway_key"
)
def test_apply_stored_credentials_config_base_url_preserves_anthropic_headers(
self,
fake_state_dir: Path, # noqa: ARG002
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A config-routed Anthropic gateway keeps its custom headers."""
import os
from deepagents_code import auth_store, model_config
from deepagents_code.model_config import apply_stored_credentials, clear_caches
config_path = tmp_path / "config.toml"
config_path.write_text("""
[models.providers.anthropic]
base_url = "https://configured.gateway.example/anthropic"
models = ["claude-sonnet-4-5"]
""")
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv(
"ANTHROPIC_BASE_URL", "https://stale.gateway.example/anthropic"
)
monkeypatch.setenv("ANTHROPIC_CUSTOM_HEADERS", "X-Api-Key: lsv2_sk_gateway_key")
auth_store.set_stored_key("anthropic", "sk-ant-personal")
with patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path):
clear_caches()
assert apply_stored_credentials("anthropic") is True
assert (
model_config.ModelConfig.load().get_base_url("anthropic")
== "https://configured.gateway.example/anthropic"
)
assert os.environ["ANTHROPIC_API_KEY"] == "sk-ant-personal"
assert "ANTHROPIC_BASE_URL" not in os.environ
assert "ANTHROPIC_API_URL" not in os.environ
assert (
os.environ["ANTHROPIC_CUSTOM_HEADERS"] == "X-Api-Key: lsv2_sk_gateway_key"
)
def test_apply_stored_credentials_prefixed_base_url_preserves_anthropic_headers(
self,
fake_state_dir: Path, # noqa: ARG002
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A scoped Anthropic endpoint override keeps its gateway headers."""
import os
from deepagents_code import auth_store
from deepagents_code.model_config import apply_stored_credentials
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv(
"ANTHROPIC_BASE_URL", "https://stale.gateway.example/anthropic"
)
monkeypatch.setenv(
"DEEPAGENTS_CODE_ANTHROPIC_BASE_URL",
"https://scoped.gateway.example/anthropic",
)
monkeypatch.setenv("ANTHROPIC_CUSTOM_HEADERS", "X-Api-Key: lsv2_sk_gateway_key")
auth_store.set_stored_key("anthropic", "sk-ant-personal")
assert apply_stored_credentials("anthropic") is True
assert os.environ["ANTHROPIC_API_KEY"] == "sk-ant-personal"
assert "ANTHROPIC_BASE_URL" not in os.environ
assert "ANTHROPIC_API_URL" not in os.environ
assert (
os.environ["DEEPAGENTS_CODE_ANTHROPIC_BASE_URL"]
== "https://scoped.gateway.example/anthropic"
)
assert (
os.environ["ANTHROPIC_CUSTOM_HEADERS"] == "X-Api-Key: lsv2_sk_gateway_key"
)
def test_apply_stored_credentials_clears_config_base_url_env(
self,
fake_state_dir: Path, # noqa: ARG002
@@ -329,6 +329,36 @@ class TestModelSwitchNoOp:
# Stable, key-sorted JSON in the echoed suffix.
assert 'with model params {"num_ctx": 16384, "temperature": 0.2}' in message
async def test_same_model_with_new_params_refreshes_status_effort(self) -> None:
"""Same-model param updates should refresh the status bar effort."""
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app._status_bar = Mock() # ty: ignore
app._agent = _make_remote_agent()
settings.model_name = "gpt-5.5"
settings.model_provider = "openai"
with patch(
"deepagents_code.model_config.get_provider_auth_status",
return_value=ProviderAuthStatus(
state=ProviderAuthState.CONFIGURED,
provider="openai",
env_var="OPENAI_API_KEY",
source=ProviderAuthSource.ENV,
),
):
await app._switch_model(
"openai:gpt-5.5",
extra_kwargs={"reasoning": {"effort": "low", "summary": "auto"}},
)
app._status_bar.set_model.assert_called_once_with( # ty: ignore[unresolved-attribute]
provider="openai",
model="gpt-5.5",
effort="low",
)
async def test_same_model_without_params_clears_prior_override(self) -> None:
"""Re-selecting the same model with no params must clear stale params.
@@ -0,0 +1,618 @@
"""Tests for `/effort` reasoning effort handling."""
import logging
from collections.abc import Coroutine, Iterator
from typing import get_args
from unittest.mock import AsyncMock, Mock
import pytest
from textual.app import App
from textual.widgets import OptionList
from deepagents_code.app import DeepAgentsApp
from deepagents_code.command_registry import COMMANDS
from deepagents_code.config import settings
from deepagents_code.reasoning_effort import (
EffortLabel,
current_effort_from_model_params,
default_effort_for_model,
merge_effort_model_params,
model_params_for_effort,
supported_efforts_for_model,
without_effort_model_params,
)
from deepagents_code.widgets.effort_selector import EffortSelectorScreen
from deepagents_code.widgets.messages import ErrorMessage
@pytest.fixture(autouse=True)
def _restore_settings() -> Iterator[None]:
original_name = settings.model_name
original_provider = settings.model_provider
yield
settings.model_name = original_name
settings.model_provider = original_provider
@pytest.mark.parametrize(
("model_spec", "efforts"),
[
("openai:gpt-5.5", ("none", "low", "medium", "high", "xhigh")),
("openai_codex:gpt-5.5", ("none", "low", "medium", "high", "xhigh")),
# A generic (non-5.5) gpt-5 still gets the full OpenAI range.
("openai:gpt-5.4", ("none", "low", "medium", "high", "xhigh")),
("anthropic:claude-opus-4-8", ("low", "medium", "high", "xhigh", "max")),
# Opus 4.7 is the first version documented for the full range; assert the
# named boundary directly rather than relying on 4.8 to exercise it.
("anthropic:claude-opus-4-7", ("low", "medium", "high", "xhigh", "max")),
("anthropic:claude-opus-4-6", ("low", "medium", "high", "max")),
("anthropic:claude-opus-4-5", ("low", "medium", "high")),
("anthropic:claude-sonnet-5", ("low", "medium", "high", "xhigh", "max")),
("anthropic:claude-sonnet-4-6", ("low", "medium", "high", "max")),
("anthropic:claude-sonnet-4-5", ()),
# Models that predate reasoning effort must report no configurable
# efforts rather than falling through to the full range.
("anthropic:claude-opus-4-1", ()),
("anthropic:claude-opus-4-0", ()),
("anthropic:claude-sonnet-4-0", ()),
# A dated snapshot of a predating version still predates: the version
# anchor tolerates a trailing `-<date>` suffix.
("anthropic:claude-opus-4-1-20250805", ()),
# Version matching is anchored on a non-digit boundary, so a
# hypothetical future double-digit minor is NOT misread as the
# single-digit version it prefixes (`opus-4-1` must not match
# `opus-4-16`); it falls through to the full range instead.
("anthropic:claude-opus-4-16", ("low", "medium", "high", "xhigh", "max")),
("google_genai:gemini-3.5-flash", ("low", "medium", "high")),
("google_genai:gemini-3.1-pro-preview", ("low", "medium", "high")),
(
"fireworks:accounts/fireworks/models/deepseek-v4-pro",
("none", "low", "medium", "high", "xhigh", "max"),
),
(
"fireworks:accounts/fireworks/models/kimi-k2p7-code",
("low", "medium", "high"),
),
("fireworks:accounts/fireworks/models/glm-5p2", ("none", "high", "max")),
# Recognized provider, wrong model family: the per-provider prefix
# guards in `_classify_reasoning_provider` (and the Fireworks family
# check) must reject these rather than fall through to an effort set.
("openai:gpt-4o", ()),
("openai_codex:gpt-4o", ()),
("anthropic:claude-3-5-haiku-latest", ()),
("google_genai:gemini-2.5-flash", ()),
("fireworks:accounts/fireworks/models/llama-v3p1-70b-instruct", ()),
],
)
def test_supported_efforts_for_model(model_spec: str, efforts: tuple[str, ...]) -> None:
assert supported_efforts_for_model(model_spec) == efforts
@pytest.mark.parametrize(
("model_spec", "default"),
[
("openai:gpt-5.5", "medium"),
("openai_codex:gpt-5.5", "medium"),
# Only gpt-5.5 has a documented default; other gpt-5 variants are None.
("openai:gpt-5.4", None),
("anthropic:claude-opus-4-8", "high"),
("anthropic:claude-opus-4-7", "high"),
("anthropic:claude-sonnet-4-6", "high"),
("anthropic:claude-sonnet-4-5", None),
("anthropic:claude-opus-4-1", None),
("google_genai:gemini-3.5-flash", "medium"),
("google_genai:gemini-3.1-pro-preview", "high"),
("google_genai:gemini-3-pro", "high"),
("google_genai:gemini-3-flash", "high"),
("fireworks:accounts/fireworks/models/deepseek-v4-pro", "high"),
("fireworks:accounts/fireworks/models/glm-5p2", "max"),
("fireworks:accounts/fireworks/models/kimi-k2p7-code", None),
("ollama:llama3.1", None),
],
)
def test_default_effort_for_model(model_spec: str, default: str | None) -> None:
assert default_effort_for_model(model_spec) == default
def test_model_params_for_effort_maps_provider_kwargs() -> None:
assert model_params_for_effort("openai:gpt-5.5", "high") == {
"reasoning": {"effort": "high", "summary": "auto"}
}
assert model_params_for_effort("anthropic:claude-opus-4-8", "xhigh") == {
"thinking": {"type": "adaptive", "display": "summarized"},
"effort": "xhigh",
}
assert model_params_for_effort("google_genai:gemini-3.5-flash", "low") == {
"thinking_level": "low"
}
assert model_params_for_effort(
"fireworks:accounts/fireworks/models/deepseek-v4-pro", "max"
) == {"model_kwargs": {"reasoning_effort": "max"}}
def test_model_params_for_effort_rejects_unsupported_effort() -> None:
assert (
model_params_for_effort(
"fireworks:accounts/fireworks/models/kimi-k2p7-code", "max"
)
is None
)
assert model_params_for_effort("ollama:llama3.1", "high") is None
def test_merge_and_clear_effort_model_params_preserves_unrelated_params() -> None:
merged = merge_effort_model_params(
{"temperature": 0.2, "model_kwargs": {"top_p": 0.9}},
{"model_kwargs": {"reasoning_effort": "high"}},
)
assert merged == {
"temperature": 0.2,
"model_kwargs": {"top_p": 0.9, "reasoning_effort": "high"},
}
assert (
current_effort_from_model_params(
"fireworks:accounts/fireworks/models/deepseek-v4-pro", merged
)
== "high"
)
assert without_effort_model_params(merged) == {
"temperature": 0.2,
"model_kwargs": {"top_p": 0.9},
}
@pytest.mark.parametrize(
("model_spec", "model_params"),
[
# `reasoning` present but not a dict (e.g. a bare-string override).
("openai:gpt-5.5", {"reasoning": "high"}),
# `reasoning.effort` present but not a str.
("openai:gpt-5.5", {"reasoning": {"effort": 5}}),
("anthropic:claude-opus-4-8", {"effort": 5}),
("google_genai:gemini-3.5-flash", {"thinking_level": 5}),
(
"fireworks:accounts/fireworks/models/deepseek-v4-pro",
{"model_kwargs": {"reasoning_effort": 5}},
),
],
)
def test_current_effort_warns_on_malformed_params(
model_spec: str,
model_params: dict[str, object],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A present-but-mistyped effort is discarded *and* logged, not silent.
Reading it as plain `None` would let the status bar show the provider
default while the malformed param still ships on the wire — the two would
disagree with no trace. The reader must warn (type only, never the value).
"""
with caplog.at_level(logging.WARNING):
assert current_effort_from_model_params(model_spec, model_params) is None
assert any(record.levelno == logging.WARNING for record in caplog.records)
def test_current_effort_non_dict_model_kwargs_is_silent() -> None:
"""A non-dict `model_kwargs` is a legit shape and must not warn."""
# No caplog assertion for silence: the value simply reads as "no effort".
assert (
current_effort_from_model_params(
"fireworks:accounts/fireworks/models/deepseek-v4-pro",
{"model_kwargs": "raw"},
)
is None
)
def test_effort_argument_hint_covers_effort_vocabulary() -> None:
"""The `/effort` argument hint must list every `EffortLabel` plus a reset.
The label vocabulary is hand-duplicated into the command's `argument_hint`
(and `COMMANDS.md`), none of which is type-checked against `EffortLabel`.
This pins the hint so a new label can't silently drift out of the hint text.
"""
effort_command = next(cmd for cmd in COMMANDS if cmd.name == "/effort")
hint = effort_command.argument_hint
assert hint is not None
tokens = set(hint.strip("[]").split("|"))
assert set(get_args(EffortLabel)) <= tokens
# At least one reset token (handled by `_set_effort_override`) is offered.
assert tokens & {"clear", "--clear", "reset"}
async def test_effort_command_sets_current_model_params() -> None:
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
settings.model_provider = "openai"
settings.model_name = "gpt-5.5"
await app._handle_effort_command("/effort high")
assert app._model_params_override == {
"reasoning": {"effort": "high", "summary": "auto"}
}
assert app._mount_message.await_count == 2 # ty: ignore[unresolved-attribute]
async def test_effort_command_without_args_opens_selector() -> None:
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app.push_screen = Mock() # ty: ignore
app._model_params_override = {"reasoning": {"effort": "medium"}}
settings.model_provider = "openai"
settings.model_name = "gpt-5.5"
await app._handle_effort_command("/effort")
app.push_screen.assert_called_once() # ty: ignore[unresolved-attribute]
screen = app.push_screen.call_args.args[0] # ty: ignore[unresolved-attribute]
assert isinstance(screen, EffortSelectorScreen)
assert screen._model_spec == "openai:gpt-5.5"
assert screen._efforts == ("none", "low", "medium", "high", "xhigh")
assert screen._current_effort == "medium"
assert screen._default_effort == "medium"
app._mount_message.assert_not_awaited() # ty: ignore[unresolved-attribute]
async def test_effort_command_clear_removes_only_effort_params() -> None:
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app._model_params_override = {
"temperature": 0.2,
"reasoning": {"effort": "high", "summary": "auto"},
"reasoning_effort": "high",
}
settings.model_provider = "openai"
settings.model_name = "gpt-5.5"
await app._handle_effort_command("/effort clear")
assert app._model_params_override == {"temperature": 0.2}
async def test_effort_command_updates_status_bar_effort() -> None:
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app._status_bar = Mock() # ty: ignore
settings.model_provider = "openai"
settings.model_name = "gpt-5.5"
await app._handle_effort_command("/effort xhigh")
app._status_bar.set_model.assert_called_once_with( # ty: ignore[unresolved-attribute]
provider="openai",
model="gpt-5.5",
effort="xhigh",
)
async def test_effort_command_clear_refreshes_status_bar_to_default() -> None:
"""Clearing an override refreshes the status bar to the reverted effort."""
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app._status_bar = Mock() # ty: ignore
app._model_params_override = {"reasoning": {"effort": "high", "summary": "auto"}}
settings.model_provider = "openai"
settings.model_name = "gpt-5.5"
await app._handle_effort_command("/effort clear")
# gpt-5.5's documented default is `medium`; the bar reverts to it once the
# `high` override is gone. A dropped `_sync_status_model()` call in the
# clear branch would leave the stale `high` suffix and fail this.
app._status_bar.set_model.assert_called_once_with( # ty: ignore[unresolved-attribute]
provider="openai",
model="gpt-5.5",
effort="medium",
)
async def test_effort_command_rejects_unsupported_effort() -> None:
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
settings.model_provider = "fireworks"
settings.model_name = "accounts/fireworks/models/kimi-k2p7-code"
await app._handle_effort_command("/effort max")
assert app._model_params_override is None
assert app._mount_message.await_count == 2 # ty: ignore[unresolved-attribute]
@pytest.mark.parametrize("token", ["clear", "--clear", "reset"])
async def test_effort_command_clear_aliases(token: str) -> None:
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app._model_params_override = {
"temperature": 0.2,
"reasoning": {"effort": "high", "summary": "auto"},
}
settings.model_provider = "openai"
settings.model_name = "gpt-5.5"
await app._handle_effort_command(f"/effort {token}")
assert app._model_params_override == {"temperature": 0.2}
async def test_effort_selector_reports_no_model_configured() -> None:
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app.push_screen = Mock() # ty: ignore
settings.model_provider = None
settings.model_name = None
await app._handle_effort_command("/effort")
app.push_screen.assert_not_called() # ty: ignore[unresolved-attribute]
assert app._mount_message.await_count == 2 # ty: ignore[unresolved-attribute]
async def test_effort_command_reports_not_configurable_model() -> None:
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
settings.model_provider = "anthropic"
settings.model_name = "claude-sonnet-4-5"
await app._handle_effort_command("/effort high")
assert app._model_params_override is None
assert app._mount_message.await_count == 2 # ty: ignore[unresolved-attribute]
async def test_effort_selector_not_configurable_model_skips_screen() -> None:
"""Bare `/effort` on a non-configurable model reports instead of opening.
The typed-arg path is covered separately; this guards the *selector* arm so
a regression can't push the modal for a model that supports no efforts.
"""
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app.push_screen = Mock() # ty: ignore
settings.model_provider = "anthropic"
settings.model_name = "claude-sonnet-4-5"
await app._handle_effort_command("/effort")
app.push_screen.assert_not_called() # ty: ignore[unresolved-attribute]
# Echoed UserMessage + the "not configurable" AppMessage.
assert app._mount_message.await_count == 2 # ty: ignore[unresolved-attribute]
async def test_set_effort_override_guards_non_configurable_model() -> None:
"""`_set_effort_override` re-checks configurability before applying.
The selector path applies effort in a worker scheduled after the model was
resolved, so the sink re-resolves the context to guard against the model
becoming non-configurable in between.
"""
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
settings.model_provider = "anthropic"
settings.model_name = "claude-sonnet-4-5"
await app._set_effort_override("high")
assert app._model_params_override is None
# Single AppMessage — the direct sink does not echo a UserMessage.
app._mount_message.assert_awaited_once() # ty: ignore[unresolved-attribute]
async def test_effort_selector_result_applies_and_refocuses() -> None:
"""Choosing an effort schedules the apply worker and restores input focus."""
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app.push_screen = Mock() # ty: ignore
app._set_effort_override = AsyncMock() # ty: ignore
app._chat_input = Mock() # ty: ignore
scheduled: list[tuple[Coroutine[object, object, None], dict[str, object]]] = []
app.run_worker = Mock( # ty: ignore
side_effect=lambda coro, **kwargs: scheduled.append((coro, kwargs))
)
settings.model_provider = "openai"
settings.model_name = "gpt-5.5"
await app._handle_effort_command("/effort")
handle_result = app.push_screen.call_args.args[1] # ty: ignore[unresolved-attribute]
handle_result("high")
assert scheduled[0][1]["group"] == "effort-selection"
app._chat_input.focus_input.assert_called_once() # ty: ignore[unresolved-attribute]
# Running the scheduled worker coroutine applies the chosen effort.
await scheduled[0][0]
app._set_effort_override.assert_awaited_once_with( # ty: ignore[unresolved-attribute]
"high"
)
async def test_effort_selector_cancel_refocuses_without_applying() -> None:
"""Dismissing the selector refocuses input and schedules no work."""
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app.push_screen = Mock() # ty: ignore
app._chat_input = Mock() # ty: ignore
app.run_worker = Mock() # ty: ignore
settings.model_provider = "openai"
settings.model_name = "gpt-5.5"
await app._handle_effort_command("/effort")
handle_result = app.push_screen.call_args.args[1] # ty: ignore[unresolved-attribute]
handle_result(None)
app.run_worker.assert_not_called() # ty: ignore[unresolved-attribute]
app._chat_input.focus_input.assert_called_once() # ty: ignore[unresolved-attribute]
async def test_effort_selector_apply_failure_reports_error(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A failure applying the selected effort logs and surfaces an error.
The worker running `apply_effort` is not covered by the app's worker-state
error net, so the callback catches, logs, and mounts an `ErrorMessage`
itself — otherwise the failure would die silently in the background.
"""
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app.push_screen = Mock() # ty: ignore
app._set_effort_override = AsyncMock( # ty: ignore
side_effect=RuntimeError("boom")
)
app._chat_input = Mock() # ty: ignore
scheduled: list[Coroutine[object, object, None]] = []
app.run_worker = Mock( # ty: ignore
side_effect=lambda coro, **_kwargs: scheduled.append(coro)
)
settings.model_provider = "openai"
settings.model_name = "gpt-5.5"
await app._handle_effort_command("/effort")
handle_result = app.push_screen.call_args.args[1] # ty: ignore[unresolved-attribute]
handle_result("high")
with caplog.at_level(logging.ERROR):
await scheduled[0]
assert any(
"Failed to apply reasoning effort" in record.message
for record in caplog.records
)
mounted = app._mount_message.await_args.args[0] # ty: ignore[unresolved-attribute]
assert isinstance(mounted, ErrorMessage)
def test_without_effort_clears_anthropic_thinking_and_effort() -> None:
effort_params = model_params_for_effort("anthropic:claude-opus-4-8", "xhigh")
assert effort_params is not None
params = merge_effort_model_params({"temperature": 0.3}, effort_params)
assert params["effort"] == "xhigh"
assert "thinking" in params
assert without_effort_model_params(params) == {"temperature": 0.3}
def test_without_effort_clears_google_thinking_level() -> None:
effort_params = model_params_for_effort("google_genai:gemini-3.5-flash", "low")
assert effort_params is not None
assert without_effort_model_params(effort_params) is None
def test_without_effort_clears_top_level_openai_reasoning_effort() -> None:
cleaned = without_effort_model_params(
{"reasoning_effort": "high", "temperature": 0.1}
)
assert cleaned == {"temperature": 0.1}
def test_without_effort_preserves_non_dict_model_kwargs() -> None:
"""A non-dict `model_kwargs` is preserved verbatim while effort keys drop."""
cleaned = without_effort_model_params(
{"model_kwargs": "raw", "temperature": 0.1, "effort": "high"}
)
assert cleaned == {"model_kwargs": "raw", "temperature": 0.1}
@pytest.mark.parametrize(
("model_spec", "effort"),
[
("openai:gpt-5.5", "none"),
("openai:gpt-5.5", "high"),
("anthropic:claude-opus-4-8", "xhigh"),
("google_genai:gemini-3.5-flash", "low"),
("fireworks:accounts/fireworks/models/deepseek-v4-pro", "max"),
],
)
def test_effort_params_round_trip_clears_to_none(model_spec: str, effort: str) -> None:
"""The clear-set must strip exactly what `model_params_for_effort` writes."""
effort_params = model_params_for_effort(model_spec, effort)
assert effort_params is not None
merged = merge_effort_model_params(None, effort_params)
assert without_effort_model_params(merged) is None
class _EffortSelectorHost(App[None]):
"""Minimal host app for mounting `EffortSelectorScreen` in tests."""
@pytest.mark.parametrize(
("current_effort", "default_effort", "expected_index"),
[("medium", "low", 2), (None, "medium", 2), (None, None, 0), ("bogus", None, 0)],
)
async def test_effort_selector_highlights_current(
current_effort: str | None, default_effort: str | None, expected_index: int
) -> None:
app = _EffortSelectorHost()
async with app.run_test() as pilot:
await app.push_screen(
EffortSelectorScreen(
model_spec="openai:gpt-5.5",
efforts=("none", "low", "medium", "high", "xhigh"),
current_effort=current_effort,
default_effort=default_effort,
)
)
await pilot.pause()
option_list = app.screen.query_one("#effort-options", OptionList)
assert option_list.highlighted == expected_index
async def test_effort_selector_enter_selects_highlighted() -> None:
app = _EffortSelectorHost()
async with app.run_test() as pilot:
results: list[str | None] = []
await app.push_screen(
EffortSelectorScreen(
model_spec="openai:gpt-5.5",
efforts=("low", "medium", "high"),
current_effort="low",
),
results.append,
)
await pilot.pause()
app.screen.query_one("#effort-options", OptionList).focus()
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
assert results == ["low"]
async def test_effort_selector_escape_cancels() -> None:
app = _EffortSelectorHost()
async with app.run_test() as pilot:
results: list[str | None] = []
await app.push_screen(
EffortSelectorScreen(
model_spec="openai:gpt-5.5",
efforts=("low", "high"),
current_effort=None,
),
results.append,
)
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
assert results == [None]
def test_effort_selector_format_label_marks_current_and_default() -> None:
screen = EffortSelectorScreen(
model_spec="openai:gpt-5.5",
efforts=("low", "high"),
current_effort="high",
default_effort="low",
)
assert "(current)" in str(screen._format_label("high"))
assert "(default)" in str(screen._format_label("low"))
def test_effort_selector_format_label_combines_current_default() -> None:
screen = EffortSelectorScreen(
model_spec="openai:gpt-5.5",
efforts=("low", "high"),
current_effort="high",
default_effort="high",
)
assert "(current, default)" in str(screen._format_label("high"))
+59 -2
View File
@@ -416,6 +416,62 @@ class TestModelLabelPrefixStripping:
rendered = str(label.render())
assert "openai:gpt-5.5" in rendered
async def test_effort_suffix_rendered(self) -> None:
"""Active reasoning effort should be shown next to the model."""
async with StatusBarApp().run_test() as pilot:
bar = pilot.app.query_one("#status-bar", StatusBar)
bar.set_model(provider="openai", model="gpt-5.5", effort="xhigh")
await pilot.pause()
label = pilot.app.query_one("#model-display", ModelLabel)
assert str(label.render()) == "openai:gpt-5.5 xhigh"
async def test_effort_suffix_survives_provider_drop(self) -> None:
"""When narrow, provider is dropped before the effort label."""
async with StatusBarApp().run_test() as pilot:
label = pilot.app.query_one("#model-display", ModelLabel)
label.provider = "openai"
label.model = "gpt-5.5"
label.effort = "xhigh"
label.styles.width = 18
await pilot.pause()
assert str(label.render()) == "gpt-5.5 xhigh"
async def test_effort_suffix_left_truncates_model(self) -> None:
"""Overflowing model text is left-truncated while the effort stays."""
async with StatusBarApp().run_test() as pilot:
label = pilot.app.query_one("#model-display", ModelLabel)
label.provider = "openai"
label.model = "gpt-5.5-turbo-preview"
label.effort = "high"
label.styles.width = 15
await pilot.pause()
width = label.content_size.width
rendered = str(label.render())
# Starts with an ellipsis (left-truncated) yet retains the effort
# label — the branch that keeps effort while dropping model chars.
assert rendered.startswith("")
assert rendered.endswith(" high")
assert "openai:" not in rendered
assert len(rendered) <= width
async def test_effort_suffix_dropped_when_only_bare_model_fits(self) -> None:
"""In the narrow window where effort can't fit, the bare model wins.
When the width is too small for even the left-truncated `model effort`
form but still fits the bare model, the effort suffix is dropped rather
than the model — the last rung before ellipsis truncation.
"""
async with StatusBarApp().run_test() as pilot:
label = pilot.app.query_one("#model-display", ModelLabel)
label.provider = ""
label.model = "o1"
label.effort = "medium"
# padding 0 2 -> content width = 6: too narrow for "o1 medium" (9)
# and below len("medium") + 2, but wide enough for bare "o1".
label.styles.width = 10
await pilot.pause()
assert str(label.render()) == "o1"
async def test_no_provider_no_stripping(self) -> None:
"""Without a provider, the model name is passed through unchanged."""
async with StatusBarApp().run_test() as pilot:
@@ -540,8 +596,9 @@ class TestConnectionIndicator:
await pilot.pause()
indicator = pilot.app.query_one("#connection-indicator", Static)
rendered = str(indicator.render())
assert get_glyphs().spinner_frames[0] in rendered
assert "Reconnecting" in rendered
frame, _, label = rendered.partition(" ")
assert frame in get_glyphs().spinner_frames
assert label == "Reconnecting"
async def test_unmount_stops_spinner(self) -> None:
"""Leaving the DOM must stop the timer so it can't tick detached."""