From c43078f99b677504b7cb85b1970d1398556be00c Mon Sep 17 00:00:00 2001 From: Shaun Smith <1936278+evalstate@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:27:03 +0100 Subject: [PATCH] update status bar, codex device auth, kitty defaults for herdr --- docs/docs/_generated/tui_runtime_reference.md | 2 +- docs/docs/guides/codex.md | 16 +- docs/docs/guides/tui.md | 30 +++ docs/docs/models/providers/openai.md | 5 +- docs/generate_reference_docs.py | 3 +- examples/setup/fast-agent.yaml | 5 + pyproject.toml | 2 +- src/fast_agent/auth/providers.py | 5 + src/fast_agent/cli/commands/auth.py | 23 +- src/fast_agent/config.py | 5 +- .../llm/provider/openai/codex_oauth.py | 118 +++++++++- src/fast_agent/ui/prompt/input.py | 4 +- .../prompt/status_bar/agent_capabilities.py | 7 +- .../ui/prompt/status_bar/renderer.py | 13 +- src/fast_agent/ui/terminal_images/renderer.py | 9 +- .../fast_agent/commands/test_auth_command.py | 77 +++++++ .../llm/providers/test_codex_oauth.py | 217 +++++++++++++++++- .../fast_agent/test_config_model_layering.py | 40 ++++ .../unit/fast_agent/ui/test_input_toolbar.py | 8 +- tests/unit/fast_agent/ui/test_prompt_input.py | 34 +++ .../fast_agent/ui/test_terminal_images.py | 30 +-- uv.lock | 2 +- 22 files changed, 594 insertions(+), 61 deletions(-) diff --git a/docs/docs/_generated/tui_runtime_reference.md b/docs/docs/_generated/tui_runtime_reference.md index 820c45047..f10214ba7 100644 --- a/docs/docs/_generated/tui_runtime_reference.md +++ b/docs/docs/_generated/tui_runtime_reference.md @@ -31,7 +31,7 @@ | `logger.tool_display.stream_edit_previews` | `LOGGER__TOOL_DISPLAY__STREAM_EDIT_PREVIEWS` | `Literal['off', 'primary', 'all']` | `primary` | Stream apply_patch/edit_file previews for the primary agent or all agents. | | `logger.tool_display.aggregate_parallel` | `LOGGER__TOOL_DISPLAY__AGGREGATE_PARALLEL` | `bool` | `True` | Aggregate safe parallel generic calls when argument bodies are disabled. | | `logger.terminal_images.enabled` | `LOGGER__TERMINAL_IMAGES__ENABLED` | `bool` | `True` | Render image content in capable terminals. | -| `logger.terminal_images.backend` | `LOGGER__TERMINAL_IMAGES__BACKEND` | `Literal['auto', 'textual-image', 'kitty', 'sixel', 'halfcell', 'unicode', 'none']` | `auto` | Terminal image backend; automatic Sixel rendering is fitted to the viewport. | +| `logger.terminal_images.backend` | `LOGGER__TERMINAL_IMAGES__BACKEND` | `Literal['auto', 'textual-image', 'kitty', 'sixel', 'halfcell', 'unicode', 'none']` | `auto` | Auto uses reported terminal support, including Kitty in Herdr; use halfcell to opt out. Automatic Sixel rendering is fitted to the viewport. | | `logger.terminal_images.width` | `LOGGER__TERMINAL_IMAGES__WIDTH` | `TerminalImageSize` | `80%` | Image render width. | | `logger.terminal_images.height` | `LOGGER__TERMINAL_IMAGES__HEIGHT` | `TerminalImageSize` | `auto` | Image render height. | | `shell_execution.tool_profile` | `SHELL_EXECUTION__TOOL_PROFILE` | `ShellToolProfile` | `auto` | Model-specific shell/process contract. | diff --git a/docs/docs/guides/codex.md b/docs/docs/guides/codex.md index 890173921..631da66c8 100644 --- a/docs/docs/guides/codex.md +++ b/docs/docs/guides/codex.md @@ -61,8 +61,20 @@ If you want to use the Codex OAuth models directly, authenticate once first: fast-agent auth provider login codex ``` -This stores the token in your OS keyring. After that you can use Codex OAuth -model aliases such as: +Login uses device auth by default: open the displayed URL in a browser and enter +the one-time code. No local callback server is required. Only enter a code from +a login you started yourself. + +If device auth is unavailable for your account, use the existing browser callback +flow instead: + +```bash +fast-agent auth provider login codex --method browser +``` + +Device login failures do not automatically switch to browser login. Credentials +are stored in your OS keyring, with a secure file fallback. After that you can use +Codex OAuth model aliases such as: - `codexplan` — GPT-6-Astra with medium reasoning diff --git a/docs/docs/guides/tui.md b/docs/docs/guides/tui.md index d77421d99..399bc081e 100644 --- a/docs/docs/guides/tui.md +++ b/docs/docs/guides/tui.md @@ -39,6 +39,36 @@ of the stdin interpreter. This includes direct interpreters such as TypeScript executed with `pnpm exec tsx -` (including `pnpm -C exec`). Highlighting is applied while the heredoc is still streaming. +## Terminal images + +Image rendering automatically uses reported terminal graphics support, including +Kitty graphics inside Herdr. Herdr can report Kitty support even when the outer +terminal cannot display it. For Herdr inside Foot or Windows Terminal, select +half-cell rendering: + +```yaml +logger: + terminal_images: + backend: halfcell +``` + +Alternatively, select it for a terminal environment: + +```bash +export LOGGER__TERMINAL_IMAGES__BACKEND=halfcell +``` + +In PowerShell: + +```powershell +$env:LOGGER__TERMINAL_IMAGES__BACKEND = "halfcell" +``` + +Explicit configuration takes precedence over this environment variable; leave +`backend` unset in YAML to select it via the environment. Restart fast-agent +after changing these settings. With Herdr inside Ghostty or Kitty, leave the +backend at its automatic default or explicitly select `kitty`. + ## Shell Integration You can run a shell command with `!` - for example `! git status`. When the active agent uses a local shell environment, commands run attached to your terminal, so interactive programs such as `! nano` work as expected. If the active agent uses a remote or sandbox environment, `!` runs in that environment; use `!!` to force a local shell command instead. diff --git a/docs/docs/models/providers/openai.md b/docs/docs/models/providers/openai.md index 11f1071a7..2545498f9 100644 --- a/docs/docs/models/providers/openai.md +++ b/docs/docs/models/providers/openai.md @@ -223,9 +223,12 @@ once, then use a Codex OAuth model alias such as `astra` (GPT-6-Astra), `codexpl **Quick Start:** ```bash -# Start OAuth login (stores tokens in your OS keyring) +# Start device auth (stores tokens in your OS keyring, with a secure file fallback) fast-agent auth provider login codex +# Alternatively, use browser callback login if device auth is unavailable +fast-agent auth provider login codex --method browser + # Use GPT-6-Astra through the Codex subscription fast-agent --model astra diff --git a/docs/generate_reference_docs.py b/docs/generate_reference_docs.py index 115bd33e9..e4efb495a 100644 --- a/docs/generate_reference_docs.py +++ b/docs/generate_reference_docs.py @@ -931,7 +931,8 @@ def generate_tui_runtime_reference() -> str: ), "logger.terminal_images.enabled": "Render image content in capable terminals.", "logger.terminal_images.backend": ( - "Terminal image backend; automatic Sixel rendering is fitted to the viewport." + "Auto uses reported terminal support, including Kitty in Herdr; " + "use halfcell to opt out. Automatic Sixel rendering is fitted to the viewport." ), "logger.terminal_images.width": "Image render width.", "logger.terminal_images.height": "Image render height.", diff --git a/examples/setup/fast-agent.yaml b/examples/setup/fast-agent.yaml index 71ca17f10..91698c188 100644 --- a/examples/setup/fast-agent.yaml +++ b/examples/setup/fast-agent.yaml @@ -159,6 +159,11 @@ logger: show_successful_file_reads: false stream_edit_previews: primary # off | primary | all aggregate_parallel: true + # Images auto-detect reported terminal support, including Kitty in Herdr. + # For Herdr inside Foot/Windows Terminal, use halfcell here or set + # LOGGER__TERMINAL_IMAGES__BACKEND=halfcell (leave backend unset for env selection). + # terminal_images: + # backend: halfcell # Streaming renderer for assistant responses: "markdown", "plain", or "none" streaming: markdown # Render markdown code fences with Rich Syntax instead of markdown fence blocks diff --git a/pyproject.toml b/pyproject.toml index 57595d190..2c1678874 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fast-agent-mcp" -version = "0.10.21" +version = "0.10.22" description = "Code, Build and Evaluate agents - excellent Model and Skills/MCP/ACP/A2A Support" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/fast_agent/auth/providers.py b/src/fast_agent/auth/providers.py index 28557fc63..d11bd91c4 100644 --- a/src/fast_agent/auth/providers.py +++ b/src/fast_agent/auth/providers.py @@ -29,6 +29,7 @@ class OAuthProvider: access_token: Callable[[], str | None] status: Callable[[], dict[str, object]] logout: Callable[[], bool] + browser_login: Callable[[], OAuthCredential] | None = None def _xai_provider() -> OAuthProvider: @@ -68,6 +69,9 @@ def _codex_provider() -> OAuthProvider: def login() -> OAuthCredential: return _codex_credential(login_codex_oauth()) + def browser_login() -> OAuthCredential: + return _codex_credential(login_codex_oauth(method="browser")) + def credential() -> OAuthCredential | None: tokens = load_codex_tokens() return _codex_credential(tokens) if tokens else None @@ -89,6 +93,7 @@ def _codex_credential(tokens: CodexOAuthTokens) -> OAuthCredential: access_token=get_codex_access_token, status=get_codex_token_status, logout=clear_codex_tokens, + browser_login=browser_login, ) diff --git a/src/fast_agent/cli/commands/auth.py b/src/fast_agent/cli/commands/auth.py index 553095b6f..13d9f36d9 100644 --- a/src/fast_agent/cli/commands/auth.py +++ b/src/fast_agent/cli/commands/auth.py @@ -7,6 +7,7 @@ import sys from dataclasses import asdict, dataclass from datetime import datetime +from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, Literal, cast from urllib.parse import urlparse @@ -488,17 +489,35 @@ def provider_show( _print_provider_views([view]) +class ProviderLoginMethod(str, Enum): + DEVICE = "device" + BROWSER = "browser" + + @provider_app.command("login") def provider_login( provider: str = typer.Argument(..., help="Provider name: xai or codex"), + method: ProviderLoginMethod = typer.Option( + ProviderLoginMethod.DEVICE, + "--method", + help="Login method. Browser login is available for Codex accounts without device auth.", + ), ) -> None: - """Authenticate with a model provider.""" + """Authenticate with a model provider using device auth by default.""" from fast_agent.auth.providers import get_oauth_provider from fast_agent.core.exceptions import ProviderKeyError, format_fast_agent_error try: handler = get_oauth_provider(provider) - handler.login() + login = handler.login + if method == ProviderLoginMethod.BROWSER: + if handler.browser_login is None: + raise ProviderKeyError( + "Unsupported login method", + f"{handler.display_name} does not support browser login. Use --method device.", + ) + login = handler.browser_login + login() typer.echo(f"{handler.display_name} OAuth login complete.") except ProviderKeyError as exc: typer.echo(format_fast_agent_error(exc), err=True) diff --git a/src/fast_agent/config.py b/src/fast_agent/config.py index 2b6631f2f..19773d4c3 100644 --- a/src/fast_agent/config.py +++ b/src/fast_agent/config.py @@ -1733,7 +1733,10 @@ class TerminalImageSettings(BaseModel): "unicode", "none", ] = "auto" - """Terminal image backend; automatic Sixel rendering is fitted to the viewport.""" + """Auto uses reported terminal support, including Kitty in Herdr; use halfcell to opt out. + + Automatic Sixel rendering is fitted to the viewport. + """ width: TerminalImageSize = "80%" """Image render width: cells, percentage (e.g. '80%'), 'auto', or null.""" diff --git a/src/fast_agent/llm/provider/openai/codex_oauth.py b/src/fast_agent/llm/provider/openai/codex_oauth.py index 46afb6f37..7921d5660 100644 --- a/src/fast_agent/llm/provider/openai/codex_oauth.py +++ b/src/fast_agent/llm/provider/openai/codex_oauth.py @@ -1,8 +1,8 @@ """Codex OAuth helpers for ChatGPT/Codex tokens. -Implements the OAuth PKCE flow used by the Codex CLI, including keyring -storage and refresh. Access tokens are used as API keys when calling the -Codex responses endpoint. +Implements device authorization and browser OAuth PKCE login, including +keyring storage and refresh. Access tokens are used as API keys when calling +the Codex responses endpoint. """ from __future__ import annotations @@ -17,11 +17,11 @@ from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path -from typing import Any +from typing import Annotated, Any, Literal from urllib.parse import parse_qs, urlencode, urlparse import httpx -from pydantic import BaseModel +from pydantic import AliasChoices, BaseModel, Field, ValidationError from fast_agent.auth.credentials import ( OAuthCredential, @@ -393,13 +393,15 @@ def build_authorization_url(code_challenge: str, state: str) -> str: return f"{CODEX_AUTHORIZE_URL}?{urlencode(params)}" -def exchange_code_for_tokens(code: str, code_verifier: str) -> CodexOAuthTokens: +def exchange_code_for_tokens( + code: str, code_verifier: str, *, redirect_uri: str = CODEX_REDIRECT_URI +) -> CodexOAuthTokens: payload = { "grant_type": "authorization_code", "client_id": CODEX_CLIENT_ID, "code": code, "code_verifier": code_verifier, - "redirect_uri": CODEX_REDIRECT_URI, + "redirect_uri": redirect_uri, } return _token_request(payload) @@ -465,7 +467,7 @@ def parse_chatgpt_account_id(access_token: str) -> str | None: return None -def login_codex_oauth(timeout_seconds: int = 300) -> CodexOAuthTokens: +def login_codex_browser_oauth(timeout_seconds: int = 300) -> CodexOAuthTokens: verifier = _pkce_verifier() challenge = _pkce_challenge(verifier) state = secrets.token_urlsafe(16) @@ -519,3 +521,103 @@ def login_codex_oauth(timeout_seconds: int = 300) -> CodexOAuthTokens: tokens = exchange_code_for_tokens(code, verifier) save_codex_tokens(tokens) return tokens + + +class _DeviceUserCode(BaseModel): + device_auth_id: Annotated[str, Field(min_length=1)] + user_code: Annotated[ + str, Field(min_length=1, validation_alias=AliasChoices("user_code", "usercode")) + ] + interval: Annotated[str, Field(pattern=r"^\s*[0-9]+\s*$")] = "5" + + +class _DeviceAuthorization(BaseModel): + authorization_code: Annotated[str, Field(min_length=1)] + code_verifier: Annotated[str, Field(min_length=1)] + + +def _device_login_error(detail: str) -> ProviderKeyError: + return ProviderKeyError( + "Codex device login failed", + f"{detail} Retry login or use `fast-agent auth provider login codex --method browser`.", + ) + + +def login_codex_device_oauth(timeout_seconds: int = 900) -> CodexOAuthTokens: + """Authorize with a user-entered device code; never fall back automatically.""" + deadline = time.monotonic() + min(timeout_seconds, 900) + + def remaining() -> float: + seconds = deadline - time.monotonic() + if seconds <= 0: + raise _device_login_error("Device authorization timed out.") + return seconds + + try: + with httpx.Client(timeout=30.0) as client: + response = client.post( + "https://auth.openai.com/api/accounts/deviceauth/usercode", + json={"client_id": CODEX_CLIENT_ID}, + timeout=min(30.0, remaining()), + ) + if not response.is_success: + raise _device_login_error( + f"Unable to request a device code (HTTP {response.status_code}). " + "Ensure device code login is enabled for your account." + ) + device = _DeviceUserCode.model_validate_json(response.content) + interval = max(1, int(device.interval)) + console.ensure_blocking_console() + console.console.print( + "Open https://auth.openai.com/codex/device and enter this one-time code:", + markup=False, + ) + console.console.print(device.user_code, markup=False) + console.console.print( + "Continue only if you started this login. If a website or another person " + "gave you this code, cancel.", + markup=False, + ) + while True: + response = client.post( + "https://auth.openai.com/api/accounts/deviceauth/token", + json={"device_auth_id": device.device_auth_id, "user_code": device.user_code}, + timeout=min(30.0, remaining()), + ) + remaining() + if response.is_success: + authorization = _DeviceAuthorization.model_validate_json(response.content) + break + if response.status_code not in (403, 404): + raise _device_login_error( + f"Device authorization failed (HTTP {response.status_code})." + ) + time.sleep(min(interval, remaining())) + try: + tokens = exchange_code_for_tokens( + authorization.authorization_code, + authorization.code_verifier, + redirect_uri="https://auth.openai.com/deviceauth/callback", + ) + except ProviderKeyError: + raise _device_login_error("Device authorization token exchange failed.") from None + except (httpx.HTTPError, ValidationError, ValueError): + # Do not expose response bodies, validation inputs, or token exchange errors. + raise _device_login_error( + "Device authorization could not be completed (request failed, invalid response, " + "or authorization timed out)." + ) from None + save_codex_tokens(tokens) + return tokens + + +def login_codex_oauth( + timeout_seconds: int | None = None, *, method: Literal["device", "browser"] = "device" +) -> CodexOAuthTokens: + if method == "device": + return login_codex_device_oauth(900 if timeout_seconds is None else timeout_seconds) + if method == "browser": + return login_codex_browser_oauth(300 if timeout_seconds is None else timeout_seconds) + raise ProviderKeyError( + "Codex OAuth login failed", "Unknown login method. Use device or browser." + ) diff --git a/src/fast_agent/ui/prompt/input.py b/src/fast_agent/ui/prompt/input.py index 07aa1f50f..40198936c 100644 --- a/src/fast_agent/ui/prompt/input.py +++ b/src/fast_agent/ui/prompt/input.py @@ -284,8 +284,7 @@ def _initialize_prompt_input_state( global in_multiline_mode, available_agents in_multiline_mode = multiline - if available_agent_names: - available_agents = set(available_agent_names) + available_agents = set(available_agent_names or [agent_name]) if agent_provider is not None: with suppress(Exception): available_agents = set(agent_provider.visible_agent_names(force_include=agent_name)) @@ -330,6 +329,7 @@ def get_toolbar() -> HTML: current_input_text = "" result = render_input_toolbar( agent_name=agent_name, + show_agent_name=len(available_agents) > 1, toolbar_color=toolbar_color, agent_provider=agent_provider, multiline_mode=in_multiline_mode, diff --git a/src/fast_agent/ui/prompt/status_bar/agent_capabilities.py b/src/fast_agent/ui/prompt/status_bar/agent_capabilities.py index db188b7f1..3fb77fff7 100644 --- a/src/fast_agent/ui/prompt/status_bar/agent_capabilities.py +++ b/src/fast_agent/ui/prompt/status_bar/agent_capabilities.py @@ -4,7 +4,6 @@ from fast_agent.core.agent_capabilities import AgentCapabilityMode from fast_agent.ui.binary_indicator import ( - TOOLBAR_BINARY_DISABLED_COLOR, TOOLBAR_BINARY_ENABLED_COLOR, render_glyph_indicator, ) @@ -18,10 +17,12 @@ def render_agent_capability_indicator(mode: AgentCapabilityMode) -> str: harness = mode in {AgentCapabilityMode.HARNESS_ONLY, AgentCapabilityMode.ORCHESTRATE} subagent_indicator = render_glyph_indicator( glyph=SUBAGENT_GLYPH, - color=TOOLBAR_BINARY_ENABLED_COLOR if subagents else TOOLBAR_BINARY_DISABLED_COLOR, + color="ansiblack" if subagents else "ansiwhite", + foreground=TOOLBAR_BINARY_ENABLED_COLOR if subagents else "ansiblack", ) harness_indicator = render_glyph_indicator( glyph=f"{HARNESS_GLYPH} ", - color=TOOLBAR_BINARY_ENABLED_COLOR if harness else TOOLBAR_BINARY_DISABLED_COLOR, + color="ansiblack" if harness else "ansiwhite", + foreground=TOOLBAR_BINARY_ENABLED_COLOR if harness else "ansiblack", ) return f"{subagent_indicator}{harness_indicator}" diff --git a/src/fast_agent/ui/prompt/status_bar/renderer.py b/src/fast_agent/ui/prompt/status_bar/renderer.py index 9f62220c0..3fb1ea2d8 100644 --- a/src/fast_agent/ui/prompt/status_bar/renderer.py +++ b/src/fast_agent/ui/prompt/status_bar/renderer.py @@ -187,6 +187,7 @@ def resolve_active_llm( def render_input_toolbar( *, agent_name: str, + show_agent_name: bool, toolbar_color: str, agent_provider: "AgentApp | None", multiline_mode: bool, @@ -203,10 +204,14 @@ def render_input_toolbar( resolved_agent_state = _resolve_toolbar_agent_state_cached( agent_name, agent_provider, cache=cache ) - agent_identity_segment = _format_toolbar_agent_identity( - agent_name, - toolbar_color, - resolved_agent_state.state.agent, + agent_identity_segment = ( + _format_toolbar_agent_identity( + agent_name, + toolbar_color, + resolved_agent_state.state.agent, + ) + if show_agent_name + else "" ) attachment_summary = _resolve_attachment_summary( current_input_text=current_input_text, diff --git a/src/fast_agent/ui/terminal_images/renderer.py b/src/fast_agent/ui/terminal_images/renderer.py index 1e9d0119a..1b8b82676 100644 --- a/src/fast_agent/ui/terminal_images/renderer.py +++ b/src/fast_agent/ui/terminal_images/renderer.py @@ -26,7 +26,6 @@ logger = get_logger(__name__) MAX_TERMINAL_IMAGE_SOURCE_BYTES = 25 * 1024 * 1024 TERMINAL_IMAGE_FETCH_TIMEOUT_SECONDS = 10.0 -HERDR_HALFCELL_NOTICE = "Warning: Herdr active; using half-cell image rendering." _TEXTUAL_IMAGE_CLASS_BY_BACKEND: dict[str, str] = { "auto": "Image", @@ -153,8 +152,6 @@ def render_image_items( continue renderables.append(Text(item.artifact.label, style="dim")) renderables.append(renderable) - if _uses_herdr_auto_halfcell(settings.backend): - renderables.append(Text(HERDR_HALFCELL_NOTICE, style="dim yellow")) renderables.extend(Text(metadata, style="dim") for metadata in item.metadata) if not renderables: @@ -350,7 +347,7 @@ def _resolve_textual_image_class(backend: str) -> Any | None: if class_name is None: return None - if backend in {"auto", "halfcell"} and _herdr_active(): + if backend == "halfcell" and _herdr_active(): try: module = import_module("fast_agent.ui.terminal_images.halfcell") except ImportError: @@ -384,7 +381,3 @@ def _resolve_textual_image_class(backend: str) -> Any | None: def _herdr_active() -> bool: return os.environ.get("HERDR_ENV") == "1" - - -def _uses_herdr_auto_halfcell(backend: str) -> bool: - return backend == "auto" and _herdr_active() diff --git a/tests/unit/fast_agent/commands/test_auth_command.py b/tests/unit/fast_agent/commands/test_auth_command.py index 085890efe..ca1e83fe7 100644 --- a/tests/unit/fast_agent/commands/test_auth_command.py +++ b/tests/unit/fast_agent/commands/test_auth_command.py @@ -770,3 +770,80 @@ def test_auth_mcp_list_reports_invalid_settings_yaml_without_traceback(tmp_path: assert result.exit_code == 1, result.output assert "Error loading fast-agent settings:" in result.output assert "Traceback" not in result.output + + +@pytest.mark.parametrize("provider", ["codex", "codexplan", "codexresponses"]) +@pytest.mark.parametrize("method", [None, "device", "browser"]) +def test_codex_login_method_selection( + monkeypatch: pytest.MonkeyPatch, provider: str, method: str | None +) -> None: + from fast_agent.llm.provider.openai import codex_oauth + + calls: list[str] = [] + + def device_login(timeout_seconds: int = 900) -> codex_oauth.CodexOAuthTokens: + calls.append("device") + return codex_oauth.CodexOAuthTokens(access_token="test-token") + + def browser_login(timeout_seconds: int = 300) -> codex_oauth.CodexOAuthTokens: + calls.append("browser") + return codex_oauth.CodexOAuthTokens(access_token="test-token") + + monkeypatch.setattr(codex_oauth, "login_codex_device_oauth", device_login) + monkeypatch.setattr(codex_oauth, "login_codex_browser_oauth", browser_login) + args = ["provider", "login", provider] + if method: + args.extend(["--method", method]) + + result = CliRunner().invoke(auth_command.app, args) + + assert result.exit_code == 0, result.output + assert calls == [method or "device"] + assert "Codex OAuth login complete." in result.output + assert "test-token" not in result.output + + +def test_xai_browser_login_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + from fast_agent.llm.provider.openai import xai_oauth + + def unexpected_login() -> OAuthCredential: + pytest.fail("unsupported browser login must not start device login") + + monkeypatch.setattr(xai_oauth, "login_xai_oauth", unexpected_login) + + result = CliRunner().invoke( + auth_command.app, ["provider", "login", "xai", "--method", "browser"] + ) + + assert result.exit_code == 1 + assert "does not support browser login" in result.output + + +def test_provider_login_rejects_unknown_method() -> None: + result = CliRunner().invoke( + auth_command.app, ["provider", "login", "codex", "--method", "unknown"] + ) + + assert result.exit_code == 2 + + +def test_codex_device_login_failure_does_not_switch_to_browser( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from fast_agent.core.exceptions import ProviderKeyError + from fast_agent.llm.provider.openai import codex_oauth + + def device_login(timeout_seconds: int = 900) -> codex_oauth.CodexOAuthTokens: + raise ProviderKeyError("Device login unavailable", "Use --method browser.") + + def unexpected_login(timeout_seconds: int = 300) -> codex_oauth.CodexOAuthTokens: + pytest.fail("failed device login must not switch to browser automatically") + + monkeypatch.setattr(codex_oauth, "login_codex_device_oauth", device_login) + monkeypatch.setattr(codex_oauth, "login_codex_browser_oauth", unexpected_login) + + result = CliRunner().invoke(auth_command.app, ["provider", "login", "codex"]) + + assert result.exit_code == 1 + assert "--method browser" in result.output + assert "login complete" not in result.output diff --git a/tests/unit/fast_agent/llm/providers/test_codex_oauth.py b/tests/unit/fast_agent/llm/providers/test_codex_oauth.py index a0a450272..6ad449992 100644 --- a/tests/unit/fast_agent/llm/providers/test_codex_oauth.py +++ b/tests/unit/fast_agent/llm/providers/test_codex_oauth.py @@ -1,6 +1,9 @@ import json from pathlib import Path +from unittest.mock import Mock +from urllib.parse import parse_qs +import httpx import pytest from fast_agent.auth.credentials import OAuthCredential, StoredCredential, save_oauth_credential @@ -290,4 +293,216 @@ def close(self) -> None: ) with pytest.raises(codex_oauth.ProviderKeyError, match="State parameter mismatch"): - codex_oauth.login_codex_oauth() + codex_oauth.login_codex_browser_oauth() + + +@pytest.fixture +def device_login(monkeypatch): + + clock = [0.0] + requests: list[httpx.Request] = [] + responses: list[httpx.Response | Exception] = [] + sleeps: list[float] = [] + + def sleep(seconds: float) -> None: + assert seconds > 0 + sleeps.append(seconds) + clock[0] += seconds + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + response = responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + client_type = httpx.Client + monkeypatch.setattr( + codex_oauth.httpx, + "Client", + lambda **kwargs: client_type(transport=httpx.MockTransport(handle)), + ) + monkeypatch.setattr(codex_oauth.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(codex_oauth.time, "sleep", sleep) + monkeypatch.setattr(codex_oauth.console, "ensure_blocking_console", lambda: None) + display = Mock() + save = Mock() + monkeypatch.setattr(codex_oauth.console.console, "print", display) + monkeypatch.setattr(codex_oauth, "save_codex_tokens", save) + monkeypatch.setattr( + codex_oauth, "login_codex_browser_oauth", Mock(side_effect=AssertionError("no fallback")) + ) + return responses, requests, sleeps, save, display + + +@pytest.mark.parametrize("alias", ["user_code", "usercode"]) +def test_device_login_protocol(device_login, alias: str) -> None: + + responses, requests, sleeps, save, display = device_login + responses.extend( + [ + httpx.Response( + 200, json={"device_auth_id": "id", alias: "[bold]code", "interval": "2"} + ), + httpx.Response(403), + httpx.Response(404), + httpx.Response( + 200, json={"authorization_code": "auth-code", "code_verifier": "verifier"} + ), + httpx.Response(200, json={"access_token": "access", "refresh_token": "refresh"}), + ] + ) + tokens = codex_oauth.login_codex_oauth() + assert tokens.access_token == "access" + save.assert_called_once_with(tokens) + assert sleeps == [2, 2] + assert str(requests[0].url) == "https://auth.openai.com/api/accounts/deviceauth/usercode" + assert json.loads(requests[0].content) == {"client_id": codex_oauth.CODEX_CLIENT_ID} + for request in requests[1:4]: + assert str(request.url) == "https://auth.openai.com/api/accounts/deviceauth/token" + assert json.loads(request.content) == {"device_auth_id": "id", "user_code": "[bold]code"} + + exchange = parse_qs(requests[-1].content.decode()) + assert exchange["redirect_uri"] == ["https://auth.openai.com/deviceauth/callback"] + assert exchange["code"] == ["auth-code"] + assert exchange["code_verifier"] == ["verifier"] + display.assert_any_call("[bold]code", markup=False) + assert any("only if you started" in call.args[0] for call in display.call_args_list) + + +@pytest.mark.parametrize("interval,expected", [(None, [5, 1]), ("0", [1] * 6), ("999", [6])]) +def test_device_timeout_has_bounded_nonzero_sleeps(device_login, interval, expected) -> None: + + responses, requests, sleeps, save, _ = device_login + payload = {"device_auth_id": "id", "user_code": "code"} + if interval is not None: + payload["interval"] = interval + responses.append(httpx.Response(200, json=payload)) + responses.extend(httpx.Response(403) for _ in expected) + with pytest.raises(codex_oauth.ProviderKeyError, match="timed out"): + codex_oauth.login_codex_device_oauth(6) + assert sleeps == expected + assert len(requests) == len(expected) + 1 + save.assert_not_called() + + +@pytest.mark.parametrize( + "stage,status,payload", + [ + ("request", 404, {}), + ("request", 500, {}), + ("request", 200, {"device_auth_id": "id"}), + ("request", 200, {"device_auth_id": 123, "user_code": "code"}), + ("request", 200, {"device_auth_id": "id", "user_code": "code", "interval": "bad"}), + ("poll", 401, {"secret": "sensitive"}), + ("poll", 429, {}), + ("poll", 200, {"authorization_code": "sensitive"}), + ("poll", 200, {"authorization_code": "sensitive", "code_verifier": ""}), + ], +) +def test_device_failures_do_not_store(device_login, stage, status, payload) -> None: + + responses, _, _, save, _ = device_login + if stage == "poll": + responses.append(httpx.Response(200, json={"device_auth_id": "id", "user_code": "code"})) + responses.append(httpx.Response(status, json=payload)) + with pytest.raises(codex_oauth.ProviderKeyError) as error: + codex_oauth.login_codex_device_oauth() + assert "fast-agent auth provider login codex --method browser" in str(error.value) + assert "sensitive" not in str(error.value) + save.assert_not_called() + + +@pytest.mark.parametrize("failure", ["network", "json", "exchange"]) +def test_device_transport_and_exchange_failures(device_login, monkeypatch, failure) -> None: + + responses, _, _, save, _ = device_login + if failure == "network": + responses.append(httpx.ConnectError("sensitive")) + elif failure == "json": + responses.append(httpx.Response(200, content=b"not json sensitive")) + else: + responses.extend( + [ + httpx.Response(200, json={"device_auth_id": "id", "user_code": "code"}), + httpx.Response( + 200, json={"authorization_code": "code", "code_verifier": "verifier"} + ), + ] + ) + monkeypatch.setattr( + codex_oauth, + "exchange_code_for_tokens", + Mock(side_effect=codex_oauth.ProviderKeyError("sensitive", "sensitive")), + ) + with pytest.raises(codex_oauth.ProviderKeyError) as error: + codex_oauth.login_codex_device_oauth() + assert "--method browser" in str(error.value) + assert "sensitive" not in str(error.value) + save.assert_not_called() + + +@pytest.mark.parametrize( + "method,timeout,expected", + [("device", None, 900), ("browser", None, 300), ("device", 7, 7), ("browser", 8, 8)], +) +def test_login_dispatch(monkeypatch, method, timeout, expected) -> None: + + device = Mock() + browser = Mock() + monkeypatch.setattr(codex_oauth, "login_codex_device_oauth", device) + monkeypatch.setattr(codex_oauth, "login_codex_browser_oauth", browser) + result = codex_oauth.login_codex_oauth(timeout, method=method) + selected, unused = (device, browser) if method == "device" else (browser, device) + selected.assert_called_once_with(expected) + assert result is selected.return_value + unused.assert_not_called() + + +def test_browser_login_preserves_callback_and_redirect(monkeypatch) -> None: + + server = Mock() + server.serve_once.return_value = ("code", "state") + monkeypatch.setattr(codex_oauth, "_CallbackServer", Mock(return_value=server)) + monkeypatch.setattr(codex_oauth.secrets, "token_urlsafe", lambda size: "state") + monkeypatch.setattr(codex_oauth.console, "ensure_blocking_console", lambda: None) + monkeypatch.setattr(codex_oauth.console.console, "print", Mock()) + request = Mock(return_value=CodexOAuthTokens(access_token="token")) + save = Mock() + monkeypatch.setattr(codex_oauth, "_token_request", request) + monkeypatch.setattr(codex_oauth, "save_codex_tokens", save) + + tokens = codex_oauth.login_codex_oauth(method="browser") + + server.start.assert_called_once() + server.serve_once.assert_called_once_with(timeout_seconds=300) + server.close.assert_called_once() + assert request.call_args.args[0]["redirect_uri"] == codex_oauth.CODEX_REDIRECT_URI + save.assert_called_once_with(tokens) + + +def test_device_deadline_is_capped_at_fifteen_minutes(device_login) -> None: + + responses, requests, sleeps, save, _ = device_login + responses.extend( + [ + httpx.Response( + 200, json={"device_auth_id": "id", "user_code": "code", "interval": "1000"} + ), + httpx.Response(404), + ] + ) + with pytest.raises(codex_oauth.ProviderKeyError, match="timed out"): + codex_oauth.login_codex_device_oauth(1800) + assert sleeps == [900] + assert len(requests) == 2 + save.assert_not_called() + + +def test_device_expired_deadline_makes_no_requests(device_login) -> None: + _, requests, sleeps, save, _ = device_login + with pytest.raises(codex_oauth.ProviderKeyError, match="timed out"): + codex_oauth.login_codex_device_oauth(0) + assert requests == [] + assert sleeps == [] + save.assert_not_called() diff --git a/tests/unit/fast_agent/test_config_model_layering.py b/tests/unit/fast_agent/test_config_model_layering.py index f72b46720..9be21f0f5 100644 --- a/tests/unit/fast_agent/test_config_model_layering.py +++ b/tests/unit/fast_agent/test_config_model_layering.py @@ -447,3 +447,43 @@ def test_get_settings_pairs_secrets_with_selected_config_directory(tmp_path: Pat os.environ.pop("FAST_AGENT_HOME", None) else: os.environ["FAST_AGENT_HOME"] = previous_home + + +@pytest.mark.parametrize("source", ["config", "environment"]) +def test_terminal_image_halfcell_selection(tmp_path: Path, monkeypatch, source: str) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(config_module, "_settings", None) + monkeypatch.delenv("LOGGER__TERMINAL_IMAGES__BACKEND", raising=False) + config_path = tmp_path / "fast-agent.yaml" + if source == "config": + config_path.write_text("logger:\n terminal_images:\n backend: halfcell\n") + else: + config_path.write_text("logger:\n terminal_images:\n width: 40\n") + monkeypatch.setenv("LOGGER__TERMINAL_IMAGES__BACKEND", "halfcell") + + settings = get_settings(config_path, no_home=True) + + assert settings.logger.terminal_images.backend == "halfcell" + assert settings.logger.terminal_images.enabled + if source == "environment": + assert settings.logger.terminal_images.width == 40 + + +def test_terminal_image_config_takes_precedence_over_environment( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(config_module, "_settings", None) + monkeypatch.setenv("LOGGER__TERMINAL_IMAGES__BACKEND", "halfcell") + config_path = tmp_path / "fast-agent.yaml" + config_path.write_text("logger:\n terminal_images:\n backend: kitty\n") + + assert get_settings(config_path, no_home=True).logger.terminal_images.backend == "kitty" + + +def test_terminal_image_environment_rejects_invalid_backend(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("LOGGER__TERMINAL_IMAGES__BACKEND", "invalid") + + with pytest.raises(ValueError, match="logger.terminal_images.backend"): + Settings() diff --git a/tests/unit/fast_agent/ui/test_input_toolbar.py b/tests/unit/fast_agent/ui/test_input_toolbar.py index aae7eeecc..8c475e67a 100644 --- a/tests/unit/fast_agent/ui/test_input_toolbar.py +++ b/tests/unit/fast_agent/ui/test_input_toolbar.py @@ -274,16 +274,16 @@ def test_build_middle_segment_renders_muted_process_indicator_when_idle() -> Non def test_agent_capability_indicator_styles_each_capability_independently() -> None: assert render_agent_capability_indicator(AgentCapabilityMode.STANDARD) == ( - "" + "" ) assert render_agent_capability_indicator(AgentCapabilityMode.DELEGATE) == ( - "" + "" ) assert render_agent_capability_indicator(AgentCapabilityMode.HARNESS_ONLY) == ( - "" + "" ) assert render_agent_capability_indicator(AgentCapabilityMode.ORCHESTRATE) == ( - "" + "" ) diff --git a/tests/unit/fast_agent/ui/test_prompt_input.py b/tests/unit/fast_agent/ui/test_prompt_input.py index d62182115..c88f0821e 100644 --- a/tests/unit/fast_agent/ui/test_prompt_input.py +++ b/tests/unit/fast_agent/ui/test_prompt_input.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any, cast import pytest +from prompt_toolkit.formatted_text import to_formatted_text if TYPE_CHECKING: from prompt_toolkit import PromptSession @@ -112,3 +113,36 @@ async def prompt_async(self, prompt_text: object, **_kwargs: object) -> str: assert getattr(prompts[0], "value", "") == ( "Enter value for name <draft> [local] (required): " ) + + +@pytest.mark.parametrize( + ("agent_names", "show_name"), + [(None, False), ([], False), (["dev"], False), (["dev", "review"], True)], +) +def test_toolbar_shows_agent_name_only_when_agents_can_be_switched( + monkeypatch: pytest.MonkeyPatch, agent_names: list[str] | None, show_name: bool +) -> None: + # A previous multi-agent prompt must not leave a stale identity in a new one. + monkeypatch.setattr(prompt_input, "available_agents", {"dev", "previous"}) + monkeypatch.setattr(prompt_input, "agent_histories", {}) + monkeypatch.setattr(prompt_input, "in_multiline_mode", False) + prompt_input._initialize_prompt_input_state( + agent_name="dev", + multiline=False, + available_agent_names=agent_names, + agent_provider=None, + ) + session = _FakeSession() + toolbar = prompt_input._build_toolbar( + agent_name="dev", + toolbar_color="ansiblue", + agent_provider=None, + shell_context=prompt_input.ShellInputContext(), + session_factory=lambda: cast("PromptSession[Any]", session), + ) + + text = "".join(fragment[1] for fragment in to_formatted_text(toolbar())) + + assert ("dev" in text) is show_name + assert "NRM" in text + assert "fast-agent" in text diff --git a/tests/unit/fast_agent/ui/test_terminal_images.py b/tests/unit/fast_agent/ui/test_terminal_images.py index 69f97b282..09d9ff7c3 100644 --- a/tests/unit/fast_agent/ui/test_terminal_images.py +++ b/tests/unit/fast_agent/ui/test_terminal_images.py @@ -223,36 +223,25 @@ def import_backend(name: str): assert terminal_image_renderer._resolve_textual_image_class("sixel") is ViewportAwareSixelImage -def test_herdr_auto_backend_uses_sanitized_halfcell_renderer(monkeypatch) -> None: - monkeypatch.setenv("HERDR_ENV", "1") - - assert ( - terminal_image_renderer._resolve_textual_image_class("auto") - is halfcell_renderer.HerdrAwareHalfcellImage - ) - assert ( - terminal_image_renderer._resolve_textual_image_class("halfcell") - is halfcell_renderer.HerdrAwareHalfcellImage - ) - - -def test_explicit_kitty_backend_is_unchanged_in_herdr(monkeypatch) -> None: +@pytest.mark.parametrize("herdr", [False, True]) +def test_auto_backend_honors_reported_kitty_support(monkeypatch, herdr: bool) -> None: class TGPImage: pass - monkeypatch.setenv("HERDR_ENV", "1") + monkeypatch.setenv("HERDR_ENV", "1" if herdr else "0") monkeypatch.setattr( terminal_image_renderer, "import_module", - lambda name: SimpleNamespace(TGPImage=TGPImage), + lambda name: SimpleNamespace(Image=TGPImage, TGPImage=TGPImage), ) + assert terminal_image_renderer._resolve_textual_image_class("auto") is TGPImage assert terminal_image_renderer._resolve_textual_image_class("kitty") is TGPImage -def test_herdr_auto_halfcell_warning_follows_image(monkeypatch) -> None: +def test_explicit_halfcell_backend_in_herdr_has_no_fallback_warning(monkeypatch) -> None: monkeypatch.setenv("HERDR_ENV", "1") - settings = TerminalImageSettings(backend="auto", width=1, height=1) + settings = TerminalImageSettings(backend="halfcell", width=1, height=1) renderable = terminal_image_renderer.render_image_items( settings, @@ -260,9 +249,8 @@ def test_herdr_auto_halfcell_warning_follows_image(monkeypatch) -> None: ) assert isinstance(renderable, Group) - assert len(renderable.renderables) == 3 - assert isinstance(renderable.renderables[2], terminal_image_renderer.Text) - assert renderable.renderables[2].plain == terminal_image_renderer.HERDR_HALFCELL_NOTICE + assert len(renderable.renderables) == 2 + assert isinstance(renderable.renderables[1], halfcell_renderer.HerdrAwareHalfcellImage) def test_herdr_halfcell_replaces_implausible_cell_geometry(monkeypatch) -> None: diff --git a/uv.lock b/uv.lock index 28ee15982..8b9b30f3e 100644 --- a/uv.lock +++ b/uv.lock @@ -903,7 +903,7 @@ requires-dist = [{ name = "fast-agent-mcp", editable = "." }] [[package]] name = "fast-agent-mcp" -version = "0.10.21" +version = "0.10.22" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },