chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli.binary_resolver import npm_prefix_bin_dirs
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_npm_prefix_bin_dirs_cache() -> None:
|
||||
"""Prevent env/platform cache leakage across llm_cli tests."""
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
yield
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Tests for the Antigravity CLI adapter (detect / build / parse / env forwarding).
|
||||
|
||||
Mirrors ``test_gemini_cli_adapter.py`` but covers the breaking differences:
|
||||
``agy`` returns plain text (no ``--output-format`` JSON envelope), does not
|
||||
expose ``--model`` in headless mode, and uses ``--print-timeout {N}s``. The
|
||||
adapter pins ``min_version = "1.0.1"`` (1.0.0 had OAuth hangs).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from integrations.llm_cli.antigravity_cli import (
|
||||
_PROBE_TIMEOUT_SEC,
|
||||
AntigravityCLIAdapter,
|
||||
_fallback_antigravity_cli_paths,
|
||||
_resolve_exec_timeout_seconds,
|
||||
)
|
||||
from integrations.llm_cli.binary_resolver import npm_prefix_bin_dirs
|
||||
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
|
||||
|
||||
|
||||
def _posix_path_set(paths: list[str]) -> set[str]:
|
||||
return {Path(p).as_posix() for p in paths}
|
||||
|
||||
|
||||
def _version_proc(version: str = "1.0.1") -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = f"{version}\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
def _auth_ok_proc() -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "ok\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.antigravity_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_logged_in(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/agy"
|
||||
mock_run.side_effect = [_version_proc(), _auth_ok_proc()]
|
||||
|
||||
probe = AntigravityCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert probe.bin_path == "/usr/bin/agy"
|
||||
assert probe.version == "1.0.1"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.antigravity_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_authenticated(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/agy"
|
||||
auth = MagicMock()
|
||||
auth.returncode = 1
|
||||
auth.stdout = ""
|
||||
auth.stderr = "Authentication required"
|
||||
mock_run.side_effect = [_version_proc(), auth]
|
||||
|
||||
with patch.dict(os.environ, {"GEMINI_API_KEY": ""}, clear=False):
|
||||
probe = AntigravityCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.antigravity_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_uses_api_key_fallback(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/agy"
|
||||
auth = MagicMock()
|
||||
auth.returncode = 1
|
||||
auth.stdout = ""
|
||||
auth.stderr = "Authentication required"
|
||||
mock_run.side_effect = [_version_proc(), auth]
|
||||
|
||||
with patch.dict(os.environ, {"GEMINI_API_KEY": "gk-test"}, clear=False):
|
||||
probe = AntigravityCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "GEMINI_API_KEY fallback" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.antigravity_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_unclear_auth_on_network_error(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/agy"
|
||||
auth = MagicMock()
|
||||
auth.returncode = 2
|
||||
auth.stdout = ""
|
||||
auth.stderr = "network unreachable"
|
||||
mock_run.side_effect = [_version_proc(), auth]
|
||||
|
||||
with patch.dict(os.environ, {"GEMINI_API_KEY": ""}, clear=False):
|
||||
probe = AntigravityCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None
|
||||
assert "Network error" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.antigravity_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_flags_outdated_version(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/agy"
|
||||
mock_run.side_effect = [_version_proc("1.0.0"), _auth_ok_proc()]
|
||||
|
||||
probe = AntigravityCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.version == "1.0.0"
|
||||
assert "below tested minimum" in probe.detail
|
||||
assert "agy update" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.antigravity_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_upgrade_note_survives_auth_env_fallback(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
# Regression guard: on agy < 1.0.1 with a failing auth probe AND
|
||||
# GEMINI_API_KEY set, the env-fallback path overwrites auth_detail —
|
||||
# the upgrade note must still surface so the user knows to run `agy update`.
|
||||
mock_which.return_value = "/usr/bin/agy"
|
||||
auth = MagicMock()
|
||||
auth.returncode = 1
|
||||
auth.stdout = ""
|
||||
auth.stderr = "Authentication required"
|
||||
mock_run.side_effect = [_version_proc("1.0.0"), auth]
|
||||
|
||||
with patch.dict(os.environ, {"GEMINI_API_KEY": "gk-test"}, clear=False):
|
||||
probe = AntigravityCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "GEMINI_API_KEY fallback" in probe.detail
|
||||
assert "below tested minimum" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.antigravity_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_version_command_fails(_mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
_mock_which.return_value = "/usr/bin/agy"
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = "some error\n"
|
||||
mock_run.return_value = m
|
||||
|
||||
probe = AntigravityCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.antigravity_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_version_timeout_expired(_mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
_mock_which.return_value = "/usr/bin/agy"
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(
|
||||
cmd=["/usr/bin/agy", "--version"], timeout=_PROBE_TIMEOUT_SEC
|
||||
)
|
||||
|
||||
probe = AntigravityCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert probe.bin_path is None
|
||||
assert "could not run" in probe.detail.lower()
|
||||
|
||||
|
||||
@patch(
|
||||
"integrations.llm_cli.antigravity_cli._fallback_antigravity_cli_paths",
|
||||
return_value=[],
|
||||
)
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value=None)
|
||||
def test_detect_not_installed(_mock_which: MagicMock, _mock_fallback: MagicMock) -> None:
|
||||
probe = AntigravityCLIAdapter().detect()
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert probe.bin_path is None
|
||||
assert "not found" in probe.detail.lower()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/agy")
|
||||
def test_build_basic_invocation(_mock_which: MagicMock) -> None:
|
||||
inv = AntigravityCLIAdapter().build(prompt="explain this alert", model=None, workspace="")
|
||||
assert inv.argv[0] == "/usr/bin/agy"
|
||||
assert "-p" in inv.argv
|
||||
assert "--print-timeout" in inv.argv
|
||||
# Default timeout: shared DEFAULT_EXEC_TIMEOUT_SEC (300s)
|
||||
idx = inv.argv.index("--print-timeout")
|
||||
assert inv.argv[idx + 1] == "300s"
|
||||
assert inv.stdin is None
|
||||
# subprocess timeout has +10s buffer over agy's own --print-timeout
|
||||
assert inv.timeout_sec == 310.0
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/agy")
|
||||
def test_build_omits_model_and_output_format_and_stateful_flags(
|
||||
_mock_which: MagicMock,
|
||||
) -> None:
|
||||
# ``agy`` 1.0.1 does not expose --model or --output-format in headless ``-p``
|
||||
# mode; the adapter must never pass them. Stateful flags break opensre's
|
||||
# ephemeral invocation contract.
|
||||
inv = AntigravityCLIAdapter().build(prompt="p", model="gemini-3.5-flash", workspace="")
|
||||
forbidden = {
|
||||
"--model",
|
||||
"--output-format",
|
||||
"--continue",
|
||||
"-c",
|
||||
"--conversation",
|
||||
"--sandbox",
|
||||
"--dangerously-skip-permissions",
|
||||
}
|
||||
assert not (forbidden & set(inv.argv)), f"argv leaked forbidden flag: {inv.argv}"
|
||||
|
||||
|
||||
def test_resolve_exec_timeout_default() -> None:
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("ANTIGRAVITY_CLI_TIMEOUT_SECONDS", None)
|
||||
assert _resolve_exec_timeout_seconds() == 300.0
|
||||
|
||||
|
||||
def test_resolve_exec_timeout_clamps_low_and_high() -> None:
|
||||
with patch.dict(os.environ, {"ANTIGRAVITY_CLI_TIMEOUT_SECONDS": "5"}, clear=False):
|
||||
assert _resolve_exec_timeout_seconds() == 30.0
|
||||
with patch.dict(os.environ, {"ANTIGRAVITY_CLI_TIMEOUT_SECONDS": "9999"}, clear=False):
|
||||
assert _resolve_exec_timeout_seconds() == 600.0
|
||||
|
||||
|
||||
def test_resolve_exec_timeout_uses_valid_value() -> None:
|
||||
with patch.dict(os.environ, {"ANTIGRAVITY_CLI_TIMEOUT_SECONDS": "240"}, clear=False):
|
||||
assert _resolve_exec_timeout_seconds() == 240.0
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/agy")
|
||||
def test_build_uses_timeout_override(_mock_which: MagicMock) -> None:
|
||||
with patch.dict(os.environ, {"ANTIGRAVITY_CLI_TIMEOUT_SECONDS": "300"}, clear=False):
|
||||
inv = AntigravityCLIAdapter().build(prompt="p", model=None, workspace="")
|
||||
idx = inv.argv.index("--print-timeout")
|
||||
assert inv.argv[idx + 1] == "300s"
|
||||
assert inv.timeout_sec == 310.0
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/agy")
|
||||
def test_build_forwards_gemini_google_env(_mock_which: MagicMock) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GEMINI_API_KEY": "gk-test",
|
||||
"GOOGLE_CLOUD_PROJECT": "proj-x",
|
||||
"GOOGLE_CLOUD_LOCATION": "us-central1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
inv = AntigravityCLIAdapter().build(prompt="p", model=None, workspace="")
|
||||
|
||||
assert inv.env is not None
|
||||
assert inv.env["GEMINI_API_KEY"] == "gk-test"
|
||||
assert inv.env["GOOGLE_CLOUD_PROJECT"] == "proj-x"
|
||||
|
||||
|
||||
def test_parse_returns_plain_stdout() -> None:
|
||||
adapter = AntigravityCLIAdapter()
|
||||
# ``agy`` returns plain text; the adapter must not try to JSON-decode it.
|
||||
assert adapter.parse(stdout=" hello world ", stderr="", returncode=0) == "hello world"
|
||||
assert (
|
||||
adapter.parse(stdout='{"not":"json-envelope"}', stderr="", returncode=0)
|
||||
== '{"not":"json-envelope"}'
|
||||
)
|
||||
|
||||
|
||||
def test_explain_failure_includes_returncode_and_stderr() -> None:
|
||||
adapter = AntigravityCLIAdapter()
|
||||
msg = adapter.explain_failure(stdout="", stderr="auth error", returncode=1)
|
||||
assert "1" in msg
|
||||
assert "auth error" in msg
|
||||
|
||||
|
||||
def test_fallback_paths_macos() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "darwin"),
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
):
|
||||
paths = _fallback_antigravity_cli_paths()
|
||||
|
||||
normalized = _posix_path_set(paths)
|
||||
assert "/opt/homebrew/bin/agy" in normalized
|
||||
assert "/usr/local/bin/agy" in normalized
|
||||
|
||||
|
||||
def test_antigravity_cli_registry_entry() -> None:
|
||||
from integrations.llm_cli.registry import get_cli_provider_registration
|
||||
|
||||
reg = get_cli_provider_registration("antigravity-cli")
|
||||
assert reg is not None
|
||||
assert reg.model_env_key == "ANTIGRAVITY_CLI_MODEL"
|
||||
assert reg.adapter_factory().name == "antigravity-cli"
|
||||
|
||||
|
||||
def test_antigravity_prefix_forwarded_to_subprocess() -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"ANTIGRAVITY_CLI_BIN": "/usr/bin/agy",
|
||||
"ANTIGRAVITY_CLI_TIMEOUT_SECONDS": "240",
|
||||
"GOOGLE_CLOUD_PROJECT": "proj-x",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
env = build_cli_subprocess_env(None)
|
||||
|
||||
assert env["ANTIGRAVITY_CLI_BIN"] == "/usr/bin/agy"
|
||||
assert env["ANTIGRAVITY_CLI_TIMEOUT_SECONDS"] == "240"
|
||||
assert env["GOOGLE_CLOUD_PROJECT"] == "proj-x"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.antigravity_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_auth_probe_uses_filtered_subprocess_env(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/agy"
|
||||
mock_run.side_effect = [_version_proc(), _auth_ok_proc()]
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"PATH": "/usr/bin",
|
||||
"RANDOM_SECRET": "must-not-leak",
|
||||
"GOOGLE_CLOUD_PROJECT": "proj-x",
|
||||
"GEMINI_API_KEY": "gk-test",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
AntigravityCLIAdapter().detect()
|
||||
|
||||
env = mock_run.call_args_list[1].kwargs["env"]
|
||||
assert env["PATH"] == "/usr/bin"
|
||||
assert env["GOOGLE_CLOUD_PROJECT"] == "proj-x"
|
||||
assert env["GEMINI_API_KEY"] == "gk-test"
|
||||
assert "RANDOM_SECRET" not in env
|
||||
|
||||
|
||||
def test_antigravity_cli_model_forwarded_to_subprocess() -> None:
|
||||
"""ANTIGRAVITY_CLI_MODEL must reach CLI subprocesses via the safe-prefix allowlist."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"ANTIGRAVITY_CLI_MODEL": "gemini-3.5-flash",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
env = build_cli_subprocess_env(None)
|
||||
|
||||
assert env["ANTIGRAVITY_CLI_MODEL"] == "gemini-3.5-flash"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/agy")
|
||||
def test_antigravity_build_absent_env_uses_defaults(_mock_which: MagicMock) -> None:
|
||||
"""When ANTIGRAVITY_CLI_BIN, _MODEL, and _TIMEOUT_SECONDS are all absent, build()
|
||||
resolves the binary via PATH and uses the default 300s timeout."""
|
||||
env_strip = {k: v for k, v in os.environ.items() if not k.startswith("ANTIGRAVITY_CLI_")}
|
||||
with patch.dict(os.environ, env_strip, clear=True):
|
||||
inv = AntigravityCLIAdapter().build(prompt="test prompt", model=None, workspace="")
|
||||
|
||||
assert inv.argv[0] == "/usr/bin/agy"
|
||||
idx = inv.argv.index("--print-timeout")
|
||||
assert inv.argv[idx + 1] == "300s"
|
||||
assert inv.timeout_sec == 310.0
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/agy")
|
||||
def test_antigravity_empty_model_env_treated_as_absent(_mock_which: MagicMock) -> None:
|
||||
"""An empty ANTIGRAVITY_CLI_MODEL must not produce a --model flag in argv,
|
||||
consistent with how other CLI providers treat empty model env vars."""
|
||||
with patch.dict(os.environ, {"ANTIGRAVITY_CLI_MODEL": ""}, clear=False):
|
||||
inv = AntigravityCLIAdapter().build(prompt="p", model="", workspace="")
|
||||
|
||||
assert "--model" not in inv.argv
|
||||
|
||||
|
||||
def test_parse_returns_stripped_stdout() -> None:
|
||||
adapter = AntigravityCLIAdapter()
|
||||
assert adapter.parse(stdout=" hello world \n", stderr="", returncode=0) == "hello world"
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout() -> None:
|
||||
import pytest
|
||||
|
||||
adapter = AntigravityCLIAdapter()
|
||||
with pytest.raises(RuntimeError, match="empty output"):
|
||||
adapter.parse(stdout=" ", stderr="", returncode=0)
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout_surfaces_stderr() -> None:
|
||||
import pytest
|
||||
|
||||
adapter = AntigravityCLIAdapter()
|
||||
with pytest.raises(RuntimeError, match="some stderr detail"):
|
||||
adapter.parse(stdout="", stderr="some stderr detail", returncode=0)
|
||||
@@ -0,0 +1,848 @@
|
||||
"""Tests for the Claude Code CLI adapter (detect / build / failure / env forwarding)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from integrations.llm_cli.binary_resolver import npm_prefix_bin_dirs
|
||||
from integrations.llm_cli.claude_code import (
|
||||
_PROBE_TIMEOUT_SEC,
|
||||
ClaudeCodeAdapter,
|
||||
_classify_claude_code_auth,
|
||||
_fallback_claude_code_paths,
|
||||
_probe_cli_auth,
|
||||
)
|
||||
from tests.integrations.llm_cli.testing_helpers import write_fake_runnable_cli_bin
|
||||
|
||||
|
||||
def _posix_path_set(paths: list[str]) -> set[str]:
|
||||
return {Path(p).as_posix() for p in paths}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth classification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_classify_auth_api_key_set() -> None:
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test-key"}, clear=False):
|
||||
logged_in, detail = _classify_claude_code_auth()
|
||||
assert logged_in is True
|
||||
assert "ANTHROPIC_API_KEY" in detail
|
||||
|
||||
|
||||
def test_classify_auth_auth_token_set() -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"ANTHROPIC_AUTH_TOKEN": "tok-test", "ANTHROPIC_API_KEY": ""},
|
||||
clear=False,
|
||||
):
|
||||
logged_in, detail = _classify_claude_code_auth()
|
||||
assert logged_in is True
|
||||
assert "ANTHROPIC_AUTH_TOKEN" in detail
|
||||
|
||||
|
||||
def test_classify_auth_no_credentials_linux() -> None:
|
||||
"""On Linux, no env var and no credentials file → definitive False."""
|
||||
with (
|
||||
patch.dict(os.environ, {"ANTHROPIC_API_KEY": ""}, clear=False),
|
||||
patch("integrations.llm_cli.claude_code.sys.platform", "linux"),
|
||||
patch("integrations.llm_cli.claude_code.Path") as mock_path,
|
||||
):
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.exists.return_value = False
|
||||
mock_path.home.return_value.__truediv__.return_value.__truediv__.return_value = mock_creds
|
||||
logged_in, _detail = _classify_claude_code_auth()
|
||||
assert logged_in is False
|
||||
|
||||
|
||||
def test_classify_auth_no_credentials_macos_returns_none() -> None:
|
||||
"""On macOS with no binary, no file, no API key → None (can't verify without binary)."""
|
||||
with (
|
||||
patch.dict(os.environ, {"ANTHROPIC_API_KEY": ""}, clear=False),
|
||||
patch("integrations.llm_cli.claude_code.sys.platform", "darwin"),
|
||||
patch("integrations.llm_cli.claude_code.Path") as mock_path,
|
||||
):
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.exists.return_value = False
|
||||
mock_path.home.return_value.__truediv__.return_value.__truediv__.return_value = mock_creds
|
||||
logged_in, detail = _classify_claude_code_auth()
|
||||
assert logged_in is None
|
||||
|
||||
|
||||
def test_classify_auth_no_credentials_windows_returns_false() -> None:
|
||||
"""On Windows, same as Linux: without binary or OAuth file, auth is definitively False."""
|
||||
with (
|
||||
patch.dict(os.environ, {"ANTHROPIC_API_KEY": ""}, clear=False),
|
||||
patch("integrations.llm_cli.claude_code.sys.platform", "win32"),
|
||||
patch("integrations.llm_cli.claude_code.Path") as mock_path,
|
||||
):
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.exists.return_value = False
|
||||
mock_path.home.return_value.__truediv__.return_value.__truediv__.return_value = mock_creds
|
||||
logged_in, _detail = _classify_claude_code_auth()
|
||||
assert logged_in is False
|
||||
|
||||
|
||||
def test_classify_auth_credentials_file_present(tmp_path: Path) -> None:
|
||||
# Create a fake ~/.claude/.credentials.json under tmp_path.
|
||||
claude_dir = tmp_path / ".claude"
|
||||
claude_dir.mkdir()
|
||||
creds = claude_dir / ".credentials.json"
|
||||
creds.write_text('{"token": "abc"}')
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"ANTHROPIC_API_KEY": ""}, clear=False),
|
||||
patch("integrations.llm_cli.claude_code.Path.home", return_value=tmp_path),
|
||||
):
|
||||
logged_in, detail = _classify_claude_code_auth()
|
||||
|
||||
assert logged_in is True
|
||||
assert "credentials.json" in detail
|
||||
|
||||
|
||||
def test_classify_auth_credentials_file_unreadable() -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"ANTHROPIC_API_KEY": ""}, clear=False),
|
||||
patch("integrations.llm_cli.claude_code.Path") as mock_path,
|
||||
):
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.exists.return_value = True
|
||||
mock_creds.stat.side_effect = OSError("permission denied")
|
||||
mock_path.home.return_value.__truediv__.return_value.__truediv__.return_value = mock_creds
|
||||
logged_in, _detail = _classify_claude_code_auth()
|
||||
|
||||
assert logged_in is None
|
||||
|
||||
|
||||
def test_classify_auth_api_key_not_blocked_by_unreadable_creds() -> None:
|
||||
"""ANTHROPIC_API_KEY must succeed even when credentials file raises OSError."""
|
||||
with (
|
||||
patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"}, clear=False),
|
||||
patch("integrations.llm_cli.claude_code.Path") as mock_path,
|
||||
):
|
||||
mock_creds = MagicMock()
|
||||
mock_creds.exists.return_value = True
|
||||
mock_creds.stat.side_effect = OSError("permission denied")
|
||||
mock_path.home.return_value.__truediv__.return_value.__truediv__.return_value = mock_creds
|
||||
logged_in, detail = _classify_claude_code_auth()
|
||||
|
||||
assert logged_in is True
|
||||
assert "ANTHROPIC_API_KEY" in detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI auth probe (_probe_cli_auth)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_subscription(mock_run: MagicMock) -> None:
|
||||
"""Subscription login: loggedIn=true, no apiKeySource → subscription detail."""
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = '{"loggedIn": true, "email": "user@example.com"}\n'
|
||||
m.stderr = ""
|
||||
mock_run.return_value = m
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is True
|
||||
assert "subscription" in detail
|
||||
assert "user@example.com" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_api_key(mock_run: MagicMock) -> None:
|
||||
"""API key auth: loggedIn=true, apiKeySource present → API key detail."""
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = '{"loggedIn": true, "apiKeySource": "ANTHROPIC_API_KEY"}\n'
|
||||
m.stderr = ""
|
||||
mock_run.return_value = m
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is True
|
||||
assert "ANTHROPIC_API_KEY" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_not_logged_in(mock_run: MagicMock) -> None:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = '{"loggedIn": false}\n'
|
||||
m.stderr = ""
|
||||
mock_run.return_value = m
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is False
|
||||
assert "Not authenticated" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_nonzero_exit(mock_run: MagicMock) -> None:
|
||||
"""Non-zero exit with no parseable JSON → None (probe failure)."""
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = "unknown command 'auth'"
|
||||
mock_run.return_value = m
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is None
|
||||
assert "failed" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_not_logged_in_exits_1(mock_run: MagicMock) -> None:
|
||||
"""Real CLI returns exit 1 with valid JSON ``loggedIn=false`` when no auth.
|
||||
|
||||
Regression for #1260. The previous implementation short-circuited on
|
||||
``returncode != 0`` before parsing JSON and returned ``None``, causing the
|
||||
wizard to show "could not verify" instead of the correct "requires login".
|
||||
"""
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = '{"loggedIn": false, "authMethod": "none", "apiProvider": "firstParty"}\n'
|
||||
m.stderr = ""
|
||||
mock_run.return_value = m
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is False
|
||||
assert "Not authenticated" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_subscription_with_env_api_key_reports_subscription(
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
"""Subscription auth wins over an env API key in the detail string.
|
||||
|
||||
The CLI emits both ``authMethod="claude.ai"`` and
|
||||
``apiKeySource="ANTHROPIC_API_KEY"`` when a subscription user also has the
|
||||
env var set, but the active method is the subscription. The detail must
|
||||
reflect that, not the env var. Regression for #1260.
|
||||
"""
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = (
|
||||
'{"loggedIn": true, "authMethod": "claude.ai", '
|
||||
'"apiKeySource": "ANTHROPIC_API_KEY", "email": "user@example.com"}\n'
|
||||
)
|
||||
m.stderr = ""
|
||||
mock_run.return_value = m
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is True
|
||||
assert "subscription" in detail
|
||||
assert "user@example.com" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_api_key_only_uses_authmethod(mock_run: MagicMock) -> None:
|
||||
"""authMethod=api_key → detail names the env source via apiKeySource."""
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = '{"loggedIn": true, "authMethod": "api_key", "apiKeySource": "ANTHROPIC_API_KEY"}\n'
|
||||
m.stderr = ""
|
||||
mock_run.return_value = m
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is True
|
||||
assert "ANTHROPIC_API_KEY" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_unrecognized_authmethod_does_not_use_apikeysource(
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
"""A future authMethod (e.g. ``oauth``) must not fall through to apiKeySource.
|
||||
|
||||
The CLI populates ``apiKeySource`` whenever the env contributes an API key,
|
||||
so a logged-in subscription/OAuth user with ``ANTHROPIC_API_KEY`` set would
|
||||
otherwise be reported as "Authenticated via ANTHROPIC_API_KEY" — the exact
|
||||
mis-reporting the legacy heuristic produced for ``claude.ai``.
|
||||
"""
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = (
|
||||
'{"loggedIn": true, "authMethod": "oauth", '
|
||||
'"apiKeySource": "ANTHROPIC_API_KEY", "email": "user@example.com"}\n'
|
||||
)
|
||||
m.stderr = ""
|
||||
mock_run.return_value = m
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is True
|
||||
assert "ANTHROPIC_API_KEY" not in detail
|
||||
assert "oauth" in detail
|
||||
assert "user@example.com" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_exit_1_json_on_stderr_only_is_probe_failure(
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
"""Exit 1 with JSON only on stderr is treated as an opaque probe failure.
|
||||
|
||||
``_try_parse_auth_status_stdout`` reads stdout only, so JSON that lands on
|
||||
stderr falls through to the exit-code branch and returns ``(None, "claude
|
||||
auth status failed: …")``. Pinning this contract guards against a future
|
||||
refactor that starts parsing stderr and silently changes the return value.
|
||||
"""
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = '{"loggedIn": false, "authMethod": "none"}'
|
||||
mock_run.return_value = m
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is None
|
||||
assert "failed" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_timeout(mock_run: MagicMock) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(
|
||||
cmd=["/usr/bin/claude", "auth", "status"], timeout=_PROBE_TIMEOUT_SEC
|
||||
)
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is None
|
||||
assert "timed out" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_os_error(mock_run: MagicMock) -> None:
|
||||
mock_run.side_effect = OSError("permission denied")
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is None
|
||||
assert "permission denied" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_non_json_exit_zero(mock_run: MagicMock) -> None:
|
||||
"""Older CLI versions that output non-JSON on exit 0 are treated as authenticated."""
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "Logged in\n"
|
||||
m.stderr = ""
|
||||
mock_run.return_value = m
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is True
|
||||
assert "CLI" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_non_json_not_logged_in_exit_zero(mock_run: MagicMock) -> None:
|
||||
"""Plain-text 'not logged in' on exit 0 must not be misclassified as authenticated."""
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "Not logged in\n"
|
||||
m.stderr = ""
|
||||
mock_run.return_value = m
|
||||
logged_in, detail = _probe_cli_auth("/usr/bin/claude")
|
||||
assert logged_in is False
|
||||
assert "Not authenticated" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
def test_probe_cli_auth_uses_filtered_subprocess_env(mock_run: MagicMock) -> None:
|
||||
"""Auth probe should use the same filtered env shape as runtime invocation."""
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = '{"loggedIn": true, "apiKeySource": "ANTHROPIC_API_KEY"}\n'
|
||||
m.stderr = ""
|
||||
mock_run.return_value = m
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"PATH": "/usr/bin",
|
||||
"OPENAI_API_KEY": "should-not-leak",
|
||||
"RANDOM_SECRET": "should-not-leak",
|
||||
"CLAUDE_CODE_MODEL": "claude-opus-4-7",
|
||||
"ANTHROPIC_API_KEY": "sk-visible",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
logged_in, _detail = _probe_cli_auth("/usr/bin/claude")
|
||||
|
||||
assert logged_in is True
|
||||
env = mock_run.call_args.kwargs["env"]
|
||||
assert env["PATH"] == "/usr/bin"
|
||||
assert env["CLAUDE_CODE_MODEL"] == "claude-opus-4-7"
|
||||
assert env["ANTHROPIC_API_KEY"] == "sk-visible"
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
assert "RANDOM_SECRET" not in env
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_subscription_authenticated(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""detect() returns logged_in=True via subscription when binary is available."""
|
||||
mock_which.return_value = "/usr/bin/claude"
|
||||
|
||||
auth_proc = MagicMock()
|
||||
auth_proc.returncode = 0
|
||||
auth_proc.stdout = '{"loggedIn": true, "email": "user@example.com"}\n'
|
||||
auth_proc.stderr = ""
|
||||
mock_run.side_effect = [_version_proc(), auth_proc]
|
||||
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": ""}, clear=False):
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "subscription" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_api_key_authenticated(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""detect() returns logged_in=True via API key when binary reports apiKeySource."""
|
||||
mock_which.return_value = "/usr/bin/claude"
|
||||
|
||||
auth_proc = MagicMock()
|
||||
auth_proc.returncode = 0
|
||||
auth_proc.stdout = '{"loggedIn": true, "apiKeySource": "ANTHROPIC_API_KEY"}\n'
|
||||
auth_proc.stderr = ""
|
||||
mock_run.side_effect = [_version_proc(), auth_proc]
|
||||
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"}, clear=False):
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "ANTHROPIC_API_KEY" in probe.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _version_proc() -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "1.2.3\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
def _auth_status_proc(logged_in: bool, api_key_source: str = "", email: str = "") -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
data: dict = {"loggedIn": logged_in}
|
||||
if api_key_source:
|
||||
data["apiKeySource"] = api_key_source
|
||||
if email:
|
||||
data["email"] = email
|
||||
m.stdout = json.dumps(data) + "\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_logged_in_via_api_key(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/claude"
|
||||
mock_run.side_effect = [
|
||||
_version_proc(),
|
||||
_auth_status_proc(True, api_key_source="ANTHROPIC_API_KEY"),
|
||||
]
|
||||
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"}, clear=False):
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert probe.bin_path == "/usr/bin/claude"
|
||||
assert probe.version == "1.2.3"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_authenticated(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""When claude auth status reports not logged in, detect() returns logged_in=False."""
|
||||
mock_which.return_value = "/usr/bin/claude"
|
||||
mock_run.side_effect = [_version_proc(), _auth_status_proc(False)]
|
||||
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": ""}, clear=False):
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_authenticated_uses_api_key_fallback(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/claude"
|
||||
mock_run.side_effect = [_version_proc(), _auth_status_proc(False)]
|
||||
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-fallback"}, clear=False):
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "ANTHROPIC_API_KEY fallback" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_authenticated_uses_auth_token_fallback(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/claude"
|
||||
mock_run.side_effect = [_version_proc(), _auth_status_proc(False)]
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"ANTHROPIC_AUTH_TOKEN": "tok-fallback", "ANTHROPIC_API_KEY": ""},
|
||||
clear=False,
|
||||
):
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "ANTHROPIC_AUTH_TOKEN fallback" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_unclear_auth_uses_api_key_fallback(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/claude"
|
||||
auth_proc = MagicMock()
|
||||
auth_proc.returncode = 1
|
||||
auth_proc.stdout = ""
|
||||
auth_proc.stderr = "unknown command 'auth'"
|
||||
mock_run.side_effect = [_version_proc(), auth_proc]
|
||||
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-fallback"}, clear=False):
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "ANTHROPIC_API_KEY fallback" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code._fallback_claude_code_paths", return_value=[])
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value=None)
|
||||
def test_detect_not_installed(_mock_which: MagicMock, _mock_fallback: MagicMock) -> None:
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert probe.bin_path is None
|
||||
assert "not found" in probe.detail.lower()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_version_command_fails(_mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
_mock_which.return_value = "/usr/bin/claude"
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = "some error\n"
|
||||
mock_run.return_value = m
|
||||
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_version_os_error(_mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
_mock_which.return_value = "/usr/bin/claude"
|
||||
mock_run.side_effect = OSError("not found")
|
||||
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_version_timeout_expired(_mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""Cold-start `claude --version` can exceed the probe timeout; must not raise."""
|
||||
import subprocess
|
||||
|
||||
_mock_which.return_value = "/usr/bin/claude"
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(
|
||||
cmd=["/usr/bin/claude", "--version"], timeout=8.0
|
||||
)
|
||||
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert probe.bin_path is None
|
||||
assert "could not run" in probe.detail.lower()
|
||||
assert "--version" in probe.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/claude")
|
||||
def test_build_basic_invocation(_mock_which: MagicMock) -> None:
|
||||
inv = ClaudeCodeAdapter().build(prompt="explain this alert", model=None, workspace="")
|
||||
assert inv.argv[0] == "/usr/bin/claude"
|
||||
assert "-p" in inv.argv
|
||||
assert "--output-format" in inv.argv
|
||||
assert "text" in inv.argv
|
||||
assert inv.stdin == "explain this alert"
|
||||
assert inv.timeout_sec == 300.0
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/claude")
|
||||
def test_build_adds_model_flag(_mock_which: MagicMock) -> None:
|
||||
inv = ClaudeCodeAdapter().build(prompt="p", model="claude-opus-4-7", workspace="")
|
||||
assert "--model" in inv.argv
|
||||
idx = inv.argv.index("--model")
|
||||
assert inv.argv[idx + 1] == "claude-opus-4-7"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/claude")
|
||||
def test_build_omits_model_flag_when_empty(_mock_which: MagicMock) -> None:
|
||||
inv = ClaudeCodeAdapter().build(prompt="p", model="", workspace="")
|
||||
assert "--model" not in inv.argv
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/claude")
|
||||
def test_build_omits_model_flag_when_none(_mock_which: MagicMock) -> None:
|
||||
inv = ClaudeCodeAdapter().build(prompt="p", model=None, workspace="")
|
||||
assert "--model" not in inv.argv
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/claude")
|
||||
def test_build_uses_provided_workspace(_mock_which: MagicMock) -> None:
|
||||
workspace = "/my/project"
|
||||
inv = ClaudeCodeAdapter().build(prompt="p", model=None, workspace=workspace)
|
||||
assert Path(inv.cwd) == Path(workspace)
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/claude")
|
||||
def test_build_sets_no_color_env(_mock_which: MagicMock) -> None:
|
||||
inv = ClaudeCodeAdapter().build(prompt="p", model=None, workspace="")
|
||||
assert inv.env is not None
|
||||
assert inv.env.get("NO_COLOR") == "1"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code._fallback_claude_code_paths", return_value=[])
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value=None)
|
||||
def test_build_raises_when_binary_not_found(
|
||||
_mock_which: MagicMock, _mock_fallback: MagicMock
|
||||
) -> None:
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError, match="Claude Code CLI not found"):
|
||||
ClaudeCodeAdapter().build(prompt="p", model=None, workspace="")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse / explain_failure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_returns_stripped_stdout() -> None:
|
||||
adapter = ClaudeCodeAdapter()
|
||||
result = adapter.parse(stdout=" hello world \n", stderr="", returncode=0)
|
||||
assert result == "hello world"
|
||||
|
||||
|
||||
def test_explain_failure_includes_returncode_and_stderr() -> None:
|
||||
adapter = ClaudeCodeAdapter()
|
||||
msg = adapter.explain_failure(stdout="", stderr="auth error", returncode=1)
|
||||
assert "1" in msg
|
||||
assert "auth error" in msg
|
||||
|
||||
|
||||
def test_explain_failure_falls_back_to_stdout() -> None:
|
||||
adapter = ClaudeCodeAdapter()
|
||||
msg = adapter.explain_failure(stdout="some output", stderr="", returncode=2)
|
||||
assert "some output" in msg
|
||||
|
||||
|
||||
def test_auth_hint_uses_claude_auth_login() -> None:
|
||||
adapter = ClaudeCodeAdapter()
|
||||
assert "claude auth login" in adapter.auth_hint
|
||||
assert " " not in adapter.auth_hint
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLAUDE_CODE_BIN env override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detect_uses_claude_code_bin_env(tmp_path: Path) -> None:
|
||||
fake_bin = write_fake_runnable_cli_bin(tmp_path, "my-claude")
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"CLAUDE_CODE_BIN": str(fake_bin), "ANTHROPIC_API_KEY": "sk-t"},
|
||||
clear=False,
|
||||
),
|
||||
patch("integrations.llm_cli.claude_code.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = _version_proc()
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.bin_path == str(fake_bin)
|
||||
assert probe.installed is True
|
||||
assert mock_run.call_args[0][0][0] == str(fake_bin)
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.claude_code.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/claude")
|
||||
def test_detect_falls_back_when_bin_env_invalid(
|
||||
_mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"CLAUDE_CODE_BIN": "/does/not/exist/claude", "ANTHROPIC_API_KEY": "sk-t"},
|
||||
clear=False,
|
||||
):
|
||||
mock_run.return_value = _version_proc()
|
||||
probe = ClaudeCodeAdapter().detect()
|
||||
|
||||
assert probe.bin_path == "/usr/bin/claude"
|
||||
assert probe.installed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fallback_paths_macos() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "darwin"),
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
):
|
||||
paths = _fallback_claude_code_paths()
|
||||
|
||||
normalized = _posix_path_set(paths)
|
||||
assert "/opt/homebrew/bin/claude" in normalized
|
||||
assert "/usr/local/bin/claude" in normalized
|
||||
assert (Path.home() / ".local/bin/claude").as_posix() in normalized
|
||||
|
||||
|
||||
def test_fallback_paths_linux() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "linux"),
|
||||
patch.dict(os.environ, {"npm_config_prefix": "/custom/npm"}, clear=False),
|
||||
):
|
||||
paths = _fallback_claude_code_paths()
|
||||
|
||||
normalized = _posix_path_set(paths)
|
||||
assert "/custom/npm/bin/claude" in normalized
|
||||
|
||||
|
||||
def test_fallback_paths_windows() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "win32"),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"APPDATA": r"C:\Users\me\AppData\Roaming",
|
||||
"LOCALAPPDATA": r"C:\Users\me\AppData\Local",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
paths = _fallback_claude_code_paths()
|
||||
|
||||
normalized = {p.replace("\\", "/") for p in paths}
|
||||
assert "C:/Users/me/AppData/Roaming/npm/claude.cmd" in normalized
|
||||
assert "C:/Users/me/AppData/Roaming/npm/claude.exe" in normalized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_claude_code_registry_entry() -> None:
|
||||
from integrations.llm_cli.registry import get_cli_provider_registration
|
||||
|
||||
reg = get_cli_provider_registration("claude-code")
|
||||
assert reg is not None
|
||||
assert reg.model_env_key == "CLAUDE_CODE_MODEL"
|
||||
assert reg.adapter_factory().name == "claude-code"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subprocess env forwarding — ANTHROPIC_ and CLAUDE_ prefixes must be safe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anthropic_key_forwarded_via_build() -> None:
|
||||
"""ANTHROPIC_API_KEY is forwarded explicitly by build(), not via the blanket prefix allowlist.
|
||||
|
||||
This keeps Codex subprocesses from receiving Anthropic credentials.
|
||||
"""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"ANTHROPIC_API_KEY": "sk-forward-me",
|
||||
"ANTHROPIC_BASE_URL": "https://proxy.example.com",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/claude"),
|
||||
):
|
||||
inv = ClaudeCodeAdapter().build(prompt="p", model=None, workspace="")
|
||||
|
||||
assert inv.env is not None
|
||||
assert inv.env["ANTHROPIC_API_KEY"] == "sk-forward-me"
|
||||
assert inv.env["ANTHROPIC_BASE_URL"] == "https://proxy.example.com"
|
||||
|
||||
|
||||
def test_anthropic_key_not_in_blanket_subprocess_env() -> None:
|
||||
"""ANTHROPIC_API_KEY must NOT be forwarded via the global prefix allowlist (would leak to Codex)."""
|
||||
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
|
||||
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-secret"}, clear=False):
|
||||
env = build_cli_subprocess_env(None)
|
||||
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
|
||||
|
||||
def test_claude_prefix_forwarded_to_subprocess() -> None:
|
||||
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"CLAUDE_CODE_MODEL": "claude-opus-4-7", "CLAUDE_CODE_BIN": "/usr/bin/claude"},
|
||||
clear=False,
|
||||
):
|
||||
env = build_cli_subprocess_env(None)
|
||||
|
||||
assert env["CLAUDE_CODE_MODEL"] == "claude-opus-4-7"
|
||||
assert env["CLAUDE_CODE_BIN"] == "/usr/bin/claude"
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout() -> None:
|
||||
import pytest
|
||||
|
||||
adapter = ClaudeCodeAdapter()
|
||||
with pytest.raises(RuntimeError, match="empty output"):
|
||||
adapter.parse(stdout=" ", stderr="", returncode=0)
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout_surfaces_stderr() -> None:
|
||||
import pytest
|
||||
|
||||
adapter = ClaudeCodeAdapter()
|
||||
with pytest.raises(RuntimeError, match="some stderr detail"):
|
||||
adapter.parse(stdout="", stderr="some stderr detail", returncode=0)
|
||||
@@ -0,0 +1,785 @@
|
||||
"""Tests for Codex CLI adapter detection and prompt helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli.binary_resolver import diagnose_binary_path, npm_prefix_bin_dirs
|
||||
from integrations.llm_cli.codex import (
|
||||
_AUTH_STATUS_PROBE_ENV,
|
||||
CodexAdapter,
|
||||
_fallback_codex_paths,
|
||||
)
|
||||
from integrations.llm_cli.text import flatten_messages_to_prompt
|
||||
from tests.integrations.llm_cli.testing_helpers import write_fake_runnable_cli_bin
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_codex_auth_status_probe(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv(_AUTH_STATUS_PROBE_ENV, "1")
|
||||
|
||||
|
||||
def _posix_path_set(paths: list[str]) -> set[str]:
|
||||
"""Normalize paths for assertions when simulating POSIX platforms on Windows CI."""
|
||||
return {Path(p).as_posix() for p in paths}
|
||||
|
||||
|
||||
def test_flatten_messages_joins_roles() -> None:
|
||||
text = flatten_messages_to_prompt(
|
||||
[
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "hi"},
|
||||
]
|
||||
)
|
||||
assert "=== SYSTEM ===" in text
|
||||
assert "sys" in text
|
||||
assert "=== USER ===" in text
|
||||
assert "hi" in text
|
||||
|
||||
|
||||
def _version_proc() -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "codex-cli 0.120.0\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
def _login_ok_proc() -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "Logged in using ChatGPT\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.codex.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_path_binary_logged_in(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/codex"
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
if len(args) >= 2 and args[1] == "--version":
|
||||
return _version_proc()
|
||||
if len(args) >= 3 and args[1] == "login" and args[2] == "status":
|
||||
return _login_ok_proc()
|
||||
raise AssertionError(args)
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
probe = CodexAdapter().detect()
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert probe.bin_path == "/usr/bin/codex"
|
||||
assert probe.version == "0.120.0"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.codex.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_logged_in(
|
||||
mock_which: MagicMock, mock_run: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Strip OPENAI_API_KEY so the dev's shell or .env can't trigger
|
||||
# the API-key fallback and flip logged_in to True.
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
mock_which.return_value = "/usr/bin/codex"
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
if len(args) >= 2 and args[1] == "--version":
|
||||
return _version_proc()
|
||||
if len(args) >= 3 and args[1] == "login":
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = "Not logged in\n"
|
||||
return m
|
||||
raise AssertionError(args)
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
probe = CodexAdapter().detect()
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.codex.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_skips_login_status_probe_by_default(
|
||||
mock_which: MagicMock, mock_run: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv(_AUTH_STATUS_PROBE_ENV, raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
mock_which.return_value = "/usr/bin/codex"
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
if len(args) >= 2 and args[1] == "--version":
|
||||
return _version_proc()
|
||||
raise AssertionError(f"unexpected auth probe: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
probe = CodexAdapter().detect()
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None
|
||||
assert probe.detail == "Codex CLI installed."
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.codex.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_logged_in_uses_openai_api_key_fallback(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/codex"
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
if len(args) >= 2 and args[1] == "--version":
|
||||
return _version_proc()
|
||||
if len(args) >= 3 and args[1] == "login":
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = "Not logged in\n"
|
||||
return m
|
||||
raise AssertionError(args)
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-fallback"}, clear=False):
|
||||
probe = CodexAdapter().detect()
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "OPENAI_API_KEY fallback" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.codex.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_logged_in_exit_zero(
|
||||
mock_which: MagicMock, mock_run: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Some Codex versions may exit 0 while printing 'Not logged in' — must not match 'logged in'."""
|
||||
# Strip OPENAI_API_KEY so the dev's shell or .env can't trigger
|
||||
# the API-key fallback and flip logged_in to True.
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
mock_which.return_value = "/usr/bin/codex"
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
if len(args) >= 2 and args[1] == "--version":
|
||||
return _version_proc()
|
||||
if len(args) >= 3 and args[1] == "login":
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "Not logged in\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
raise AssertionError(args)
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
probe = CodexAdapter().detect()
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.codex.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_unclear_auth_uses_openai_api_key_fallback(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/codex"
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
if len(args) >= 2 and args[1] == "--version":
|
||||
return _version_proc()
|
||||
if len(args) >= 3 and args[1] == "login":
|
||||
m = MagicMock()
|
||||
m.returncode = 2
|
||||
m.stdout = ""
|
||||
m.stderr = "network unreachable"
|
||||
return m
|
||||
raise AssertionError(args)
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-fallback"}, clear=False):
|
||||
probe = CodexAdapter().detect()
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "OPENAI_API_KEY fallback" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/codex")
|
||||
def test_build_forwards_openai_platform_env(mock_which: MagicMock) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPENAI_API_KEY": "sk-test",
|
||||
"OPENAI_ORG_ID": "org-x",
|
||||
"OPENAI_PROJECT_ID": "proj-y",
|
||||
"OPENAI_BASE_URL": "https://example.invalid/v1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
inv = CodexAdapter().build(prompt="p", model=None, workspace="")
|
||||
|
||||
mock_which.assert_called()
|
||||
assert inv.env is not None
|
||||
assert inv.env["OPENAI_API_KEY"] == "sk-test"
|
||||
assert inv.env["OPENAI_ORG_ID"] == "org-x"
|
||||
assert inv.env["OPENAI_PROJECT_ID"] == "proj-y"
|
||||
assert inv.env["OPENAI_BASE_URL"] == "https://example.invalid/v1"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/codex")
|
||||
def test_build_omits_env_without_openai_platform_vars(mock_which: MagicMock) -> None:
|
||||
"""Strip OPENAI_* so build() does not attach env overrides from the real shell."""
|
||||
base = {k: v for k, v in os.environ.items() if not k.startswith("OPENAI_")}
|
||||
with patch.dict(os.environ, base, clear=True):
|
||||
inv = CodexAdapter().build(prompt="p", model=None, workspace="")
|
||||
mock_which.assert_called()
|
||||
assert inv.env is None
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/codex")
|
||||
def test_build_adds_model_flag_when_not_default(mock_which: MagicMock) -> None:
|
||||
inv = CodexAdapter().build(prompt="p", model="o3", workspace="")
|
||||
assert inv.stdin == "p"
|
||||
assert "-m" in inv.argv
|
||||
assert inv.argv[-1] == "-"
|
||||
idx = inv.argv.index("-m")
|
||||
assert inv.argv[idx + 1] == "o3"
|
||||
mock_which.assert_called()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/codex")
|
||||
def test_build_adds_reasoning_effort_override(mock_which: MagicMock) -> None:
|
||||
inv = CodexAdapter().build(prompt="p", model=None, workspace="", reasoning_effort="xhigh")
|
||||
|
||||
assert "-c" in inv.argv
|
||||
idx = inv.argv.index("-c")
|
||||
assert inv.argv[idx + 1] == 'model_reasoning_effort="xhigh"'
|
||||
mock_which.assert_called()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_invoke(mock_run: MagicMock) -> None:
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "codex"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="/usr/bin/codex",
|
||||
logged_in=True,
|
||||
detail="ok",
|
||||
)
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=["/usr/bin/codex", "exec", "-"],
|
||||
stdin="hello",
|
||||
cwd="/tmp",
|
||||
env={"CODEX_BIN": "/custom/codex"},
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.parse.return_value = "answer"
|
||||
mock_adapter.explain_failure.return_value = "fail"
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
||||
|
||||
with (
|
||||
patch("platform.guardrails.engine.get_guardrail_engine") as gr,
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"ANTHROPIC_API_KEY": "anthropic-secret",
|
||||
"OPENAI_API_KEY": "openai-secret",
|
||||
"PATH": "/usr/bin",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model="codex", max_tokens=256)
|
||||
resp = client.invoke("hello")
|
||||
|
||||
assert resp.content == "answer"
|
||||
mock_adapter.build.assert_called_once()
|
||||
mock_run.assert_called_once()
|
||||
env = mock_run.call_args.kwargs["env"]
|
||||
assert env["PATH"] == "/usr/bin"
|
||||
assert env["CODEX_BIN"] == "/custom/codex"
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_passes_reasoning_effort_to_adapter(mock_run: MagicMock) -> None:
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "codex"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="/usr/bin/codex",
|
||||
logged_in=True,
|
||||
detail="ok",
|
||||
)
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=["/usr/bin/codex", "exec", "-"],
|
||||
stdin="hello",
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.parse.return_value = "answer"
|
||||
mock_adapter.explain_failure.return_value = "fail"
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
||||
|
||||
with (
|
||||
patch("platform.guardrails.engine.get_guardrail_engine") as gr,
|
||||
patch.dict(os.environ, {"OPENSRE_REASONING_EFFORT": "high"}, clear=False),
|
||||
):
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model="codex", max_tokens=256)
|
||||
client.invoke("hello")
|
||||
|
||||
mock_adapter.build.assert_called_once_with(
|
||||
prompt="hello",
|
||||
model="codex",
|
||||
workspace="",
|
||||
reasoning_effort="high",
|
||||
)
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
@patch.object(CodexAdapter, "_probe_binary")
|
||||
@patch.object(CodexAdapter, "_resolve_binary", return_value="/usr/bin/codex")
|
||||
def test_cli_backed_client_codex_merge_openai_platform_env(
|
||||
_mock_resolve: MagicMock,
|
||||
mock_probe_binary: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
"""Codex invoke merges OPENAI_* from the adapter into the filtered subprocess env."""
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
mock_probe_binary.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="/usr/bin/codex",
|
||||
logged_in=True,
|
||||
detail="ok",
|
||||
)
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="ok\n", stderr="")
|
||||
|
||||
with (
|
||||
patch("platform.guardrails.engine.get_guardrail_engine") as gr,
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPENAI_API_KEY": "sk-from-env",
|
||||
"OPENAI_BASE_URL": "https://proxy.example/v1",
|
||||
"PATH": "/usr/bin",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(CodexAdapter(), model=None, max_tokens=256)
|
||||
client.invoke("hello")
|
||||
|
||||
merged = mock_run.call_args.kwargs["env"]
|
||||
assert merged["OPENAI_API_KEY"] == "sk-from-env"
|
||||
assert merged["OPENAI_BASE_URL"] == "https://proxy.example/v1"
|
||||
assert "ANTHROPIC_API_KEY" not in merged
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_caches_probe_between_invokes(mock_run: MagicMock) -> None:
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "codex"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="/usr/bin/codex",
|
||||
logged_in=True,
|
||||
detail="ok",
|
||||
)
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=["/usr/bin/codex", "exec", "-"],
|
||||
stdin="hello",
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.parse.return_value = "answer"
|
||||
mock_adapter.explain_failure.return_value = "fail"
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model="codex", max_tokens=256)
|
||||
client.invoke("a")
|
||||
client.invoke("b")
|
||||
|
||||
assert mock_adapter.detect.call_count == 1
|
||||
assert mock_adapter.build.call_count == 2
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_failure_mentions_unclear_auth_probe(mock_run: MagicMock) -> None:
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "claude-code"
|
||||
mock_adapter.auth_hint = "Run: claude auth login"
|
||||
mock_adapter.binary_env_key = "CLAUDE_CODE_BIN"
|
||||
mock_adapter.install_hint = "npm i -g @anthropic-ai/claude-code"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="/usr/bin/claude",
|
||||
logged_in=None,
|
||||
detail="claude auth status failed: unknown command",
|
||||
)
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=["/usr/bin/claude", "-p"],
|
||||
stdin="hello",
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.explain_failure.return_value = "claude -p exited with code 1"
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="unauthorized")
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model="claude-code", max_tokens=256)
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError, match="Auth status could not be verified"):
|
||||
client.invoke("hello")
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_invoke_raises_cli_authentication_required_when_logged_out(
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli.errors import CLIAuthenticationRequired
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "cursor"
|
||||
mock_adapter.auth_hint = "Run: agent login."
|
||||
mock_adapter.binary_env_key = "CURSOR_BIN"
|
||||
mock_adapter.install_hint = "install cursor"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="/usr/bin/agent",
|
||||
logged_in=False,
|
||||
detail="Not logged in.",
|
||||
)
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model=None, max_tokens=256)
|
||||
with pytest.raises(CLIAuthenticationRequired, match="not authenticated"):
|
||||
client.invoke("hello")
|
||||
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_unclear_auth_no_double_period_when_explain_failure_trailing_period(
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
"""No double period in the composed error message.
|
||||
|
||||
When stderr contains an auth-related term ('unauthorized'), the failure
|
||||
classifier replaces the raw exit-code text with a clean, actionable message
|
||||
— so neither the original trailing period nor a '..' can appear.
|
||||
"""
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "claude-code"
|
||||
mock_adapter.auth_hint = "Run: claude auth login"
|
||||
mock_adapter.binary_env_key = "CLAUDE_CODE_BIN"
|
||||
mock_adapter.install_hint = "npm i -g @anthropic-ai/claude-code"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="/usr/bin/claude",
|
||||
logged_in=None,
|
||||
detail="probe unclear",
|
||||
)
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=["/usr/bin/claude", "-p"],
|
||||
stdin="hello",
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.explain_failure.return_value = "claude -p exited with code 1."
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model="claude-code", max_tokens=256)
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
client.invoke("hello")
|
||||
|
||||
msg = str(exc_info.value)
|
||||
assert ".." not in msg
|
||||
assert "Auth status could not be verified" in msg
|
||||
|
||||
|
||||
def test_detect_uses_codex_bin_env_file(tmp_path) -> None:
|
||||
fake_bin = write_fake_runnable_cli_bin(tmp_path, "my-codex")
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"CODEX_BIN": str(fake_bin)}, clear=False),
|
||||
patch("integrations.llm_cli.codex.subprocess.run") as mock_run,
|
||||
):
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
assert args[0] == str(fake_bin)
|
||||
if args[1] == "--version":
|
||||
return _version_proc()
|
||||
if args[1] == "login":
|
||||
return _login_ok_proc()
|
||||
raise AssertionError(args)
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
probe = CodexAdapter().detect()
|
||||
|
||||
assert probe.bin_path == str(fake_bin)
|
||||
assert probe.installed is True
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.codex.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/codex")
|
||||
def test_detect_falls_back_when_codex_bin_invalid(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
with patch.dict(os.environ, {"CODEX_BIN": "/does/not/exist/codex"}, clear=False):
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
assert args[0] == "/usr/bin/codex"
|
||||
if args[1] == "--version":
|
||||
return _version_proc()
|
||||
if args[1] == "login":
|
||||
return _login_ok_proc()
|
||||
raise AssertionError(args)
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
probe = CodexAdapter().detect()
|
||||
|
||||
assert probe.bin_path == "/usr/bin/codex"
|
||||
assert probe.installed is True
|
||||
mock_which.assert_called()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.codex.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value=None)
|
||||
@patch("integrations.llm_cli.codex._fallback_codex_paths", return_value=["/x/codex", "/y/codex"])
|
||||
@patch("integrations.llm_cli.binary_resolver.is_runnable_binary")
|
||||
def test_detect_uses_first_runnable_fallback_path(
|
||||
mock_is_runnable: MagicMock,
|
||||
mock_fallback: MagicMock,
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_is_runnable.side_effect = lambda p: p == "/y/codex"
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
assert args[0] == "/y/codex"
|
||||
if args[1] == "--version":
|
||||
return _version_proc()
|
||||
if args[1] == "login":
|
||||
return _login_ok_proc()
|
||||
raise AssertionError(args)
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
probe = CodexAdapter().detect()
|
||||
|
||||
assert probe.bin_path == "/y/codex"
|
||||
assert probe.installed is True
|
||||
mock_fallback.assert_called_once()
|
||||
mock_which.assert_called()
|
||||
|
||||
|
||||
def test_fallback_paths_include_env_and_npm_prefix() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "linux"),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"PNPM_HOME": "/pnpm/home",
|
||||
"XDG_DATA_HOME": "/xdg/data",
|
||||
"npm_config_prefix": "/custom/npm",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
paths = _fallback_codex_paths()
|
||||
|
||||
normalized = _posix_path_set(paths)
|
||||
assert "/pnpm/home/codex" in normalized
|
||||
assert "/xdg/data/pnpm/codex" in normalized
|
||||
assert "/custom/npm/bin/codex" in normalized
|
||||
|
||||
|
||||
def test_fallback_paths_include_macos_defaults() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "darwin"),
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
):
|
||||
paths = _fallback_codex_paths()
|
||||
|
||||
normalized = _posix_path_set(paths)
|
||||
assert "/opt/homebrew/bin/codex" in normalized
|
||||
assert "/usr/local/bin/codex" in normalized
|
||||
assert (Path.home() / ".local/bin/codex").as_posix() in normalized
|
||||
assert (Path.home() / ".npm-global/bin/codex").as_posix() in normalized
|
||||
assert (Path.home() / ".volta/bin/codex").as_posix() in normalized
|
||||
|
||||
|
||||
def test_fallback_paths_include_windows_defaults() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "win32"),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"APPDATA": r"C:\Users\me\AppData\Roaming",
|
||||
"LOCALAPPDATA": r"C:\Users\me\AppData\Local",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
paths = _fallback_codex_paths()
|
||||
|
||||
normalized = {p.replace("\\", "/") for p in paths}
|
||||
assert "C:/Users/me/AppData/Roaming/npm/codex.cmd" in normalized
|
||||
assert "C:/Users/me/AppData/Roaming/npm/codex.exe" in normalized
|
||||
assert "C:/Users/me/AppData/Roaming/npm/codex.ps1" in normalized
|
||||
assert "C:/Users/me/AppData/Local/Programs/codex/codex.cmd" in normalized
|
||||
assert "C:/Users/me/AppData/Local/Programs/codex/codex.exe" in normalized
|
||||
|
||||
|
||||
def test_npm_prefix_bin_dirs_windows_uses_prefix_root() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "win32"),
|
||||
patch.dict(os.environ, {"NPM_CONFIG_PREFIX": r"C:\npm\prefix"}, clear=False),
|
||||
):
|
||||
dirs = npm_prefix_bin_dirs()
|
||||
assert dirs == (r"C:\npm\prefix",)
|
||||
|
||||
|
||||
def test_npm_prefix_bin_dirs_unix_uses_prefix_bin() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "linux"),
|
||||
patch.dict(os.environ, {"NPM_CONFIG_PREFIX": "/opt/npm"}, clear=False),
|
||||
):
|
||||
dirs = npm_prefix_bin_dirs()
|
||||
assert tuple(Path(d).as_posix() for d in dirs) == ("/opt/npm/bin",)
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/codex")
|
||||
def test_codex_default_exec_timeout_is_shorter(mock_which) -> None:
|
||||
"""Default timeout is asserted without requiring a real codex binary on CI PATH."""
|
||||
inv = CodexAdapter().build(prompt="p", model=None, workspace="")
|
||||
assert inv.timeout_sec == 300.0
|
||||
mock_which.assert_called()
|
||||
|
||||
|
||||
def test_diagnose_binary_path_missing_file(tmp_path: Path) -> None:
|
||||
result = diagnose_binary_path(str(tmp_path / "no-such-binary"))
|
||||
assert result is not None
|
||||
assert "does not exist" in result
|
||||
|
||||
|
||||
def test_diagnose_binary_path_valid_executable(tmp_path: Path) -> None:
|
||||
exe = write_fake_runnable_cli_bin(tmp_path, "my-bin")
|
||||
assert diagnose_binary_path(str(exe)) is None
|
||||
|
||||
|
||||
def test_diagnose_binary_path_not_executable(tmp_path: Path) -> None:
|
||||
f = tmp_path / "not-executable"
|
||||
f.write_bytes(b"")
|
||||
os.chmod(f, 0o600)
|
||||
result = diagnose_binary_path(str(f))
|
||||
if sys.platform != "win32":
|
||||
assert result is not None
|
||||
assert "not executable" in result
|
||||
|
||||
|
||||
def test_diagnose_binary_path_broken_symlink_windows_skip(tmp_path: Path) -> None:
|
||||
"""Skip gracefully on Windows hosts where symlink creation requires elevation."""
|
||||
link = tmp_path / "broken-link-win"
|
||||
try:
|
||||
link.symlink_to(tmp_path / "ghost")
|
||||
except (OSError, NotImplementedError):
|
||||
return # symlinks not available on this runner; skip
|
||||
result = diagnose_binary_path(str(link))
|
||||
assert result is not None
|
||||
assert "broken symlink" in result
|
||||
|
||||
|
||||
def test_resolve_cli_binary_warns_on_broken_symlink(tmp_path: Path, caplog) -> None:
|
||||
from integrations.llm_cli.binary_resolver import resolve_cli_binary
|
||||
|
||||
link = tmp_path / "broken-codex"
|
||||
try:
|
||||
link.symlink_to(tmp_path / "ghost")
|
||||
except (OSError, NotImplementedError):
|
||||
return # symlinks not available on this runner; skip
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"CODEX_BIN": str(link)}, clear=False),
|
||||
patch("integrations.llm_cli.binary_resolver.shutil.which", return_value=None),
|
||||
caplog.at_level(logging.WARNING, logger="integrations.llm_cli.binary_resolver"),
|
||||
):
|
||||
result = resolve_cli_binary(
|
||||
explicit_env_key="CODEX_BIN",
|
||||
binary_names=("codex",),
|
||||
fallback_paths=[],
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert any("broken symlink" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_codex_cli_registry_entry() -> None:
|
||||
from integrations.llm_cli.registry import get_cli_provider_registration
|
||||
|
||||
reg = get_cli_provider_registration("codex")
|
||||
assert reg is not None
|
||||
assert reg.model_env_key == "CODEX_MODEL"
|
||||
assert reg.adapter_factory().name == "codex"
|
||||
|
||||
|
||||
def test_parse_returns_stripped_stdout() -> None:
|
||||
adapter = CodexAdapter()
|
||||
assert adapter.parse(stdout=" hello world \n", stderr="", returncode=0) == "hello world"
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout() -> None:
|
||||
adapter = CodexAdapter()
|
||||
with pytest.raises(RuntimeError, match="empty output"):
|
||||
adapter.parse(stdout=" ", stderr="", returncode=0)
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout_surfaces_stderr() -> None:
|
||||
adapter = CodexAdapter()
|
||||
with pytest.raises(RuntimeError, match="some stderr detail"):
|
||||
adapter.parse(stdout="", stderr="some stderr detail", returncode=0)
|
||||
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import socket
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli import codex_oauth
|
||||
|
||||
|
||||
def _json_response(payload: dict[str, object], status_code: int = 200) -> httpx.Response:
|
||||
return httpx.Response(status_code, json=payload)
|
||||
|
||||
|
||||
def _jwt(payload: dict[str, object]) -> str:
|
||||
def _part(data: dict[str, object]) -> str:
|
||||
raw = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
return f"{_part({'alg': 'none'})}.{_part(payload)}.sig"
|
||||
|
||||
|
||||
def _token_response() -> dict[str, object]:
|
||||
return {
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"id_token": _jwt(
|
||||
{
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "account-123",
|
||||
}
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("localhost", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def test_build_codex_oauth_request_uses_pkce_and_localhost_callback() -> None:
|
||||
request = codex_oauth.build_codex_oauth_request()
|
||||
|
||||
assert f"client_id={codex_oauth.CODEX_OAUTH_CLIENT_ID}" in request.authorize_url
|
||||
assert "redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback" in request.authorize_url
|
||||
assert "code_challenge_method=S256" in request.authorize_url
|
||||
assert "code_challenge=" in request.authorize_url
|
||||
assert "code=" not in request.authorize_url
|
||||
assert len(request.state) >= 32
|
||||
assert len(request.code_verifier) >= 43
|
||||
|
||||
|
||||
def test_wait_for_codex_oauth_callback_writes_tokens_and_redirects_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex-home"))
|
||||
port = _free_port()
|
||||
monkeypatch.setattr(codex_oauth, "CODEX_OAUTH_PORT", port)
|
||||
request = codex_oauth.build_codex_oauth_request()
|
||||
|
||||
def _open_browser(_url: str) -> bool:
|
||||
response = httpx.get(
|
||||
f"http://localhost:{port}/auth/callback",
|
||||
params={"code": "auth-code", "state": request.state},
|
||||
follow_redirects=False,
|
||||
timeout=5.0,
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["Location"].startswith(f"http://localhost:{port}/success?")
|
||||
assert "id_token=" in response.headers["Location"]
|
||||
return True
|
||||
|
||||
result = codex_oauth.wait_for_codex_oauth_callback(
|
||||
request=request,
|
||||
open_browser=_open_browser,
|
||||
post=lambda *_args, **_kwargs: _json_response(_token_response()),
|
||||
timeout_seconds=5.0,
|
||||
)
|
||||
|
||||
assert result.account_id == "account-123"
|
||||
assert result.auth_path == tmp_path / "codex-home" / "auth.json"
|
||||
data = json.loads(result.auth_path.read_text(encoding="utf-8"))
|
||||
assert data["tokens"]["access_token"] == "access-token"
|
||||
captured = capsys.readouterr()
|
||||
assert "auth-code" not in captured.out
|
||||
assert "auth-code" not in captured.err
|
||||
assert "access-token" not in captured.out
|
||||
assert "access-token" not in captured.err
|
||||
assert "refresh-token" not in captured.out
|
||||
assert "refresh-token" not in captured.err
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("params", "message"),
|
||||
[
|
||||
({"code": "auth-code", "state": "wrong"}, "invalid_state"),
|
||||
({}, "missing_code"),
|
||||
({"error": "access_denied"}, "access_denied"),
|
||||
],
|
||||
)
|
||||
def test_wait_for_codex_oauth_callback_rejects_invalid_callbacks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
params: dict[str, str],
|
||||
message: str,
|
||||
) -> None:
|
||||
port = _free_port()
|
||||
monkeypatch.setattr(codex_oauth, "CODEX_OAUTH_PORT", port)
|
||||
request = codex_oauth.build_codex_oauth_request()
|
||||
params = {**params, "state": params.get("state", request.state)}
|
||||
|
||||
def _open_browser(_url: str) -> bool:
|
||||
response = httpx.get(
|
||||
f"http://localhost:{port}/auth/callback",
|
||||
params=params,
|
||||
timeout=5.0,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
return True
|
||||
|
||||
with pytest.raises(codex_oauth.CodexOAuthError, match=message):
|
||||
codex_oauth.wait_for_codex_oauth_callback(
|
||||
request=request,
|
||||
open_browser=_open_browser,
|
||||
timeout_seconds=5.0,
|
||||
)
|
||||
|
||||
|
||||
def test_wait_for_codex_oauth_callback_stores_success_id_token_callback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex-home"))
|
||||
port = _free_port()
|
||||
monkeypatch.setattr(codex_oauth, "CODEX_OAUTH_PORT", port)
|
||||
request = codex_oauth.build_codex_oauth_request()
|
||||
id_token = _jwt(
|
||||
{
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "account-from-success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def _open_browser(_url: str) -> bool:
|
||||
response = httpx.get(
|
||||
f"http://localhost:{port}/success",
|
||||
params={"id_token": id_token},
|
||||
timeout=5.0,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return True
|
||||
|
||||
result = codex_oauth.wait_for_codex_oauth_callback(
|
||||
request=request,
|
||||
open_browser=_open_browser,
|
||||
timeout_seconds=5.0,
|
||||
)
|
||||
|
||||
assert result.account_id == "account-from-success"
|
||||
data = json.loads(result.auth_path.read_text(encoding="utf-8"))
|
||||
assert data["tokens"]["id_token"] == id_token
|
||||
assert data["tokens"]["access_token"] == id_token
|
||||
assert data["tokens"]["refresh_token"] == ""
|
||||
|
||||
|
||||
def test_wait_for_codex_oauth_callback_reports_port_in_use(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
port = _free_port()
|
||||
monkeypatch.setattr(codex_oauth, "CODEX_OAUTH_PORT", port)
|
||||
request = codex_oauth.build_codex_oauth_request()
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("localhost", port))
|
||||
sock.listen(1)
|
||||
with pytest.raises(codex_oauth.CodexOAuthError, match=f"port {port}"):
|
||||
codex_oauth.wait_for_codex_oauth_callback(
|
||||
request=request,
|
||||
open_browser=lambda _url: True,
|
||||
timeout_seconds=0.1,
|
||||
)
|
||||
|
||||
|
||||
def test_exchange_codex_oauth_code_posts_code_without_logging_tokens() -> None:
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
def _post(*args: object, **kwargs: object) -> httpx.Response:
|
||||
calls.append({"args": args, "kwargs": kwargs})
|
||||
return _json_response(_token_response())
|
||||
|
||||
result = codex_oauth.exchange_codex_oauth_code(
|
||||
code="auth-code",
|
||||
code_verifier="verifier",
|
||||
post=_post,
|
||||
)
|
||||
|
||||
assert result["access_token"] == "access-token"
|
||||
data = calls[0]["kwargs"]["data"] # type: ignore[index]
|
||||
assert data["grant_type"] == "authorization_code"
|
||||
assert data["code"] == "auth-code"
|
||||
assert data["redirect_uri"] == codex_oauth.CODEX_OAUTH_REDIRECT_URI
|
||||
assert data["code_verifier"] == "verifier"
|
||||
|
||||
|
||||
def test_exchange_codex_oauth_code_rejects_failed_response() -> None:
|
||||
def _post(*_args: object, **_kwargs: object) -> httpx.Response:
|
||||
return _json_response({"error": "invalid_grant"}, status_code=400)
|
||||
|
||||
with pytest.raises(codex_oauth.CodexOAuthError, match="HTTP 400"):
|
||||
codex_oauth.exchange_codex_oauth_code(
|
||||
code="auth-code",
|
||||
code_verifier="verifier",
|
||||
post=_post,
|
||||
)
|
||||
|
||||
|
||||
def test_codex_auth_payload_and_write_auth_shape(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex-home"))
|
||||
payload = codex_oauth.codex_auth_payload(_token_response())
|
||||
|
||||
assert payload["OPENAI_API_KEY"] is None
|
||||
assert payload["auth_mode"] == "chatgpt"
|
||||
assert payload["tokens"]["access_token"] == "access-token" # type: ignore[index]
|
||||
assert payload["tokens"]["refresh_token"] == "refresh-token" # type: ignore[index]
|
||||
assert payload["tokens"]["account_id"] == "account-123" # type: ignore[index]
|
||||
|
||||
path = codex_oauth.write_codex_auth(payload)
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert data == payload
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700
|
||||
|
||||
|
||||
def test_run_codex_oauth_login_writes_tokens(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex-home"))
|
||||
request = codex_oauth.build_codex_oauth_request()
|
||||
monkeypatch.setattr(codex_oauth, "build_codex_oauth_request", lambda: request)
|
||||
monkeypatch.setattr(
|
||||
codex_oauth,
|
||||
"wait_for_codex_oauth_callback",
|
||||
lambda **_kwargs: codex_oauth.CodexOAuthResult(
|
||||
account_id="account-123",
|
||||
auth_path=tmp_path / "codex-home" / "auth.json",
|
||||
detail="OpenAI OAuth tokens stored for Codex.",
|
||||
),
|
||||
)
|
||||
|
||||
def _post(*_args: object, **_kwargs: object) -> httpx.Response:
|
||||
raise AssertionError("token exchange should happen in the callback server")
|
||||
|
||||
result = codex_oauth.run_codex_oauth_login(
|
||||
open_browser=lambda _url: True,
|
||||
post=_post,
|
||||
)
|
||||
|
||||
assert result.account_id == "account-123"
|
||||
assert result.auth_path == tmp_path / "codex-home" / "auth.json"
|
||||
assert "OpenAI OAuth tokens stored" in result.detail
|
||||
@@ -0,0 +1,692 @@
|
||||
"""Tests for the GitHub Copilot CLI adapter (non-interactive ``copilot -p``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli.copilot import CopilotAdapter
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
from tests.integrations.llm_cli.testing_helpers import write_fake_runnable_cli_bin
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _copilot_detect_treats_which_hits_as_runnable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Tests mock ``which`` to ``/usr/bin/copilot`` without creating a real file on disk."""
|
||||
import integrations.llm_cli.binary_resolver as br
|
||||
|
||||
real_runnable = br.is_runnable_binary
|
||||
shim = "/usr/bin/copilot"
|
||||
|
||||
def _fake(path: str) -> bool:
|
||||
if path == shim:
|
||||
return True
|
||||
p = Path(path)
|
||||
if p.is_file():
|
||||
return real_runnable(path)
|
||||
return real_runnable(path)
|
||||
|
||||
monkeypatch.setattr(br, "is_runnable_binary", _fake)
|
||||
|
||||
|
||||
def _version_proc() -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "copilot 1.4.2\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
def _gh_logged_in_proc() -> MagicMock:
|
||||
"""Shape matches current ``gh auth status`` (github.com block + checkmark line)."""
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = (
|
||||
"github.com\n"
|
||||
" ✓ Logged in to github.com account alice (keyring)\n"
|
||||
" - Active account: true\n"
|
||||
" - Git operations protocol: https\n"
|
||||
" - Token: gho_************************************\n"
|
||||
" - Token scopes: 'gist', 'read:org', 'repo', 'workflow'\n"
|
||||
)
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
def _gh_logged_out_proc() -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = "You are not logged into any GitHub hosts. Run `gh auth login` to authenticate.\n"
|
||||
return m
|
||||
|
||||
|
||||
def _clean_copilot_env(monkeypatch: pytest.MonkeyPatch, *, home: Path | None = None) -> None:
|
||||
for key in (
|
||||
"COPILOT_BIN",
|
||||
"COPILOT_MODEL",
|
||||
"COPILOT_HOME",
|
||||
"COPILOT_GH_HOST",
|
||||
"GH_HOST",
|
||||
"COPILOT_GITHUB_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
if home is not None:
|
||||
monkeypatch.setenv("COPILOT_HOME", str(home))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect() — token env
|
||||
#
|
||||
# When stacking ``@patch`` decorators, pytest binds the *topmost* decorator to the
|
||||
# *first* function parameter. Here ``copilot.shutil.which`` (``gh`` lookup) is
|
||||
# first; ``binary_resolver.shutil.which`` (``copilot`` binary resolution) is third.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.copilot.shutil.which")
|
||||
@patch("integrations.llm_cli.copilot.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_with_token_env_is_logged_in(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_copilot_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Token env is checked first; a non-empty var yields logged_in=True immediately."""
|
||||
mock_copilot_which.return_value = "/usr/bin/copilot"
|
||||
mock_which.return_value = None # gh not on PATH — should not matter
|
||||
mock_run.return_value = _version_proc()
|
||||
|
||||
_clean_copilot_env(monkeypatch, home=tmp_path / "empty")
|
||||
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_test")
|
||||
|
||||
probe = CopilotAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "COPILOT_GITHUB_TOKEN" in probe.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect() — gh auth status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.copilot.shutil.which")
|
||||
@patch("integrations.llm_cli.copilot.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_gh_logged_in_yields_true(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_copilot_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When gh auth status reports a session, logged_in=True."""
|
||||
mock_copilot_which.return_value = "/usr/bin/copilot"
|
||||
mock_which.return_value = "/usr/bin/gh"
|
||||
|
||||
def side_effect(args: list[str], **_kwargs: object) -> MagicMock:
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "auth" in args and "status" in args:
|
||||
return _gh_logged_in_proc()
|
||||
raise AssertionError(f"unexpected subprocess call: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
_clean_copilot_env(monkeypatch, home=tmp_path / "empty")
|
||||
|
||||
probe = CopilotAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "gh" in probe.detail.lower()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.copilot.shutil.which")
|
||||
@patch("integrations.llm_cli.copilot.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_gh_auth_status_uses_hostname_when_copilot_gh_host_set(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_copilot_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Non-github.com COPILOT_GH_HOST adds ``gh auth status --hostname`` per GitHub docs."""
|
||||
mock_copilot_which.return_value = "/usr/bin/copilot"
|
||||
mock_which.return_value = "/usr/bin/gh"
|
||||
|
||||
captured: list[list[str]] = []
|
||||
|
||||
def side_effect(args: list[str], **_kwargs: object) -> MagicMock:
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "auth" in args and "status" in args:
|
||||
captured.append(list(args))
|
||||
return _gh_logged_in_proc()
|
||||
raise AssertionError(f"unexpected subprocess call: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
_clean_copilot_env(monkeypatch, home=tmp_path / "empty")
|
||||
monkeypatch.setenv("COPILOT_GH_HOST", "acme.ghe.com")
|
||||
|
||||
probe = CopilotAdapter().detect()
|
||||
|
||||
assert probe.logged_in is True
|
||||
assert captured and captured[0][-2:] == ["--hostname", "acme.ghe.com"]
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.copilot.shutil.which")
|
||||
@patch("integrations.llm_cli.copilot.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_gh_logged_in_via_token_line_fine_grained_pat(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_copilot_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Copilot supports ``github_pat_`` tokens; treat matching ``- Token:`` line as logged in."""
|
||||
mock_copilot_which.return_value = "/usr/bin/copilot"
|
||||
mock_which.return_value = "/usr/bin/gh"
|
||||
|
||||
fg_proc = MagicMock()
|
||||
fg_proc.returncode = 0
|
||||
fg_proc.stdout = "github.enterprise.com\n - Token: github_pat_xxxxxxxxxxxx\n"
|
||||
fg_proc.stderr = ""
|
||||
|
||||
def side_effect(args: list[str], **_kwargs: object) -> MagicMock:
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "auth" in args and "status" in args:
|
||||
return fg_proc
|
||||
raise AssertionError(f"unexpected subprocess call: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
_clean_copilot_env(monkeypatch, home=tmp_path / "empty")
|
||||
|
||||
probe = CopilotAdapter().detect()
|
||||
assert probe.logged_in is True
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.copilot.shutil.which")
|
||||
@patch("integrations.llm_cli.copilot.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_gh_logged_out_yields_false(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_copilot_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When gh auth status clearly says not logged in, logged_in=False (hard negative)."""
|
||||
mock_copilot_which.return_value = "/usr/bin/copilot"
|
||||
mock_which.return_value = "/usr/bin/gh"
|
||||
|
||||
def side_effect(args: list[str], **_kwargs: object) -> MagicMock:
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "auth" in args and "status" in args:
|
||||
return _gh_logged_out_proc()
|
||||
raise AssertionError(f"unexpected subprocess call: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
_clean_copilot_env(monkeypatch, home=tmp_path / "empty")
|
||||
|
||||
probe = CopilotAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
assert "not logged in" in probe.detail.lower() or "gh auth login" in probe.detail.lower()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.copilot.shutil.which")
|
||||
@patch("integrations.llm_cli.copilot.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_gh_timeout_maps_to_unknown_auth(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_copilot_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A gh timeout maps to None for gh; overall auth stays unknown without token env."""
|
||||
mock_copilot_which.return_value = "/usr/bin/copilot"
|
||||
mock_which.return_value = "/usr/bin/gh"
|
||||
|
||||
def side_effect(args: list[str], **_kwargs: object) -> MagicMock:
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "auth" in args and "status" in args:
|
||||
raise subprocess.TimeoutExpired(cmd=args, timeout=5.0)
|
||||
raise AssertionError(f"unexpected subprocess call: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
_clean_copilot_env(monkeypatch, home=tmp_path / "empty")
|
||||
|
||||
probe = CopilotAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None
|
||||
assert "Could not verify" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.copilot.shutil.which")
|
||||
@patch("integrations.llm_cli.copilot.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_gh_not_installed_falls_through(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_copilot_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When gh is not on PATH, auth resolves to unknown if no token env."""
|
||||
mock_copilot_which.return_value = "/usr/bin/copilot"
|
||||
mock_which.return_value = None # gh not installed
|
||||
|
||||
mock_run.return_value = _version_proc()
|
||||
_clean_copilot_env(monkeypatch, home=tmp_path / "empty")
|
||||
|
||||
probe = CopilotAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.copilot.shutil.which")
|
||||
@patch("integrations.llm_cli.copilot.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_config_json_plaintext_not_used_for_auth(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_copilot_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Copilot may store plaintext tokens in config.json — we do not treat that as probe signal."""
|
||||
mock_copilot_which.return_value = "/usr/bin/copilot"
|
||||
mock_which.return_value = None
|
||||
mock_run.return_value = _version_proc()
|
||||
|
||||
home = tmp_path / "copilot_home"
|
||||
home.mkdir()
|
||||
(home / "config.json").write_text('{"github_token": "ghu_realtoken"}')
|
||||
|
||||
_clean_copilot_env(monkeypatch, home=home)
|
||||
probe = CopilotAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None
|
||||
assert "Could not verify" in probe.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect() — binary / version failures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.copilot.shutil.which")
|
||||
@patch("integrations.llm_cli.copilot.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_with_unrelated_files_is_not_logged_in(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_copilot_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Junk files in COPILOT_HOME must not yield logged_in=True."""
|
||||
mock_copilot_which.return_value = "/usr/bin/copilot"
|
||||
mock_which.return_value = None
|
||||
mock_run.return_value = _version_proc()
|
||||
|
||||
home = tmp_path / "copilot_home"
|
||||
home.mkdir()
|
||||
(home / "telemetry.log").write_text("noise")
|
||||
|
||||
_clean_copilot_env(monkeypatch, home=home)
|
||||
probe = CopilotAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value=None)
|
||||
def test_detect_when_binary_not_found(
|
||||
_mock_which: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_clean_copilot_env(monkeypatch)
|
||||
# resolve_cli_binary falls back to known install dirs (e.g. /opt/homebrew/bin)
|
||||
# after the PATH lookup. Mocking shutil.which alone still finds a real copilot
|
||||
# install on a dev machine, so force nothing to resolve as runnable — this also
|
||||
# overrides the autouse fixture that treats real fallback paths as runnable.
|
||||
import integrations.llm_cli.binary_resolver as br
|
||||
|
||||
monkeypatch.setattr(br, "is_runnable_binary", lambda _path: False)
|
||||
probe = CopilotAdapter().detect()
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert "not found" in probe.detail.lower()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.copilot.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_when_version_fails(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/copilot"
|
||||
failed = MagicMock()
|
||||
failed.returncode = 1
|
||||
failed.stdout = ""
|
||||
failed.stderr = "boom"
|
||||
mock_run.return_value = failed
|
||||
_clean_copilot_env(monkeypatch)
|
||||
probe = CopilotAdapter().detect()
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert "boom" in probe.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect() — no creds at all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.copilot.shutil.which")
|
||||
@patch("integrations.llm_cli.copilot.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_no_creds_no_token_is_unclear(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_copilot_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Without any credential signal, auth state is unclear (None)."""
|
||||
mock_copilot_which.return_value = "/usr/bin/copilot"
|
||||
mock_which.return_value = None # gh not installed
|
||||
mock_run.return_value = _version_proc()
|
||||
|
||||
_clean_copilot_env(monkeypatch, home=tmp_path / "empty")
|
||||
|
||||
probe = CopilotAdapter().detect()
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None
|
||||
assert "Could not verify" in probe.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/copilot")
|
||||
def test_build_argv_uses_non_interactive_flags(
|
||||
mock_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_clean_copilot_env(monkeypatch)
|
||||
inv = CopilotAdapter().build(prompt="hello world", model=None, workspace="")
|
||||
|
||||
assert inv.argv[0] == "/usr/bin/copilot"
|
||||
assert "-p" in inv.argv
|
||||
idx = inv.argv.index("-p")
|
||||
assert inv.argv[idx + 1] == "hello world"
|
||||
assert "--no-color" in inv.argv
|
||||
assert "--no-ask-user" in inv.argv
|
||||
assert "--silent" in inv.argv
|
||||
assert inv.stdin is None
|
||||
assert inv.cwd
|
||||
assert inv.env is None
|
||||
mock_which.assert_called()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/copilot")
|
||||
def test_build_uses_workspace_when_provided(
|
||||
_mock_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_clean_copilot_env(monkeypatch)
|
||||
ws = tmp_path / "repo"
|
||||
ws.mkdir()
|
||||
inv = CopilotAdapter().build(prompt="p", model=None, workspace=str(ws))
|
||||
assert inv.cwd == str(ws)
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/copilot")
|
||||
def test_build_adds_model_flag_when_provided(
|
||||
_mock_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_clean_copilot_env(monkeypatch)
|
||||
inv = CopilotAdapter().build(prompt="p", model="claude-sonnet-4.6", workspace="")
|
||||
assert "--model" in inv.argv
|
||||
idx = inv.argv.index("--model")
|
||||
assert inv.argv[idx + 1] == "claude-sonnet-4.6"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/copilot")
|
||||
def test_build_forwards_token_env_keys(
|
||||
_mock_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_clean_copilot_env(monkeypatch)
|
||||
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_a")
|
||||
monkeypatch.setenv("GH_TOKEN", "ghp_b")
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_c")
|
||||
inv = CopilotAdapter().build(prompt="p", model=None, workspace="")
|
||||
assert inv.env is not None
|
||||
assert inv.env["COPILOT_GITHUB_TOKEN"] == "ghp_a"
|
||||
assert inv.env["GH_TOKEN"] == "ghp_b"
|
||||
assert inv.env["GITHUB_TOKEN"] == "ghp_c"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/copilot")
|
||||
def test_build_forwards_copilot_config_envs(
|
||||
_mock_which: MagicMock,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Copilot HOME/MODEL/host envs flow through the adapter's invocation env."""
|
||||
_clean_copilot_env(monkeypatch)
|
||||
monkeypatch.setenv("COPILOT_HOME", "/x/copilot")
|
||||
monkeypatch.setenv("COPILOT_MODEL", "gpt-5.2")
|
||||
monkeypatch.setenv("COPILOT_GH_HOST", "corp.github.example")
|
||||
monkeypatch.setenv("GH_HOST", "https://gh.enterprise.test")
|
||||
inv = CopilotAdapter().build(prompt="p", model=None, workspace="")
|
||||
assert inv.env is not None
|
||||
assert inv.env["COPILOT_HOME"] == "/x/copilot"
|
||||
assert inv.env["COPILOT_MODEL"] == "gpt-5.2"
|
||||
assert inv.env["COPILOT_GH_HOST"] == "corp.github.example"
|
||||
assert inv.env["GH_HOST"] == "https://gh.enterprise.test"
|
||||
|
||||
|
||||
def test_build_raises_when_binary_unresolved(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_clean_copilot_env(monkeypatch)
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.shutil.which", return_value=None) as mock_which,
|
||||
patch(
|
||||
"integrations.llm_cli.copilot._fallback_copilot_paths",
|
||||
return_value=[],
|
||||
),
|
||||
pytest.raises(RuntimeError, match="Copilot CLI not found"),
|
||||
):
|
||||
CopilotAdapter().build(prompt="p", model=None, workspace="")
|
||||
mock_which.assert_called()
|
||||
|
||||
|
||||
def test_explicit_copilot_bin_used_when_runnable(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
_clean_copilot_env(monkeypatch)
|
||||
bin_path = write_fake_runnable_cli_bin(tmp_path, "copilot")
|
||||
monkeypatch.setenv("COPILOT_BIN", str(bin_path))
|
||||
resolved = CopilotAdapter()._resolve_binary()
|
||||
assert resolved == str(bin_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse() / explain_failure()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_strips_whitespace() -> None:
|
||||
adapter = CopilotAdapter()
|
||||
assert adapter.parse(stdout=" hello \n", stderr="", returncode=0) == "hello"
|
||||
|
||||
|
||||
def test_explain_failure_includes_auth_hint_on_unauthorized() -> None:
|
||||
adapter = CopilotAdapter()
|
||||
msg = adapter.explain_failure(
|
||||
stdout="",
|
||||
stderr="error: unauthorized — please /login",
|
||||
returncode=1,
|
||||
)
|
||||
assert "code 1" in msg
|
||||
assert "COPILOT_GITHUB_TOKEN" in msg or "/login" in msg
|
||||
assert "unauthorized" in msg
|
||||
|
||||
|
||||
def test_explain_failure_does_not_mask_unrelated_error_with_login_in_text() -> None:
|
||||
"""Regression: the substring 'login' must not trigger the auth hint."""
|
||||
adapter = CopilotAdapter()
|
||||
err = "Your current login: johndoe@github.com — Error: model 'gpt-5.2' not found in your plan"
|
||||
msg = adapter.explain_failure(stdout="", stderr=err, returncode=1)
|
||||
assert "model 'gpt-5.2' not found" in msg
|
||||
assert "COPILOT_GITHUB_TOKEN" not in msg
|
||||
assert "copilot login" not in msg
|
||||
|
||||
|
||||
def test_explain_failure_truncates_long_output() -> None:
|
||||
adapter = CopilotAdapter()
|
||||
err = "x" * 5000
|
||||
msg = adapter.explain_failure(stdout="", stderr=err, returncode=2)
|
||||
assert "code 2" in msg
|
||||
assert "x" * 2000 in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runner integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_invokes_copilot_and_forwards_token_env(
|
||||
mock_run: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Runner merges adapter env (token vars) into the subprocess env."""
|
||||
_clean_copilot_env(monkeypatch)
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_runner")
|
||||
monkeypatch.setenv("COPILOT_HOME", "/custom/copilot")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "should-not-leak")
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "copilot"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="/usr/bin/copilot",
|
||||
logged_in=True,
|
||||
detail="ok",
|
||||
)
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=["/usr/bin/copilot", "-p", "hi", "--silent"],
|
||||
stdin=None,
|
||||
cwd="/tmp",
|
||||
env={"COPILOT_GITHUB_TOKEN": "ghp_runner", "COPILOT_HOME": "/custom/copilot"},
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.parse.return_value = "answer"
|
||||
mock_adapter.explain_failure.return_value = "fail"
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model=None, max_tokens=256)
|
||||
resp = client.invoke("hello")
|
||||
|
||||
assert resp.content == "answer"
|
||||
env = mock_run.call_args.kwargs["env"]
|
||||
assert env["COPILOT_HOME"] == "/custom/copilot"
|
||||
assert env["COPILOT_GITHUB_TOKEN"] == "ghp_runner"
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_resolves_copilot_provider() -> None:
|
||||
from integrations.llm_cli.registry import (
|
||||
CLI_PROVIDER_REGISTRY,
|
||||
get_cli_provider_registration,
|
||||
)
|
||||
|
||||
reg = get_cli_provider_registration("copilot")
|
||||
assert reg is not None
|
||||
assert reg.model_env_key == "COPILOT_MODEL"
|
||||
assert "copilot" in CLI_PROVIDER_REGISTRY
|
||||
adapter = reg.adapter_factory()
|
||||
assert isinstance(adapter, CopilotAdapter)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# subprocess env security
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_subprocess_env_does_not_leak_copilot_token_via_prefix_allowlist(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``COPILOT_*`` must NOT be a prefix entry — COPILOT_GITHUB_TOKEN is a PAT."""
|
||||
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
|
||||
|
||||
monkeypatch.setenv("COPILOT_HOME", "/x/copilot")
|
||||
monkeypatch.setenv("COPILOT_MODEL", "gpt-5.2")
|
||||
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_super_secret")
|
||||
monkeypatch.setenv("COPILOT_BIN", "/usr/local/bin/copilot")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "leak")
|
||||
|
||||
env = build_cli_subprocess_env(None)
|
||||
|
||||
assert "COPILOT_GITHUB_TOKEN" not in env
|
||||
assert "COPILOT_HOME" not in env
|
||||
assert "COPILOT_MODEL" not in env
|
||||
assert "COPILOT_BIN" not in env
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
assert "PATH" in env or os.environ.get("PATH") is None
|
||||
|
||||
|
||||
def test_parse_returns_stripped_stdout() -> None:
|
||||
adapter = CopilotAdapter()
|
||||
assert adapter.parse(stdout=" hello world \n", stderr="", returncode=0) == "hello world"
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout() -> None:
|
||||
import pytest
|
||||
|
||||
adapter = CopilotAdapter()
|
||||
with pytest.raises(RuntimeError, match="empty output"):
|
||||
adapter.parse(stdout=" ", stderr="", returncode=0)
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout_surfaces_stderr() -> None:
|
||||
import pytest
|
||||
|
||||
adapter = CopilotAdapter()
|
||||
with pytest.raises(RuntimeError, match="some stderr detail"):
|
||||
adapter.parse(stdout="", stderr="some stderr detail", returncode=0)
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Tests for Cursor Agent CLI adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from subprocess import TimeoutExpired
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli.cursor import CursorAdapter
|
||||
|
||||
|
||||
def _version_proc() -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stdout = "2026.04.29-c83a488\n"
|
||||
proc.stderr = ""
|
||||
return proc
|
||||
|
||||
|
||||
def _status_proc(text: str, returncode: int = 0) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = returncode
|
||||
proc.stdout = text
|
||||
proc.stderr = ""
|
||||
return proc
|
||||
|
||||
|
||||
def _fallback_proc() -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stdout = ""
|
||||
proc.stderr = ""
|
||||
return proc
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.cursor.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_logged_in_status_with_cursor_key_stays_not_logged_in(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "agent"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "status" in args:
|
||||
return _status_proc("Not logged in\n", returncode=1)
|
||||
return _fallback_proc()
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"CURSOR_BIN": "agent", "CURSOR_API_KEY": "cursor-key", "USERPROFILE": r"C:\Users\test"},
|
||||
clear=True,
|
||||
):
|
||||
probe = CursorAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
assert "agent login" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.cursor.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_unclear_status_with_cursor_key_returns_logged_in(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "agent"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "status" in args:
|
||||
return _status_proc("", returncode=0)
|
||||
return _fallback_proc()
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"CURSOR_BIN": "agent", "CURSOR_API_KEY": "cursor-key", "USERPROFILE": r"C:\Users\test"},
|
||||
clear=True,
|
||||
):
|
||||
probe = CursorAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.cursor.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_logged_in_status_ignores_cursor_key(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "agent"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "status" in args:
|
||||
return _status_proc("✓ Logged in as user@example.com\n")
|
||||
return _fallback_proc()
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"CURSOR_BIN": "agent", "CURSOR_API_KEY": "cursor-key", "USERPROFILE": r"C:\Users\test"},
|
||||
clear=True,
|
||||
):
|
||||
probe = CursorAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "Logged in as" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.cursor.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_logged_in_via_status(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "agent"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "status" in args:
|
||||
return _status_proc("✓ Logged in as user@example.com\n")
|
||||
return _fallback_proc()
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"CURSOR_BIN": "agent", "USERPROFILE": r"C:\Users\test"},
|
||||
clear=True,
|
||||
):
|
||||
probe = CursorAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "Logged in as" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.cursor.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_logged_in(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "agent"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "status" in args:
|
||||
return _status_proc("Not logged in\n", returncode=1)
|
||||
return _fallback_proc()
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"CURSOR_BIN": "agent", "USERPROFILE": r"C:\Users\test"},
|
||||
clear=True,
|
||||
):
|
||||
probe = CursorAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.cursor.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_status_timeout_without_api_key(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "agent"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "status" in args:
|
||||
raise TimeoutExpired(cmd=args, timeout=kwargs.get("timeout", 0))
|
||||
return _fallback_proc()
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"CURSOR_BIN": "agent", "CURSOR_API_KEY": "", "USERPROFILE": r"C:\Users\test"},
|
||||
clear=True,
|
||||
):
|
||||
probe = CursorAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None
|
||||
assert "status" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.cursor.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_status_timeout_with_api_key(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "agent"
|
||||
|
||||
def side_effect(args, **kwargs):
|
||||
if "--version" in args:
|
||||
return _version_proc()
|
||||
if "status" in args:
|
||||
raise TimeoutExpired(cmd=args, timeout=kwargs.get("timeout", 0))
|
||||
return _fallback_proc()
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"CURSOR_BIN": "agent", "CURSOR_API_KEY": "ck", "USERPROFILE": r"C:\Users\test"},
|
||||
clear=True,
|
||||
):
|
||||
probe = CursorAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "CURSOR_API_KEY" in probe.detail
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"CURSOR_API_KEY": ""}, clear=False)
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="agent")
|
||||
def test_build_adds_trust_workspace_and_model(mock_which: MagicMock) -> None:
|
||||
inv = CursorAdapter().build(prompt="hello", model="auto", workspace="/tmp/project")
|
||||
|
||||
assert inv.stdin == "hello"
|
||||
assert inv.cwd == "/tmp/project"
|
||||
assert "--print" in inv.argv
|
||||
assert "--trust" in inv.argv
|
||||
assert "--output-format" in inv.argv
|
||||
assert "text" in inv.argv
|
||||
assert "--workspace" in inv.argv
|
||||
assert "/tmp/project" in inv.argv
|
||||
assert "--model" in inv.argv
|
||||
assert "auto" in inv.argv
|
||||
assert inv.env is None
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"CURSOR_API_KEY": "ck-headless"}, clear=False)
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="agent")
|
||||
def test_build_forwards_cursor_api_key(mock_which: MagicMock) -> None:
|
||||
"""Headless auth must reach the subprocess (same pattern as Codex/OpenCode env overrides)."""
|
||||
inv = CursorAdapter().build(prompt="hello", model=None, workspace="/tmp/project")
|
||||
|
||||
assert inv.env is not None
|
||||
assert inv.env.get("CURSOR_API_KEY") == "ck-headless"
|
||||
|
||||
|
||||
def test_cursor_cli_registry_entry() -> None:
|
||||
from integrations.llm_cli.registry import get_cli_provider_registration
|
||||
|
||||
reg = get_cli_provider_registration("cursor")
|
||||
assert reg is not None
|
||||
assert reg.model_env_key == "CURSOR_MODEL"
|
||||
assert reg.adapter_factory().name == "cursor"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_invoke_forwards_cursor_env(mock_run: MagicMock) -> None:
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
mock_adapter = MagicMock(spec=CursorAdapter)
|
||||
mock_adapter.name = "cursor"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="agent",
|
||||
logged_in=True,
|
||||
detail="ok",
|
||||
)
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=["agent", "--print", "--trust"],
|
||||
stdin="hello",
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.parse.return_value = "answer"
|
||||
mock_adapter.explain_failure.return_value = "fail"
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
||||
|
||||
with (
|
||||
patch("platform.guardrails.engine.get_guardrail_engine") as guardrails,
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"CURSOR_API_KEY": "cursor-key",
|
||||
"CURSOR_MODEL": "auto",
|
||||
"OPENAI_API_KEY": "openai-key",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
guardrails.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model="auto", max_tokens=256)
|
||||
resp = client.invoke("hello")
|
||||
|
||||
assert resp.content == "answer"
|
||||
env = mock_run.call_args.kwargs["env"]
|
||||
assert env["CURSOR_API_KEY"] == "cursor-key"
|
||||
assert env["CURSOR_MODEL"] == "auto"
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
|
||||
|
||||
def test_parse_and_explain_failure() -> None:
|
||||
adapter = CursorAdapter()
|
||||
|
||||
assert adapter.parse(stdout=" Hello! \n", stderr="", returncode=0) == "Hello!"
|
||||
|
||||
with pytest.raises(RuntimeError, match="empty output"):
|
||||
adapter.parse(stdout=" ", stderr="", returncode=0)
|
||||
|
||||
auth_failure = adapter.explain_failure(
|
||||
stdout="",
|
||||
stderr="Error: Authentication required",
|
||||
returncode=1,
|
||||
)
|
||||
assert "Not logged in" in auth_failure
|
||||
|
||||
trust_failure = adapter.explain_failure(
|
||||
stdout="Workspace Trust Required",
|
||||
stderr="",
|
||||
returncode=1,
|
||||
)
|
||||
assert "Workspace trust required" in trust_failure
|
||||
|
||||
model_failure = adapter.explain_failure(
|
||||
stdout="Named models unavailable",
|
||||
stderr="",
|
||||
returncode=1,
|
||||
)
|
||||
assert "CURSOR_MODEL" in model_failure
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for ``env_overrides`` helpers shared by CLI LLM adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from integrations.llm_cli import env_overrides
|
||||
|
||||
|
||||
def test_nonempty_env_values_skips_empty_and_missing() -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"FOO_API_KEY": " sk-secret ", "BAR_API_KEY": "", "OTHER": "x"},
|
||||
clear=False,
|
||||
):
|
||||
got = env_overrides.nonempty_env_values(("FOO_API_KEY", "BAR_API_KEY", "MISSING"))
|
||||
assert got == {"FOO_API_KEY": "sk-secret"}
|
||||
|
||||
|
||||
def test_http_llm_provider_keys_include_openai_platform_subset() -> None:
|
||||
"""Codex subset must remain embedded in multi-provider OpenCode forwarding list."""
|
||||
for key in env_overrides.OPENAI_PLATFORM_ENV_KEYS:
|
||||
assert key in env_overrides.HTTP_LLM_PROVIDER_ENV_KEYS
|
||||
|
||||
|
||||
def test_openai_platform_keys_stable_tuple() -> None:
|
||||
assert env_overrides.OPENAI_PLATFORM_ENV_KEYS[:4] == (
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_ORG_ID",
|
||||
"OPENAI_PROJECT_ID",
|
||||
"OPENAI_BASE_URL",
|
||||
)
|
||||
|
||||
|
||||
def test_http_llm_provider_keys_include_deepseek_api_key() -> None:
|
||||
assert "DEEPSEEK_API_KEY" in env_overrides.HTTP_LLM_PROVIDER_ENV_KEYS
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from integrations.llm_cli.failure_explain import (
|
||||
classify_cli_failure_category_hint,
|
||||
classify_cli_failure_hint,
|
||||
explain_cli_failure,
|
||||
is_context_length_overflow,
|
||||
)
|
||||
|
||||
|
||||
def test_is_context_length_overflow_distinguishes_timeouts() -> None:
|
||||
assert is_context_length_overflow("prompt is too long: 200001 tokens > 200000 maximum")
|
||||
assert not is_context_length_overflow("The request took too long to complete")
|
||||
|
||||
|
||||
def test_classify_context_hint_ignores_timeout_too_long() -> None:
|
||||
hint = classify_cli_failure_category_hint("", "The request took too long to complete", 1)
|
||||
assert hint is None
|
||||
|
||||
|
||||
def test_classify_quota_hint() -> None:
|
||||
hint = classify_cli_failure_hint("", "429 Too Many Requests: quota exceeded", 1)
|
||||
assert hint is not None
|
||||
assert "quota" in hint
|
||||
|
||||
|
||||
def test_classify_silent_exit_hint() -> None:
|
||||
hint = classify_cli_failure_hint("", "OpenAI Codex v0.134.0", 1)
|
||||
assert hint is not None
|
||||
assert "quota exhausted" in hint
|
||||
|
||||
|
||||
def test_explain_cli_failure_with_extra_messages() -> None:
|
||||
msg = explain_cli_failure(
|
||||
exit_label="kimi",
|
||||
stdout="",
|
||||
stderr="LLM not set",
|
||||
returncode=1,
|
||||
extra_messages=("Not logged in or model unavailable. Run: kimi login",),
|
||||
)
|
||||
assert "kimi exited with code 1" in msg
|
||||
assert "kimi login" in msg
|
||||
assert "quota" not in msg.lower()
|
||||
|
||||
|
||||
def test_explain_cli_failure_generic_quota() -> None:
|
||||
msg = explain_cli_failure(
|
||||
exit_label="codex exec",
|
||||
stdout="",
|
||||
stderr="rate limit exceeded",
|
||||
returncode=1,
|
||||
)
|
||||
assert "codex exec exited with code 1" in msg
|
||||
assert "quota or rate limit" in msg
|
||||
|
||||
|
||||
def test_explain_cli_failure_prefers_stdout_over_silent_hint() -> None:
|
||||
msg = explain_cli_failure(
|
||||
exit_label="claude -p",
|
||||
stdout="some output",
|
||||
stderr="",
|
||||
returncode=2,
|
||||
)
|
||||
assert "some output" in msg
|
||||
assert "quota exhausted" not in msg
|
||||
|
||||
|
||||
def test_explain_cli_failure_empty_extra_messages_falls_through() -> None:
|
||||
msg = explain_cli_failure(
|
||||
exit_label="codex exec",
|
||||
stdout="",
|
||||
stderr="rate limit exceeded",
|
||||
returncode=1,
|
||||
extra_messages=("",),
|
||||
)
|
||||
assert "quota or rate limit" in msg
|
||||
|
||||
|
||||
def test_explain_cli_failure_always_include_output_snippet_without_extra() -> None:
|
||||
msg = explain_cli_failure(
|
||||
exit_label="copilot -p",
|
||||
stdout="model error details",
|
||||
stderr="",
|
||||
returncode=1,
|
||||
always_include_output_snippet=True,
|
||||
)
|
||||
assert "copilot -p exited with code 1" in msg
|
||||
assert "model error details" in msg
|
||||
assert "quota or rate limit" not in msg
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Tests for the Gemini CLI adapter (detect / build / parse / env forwarding)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli.binary_resolver import npm_prefix_bin_dirs
|
||||
from integrations.llm_cli.gemini_cli import (
|
||||
_PROBE_TIMEOUT_SEC,
|
||||
GeminiCLIAdapter,
|
||||
_fallback_gemini_cli_paths,
|
||||
_resolve_exec_timeout_seconds,
|
||||
)
|
||||
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
|
||||
|
||||
|
||||
def _posix_path_set(paths: list[str]) -> set[str]:
|
||||
return {Path(p).as_posix() for p in paths}
|
||||
|
||||
|
||||
def _version_proc() -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "gemini-cli 0.1.2\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
def _auth_ok_proc() -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = '{"response":"ok"}\n'
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_logged_in(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/gemini"
|
||||
mock_run.side_effect = [_version_proc(), _auth_ok_proc()]
|
||||
|
||||
probe = GeminiCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert probe.bin_path == "/usr/bin/gemini"
|
||||
assert probe.version == "0.1.2"
|
||||
# The probe should always carry the 2026-06-18 sunset notice when installed,
|
||||
# pointing users to antigravity-cli (additive — paid Code Assist exempt).
|
||||
assert "2026-06-18" in probe.detail
|
||||
assert "antigravity-cli" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_authenticated(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/gemini"
|
||||
auth = MagicMock()
|
||||
auth.returncode = 1
|
||||
auth.stdout = ""
|
||||
auth.stderr = "Authentication required"
|
||||
mock_run.side_effect = [_version_proc(), auth]
|
||||
|
||||
with patch.dict(os.environ, {"GEMINI_API_KEY": ""}, clear=False):
|
||||
probe = GeminiCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
# The sunset notice is suppressed on confirmed auth failures so users
|
||||
# debugging "Not authenticated" don't read it next to the actual error.
|
||||
assert "2026-06-18" not in probe.detail
|
||||
assert "antigravity-cli" not in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_authenticated_when_json_error_requests_auth(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/gemini"
|
||||
auth = MagicMock()
|
||||
auth.returncode = 41
|
||||
auth.stdout = json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"type": "Error",
|
||||
"message": "Please set an Auth method in your settings.json or set GEMINI_API_KEY",
|
||||
"code": 41,
|
||||
}
|
||||
}
|
||||
)
|
||||
auth.stderr = ""
|
||||
mock_run.side_effect = [_version_proc(), auth]
|
||||
|
||||
with patch.dict(os.environ, {"GEMINI_API_KEY": ""}, clear=False):
|
||||
probe = GeminiCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_not_authenticated_uses_api_key_fallback(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/gemini"
|
||||
auth = MagicMock()
|
||||
auth.returncode = 1
|
||||
auth.stdout = ""
|
||||
auth.stderr = "Authentication required"
|
||||
mock_run.side_effect = [_version_proc(), auth]
|
||||
|
||||
with patch.dict(os.environ, {"GEMINI_API_KEY": "gk-test"}, clear=False):
|
||||
probe = GeminiCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "GEMINI_API_KEY fallback" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_unclear_auth(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/gemini"
|
||||
auth = MagicMock()
|
||||
auth.returncode = 2
|
||||
auth.stdout = ""
|
||||
auth.stderr = "network unreachable"
|
||||
mock_run.side_effect = [_version_proc(), auth]
|
||||
|
||||
with patch.dict(os.environ, {"GEMINI_API_KEY": ""}, clear=False):
|
||||
probe = GeminiCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None
|
||||
assert "Network error" in probe.detail
|
||||
# Ambiguous auth (network blip, timeout) still surfaces the sunset note —
|
||||
# the user's CLI is presumed operational; they need the migration warning.
|
||||
assert "2026-06-18" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_version_command_fails(_mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
_mock_which.return_value = "/usr/bin/gemini"
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = "some error\n"
|
||||
mock_run.return_value = m
|
||||
|
||||
probe = GeminiCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_version_timeout_expired(_mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
import subprocess
|
||||
|
||||
_mock_which.return_value = "/usr/bin/gemini"
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(
|
||||
cmd=["/usr/bin/gemini", "--version"], timeout=_PROBE_TIMEOUT_SEC
|
||||
)
|
||||
|
||||
probe = GeminiCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert probe.bin_path is None
|
||||
assert "could not run" in probe.detail.lower()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.gemini_cli._fallback_gemini_cli_paths", return_value=[])
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value=None)
|
||||
def test_detect_not_installed(_mock_which: MagicMock, _mock_fallback: MagicMock) -> None:
|
||||
probe = GeminiCLIAdapter().detect()
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert probe.bin_path is None
|
||||
assert "not found" in probe.detail.lower()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/gemini")
|
||||
def test_build_basic_invocation(_mock_which: MagicMock) -> None:
|
||||
inv = GeminiCLIAdapter().build(prompt="explain this alert", model=None, workspace="")
|
||||
assert inv.argv[0] == "/usr/bin/gemini"
|
||||
assert "-p" in inv.argv
|
||||
assert "--output-format" in inv.argv
|
||||
assert "json" in inv.argv
|
||||
assert inv.stdin is None
|
||||
assert inv.timeout_sec == 300.0
|
||||
|
||||
|
||||
def test_resolve_exec_timeout_default() -> None:
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("GEMINI_CLI_TIMEOUT_SECONDS", None)
|
||||
assert _resolve_exec_timeout_seconds() == 300.0
|
||||
|
||||
|
||||
def test_resolve_exec_timeout_clamps_low_and_high() -> None:
|
||||
with patch.dict(os.environ, {"GEMINI_CLI_TIMEOUT_SECONDS": "5"}, clear=False):
|
||||
assert _resolve_exec_timeout_seconds() == 30.0
|
||||
with patch.dict(os.environ, {"GEMINI_CLI_TIMEOUT_SECONDS": "9999"}, clear=False):
|
||||
assert _resolve_exec_timeout_seconds() == 600.0
|
||||
|
||||
|
||||
def test_resolve_exec_timeout_uses_valid_value() -> None:
|
||||
with patch.dict(os.environ, {"GEMINI_CLI_TIMEOUT_SECONDS": "240"}, clear=False):
|
||||
assert _resolve_exec_timeout_seconds() == 240.0
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/gemini")
|
||||
def test_build_uses_timeout_override(_mock_which: MagicMock) -> None:
|
||||
with patch.dict(os.environ, {"GEMINI_CLI_TIMEOUT_SECONDS": "180"}, clear=False):
|
||||
inv = GeminiCLIAdapter().build(prompt="p", model=None, workspace="")
|
||||
assert inv.timeout_sec == 180.0
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/gemini")
|
||||
def test_build_adds_model_flag(_mock_which: MagicMock) -> None:
|
||||
inv = GeminiCLIAdapter().build(prompt="p", model="gemini-2.5-pro", workspace="")
|
||||
assert "--model" in inv.argv
|
||||
idx = inv.argv.index("--model")
|
||||
assert inv.argv[idx + 1] == "gemini-2.5-pro"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/gemini")
|
||||
def test_build_forwards_gemini_google_env(_mock_which: MagicMock) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GEMINI_API_KEY": "gk-test",
|
||||
"GOOGLE_CLOUD_PROJECT": "proj-x",
|
||||
"GOOGLE_CLOUD_LOCATION": "us-central1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
inv = GeminiCLIAdapter().build(prompt="p", model=None, workspace="")
|
||||
|
||||
assert inv.env is not None
|
||||
assert inv.env["GEMINI_API_KEY"] == "gk-test"
|
||||
assert inv.env["GOOGLE_CLOUD_PROJECT"] == "proj-x"
|
||||
|
||||
|
||||
def test_parse_json_response() -> None:
|
||||
adapter = GeminiCLIAdapter()
|
||||
result = adapter.parse(
|
||||
stdout=json.dumps({"response": " hello world "}),
|
||||
stderr="",
|
||||
returncode=0,
|
||||
)
|
||||
assert result == "hello world"
|
||||
|
||||
|
||||
def test_parse_non_json_falls_back_to_stdout() -> None:
|
||||
adapter = GeminiCLIAdapter()
|
||||
result = adapter.parse(stdout="plain text answer", stderr="", returncode=0)
|
||||
assert result == "plain text answer"
|
||||
|
||||
|
||||
def test_parse_raises_runtime_error_on_error_payload() -> None:
|
||||
adapter = GeminiCLIAdapter()
|
||||
with pytest.raises(RuntimeError, match="token expired"):
|
||||
adapter.parse(
|
||||
stdout=json.dumps({"error": {"message": "token expired"}}),
|
||||
stderr="",
|
||||
returncode=0,
|
||||
)
|
||||
|
||||
|
||||
def test_explain_failure_includes_returncode_and_stderr() -> None:
|
||||
adapter = GeminiCLIAdapter()
|
||||
msg = adapter.explain_failure(stdout="", stderr="auth error", returncode=1)
|
||||
assert "1" in msg
|
||||
assert "auth error" in msg
|
||||
|
||||
|
||||
def test_fallback_paths_macos() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "darwin"),
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
):
|
||||
paths = _fallback_gemini_cli_paths()
|
||||
|
||||
normalized = _posix_path_set(paths)
|
||||
assert "/opt/homebrew/bin/gemini" in normalized
|
||||
assert "/usr/local/bin/gemini" in normalized
|
||||
|
||||
|
||||
def test_fallback_paths_windows() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "win32"),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"APPDATA": r"C:\Users\me\AppData\Roaming",
|
||||
"LOCALAPPDATA": r"C:\Users\me\AppData\Local",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
paths = _fallback_gemini_cli_paths()
|
||||
|
||||
normalized = {p.replace("\\", "/") for p in paths}
|
||||
assert "C:/Users/me/AppData/Roaming/npm/gemini.cmd" in normalized
|
||||
|
||||
|
||||
def test_gemini_cli_registry_entry() -> None:
|
||||
from integrations.llm_cli.registry import get_cli_provider_registration
|
||||
|
||||
reg = get_cli_provider_registration("gemini-cli")
|
||||
assert reg is not None
|
||||
assert reg.model_env_key == "GEMINI_CLI_MODEL"
|
||||
assert reg.adapter_factory().name == "gemini-cli"
|
||||
|
||||
|
||||
def test_gemini_google_prefix_forwarded_to_subprocess() -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GEMINI_CLI_MODEL": "gemini-2.5-pro",
|
||||
"GEMINI_CLI_BIN": "/usr/bin/gemini",
|
||||
"GOOGLE_CLOUD_PROJECT": "proj-x",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
env = build_cli_subprocess_env(None)
|
||||
|
||||
assert env["GEMINI_CLI_MODEL"] == "gemini-2.5-pro"
|
||||
assert env["GEMINI_CLI_BIN"] == "/usr/bin/gemini"
|
||||
assert env["GOOGLE_CLOUD_PROJECT"] == "proj-x"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.gemini_cli.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_auth_probe_uses_filtered_subprocess_env(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/gemini"
|
||||
mock_run.side_effect = [_version_proc(), _auth_ok_proc()]
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"PATH": "/usr/bin",
|
||||
"RANDOM_SECRET": "must-not-leak",
|
||||
"GOOGLE_CLOUD_PROJECT": "proj-x",
|
||||
"GEMINI_API_KEY": "gk-test",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
GeminiCLIAdapter().detect()
|
||||
|
||||
env = mock_run.call_args_list[1].kwargs["env"]
|
||||
assert env["PATH"] == "/usr/bin"
|
||||
assert env["GOOGLE_CLOUD_PROJECT"] == "proj-x"
|
||||
assert env["GEMINI_API_KEY"] == "gk-test"
|
||||
assert "RANDOM_SECRET" not in env
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout() -> None:
|
||||
adapter = GeminiCLIAdapter()
|
||||
with pytest.raises(RuntimeError, match="empty output"):
|
||||
adapter.parse(stdout=" ", stderr="", returncode=0)
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout_surfaces_stderr() -> None:
|
||||
adapter = GeminiCLIAdapter()
|
||||
with pytest.raises(RuntimeError, match="some stderr detail"):
|
||||
adapter.parse(stdout="", stderr="some stderr detail", returncode=0)
|
||||
@@ -0,0 +1,547 @@
|
||||
"""Tests for the xAI Grok Build CLI adapter (detect / build / failure / env forwarding)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli.binary_resolver import npm_prefix_bin_dirs
|
||||
from integrations.llm_cli.grok_cli import (
|
||||
GrokCLIAdapter,
|
||||
_classify_grok_auth_from_probe,
|
||||
_fallback_grok_paths,
|
||||
_has_explicit_grok_auth_env,
|
||||
parse_grok_models_output,
|
||||
)
|
||||
from tests.integrations.llm_cli.testing_helpers import write_fake_runnable_cli_bin
|
||||
|
||||
_SUBPROCESS_RUN = "integrations.llm_cli.grok_cli.subprocess.run"
|
||||
_WHICH = "integrations.llm_cli.binary_resolver.shutil.which"
|
||||
|
||||
|
||||
def _posix_path_set(paths: list[str]) -> set[str]:
|
||||
return {Path(p).as_posix() for p in paths}
|
||||
|
||||
|
||||
def _version_proc(version: str = "0.1.0") -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = f"grok {version}\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
def _auth_proc(returncode: int = 0, stdout: str = "ok", stderr: str = "") -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = returncode
|
||||
m.stdout = stdout
|
||||
m.stderr = stderr
|
||||
return m
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth classification from probe output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_classify_auth_logged_in_string() -> None:
|
||||
logged_in, detail = _classify_grok_auth_from_probe(
|
||||
0, "You are logged in with grok.com.\n\nDefault model: grok-build\n", ""
|
||||
)
|
||||
assert logged_in is True
|
||||
assert "authenticated" in detail.lower()
|
||||
|
||||
|
||||
def test_classify_auth_exit0_is_authenticated() -> None:
|
||||
logged_in, detail = _classify_grok_auth_from_probe(0, "some other output", "")
|
||||
assert logged_in is True
|
||||
assert "authenticated" in detail.lower()
|
||||
|
||||
|
||||
def test_classify_auth_unauthorized_string() -> None:
|
||||
logged_in, detail = _classify_grok_auth_from_probe(1, "", "Error: Unauthorized")
|
||||
assert logged_in is False
|
||||
assert "grok login" in detail.lower() or "xai_api_key" in detail.lower()
|
||||
|
||||
|
||||
def test_classify_auth_401_in_output() -> None:
|
||||
logged_in, detail = _classify_grok_auth_from_probe(1, "", "HTTP 401 Unauthorized")
|
||||
assert logged_in is False
|
||||
|
||||
|
||||
def test_classify_auth_not_logged_in_string() -> None:
|
||||
logged_in, detail = _classify_grok_auth_from_probe(1, "not logged in", "")
|
||||
assert logged_in is False
|
||||
|
||||
|
||||
def test_classify_auth_network_error_is_unclear() -> None:
|
||||
logged_in, detail = _classify_grok_auth_from_probe(1, "", "network unreachable")
|
||||
assert logged_in is None
|
||||
assert "network" in detail.lower()
|
||||
|
||||
|
||||
def test_classify_auth_unknown_nonzero_is_unclear() -> None:
|
||||
logged_in, detail = _classify_grok_auth_from_probe(2, "", "something weird")
|
||||
assert logged_in is None
|
||||
assert "unclear" in detail.lower()
|
||||
|
||||
|
||||
def test_classify_auth_api_key_invalid() -> None:
|
||||
logged_in, detail = _classify_grok_auth_from_probe(1, "", "api key is invalid")
|
||||
assert logged_in is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _has_explicit_grok_auth_env
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_has_explicit_auth_env_with_key() -> None:
|
||||
with patch.dict(os.environ, {"XAI_API_KEY": "xai-test"}, clear=False):
|
||||
assert _has_explicit_grok_auth_env() == "XAI_API_KEY"
|
||||
|
||||
|
||||
def test_has_explicit_auth_env_empty_key() -> None:
|
||||
with patch.dict(os.environ, {"XAI_API_KEY": ""}, clear=False):
|
||||
assert _has_explicit_grok_auth_env() is None
|
||||
|
||||
|
||||
def test_has_explicit_auth_env_missing_key() -> None:
|
||||
env = {k: v for k, v in os.environ.items() if k != "XAI_API_KEY"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
assert _has_explicit_grok_auth_env() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch(_SUBPROCESS_RUN)
|
||||
@patch(_WHICH)
|
||||
def test_detect_logged_in_via_probe(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/grok"
|
||||
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=0, stdout="ok")]
|
||||
|
||||
with patch.dict(os.environ, {"XAI_API_KEY": "", "GROK_CLI_BIN": ""}, clear=False):
|
||||
probe = GrokCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert probe.bin_path == "/usr/bin/grok"
|
||||
assert probe.version == "0.1.0"
|
||||
|
||||
|
||||
@patch(_SUBPROCESS_RUN)
|
||||
@patch(_WHICH)
|
||||
def test_detect_logged_in_via_api_key_fallback(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""XAI_API_KEY promotes to authenticated even when the probe result is unclear."""
|
||||
mock_which.return_value = "/usr/bin/grok"
|
||||
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=1, stderr="network unreachable")]
|
||||
|
||||
with patch.dict(os.environ, {"XAI_API_KEY": "xai-test", "GROK_CLI_BIN": ""}, clear=False):
|
||||
probe = GrokCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "XAI_API_KEY" in probe.detail
|
||||
|
||||
|
||||
@patch(_SUBPROCESS_RUN)
|
||||
@patch(_WHICH)
|
||||
def test_detect_not_authenticated(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/grok"
|
||||
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=1, stderr="Error: Unauthorized")]
|
||||
|
||||
with patch.dict(os.environ, {"XAI_API_KEY": "", "GROK_CLI_BIN": ""}, clear=False):
|
||||
probe = GrokCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
|
||||
|
||||
@patch(_SUBPROCESS_RUN)
|
||||
@patch(_WHICH)
|
||||
def test_detect_api_key_does_not_override_explicit_false(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
"""XAI_API_KEY must not promote logged_in when the probe explicitly returned False.
|
||||
|
||||
The key itself may be what was rejected; masking that defers the failure
|
||||
to invocation time with a confusing 300s timeout instead of a clear error.
|
||||
"""
|
||||
mock_which.return_value = "/usr/bin/grok"
|
||||
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=1, stderr="Error: Unauthorized")]
|
||||
|
||||
with patch.dict(os.environ, {"XAI_API_KEY": "xai-invalid", "GROK_CLI_BIN": ""}, clear=False):
|
||||
probe = GrokCLIAdapter().detect()
|
||||
|
||||
assert probe.logged_in is False
|
||||
|
||||
|
||||
@patch(_SUBPROCESS_RUN)
|
||||
@patch(_WHICH)
|
||||
def test_detect_auth_unclear_on_network_error(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/grok"
|
||||
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=1, stderr="connection refused")]
|
||||
|
||||
with patch.dict(os.environ, {"XAI_API_KEY": "", "GROK_CLI_BIN": ""}, clear=False):
|
||||
probe = GrokCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None
|
||||
|
||||
|
||||
@patch(_SUBPROCESS_RUN)
|
||||
@patch(_WHICH)
|
||||
def test_detect_auth_probe_timeout_is_unclear(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/grok"
|
||||
mock_run.side_effect = [
|
||||
_version_proc(),
|
||||
subprocess.TimeoutExpired(cmd=["/usr/bin/grok", "models"], timeout=10.0),
|
||||
]
|
||||
|
||||
with patch.dict(os.environ, {"XAI_API_KEY": "", "GROK_CLI_BIN": ""}, clear=False):
|
||||
probe = GrokCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None
|
||||
assert "timed out" in probe.detail.lower()
|
||||
|
||||
|
||||
@patch(_SUBPROCESS_RUN)
|
||||
@patch(_WHICH)
|
||||
def test_detect_api_key_fallback_overrides_probe_timeout(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/grok"
|
||||
mock_run.side_effect = [
|
||||
_version_proc(),
|
||||
subprocess.TimeoutExpired(cmd=["/usr/bin/grok", "models"], timeout=10.0),
|
||||
]
|
||||
|
||||
with patch.dict(os.environ, {"XAI_API_KEY": "xai-key", "GROK_CLI_BIN": ""}, clear=False):
|
||||
probe = GrokCLIAdapter().detect()
|
||||
|
||||
assert probe.logged_in is True
|
||||
assert "XAI_API_KEY" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.grok_cli._fallback_grok_paths", return_value=[])
|
||||
@patch(_WHICH, return_value=None)
|
||||
def test_detect_not_installed(_mock_which: MagicMock, _mock_fallback: MagicMock) -> None:
|
||||
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
|
||||
probe = GrokCLIAdapter().detect()
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert probe.bin_path is None
|
||||
assert "not found" in probe.detail.lower()
|
||||
|
||||
|
||||
@patch(_SUBPROCESS_RUN)
|
||||
@patch(_WHICH)
|
||||
def test_detect_version_command_fails(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/grok"
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = "some error\n"
|
||||
mock_run.return_value = m
|
||||
|
||||
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
|
||||
probe = GrokCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
|
||||
|
||||
@patch(_SUBPROCESS_RUN)
|
||||
@patch(_WHICH)
|
||||
def test_detect_version_timeout(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/grok"
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(
|
||||
cmd=["/usr/bin/grok", "--version"], timeout=5.0
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
|
||||
probe = GrokCLIAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert "--version" in probe.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch(_WHICH, return_value="/usr/bin/grok")
|
||||
def test_build_basic_invocation(_mock_which: MagicMock) -> None:
|
||||
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
|
||||
inv = GrokCLIAdapter().build(prompt="explain this alert", model=None, workspace="")
|
||||
assert inv.argv[0] == "/usr/bin/grok"
|
||||
assert "-p" in inv.argv
|
||||
assert "explain this alert" in inv.argv
|
||||
assert "--output-format" in inv.argv
|
||||
assert "plain" in inv.argv
|
||||
assert "--no-auto-update" not in inv.argv
|
||||
# Prompt is delivered as the -p argument, not stdin.
|
||||
assert inv.stdin is None
|
||||
assert inv.timeout_sec == 300.0
|
||||
|
||||
|
||||
@patch(_WHICH, return_value="/usr/bin/grok")
|
||||
def test_build_never_auto_approves(_mock_which: MagicMock) -> None:
|
||||
"""OpenSRE uses Grok as a text responder; it must not auto-run Grok's own tools."""
|
||||
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
|
||||
inv = GrokCLIAdapter().build(prompt="p", model=None, workspace="")
|
||||
assert "--always-approve" not in inv.argv
|
||||
|
||||
|
||||
@patch(_WHICH, return_value="/usr/bin/grok")
|
||||
def test_build_adds_model_flag(_mock_which: MagicMock) -> None:
|
||||
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
|
||||
inv = GrokCLIAdapter().build(prompt="p", model="grok-build-0.1", workspace="")
|
||||
assert "-m" in inv.argv
|
||||
idx = inv.argv.index("-m")
|
||||
assert inv.argv[idx + 1] == "grok-build-0.1"
|
||||
|
||||
|
||||
@patch(_WHICH, return_value="/usr/bin/grok")
|
||||
def test_build_omits_model_flag_when_empty(_mock_which: MagicMock) -> None:
|
||||
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
|
||||
inv = GrokCLIAdapter().build(prompt="p", model="", workspace="")
|
||||
assert "-m" not in inv.argv
|
||||
|
||||
|
||||
@patch(_WHICH, return_value="/usr/bin/grok")
|
||||
def test_build_uses_provided_workspace(_mock_which: MagicMock) -> None:
|
||||
workspace = "/my/project"
|
||||
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
|
||||
inv = GrokCLIAdapter().build(prompt="p", model=None, workspace=workspace)
|
||||
assert Path(inv.cwd) == Path(workspace)
|
||||
|
||||
|
||||
@patch(_WHICH, return_value="/usr/bin/grok")
|
||||
def test_build_sets_no_color_env(_mock_which: MagicMock) -> None:
|
||||
with patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False):
|
||||
inv = GrokCLIAdapter().build(prompt="p", model=None, workspace="")
|
||||
assert inv.env is not None
|
||||
assert inv.env.get("NO_COLOR") == "1"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.grok_cli._fallback_grok_paths", return_value=[])
|
||||
@patch(_WHICH, return_value=None)
|
||||
def test_build_raises_when_binary_not_found(
|
||||
_mock_which: MagicMock, _mock_fallback: MagicMock
|
||||
) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"GROK_CLI_BIN": ""}, clear=False),
|
||||
pytest.raises(RuntimeError, match="Grok Build CLI not found"),
|
||||
):
|
||||
GrokCLIAdapter().build(prompt="p", model=None, workspace="")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse / explain_failure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_returns_stripped_stdout() -> None:
|
||||
result = GrokCLIAdapter().parse(stdout=" hello world \n", stderr="", returncode=0)
|
||||
assert result == "hello world"
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout() -> None:
|
||||
"""Empty stdout on exit 0 must raise rather than return a silent blank response."""
|
||||
with pytest.raises(RuntimeError, match="empty output"):
|
||||
GrokCLIAdapter().parse(stdout="", stderr="", returncode=0)
|
||||
|
||||
|
||||
def test_parse_raises_on_whitespace_only_stdout() -> None:
|
||||
with pytest.raises(RuntimeError, match="empty output"):
|
||||
GrokCLIAdapter().parse(stdout=" \n ", stderr="", returncode=0)
|
||||
|
||||
|
||||
def test_parse_surfaces_stderr_via_explain_failure() -> None:
|
||||
"""When stdout is empty, stderr is surfaced through explain_failure in the error message."""
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
GrokCLIAdapter().parse(stdout="", stderr="401 Unauthorized", returncode=1)
|
||||
assert "401" in str(exc_info.value) or "auth" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_explain_failure_includes_returncode_and_stderr() -> None:
|
||||
msg = GrokCLIAdapter().explain_failure(stdout="", stderr="boom", returncode=1)
|
||||
assert "1" in msg
|
||||
assert "boom" in msg
|
||||
|
||||
|
||||
def test_explain_failure_maps_auth_errors() -> None:
|
||||
msg = GrokCLIAdapter().explain_failure(stdout="", stderr="401 Unauthorized", returncode=1)
|
||||
assert "grok login" in msg.lower() or "xai_api_key" in msg.lower()
|
||||
|
||||
|
||||
def test_explain_failure_falls_back_to_stdout() -> None:
|
||||
msg = GrokCLIAdapter().explain_failure(stdout="some output", stderr="", returncode=2)
|
||||
assert "some output" in msg
|
||||
|
||||
|
||||
def test_explain_failure_maps_quota_via_shared_helper() -> None:
|
||||
"""Quota/rate-limit errors get an actionable hint from the shared classifier."""
|
||||
msg = GrokCLIAdapter().explain_failure(
|
||||
stdout="", stderr="Error 429: rate limit exceeded", returncode=1
|
||||
)
|
||||
assert "quota or rate limit" in msg.lower()
|
||||
|
||||
|
||||
def test_auth_hint_mentions_login_and_api_key() -> None:
|
||||
adapter = GrokCLIAdapter()
|
||||
assert "grok login" in adapter.auth_hint
|
||||
assert "XAI_API_KEY" in adapter.auth_hint
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GROK_CLI_BIN env override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch(_SUBPROCESS_RUN)
|
||||
def test_detect_uses_grok_cli_bin_env(mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
fake_bin = write_fake_runnable_cli_bin(tmp_path, "my-grok")
|
||||
mock_run.side_effect = [_version_proc(), _auth_proc(returncode=0, stdout="ok")]
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"GROK_CLI_BIN": str(fake_bin), "XAI_API_KEY": ""},
|
||||
clear=False,
|
||||
):
|
||||
probe = GrokCLIAdapter().detect()
|
||||
|
||||
assert probe.bin_path == str(fake_bin)
|
||||
assert probe.installed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_grok_cli_registry_entry() -> None:
|
||||
from integrations.llm_cli.registry import get_cli_provider_registration
|
||||
|
||||
reg = get_cli_provider_registration("grok-cli")
|
||||
assert reg is not None
|
||||
assert reg.model_env_key == "GROK_CLI_MODEL"
|
||||
assert reg.adapter_factory().name == "grok-cli"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subprocess env forwarding — XAI_API_KEY must be scoped to the Grok subprocess
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_xai_key_forwarded_via_build() -> None:
|
||||
"""XAI_API_KEY is forwarded explicitly by build(), not via the blanket prefix allowlist."""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"XAI_API_KEY": "xai-forward-me",
|
||||
"XAI_BASE_URL": "https://proxy.example.com",
|
||||
"GROK_CLI_BIN": "",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
patch(_WHICH, return_value="/usr/bin/grok"),
|
||||
):
|
||||
inv = GrokCLIAdapter().build(prompt="p", model=None, workspace="")
|
||||
|
||||
assert inv.env is not None
|
||||
assert inv.env["XAI_API_KEY"] == "xai-forward-me"
|
||||
assert inv.env["XAI_BASE_URL"] == "https://proxy.example.com"
|
||||
|
||||
|
||||
def test_xai_key_not_in_blanket_subprocess_env() -> None:
|
||||
"""XAI_API_KEY must NOT be forwarded via the global prefix allowlist (would leak to others)."""
|
||||
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
|
||||
|
||||
with patch.dict(os.environ, {"XAI_API_KEY": "xai-secret"}, clear=False):
|
||||
env = build_cli_subprocess_env(None)
|
||||
|
||||
assert "XAI_API_KEY" not in env
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fallback_paths_linux() -> None:
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "linux"),
|
||||
patch.dict(os.environ, {"npm_config_prefix": "/custom/npm"}, clear=False),
|
||||
):
|
||||
paths = _fallback_grok_paths()
|
||||
|
||||
normalized = _posix_path_set(paths)
|
||||
assert "/custom/npm/bin/grok" in normalized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_grok_models_output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_models_output_typical() -> None:
|
||||
text = (
|
||||
"You are logged in with grok.com.\n\n"
|
||||
"Default model: grok-build\n\n"
|
||||
"Available models:\n"
|
||||
" - grok-composer-2.5-fast\n"
|
||||
" * grok-build (default)\n"
|
||||
)
|
||||
assert parse_grok_models_output(text) == ["grok-composer-2.5-fast", "grok-build"]
|
||||
|
||||
|
||||
def test_parse_models_output_empty_string() -> None:
|
||||
assert parse_grok_models_output("") == []
|
||||
|
||||
|
||||
def test_parse_models_output_no_available_models_section() -> None:
|
||||
assert parse_grok_models_output("You are logged in.\n\nDefault model: grok-build\n") == []
|
||||
|
||||
|
||||
def test_parse_models_output_stops_at_non_list_line() -> None:
|
||||
text = (
|
||||
"Available models:\n"
|
||||
" - grok-fast\n"
|
||||
" - grok-slow\n"
|
||||
"\n"
|
||||
"Some trailing info that is not a model.\n"
|
||||
" - grok-phantom\n"
|
||||
)
|
||||
# Blank line terminates the section; grok-phantom must not be included.
|
||||
assert parse_grok_models_output(text) == ["grok-fast", "grok-slow"]
|
||||
|
||||
|
||||
def test_parse_models_output_strips_parenthetical_annotation() -> None:
|
||||
text = "Available models:\n * grok-build (default)\n"
|
||||
assert parse_grok_models_output(text) == ["grok-build"]
|
||||
|
||||
|
||||
def test_parse_models_output_model_id_with_parenthesis_in_name() -> None:
|
||||
# A model whose ID itself contains '(' should be truncated at the first '('
|
||||
# per the current split("(")[0] behaviour — this test documents that contract.
|
||||
text = "Available models:\n - grok-weird(beta)\n"
|
||||
assert parse_grok_models_output(text) == ["grok-weird"]
|
||||
|
||||
|
||||
def test_parse_models_output_section_header_case_insensitive() -> None:
|
||||
text = "AVAILABLE MODELS:\n - grok-x\n"
|
||||
assert parse_grok_models_output(text) == ["grok-x"]
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Tests for Kimi Code CLI adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from integrations.llm_cli.kimi import KimiAdapter
|
||||
|
||||
|
||||
def _version_proc() -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "kimi-cli version: 1.40.0\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
def _login_status_logged_in_proc() -> MagicMock:
|
||||
"""Mock response for 'kimi login status' when logged in."""
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "You are logged in.\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.kimi.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_path_binary_logged_in_env(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/kimi"
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
if len(args) >= 2 and args[1] == "--version":
|
||||
return _version_proc()
|
||||
elif len(args) >= 3 and args[1] == "login" and args[2] == "status":
|
||||
return _login_status_logged_in_proc()
|
||||
raise AssertionError(f"Unexpected call: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
with patch.dict(os.environ, {"KIMI_API_KEY": "sk-test"}, clear=False):
|
||||
probe = KimiAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert probe.bin_path == "/usr/bin/kimi"
|
||||
assert probe.version == "1.40.0"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.kimi.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_min_version_enforcement(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_which.return_value = "/usr/bin/kimi"
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
if len(args) >= 2 and args[1] == "--version":
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "kimi-cli version: 1.39.0\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
elif len(args) >= 3 and args[1] == "login" and args[2] == "status":
|
||||
# When version is below minimum, login status might still succeed
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "You are logged in.\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
raise AssertionError(f"Unexpected call: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
probe = KimiAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.version == "1.39.0"
|
||||
assert "upgrade: uv tool upgrade kimi-cli" in probe.detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.kimi.pathlib.Path.exists", return_value=False)
|
||||
@patch("integrations.llm_cli.kimi.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_missing_config_not_logged_in(
|
||||
mock_which: MagicMock, mock_run: MagicMock, _mock_exists: MagicMock
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/kimi"
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
if len(args) >= 2 and args[1] == "--version":
|
||||
return _version_proc()
|
||||
elif len(args) >= 3 and args[1] == "login" and args[2] == "status":
|
||||
# Not logged in case
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = "Not logged in."
|
||||
return m
|
||||
raise AssertionError(f"Unexpected call: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
probe = KimiAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.kimi.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_login_status_not_logged_in_uses_api_key_fallback(
|
||||
mock_which: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
"""When kimi login status reports not logged in, check KIMI_API_KEY fallback."""
|
||||
mock_which.return_value = "/usr/bin/kimi"
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
if len(args) >= 2 and args[1] == "--version":
|
||||
return _version_proc()
|
||||
if len(args) >= 3 and args[1] == "login" and args[2] == "status":
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = "Not logged in."
|
||||
return m
|
||||
raise AssertionError(f"Unexpected call: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
with patch.dict(os.environ, {"KIMI_API_KEY": " sk-test "}, clear=True):
|
||||
probe = KimiAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "KIMI_API_KEY" in probe.detail
|
||||
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/kimi", "--version"]
|
||||
assert mock_run.call_args_list[1].args[0] == ["/usr/bin/kimi", "login", "status"]
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.kimi.pathlib.Path.read_text")
|
||||
@patch("integrations.llm_cli.kimi.pathlib.Path.exists", return_value=True)
|
||||
@patch("integrations.llm_cli.kimi.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_login_status_unavailable_uses_config_fallback(
|
||||
mock_which: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
_mock_exists: MagicMock,
|
||||
mock_read_text: MagicMock,
|
||||
) -> None:
|
||||
mock_which.return_value = "/usr/bin/kimi"
|
||||
mock_read_text.return_value = '[providers.moonshot]\napi_key = "sk-config"\n'
|
||||
|
||||
def side_effect(args: list[str], **kwargs: object) -> MagicMock:
|
||||
if len(args) >= 2 and args[1] == "--version":
|
||||
return _version_proc()
|
||||
if len(args) >= 3 and args[1] == "login" and args[2] == "status":
|
||||
m = MagicMock()
|
||||
m.returncode = 2
|
||||
m.stdout = ""
|
||||
m.stderr = "unknown command: login"
|
||||
return m
|
||||
raise AssertionError(f"Unexpected call: {args}")
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
probe = KimiAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "config.toml" in probe.detail
|
||||
|
||||
|
||||
@patch(
|
||||
"integrations.llm_cli.binary_resolver.shutil.which",
|
||||
return_value="/usr/bin/kimi",
|
||||
)
|
||||
def test_build_adds_model_flag_and_yolo(mock_which: MagicMock) -> None:
|
||||
inv = KimiAdapter().build(prompt="p", model="kimi-k2.5", workspace="")
|
||||
assert inv.stdin == "p"
|
||||
assert "--yolo" in inv.argv
|
||||
assert "--print" in inv.argv
|
||||
assert "-m" in inv.argv
|
||||
idx = inv.argv.index("-m")
|
||||
assert inv.argv[idx + 1] == "kimi-k2.5"
|
||||
mock_which.assert_called()
|
||||
|
||||
|
||||
def test_kimi_cli_registry_entry() -> None:
|
||||
from integrations.llm_cli.registry import get_cli_provider_registration
|
||||
|
||||
reg = get_cli_provider_registration("kimi")
|
||||
assert reg is not None
|
||||
assert reg.model_env_key == "KIMI_MODEL"
|
||||
assert reg.adapter_factory().name == "kimi"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_invoke_forwards_kimi_env(mock_run: MagicMock) -> None:
|
||||
from integrations.llm_cli.kimi import KimiAdapter
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
mock_adapter = MagicMock(spec=KimiAdapter)
|
||||
mock_adapter.name = "kimi"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="/usr/bin/kimi",
|
||||
logged_in=True,
|
||||
detail="ok",
|
||||
)
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=["/usr/bin/kimi", "--print", "--yolo"],
|
||||
stdin="hello",
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.parse.return_value = "answer"
|
||||
mock_adapter.explain_failure.return_value = "fail"
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
||||
|
||||
with (
|
||||
patch("platform.guardrails.engine.get_guardrail_engine") as gr,
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"KIMI_API_KEY": "sk-kimi",
|
||||
"KIMI_BASE_URL": "https://custom.kimi.com",
|
||||
"OPENAI_API_KEY": "sk-openai",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model="kimi-k2.5", max_tokens=256)
|
||||
resp = client.invoke("hello")
|
||||
|
||||
assert resp.content == "answer"
|
||||
env = mock_run.call_args.kwargs["env"]
|
||||
assert env["KIMI_API_KEY"] == "sk-kimi"
|
||||
assert env["KIMI_BASE_URL"] == "https://custom.kimi.com"
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.time.sleep")
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_retries_on_ex_tempfail(
|
||||
mock_run: MagicMock, mock_sleep: MagicMock
|
||||
) -> None:
|
||||
"""EX_TEMPFAIL (75) should be retried; on final success returns the answer."""
|
||||
from integrations.llm_cli.kimi import KimiAdapter
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
mock_adapter = MagicMock(spec=KimiAdapter)
|
||||
mock_adapter.name = "kimi"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="/usr/bin/kimi",
|
||||
logged_in=True,
|
||||
detail="ok",
|
||||
)
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=["/usr/bin/kimi", "--print", "--yolo"],
|
||||
stdin="hello",
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.parse.return_value = "answer"
|
||||
mock_adapter.explain_failure.return_value = (
|
||||
"kimi exited with code 75. To resume this session: kimi -r abc"
|
||||
)
|
||||
|
||||
tempfail = MagicMock(returncode=75, stdout="To resume this session: kimi -r abc", stderr="")
|
||||
success = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
||||
mock_run.side_effect = [tempfail, success]
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model="kimi-k2.5", max_tokens=256)
|
||||
resp = client.invoke("hello")
|
||||
|
||||
assert resp.content == "answer"
|
||||
assert mock_run.call_count == 2
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.time.sleep")
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_raises_after_all_tempfail_retries(
|
||||
mock_run: MagicMock, mock_sleep: MagicMock
|
||||
) -> None:
|
||||
"""EX_TEMPFAIL (75) exhausting all retries raises RuntimeError."""
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli.kimi import KimiAdapter
|
||||
from integrations.llm_cli.runner import _TEMPFAIL_MAX_RETRIES, CLIBackedLLMClient
|
||||
|
||||
mock_adapter = MagicMock(spec=KimiAdapter)
|
||||
mock_adapter.name = "kimi"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True,
|
||||
bin_path="/usr/bin/kimi",
|
||||
logged_in=True,
|
||||
detail="ok",
|
||||
)
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=["/usr/bin/kimi", "--print", "--yolo"],
|
||||
stdin="hello",
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.explain_failure.return_value = (
|
||||
"kimi exited with code 75. To resume this session: kimi -r abc"
|
||||
)
|
||||
|
||||
tempfail = MagicMock(returncode=75, stdout="To resume this session: kimi -r abc", stderr="")
|
||||
mock_run.side_effect = [tempfail] * (_TEMPFAIL_MAX_RETRIES + 1)
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model="kimi-k2.5", max_tokens=256)
|
||||
with pytest.raises(RuntimeError):
|
||||
client.invoke("hello")
|
||||
|
||||
assert mock_run.call_count == _TEMPFAIL_MAX_RETRIES + 1
|
||||
assert mock_sleep.call_count == _TEMPFAIL_MAX_RETRIES
|
||||
|
||||
|
||||
def test_parse_and_explain_failure() -> None:
|
||||
adapter = KimiAdapter()
|
||||
|
||||
# Test parse
|
||||
assert adapter.parse(stdout=" hello world \n", stderr="", returncode=0) == "hello world"
|
||||
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError, match="empty output"):
|
||||
adapter.parse(stdout=" ", stderr="", returncode=0)
|
||||
|
||||
# Test explain_failure: Auth error
|
||||
fail_auth = adapter.explain_failure(stdout="LLM not set", stderr="", returncode=1)
|
||||
assert "Not logged in" in fail_auth
|
||||
assert "kimi login" in fail_auth
|
||||
|
||||
fail_401 = adapter.explain_failure(stdout="", stderr="Error code: 401", returncode=1)
|
||||
assert "API key invalid" in fail_401
|
||||
|
||||
# Test explain_failure: generic error
|
||||
fail_generic = adapter.explain_failure(stdout="", stderr="some error", returncode=1)
|
||||
assert "kimi exited with code 1" in fail_generic
|
||||
assert "some error" in fail_generic
|
||||
|
||||
# Test explain_failure: empty output with code 0
|
||||
fail_empty = adapter.explain_failure(stdout="", stderr="", returncode=0)
|
||||
assert fail_empty == "kimi returned no output"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_backed_client_exit_75_raises_cli_timeout_error(mock_run: MagicMock) -> None:
|
||||
"""Exit code 75 (EX_TEMPFAIL) must raise CLITimeoutError, not RuntimeError.
|
||||
|
||||
Sentry ignores CLITimeoutError so transient kimi failures do not create
|
||||
spurious bug reports.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli.kimi import KimiAdapter
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient, CLITimeoutError
|
||||
|
||||
mock_adapter = MagicMock(spec=KimiAdapter)
|
||||
mock_adapter.name = "kimi"
|
||||
mock_adapter.detect.return_value = MagicMock(
|
||||
installed=True, bin_path="/usr/bin/kimi", logged_in=True, detail="ok"
|
||||
)
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=["/usr/bin/kimi", "--print", "--yolo"],
|
||||
stdin="hello",
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_run.return_value = MagicMock(returncode=75, stdout="", stderr="rate limit")
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter, model="kimi-k2.5", max_tokens=256)
|
||||
with pytest.raises(CLITimeoutError, match="exit 75"):
|
||||
client.invoke("hello")
|
||||
@@ -0,0 +1,654 @@
|
||||
"""Tests for the OpenCode CLI adapter (detect / build / failure / auth detection / fallback paths)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from integrations.llm_cli.binary_resolver import npm_prefix_bin_dirs
|
||||
from integrations.llm_cli.opencode import (
|
||||
OpenCodeAdapter,
|
||||
_fallback_opencode_paths,
|
||||
_parse_opencode_auth_list_output,
|
||||
_probe_opencode_auth_via_cli,
|
||||
)
|
||||
|
||||
|
||||
def _posix_path_set(paths: list[str]) -> set[str]:
|
||||
"""Normalize paths for cross-platform assertions (Windows backslashes -> forward slashes)."""
|
||||
return {Path(p).as_posix() for p in paths}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `opencode auth list` output parsing (multi-provider: file + env)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_auth_list_file_credentials_only() -> None:
|
||||
raw = """
|
||||
┌ Credentials ~/.local/share/opencode/auth.json
|
||||
│
|
||||
● OpenCode Go api
|
||||
│
|
||||
└ 1 credentials
|
||||
"""
|
||||
logged_in, detail = _parse_opencode_auth_list_output(raw, "")
|
||||
assert logged_in is True
|
||||
assert "1 credential group" in detail
|
||||
|
||||
|
||||
def test_parse_auth_list_env_provider_only() -> None:
|
||||
raw = """
|
||||
┌ Credentials ~/.local/share/opencode/auth.json
|
||||
│
|
||||
└ 0 credentials
|
||||
|
||||
┌ Environment
|
||||
│
|
||||
● Anthropic ANTHROPIC_API_KEY
|
||||
│
|
||||
└ 1 environment variable
|
||||
"""
|
||||
logged_in, detail = _parse_opencode_auth_list_output(raw, "")
|
||||
assert logged_in is True
|
||||
assert "1 environment provider" in detail
|
||||
|
||||
|
||||
def test_parse_auth_list_fully_unauthenticated() -> None:
|
||||
raw = """
|
||||
┌ Credentials ~/.local/share/opencode/auth.json
|
||||
│
|
||||
└ 0 credentials
|
||||
"""
|
||||
logged_in, detail = _parse_opencode_auth_list_output(raw, "")
|
||||
assert logged_in is False
|
||||
assert "no file credentials" in detail
|
||||
|
||||
|
||||
def test_parse_auth_list_strips_ansi() -> None:
|
||||
raw = "\x1b[0m\n└ \x1b[90m0 credentials\x1b[0m\n"
|
||||
logged_in, detail = _parse_opencode_auth_list_output(raw, "")
|
||||
assert logged_in is False
|
||||
|
||||
|
||||
def test_parse_auth_list_plural_environment_variables() -> None:
|
||||
raw = """
|
||||
└ 0 credentials
|
||||
└ 2 environment variables
|
||||
"""
|
||||
logged_in, detail = _parse_opencode_auth_list_output(raw, "")
|
||||
assert logged_in is True
|
||||
assert "2 environment" in detail
|
||||
|
||||
|
||||
def test_parse_auth_list_missing_credentials_line() -> None:
|
||||
logged_in, detail = _parse_opencode_auth_list_output("no summary here", "")
|
||||
assert logged_in is None
|
||||
assert "missing credentials summary" in detail
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.opencode.subprocess.run")
|
||||
def test_probe_auth_via_cli_success(mock_run: MagicMock) -> None:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.stdout = "└ 1 credentials\n"
|
||||
proc.stderr = ""
|
||||
mock_run.return_value = proc
|
||||
|
||||
logged_in, detail = _probe_opencode_auth_via_cli("/bin/opencode")
|
||||
assert logged_in is True
|
||||
mock_run.assert_called_once()
|
||||
call_kw = mock_run.call_args.kwargs
|
||||
assert call_kw["env"]["NO_COLOR"] == "1"
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv == ["/bin/opencode", "auth", "list"]
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.opencode.subprocess.run")
|
||||
def test_probe_auth_via_cli_nonzero_exit(mock_run: MagicMock) -> None:
|
||||
proc = MagicMock()
|
||||
proc.returncode = 2
|
||||
proc.stdout = ""
|
||||
proc.stderr = "boom"
|
||||
mock_run.return_value = proc
|
||||
|
||||
logged_in, detail = _probe_opencode_auth_via_cli("/bin/opencode")
|
||||
assert logged_in is None
|
||||
assert "failed" in detail.lower()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.opencode.subprocess.run")
|
||||
def test_probe_auth_via_cli_timeout(mock_run: MagicMock) -> None:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["x"], timeout=1.0)
|
||||
|
||||
logged_in, detail = _probe_opencode_auth_via_cli("/bin/opencode")
|
||||
assert logged_in is None
|
||||
assert "timed out" in detail.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect() tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _version_proc() -> MagicMock:
|
||||
"""Mock successful version command response."""
|
||||
m = MagicMock()
|
||||
m.returncode = 0
|
||||
m.stdout = "opencode 1.2.3\n"
|
||||
m.stderr = ""
|
||||
return m
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.opencode.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_installed_and_authenticated(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""Should detect installed binary and authenticated user."""
|
||||
mock_which.return_value = "/usr/bin/opencode"
|
||||
mock_run.return_value = _version_proc()
|
||||
|
||||
with patch(
|
||||
"integrations.llm_cli.opencode._probe_opencode_auth_via_cli",
|
||||
return_value=(True, "Authenticated"),
|
||||
):
|
||||
probe = OpenCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert probe.bin_path == "/usr/bin/opencode"
|
||||
assert probe.version == "1.2.3"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.opencode.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_installed_not_authenticated(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""Should detect installed binary but user not authenticated."""
|
||||
mock_which.return_value = "/usr/bin/opencode"
|
||||
mock_run.return_value = _version_proc()
|
||||
|
||||
with patch(
|
||||
"integrations.llm_cli.opencode._probe_opencode_auth_via_cli",
|
||||
return_value=(False, "Not authenticated"),
|
||||
):
|
||||
probe = OpenCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.opencode._fallback_opencode_paths", return_value=[])
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value=None)
|
||||
def test_detect_not_installed(mock_which: MagicMock, mock_fallback: MagicMock) -> None:
|
||||
"""Should detect that binary is not installed."""
|
||||
probe = OpenCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert probe.bin_path is None
|
||||
assert "not found" in probe.detail.lower()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.opencode.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_version_command_fails(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""Should return installed=False when version command fails."""
|
||||
mock_which.return_value = "/usr/bin/opencode"
|
||||
m = MagicMock()
|
||||
m.returncode = 1
|
||||
m.stdout = ""
|
||||
m.stderr = "some error\n"
|
||||
mock_run.return_value = m
|
||||
|
||||
probe = OpenCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.opencode.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_version_os_error(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""Should handle OSError when running version command."""
|
||||
mock_which.return_value = "/usr/bin/opencode"
|
||||
mock_run.side_effect = OSError("not found")
|
||||
|
||||
probe = OpenCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.opencode.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_version_timeout_expired(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""Should handle timeout when version command hangs."""
|
||||
import subprocess
|
||||
|
||||
mock_which.return_value = "/usr/bin/opencode"
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(
|
||||
cmd=["/usr/bin/opencode", "--version"], timeout=8.0
|
||||
)
|
||||
|
||||
probe = OpenCodeAdapter().detect()
|
||||
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert probe.bin_path is None
|
||||
assert "could not run" in probe.detail.lower()
|
||||
assert "--version" in probe.detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build() tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/opencode")
|
||||
def test_build_basic_invocation(mock_which: MagicMock) -> None:
|
||||
"""Should build correct basic command without model flag."""
|
||||
inv = OpenCodeAdapter().build(prompt="explain this alert", model=None, workspace="")
|
||||
|
||||
assert inv.argv[0] == "/usr/bin/opencode"
|
||||
assert "run" in inv.argv
|
||||
assert inv.stdin == "explain this alert"
|
||||
assert inv.timeout_sec == 300.0
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/opencode")
|
||||
def test_build_adds_model_flag(mock_which: MagicMock) -> None:
|
||||
"""Should add -m flag when model is provided."""
|
||||
inv = OpenCodeAdapter().build(prompt="p", model="openai/gpt-5.4-mini", workspace="")
|
||||
|
||||
assert "-m" in inv.argv
|
||||
idx = inv.argv.index("-m")
|
||||
assert inv.argv[idx + 1] == "openai/gpt-5.4-mini"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/opencode")
|
||||
def test_build_omits_model_flag_when_empty_string(mock_which: MagicMock) -> None:
|
||||
"""Should omit -m flag when model is empty string."""
|
||||
inv = OpenCodeAdapter().build(prompt="p", model="", workspace="")
|
||||
assert "-m" not in inv.argv
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/opencode")
|
||||
def test_build_omits_model_flag_when_none(mock_which: MagicMock) -> None:
|
||||
"""Should omit -m flag when model is None."""
|
||||
inv = OpenCodeAdapter().build(prompt="p", model=None, workspace="")
|
||||
assert "-m" not in inv.argv
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/opencode")
|
||||
def test_build_uses_provided_workspace(mock_which: MagicMock) -> None:
|
||||
"""Should use provided workspace as working directory."""
|
||||
inv = OpenCodeAdapter().build(prompt="p", model=None, workspace="/my/project")
|
||||
assert inv.cwd == "/my/project"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/opencode")
|
||||
def test_build_defaults_to_cwd_when_workspace_empty(mock_which: MagicMock) -> None:
|
||||
"""Should default to current working directory when workspace not provided."""
|
||||
inv = OpenCodeAdapter().build(prompt="p", model=None, workspace="")
|
||||
assert inv.cwd == os.getcwd()
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/opencode")
|
||||
def test_build_sets_no_color_env(mock_which: MagicMock) -> None:
|
||||
"""Should set NO_COLOR=1 to disable ANSI colors."""
|
||||
inv = OpenCodeAdapter().build(prompt="p", model=None, workspace="")
|
||||
assert inv.env is not None
|
||||
assert inv.env.get("NO_COLOR") == "1"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/opencode")
|
||||
def test_build_forwards_http_llm_env_when_set(mock_which: MagicMock) -> None:
|
||||
"""Should mirror ``opencode auth list`` env-backed credentials into invocation overrides."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"ANTHROPIC_API_KEY": "sk-ant-test", "OPENAI_PROJECT_ID": "proj-1"},
|
||||
clear=False,
|
||||
):
|
||||
inv = OpenCodeAdapter().build(prompt="p", model=None, workspace="")
|
||||
|
||||
assert inv.env is not None
|
||||
assert inv.env.get("ANTHROPIC_API_KEY") == "sk-ant-test"
|
||||
assert inv.env.get("OPENAI_PROJECT_ID") == "proj-1"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value="/usr/bin/opencode")
|
||||
def test_build_forwards_proxy_env_vars(mock_which: MagicMock) -> None:
|
||||
"""Should forward proxy environment variables to subprocess."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HTTP_PROXY": "http://proxy:8080",
|
||||
"HTTPS_PROXY": "https://proxy:8080",
|
||||
"NO_PROXY": "localhost,127.0.0.1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
inv = OpenCodeAdapter().build(prompt="p", model=None, workspace="")
|
||||
|
||||
assert inv.env["HTTP_PROXY"] == "http://proxy:8080"
|
||||
assert inv.env["HTTPS_PROXY"] == "https://proxy:8080"
|
||||
assert inv.env["NO_PROXY"] == "localhost,127.0.0.1"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.opencode._fallback_opencode_paths", return_value=[])
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which", return_value=None)
|
||||
def test_build_raises_when_binary_not_found(
|
||||
mock_which: MagicMock, mock_fallback: MagicMock
|
||||
) -> None:
|
||||
"""Should raise RuntimeError when binary cannot be found."""
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError, match="OpenCode CLI not found"):
|
||||
OpenCodeAdapter().build(prompt="p", model=None, workspace="")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse() tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_returns_stripped_stdout() -> None:
|
||||
"""Should strip whitespace from stdout."""
|
||||
adapter = OpenCodeAdapter()
|
||||
result = adapter.parse(stdout=" hello world \n", stderr="", returncode=0)
|
||||
assert result == "hello world"
|
||||
|
||||
|
||||
def test_parse_handles_empty_stdout() -> None:
|
||||
"""Should raise RuntimeError when stdout is empty."""
|
||||
import pytest
|
||||
|
||||
adapter = OpenCodeAdapter()
|
||||
with pytest.raises(RuntimeError, match="empty output"):
|
||||
adapter.parse(stdout="", stderr="", returncode=0)
|
||||
|
||||
|
||||
def test_parse_ignores_stderr() -> None:
|
||||
"""Should ignore stderr content and only use stdout."""
|
||||
adapter = OpenCodeAdapter()
|
||||
result = adapter.parse(stdout="response", stderr="some logs", returncode=0)
|
||||
assert result == "response"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# explain_failure() tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_explain_failure_includes_returncode_and_stderr() -> None:
|
||||
"""Should include return code and stderr in error message."""
|
||||
adapter = OpenCodeAdapter()
|
||||
msg = adapter.explain_failure(stdout="", stderr="auth error", returncode=1)
|
||||
assert "1" in msg
|
||||
assert "auth error" in msg
|
||||
|
||||
|
||||
def test_explain_failure_falls_back_to_stdout() -> None:
|
||||
"""Should use stdout when stderr is empty."""
|
||||
adapter = OpenCodeAdapter()
|
||||
msg = adapter.explain_failure(stdout="some output", stderr="", returncode=2)
|
||||
assert "some output" in msg
|
||||
|
||||
|
||||
def test_explain_failure_auth_error() -> None:
|
||||
"""Should provide helpful auth error message."""
|
||||
adapter = OpenCodeAdapter()
|
||||
msg = adapter.explain_failure(stdout="", stderr="not authenticated", returncode=1)
|
||||
assert "Authentication failed" in msg
|
||||
assert "opencode auth login" in msg
|
||||
|
||||
|
||||
def test_explain_failure_model_error() -> None:
|
||||
"""Should provide helpful model format error message."""
|
||||
adapter = OpenCodeAdapter()
|
||||
msg = adapter.explain_failure(stdout="", stderr="model not found", returncode=1)
|
||||
assert "Model not found" in msg
|
||||
assert "provider/model" in msg
|
||||
|
||||
|
||||
def test_explain_failure_rate_limit_error() -> None:
|
||||
"""Should provide helpful rate limit error message."""
|
||||
adapter = OpenCodeAdapter()
|
||||
msg = adapter.explain_failure(stdout="", stderr="rate limit exceeded", returncode=1)
|
||||
assert "Rate limited" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OPENCODE_BIN env override tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detect_uses_opencode_bin_env(tmp_path: Path) -> None:
|
||||
"""Should respect OPENCODE_BIN environment variable."""
|
||||
fake_bin = tmp_path / "my-opencode"
|
||||
fake_bin.write_bytes(b"")
|
||||
os.chmod(fake_bin, 0o700)
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"OPENCODE_BIN": str(fake_bin)}, clear=False),
|
||||
patch("integrations.llm_cli.opencode.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = _version_proc()
|
||||
with patch(
|
||||
"integrations.llm_cli.opencode._probe_opencode_auth_via_cli",
|
||||
return_value=(True, "ok"),
|
||||
):
|
||||
probe = OpenCodeAdapter().detect()
|
||||
|
||||
assert probe.bin_path == str(fake_bin)
|
||||
assert probe.installed is True
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.opencode.subprocess.run")
|
||||
@patch("integrations.llm_cli.binary_resolver.shutil.which")
|
||||
def test_detect_falls_back_when_bin_env_invalid(mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""Should fall back to PATH search when OPENCODE_BIN points to invalid binary."""
|
||||
mock_which.return_value = "/usr/bin/opencode"
|
||||
mock_run.return_value = _version_proc()
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"OPENCODE_BIN": "/does/not/exist/opencode"}, clear=False),
|
||||
patch(
|
||||
"integrations.llm_cli.opencode._probe_opencode_auth_via_cli",
|
||||
return_value=(True, "ok"),
|
||||
),
|
||||
):
|
||||
probe = OpenCodeAdapter().detect()
|
||||
|
||||
assert probe.bin_path == "/usr/bin/opencode"
|
||||
assert probe.installed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback paths tests (binary resolution, NOT auth.json)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fallback_paths_macos() -> None:
|
||||
"""Test binary search paths on macOS (Homebrew, local bins, etc.)."""
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "darwin"),
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
):
|
||||
paths = _fallback_opencode_paths()
|
||||
|
||||
normalized = _posix_path_set(paths)
|
||||
# Homebrew paths on Apple Silicon and Intel
|
||||
assert "/opt/homebrew/bin/opencode" in normalized
|
||||
assert "/usr/local/bin/opencode" in normalized
|
||||
# User local bin
|
||||
assert (Path.home() / ".local/bin/opencode").as_posix() in normalized
|
||||
# npm global bins
|
||||
assert (Path.home() / ".npm-global/bin/opencode").as_posix() in normalized
|
||||
# Volta (Node version manager)
|
||||
assert (Path.home() / ".volta/bin/opencode").as_posix() in normalized
|
||||
|
||||
|
||||
def test_fallback_paths_linux() -> None:
|
||||
"""Test binary search paths on Linux (npm prefixes, local bins)."""
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "linux"),
|
||||
patch.dict(os.environ, {"npm_config_prefix": "/custom/npm"}, clear=False),
|
||||
):
|
||||
paths = _fallback_opencode_paths()
|
||||
|
||||
normalized = _posix_path_set(paths)
|
||||
assert "/custom/npm/bin/opencode" in normalized
|
||||
assert (Path.home() / ".local/bin/opencode").as_posix() in normalized
|
||||
assert (Path.home() / ".npm-global/bin/opencode").as_posix() in normalized
|
||||
|
||||
|
||||
def test_fallback_paths_windows() -> None:
|
||||
"""Test binary search paths on Windows (npm, Scoop, local bins)."""
|
||||
npm_prefix_bin_dirs.cache_clear()
|
||||
with (
|
||||
patch("integrations.llm_cli.binary_resolver.sys.platform", "win32"),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"APPDATA": r"C:\Users\me\AppData\Roaming",
|
||||
"LOCALAPPDATA": r"C:\Users\me\AppData\Local",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
paths = _fallback_opencode_paths()
|
||||
|
||||
normalized = {p.replace("\\", "/") for p in paths}
|
||||
|
||||
# npm install locations (APPDATA)
|
||||
assert "C:/Users/me/AppData/Roaming/npm/opencode.cmd" in normalized
|
||||
assert "C:/Users/me/AppData/Roaming/npm/opencode.exe" in normalized
|
||||
# Note: .ps1 and .bat are NOT added by default_cli_fallback_paths for npm
|
||||
|
||||
# Scoop install location (LOCALAPPDATA/Programs/opencode)
|
||||
assert "C:/Users/me/AppData/Local/Programs/opencode/opencode.cmd" in normalized
|
||||
assert "C:/Users/me/AppData/Local/Programs/opencode/opencode.exe" in normalized
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_opencode_registry_entry() -> None:
|
||||
"""Should be properly registered in CLI provider registry."""
|
||||
from integrations.llm_cli.registry import get_cli_provider_registration
|
||||
|
||||
reg = get_cli_provider_registration("opencode")
|
||||
assert reg is not None
|
||||
assert reg.model_env_key == "OPENCODE_MODEL"
|
||||
assert reg.adapter_factory().name == "opencode"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: Config model options test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_opencode_model_options_in_wizard() -> None:
|
||||
"""Verify OpenCode model options are properly defined in wizard config."""
|
||||
from surfaces.cli.wizard.config import OPENCODE_MODELS, SUPPORTED_PROVIDERS
|
||||
|
||||
# Find OpenCode provider
|
||||
opencode_provider = None
|
||||
for provider in SUPPORTED_PROVIDERS:
|
||||
if provider.value == "opencode":
|
||||
opencode_provider = provider
|
||||
break
|
||||
|
||||
assert opencode_provider is not None
|
||||
assert opencode_provider.models == OPENCODE_MODELS
|
||||
assert opencode_provider.model_env == "OPENCODE_MODEL"
|
||||
assert opencode_provider.credential_kind == "cli"
|
||||
|
||||
# Check that first model option is empty string (CLI default)
|
||||
assert OPENCODE_MODELS[0].value == ""
|
||||
assert "CLI default" in OPENCODE_MODELS[0].label
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subprocess env forwarding — OPENCODE_ prefix must be forwarded
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_opencode_prefix_forwarded_to_subprocess() -> None:
|
||||
"""OPENCODE_* environment variables should be forwarded via blanket prefix allowlist."""
|
||||
from integrations.llm_cli.runner import _build_subprocess_env
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPENCODE_MODEL": "openai/gpt-5.4-mini",
|
||||
"OPENCODE_BIN": "/custom/bin/opencode",
|
||||
"OPENCODE_CONFIG": "/custom/config.json",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
env = _build_subprocess_env(None)
|
||||
|
||||
assert env["OPENCODE_MODEL"] == "openai/gpt-5.4-mini"
|
||||
assert env["OPENCODE_BIN"] == "/custom/bin/opencode"
|
||||
assert env["OPENCODE_CONFIG"] == "/custom/config.json"
|
||||
|
||||
|
||||
def test_non_opencode_vars_not_forwarded() -> None:
|
||||
"""Only OPENCODE_* vars should be forwarded, not arbitrary vars."""
|
||||
from integrations.llm_cli.runner import _build_subprocess_env
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPENCODE_MODEL": "test-model",
|
||||
"RANDOM_VAR": "should-not-forward",
|
||||
"AWS_SECRET_KEY": "should-not-forward",
|
||||
"MY_CONFIG": "should-not-forward",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
env = _build_subprocess_env(None)
|
||||
|
||||
assert env["OPENCODE_MODEL"] == "test-model"
|
||||
assert "RANDOM_VAR" not in env
|
||||
assert "AWS_SECRET_KEY" not in env
|
||||
assert "MY_CONFIG" not in env
|
||||
|
||||
|
||||
def test_adapter_build_does_not_need_to_forward_opencode_vars() -> None:
|
||||
"""OpenCode adapter should NOT manually forward OPENCODE_* vars (runner handles it)."""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPENCODE_MODEL": "openai/gpt-5.4-mini",
|
||||
"OPENCODE_CONFIG": "/custom/config.json",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
patch(
|
||||
"integrations.llm_cli.binary_resolver.shutil.which",
|
||||
return_value="/usr/bin/opencode",
|
||||
),
|
||||
):
|
||||
inv = OpenCodeAdapter().build(prompt="test", model=None, workspace=".")
|
||||
|
||||
assert "OPENCODE_MODEL" not in inv.env
|
||||
assert "OPENCODE_CONFIG" not in inv.env
|
||||
|
||||
assert inv.env.get("NO_COLOR") == "1"
|
||||
|
||||
|
||||
def test_parse_raises_on_empty_stdout_surfaces_stderr() -> None:
|
||||
import pytest
|
||||
|
||||
adapter = OpenCodeAdapter()
|
||||
with pytest.raises(RuntimeError, match="some stderr detail"):
|
||||
adapter.parse(stdout="", stderr="some stderr detail", returncode=0)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Tests for the Pi CLI adapter (``pi -p`` non-interactive print mode).
|
||||
|
||||
Unit tests mock the version probe, ``shutil.which``, and the credential file so
|
||||
they run fully offline. The final ``live_llm`` test exercises a real ``pi``
|
||||
binary against a real Gemini model and self-skips unless both are available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli.pi_cli import PiAdapter
|
||||
|
||||
_PROBE = "integrations.llm_cli.pi_cli.run_version_probe"
|
||||
_WHICH = "integrations.llm_cli.binary_resolver.shutil.which"
|
||||
_PI_FALLBACK_PATHS = "integrations.llm_cli.pi_cli._fallback_pi_paths"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# detect()
|
||||
# --------------------------------------------------------------------------- #
|
||||
@patch(_PROBE, return_value=("pi 0.5.0", None))
|
||||
@patch(_WHICH, return_value="/usr/bin/pi")
|
||||
def test_detect_logged_in_via_provider_env_key(
|
||||
_mock_which: MagicMock, _mock_probe: MagicMock
|
||||
) -> None:
|
||||
with patch.dict(os.environ, {"GEMINI_API_KEY": " test-key "}, clear=True):
|
||||
probe = PiAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert probe.bin_path == "/usr/bin/pi"
|
||||
assert probe.version == "0.5.0"
|
||||
assert "GEMINI_API_KEY" in probe.detail
|
||||
|
||||
|
||||
@patch(_PROBE, return_value=("pi 0.5.0", None))
|
||||
@patch(_WHICH, return_value="/usr/bin/pi")
|
||||
def test_detect_logged_in_via_auth_json(
|
||||
_mock_which: MagicMock, _mock_probe: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
agent_dir = tmp_path / "agent"
|
||||
agent_dir.mkdir()
|
||||
(agent_dir / "auth.json").write_text(
|
||||
'{"anthropic": {"type": "oauth", "access": "tok"}}', encoding="utf-8"
|
||||
)
|
||||
|
||||
with patch.dict(os.environ, {"PI_AGENT_DIR": str(agent_dir)}, clear=True):
|
||||
probe = PiAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is True
|
||||
assert "auth.json" in probe.detail
|
||||
|
||||
|
||||
@patch(_PROBE, return_value=("pi 0.5.0", None))
|
||||
@patch(_WHICH, return_value="/usr/bin/pi")
|
||||
def test_detect_not_logged_in_when_no_key_and_no_auth_file(
|
||||
_mock_which: MagicMock, _mock_probe: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
with patch.dict(os.environ, {"PI_AGENT_DIR": str(tmp_path)}, clear=True):
|
||||
probe = PiAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
assert "/login" in probe.detail or "API key" in probe.detail
|
||||
|
||||
|
||||
@patch(_PROBE, return_value=("pi 0.5.0", None))
|
||||
@patch(_WHICH, return_value="/usr/bin/pi")
|
||||
def test_detect_unreadable_auth_json_returns_none(
|
||||
_mock_which: MagicMock, _mock_probe: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
(tmp_path / "auth.json").write_text("not-json{", encoding="utf-8")
|
||||
|
||||
with patch.dict(os.environ, {"PI_AGENT_DIR": str(tmp_path)}, clear=True):
|
||||
probe = PiAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is None # unclear, invocation will verify
|
||||
|
||||
|
||||
@patch(_PROBE, return_value=("pi 0.5.0", None))
|
||||
@patch(_WHICH, return_value="/usr/bin/pi")
|
||||
def test_detect_empty_auth_json_not_logged_in(
|
||||
_mock_which: MagicMock, _mock_probe: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
(tmp_path / "auth.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
with patch.dict(os.environ, {"PI_AGENT_DIR": str(tmp_path)}, clear=True):
|
||||
probe = PiAdapter().detect()
|
||||
|
||||
assert probe.installed is True
|
||||
assert probe.logged_in is False
|
||||
|
||||
|
||||
@patch(_PROBE, return_value=(None, "`/usr/bin/pi --version` failed: boom"))
|
||||
@patch(_WHICH, return_value="/usr/bin/pi")
|
||||
def test_detect_version_probe_failure_marks_not_installed(
|
||||
_mock_which: MagicMock, _mock_probe: MagicMock
|
||||
) -> None:
|
||||
probe = PiAdapter().detect()
|
||||
assert probe.installed is False
|
||||
assert probe.logged_in is None
|
||||
assert "boom" in probe.detail
|
||||
|
||||
|
||||
@patch(_PI_FALLBACK_PATHS, return_value=[])
|
||||
@patch(_WHICH, return_value=None)
|
||||
def test_detect_binary_missing(_mock_which: MagicMock, _mock_fallbacks: MagicMock) -> None:
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
probe = PiAdapter().detect()
|
||||
assert probe.installed is False
|
||||
assert probe.bin_path is None
|
||||
assert "Pi CLI not found" in probe.detail
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# build()
|
||||
# --------------------------------------------------------------------------- #
|
||||
@patch(_WHICH, return_value="/usr/bin/pi")
|
||||
def test_build_print_mode_and_model_flag(_mock_which: MagicMock) -> None:
|
||||
inv = PiAdapter().build(prompt="hello", model="google/gemini-2.5-flash-lite", workspace="")
|
||||
assert inv.stdin is None
|
||||
assert inv.argv[0] == "/usr/bin/pi"
|
||||
assert "-p" in inv.argv
|
||||
assert "hello" in inv.argv
|
||||
assert "--model" in inv.argv
|
||||
idx = inv.argv.index("--model")
|
||||
assert inv.argv[idx + 1] == "google/gemini-2.5-flash-lite"
|
||||
assert inv.env is not None and inv.env.get("NO_COLOR") == "1"
|
||||
assert inv.cwd == os.getcwd()
|
||||
|
||||
|
||||
@patch(_WHICH, return_value="/usr/bin/pi")
|
||||
def test_build_omits_model_when_empty(_mock_which: MagicMock) -> None:
|
||||
inv = PiAdapter().build(prompt="p", model="", workspace="")
|
||||
assert "--model" not in inv.argv
|
||||
|
||||
|
||||
@patch(_WHICH, return_value="/usr/bin/pi")
|
||||
def test_build_forwards_provider_api_key(_mock_which: MagicMock) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"GEMINI_API_KEY": "sk-gemini", "SOME_UNRELATED_SECRET": "nope"},
|
||||
clear=True,
|
||||
):
|
||||
inv = PiAdapter().build(prompt="p", model="google/gemini-2.5-flash-lite", workspace="")
|
||||
assert inv.env is not None
|
||||
assert inv.env["GEMINI_API_KEY"] == "sk-gemini"
|
||||
assert "SOME_UNRELATED_SECRET" not in inv.env
|
||||
|
||||
|
||||
@patch(_PI_FALLBACK_PATHS, return_value=[])
|
||||
@patch(_WHICH, return_value=None)
|
||||
def test_build_raises_when_binary_missing(
|
||||
_mock_which: MagicMock, _mock_fallbacks: MagicMock
|
||||
) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
pytest.raises(RuntimeError, match="Pi CLI not found"),
|
||||
):
|
||||
PiAdapter().build(prompt="p", model=None, workspace="")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# registry / parse / explain_failure
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_pi_registry_entry() -> None:
|
||||
from integrations.llm_cli.registry import get_cli_provider_registration
|
||||
|
||||
reg = get_cli_provider_registration("pi")
|
||||
assert reg is not None
|
||||
assert reg.model_env_key == "PI_MODEL"
|
||||
assert reg.adapter_factory().name == "pi"
|
||||
|
||||
|
||||
def test_parse_strips_and_raises_on_empty() -> None:
|
||||
adapter = PiAdapter()
|
||||
assert adapter.parse(stdout=" pong \n", stderr="", returncode=0) == "pong"
|
||||
with pytest.raises(RuntimeError, match="empty output"):
|
||||
adapter.parse(stdout=" ", stderr="", returncode=0)
|
||||
|
||||
|
||||
def test_explain_failure_classifies_messages() -> None:
|
||||
adapter = PiAdapter()
|
||||
|
||||
auth = adapter.explain_failure(stdout="", stderr="Error: not logged in", returncode=1)
|
||||
assert "Authentication failed" in auth
|
||||
|
||||
model = adapter.explain_failure(stdout="", stderr="model not found: foo", returncode=1)
|
||||
assert "PI_MODEL format" in model
|
||||
|
||||
quota = adapter.explain_failure(stdout="", stderr="rate limit exceeded", returncode=1)
|
||||
assert "Rate limited" in quota
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# runner integration (real adapter, mocked subprocess)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
@patch(_PROBE, return_value=("pi 0.5.0", None))
|
||||
@patch(_WHICH, return_value="/usr/bin/pi")
|
||||
def test_cli_backed_client_invoke_forwards_pi_env(
|
||||
_mock_which: MagicMock, _mock_probe: MagicMock, mock_run: MagicMock
|
||||
) -> None:
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
||||
|
||||
with (
|
||||
patch("platform.guardrails.engine.get_guardrail_engine") as gr,
|
||||
patch.dict(os.environ, {"GEMINI_API_KEY": "sk-gemini"}, clear=False),
|
||||
):
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(
|
||||
PiAdapter(), model="google/gemini-2.5-flash-lite", max_tokens=256
|
||||
)
|
||||
resp = client.invoke("hello")
|
||||
|
||||
assert resp.content == "answer"
|
||||
env = mock_run.call_args.kwargs["env"]
|
||||
assert env["GEMINI_API_KEY"] == "sk-gemini"
|
||||
assert env["NO_COLOR"] == "1"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# live model test (real pi + real Gemini) — self-skips without creds/binary
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _require_live_pi_gemini() -> str:
|
||||
"""Skip unless the ``pi`` binary is installed and a Gemini key is present."""
|
||||
import shutil
|
||||
|
||||
binary = shutil.which("pi") or os.environ.get("PI_BIN", "").strip()
|
||||
if not binary:
|
||||
pytest.skip("pi binary not installed; skipping live Pi test")
|
||||
if not os.environ.get("GEMINI_API_KEY", "").strip():
|
||||
pytest.skip("GEMINI_API_KEY not set; skipping live Pi test")
|
||||
return os.environ.get("PI_MODEL", "").strip() or "google/gemini-2.5-flash-lite"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.live_llm
|
||||
def test_live_pi_gemini_round_trip() -> None:
|
||||
model = _require_live_pi_gemini()
|
||||
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
adapter = PiAdapter()
|
||||
probe = adapter.detect()
|
||||
assert probe.installed is True, probe.detail
|
||||
assert probe.logged_in is not False, f"Pi reports not authenticated: {probe.detail}"
|
||||
|
||||
client = CLIBackedLLMClient(adapter, model=model, max_tokens=256)
|
||||
resp = client.invoke("Reply with exactly one word: pong")
|
||||
|
||||
assert resp.content.strip(), "Pi returned an empty response"
|
||||
assert "pong" in resp.content.lower()
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from integrations.llm_cli.probe_utils import run_version_probe
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.probe_utils.subprocess.run")
|
||||
def test_run_version_probe_success(mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=["/bin/tool", "--version"],
|
||||
returncode=0,
|
||||
stdout="1.2.3\n",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
output, detail = run_version_probe("/bin/tool", timeout_sec=3.0)
|
||||
|
||||
assert output == "1.2.3\n"
|
||||
assert detail is None
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.probe_utils.subprocess.run")
|
||||
def test_run_version_probe_nonzero(mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = subprocess.CompletedProcess(
|
||||
args=["/bin/tool", "--version"],
|
||||
returncode=2,
|
||||
stdout="",
|
||||
stderr="broken",
|
||||
)
|
||||
|
||||
output, detail = run_version_probe("/bin/tool", timeout_sec=3.0)
|
||||
|
||||
assert output is None
|
||||
assert detail == "`/bin/tool --version` failed: broken"
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.probe_utils.subprocess.run")
|
||||
def test_run_version_probe_timeout(mock_run: MagicMock) -> None:
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["/bin/tool", "--version"], timeout=3.0)
|
||||
|
||||
output, detail = run_version_probe("/bin/tool", timeout_sec=3.0)
|
||||
|
||||
assert output is None
|
||||
assert detail is not None
|
||||
assert detail.startswith("Could not run `/bin/tool --version`:")
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.probe_utils.subprocess.run")
|
||||
def test_run_version_probe_oserror(mock_run: MagicMock) -> None:
|
||||
mock_run.side_effect = OSError("no exec")
|
||||
|
||||
output, detail = run_version_probe("/bin/tool", timeout_sec=3.0)
|
||||
|
||||
assert output is None
|
||||
assert detail == "Could not run `/bin/tool --version`: no exec"
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from integrations.llm_cli.runner import CLIBackedLLMClient
|
||||
|
||||
|
||||
def _mock_probe() -> MagicMock:
|
||||
return MagicMock(installed=True, bin_path="/usr/bin/mock-cli", logged_in=True, detail="ok")
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_llm_spawn_log_redacts_prompt_in_argv(mock_run: MagicMock, caplog) -> None:
|
||||
prompt = "customer secret investigation context"
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "copilot"
|
||||
mock_adapter.detect.return_value = _mock_probe()
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=("/usr/bin/mock-cli", "-p", prompt, "--model", "gpt-5.1"),
|
||||
stdin=None,
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.parse.return_value = "answer"
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
with caplog.at_level(logging.DEBUG, logger="integrations.llm_cli.runner"):
|
||||
client = CLIBackedLLMClient(mock_adapter)
|
||||
client.invoke(prompt)
|
||||
|
||||
spawn = next(record for record in caplog.records if record.msg == "cli_llm_spawn")
|
||||
assert spawn.provider == "copilot"
|
||||
assert spawn.argv == ["/usr/bin/mock-cli", "-p", "<redacted-prompt>", "--model", "gpt-5.1"]
|
||||
assert prompt not in spawn.argv
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_llm_spawn_log_keeps_non_prompt_argv_args(mock_run: MagicMock, caplog) -> None:
|
||||
prompt = "customer secret investigation context"
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "claude-code"
|
||||
mock_adapter.detect.return_value = _mock_probe()
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=("/usr/bin/mock-cli", "-p", "--output-format", "text"),
|
||||
stdin=prompt,
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.parse.return_value = "answer"
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
with caplog.at_level(logging.DEBUG, logger="integrations.llm_cli.runner"):
|
||||
client = CLIBackedLLMClient(mock_adapter)
|
||||
client.invoke(prompt)
|
||||
|
||||
spawn = next(record for record in caplog.records if record.msg == "cli_llm_spawn")
|
||||
assert spawn.provider == "claude-code"
|
||||
assert spawn.argv == ["/usr/bin/mock-cli", "-p", "--output-format", "text"]
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_cli_llm_spawn_log_redacts_prompt_equals_form(mock_run: MagicMock, caplog) -> None:
|
||||
prompt = "customer secret investigation context"
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "mock-cli"
|
||||
mock_adapter.detect.return_value = _mock_probe()
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=("/usr/bin/mock-cli", f"--prompt={prompt}", "--model", "gpt-5.1"),
|
||||
stdin=None,
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.parse.return_value = "answer"
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="answer\n", stderr="")
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
with caplog.at_level(logging.DEBUG, logger="integrations.llm_cli.runner"):
|
||||
client = CLIBackedLLMClient(mock_adapter)
|
||||
client.invoke(prompt)
|
||||
|
||||
spawn = next(record for record in caplog.records if record.msg == "cli_llm_spawn")
|
||||
assert spawn.provider == "mock-cli"
|
||||
assert spawn.argv == ["/usr/bin/mock-cli", "--prompt=<redacted-prompt>", "--model", "gpt-5.1"]
|
||||
assert all(prompt not in arg for arg in spawn.argv)
|
||||
|
||||
|
||||
@patch("integrations.llm_cli.runner.subprocess.run")
|
||||
def test_runner_uses_adapter_explain_failure_for_quota(mock_run: MagicMock) -> None:
|
||||
"""Quota stderr is enriched via adapter explain_failure, not a runner-side classifier."""
|
||||
import pytest
|
||||
|
||||
from integrations.llm_cli.failure_explain import explain_cli_failure
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "codex"
|
||||
mock_adapter.auth_hint = "codex login"
|
||||
mock_adapter.detect.return_value = _mock_probe()
|
||||
mock_adapter.build.return_value = MagicMock(
|
||||
argv=("/usr/bin/codex", "exec", "-"),
|
||||
stdin="hello",
|
||||
cwd="/tmp",
|
||||
env=None,
|
||||
timeout_sec=30.0,
|
||||
)
|
||||
mock_adapter.explain_failure.side_effect = lambda **kwargs: explain_cli_failure(
|
||||
exit_label="codex exec", **kwargs
|
||||
)
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1, stdout="", stderr="429 Too Many Requests: quota exceeded"
|
||||
)
|
||||
|
||||
with patch("platform.guardrails.engine.get_guardrail_engine") as gr:
|
||||
gr.return_value.is_active = False
|
||||
client = CLIBackedLLMClient(mock_adapter)
|
||||
with pytest.raises(RuntimeError, match="quota or rate limit exceeded"):
|
||||
client.invoke("hello")
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from integrations.llm_cli.semver_utils import parse_semver_three_part, semver_to_tuple
|
||||
|
||||
|
||||
def test_parse_semver_three_part_finds_version() -> None:
|
||||
assert parse_semver_three_part("tool v1.2.3-beta") == "1.2.3"
|
||||
|
||||
|
||||
def test_parse_semver_three_part_returns_none_when_missing() -> None:
|
||||
assert parse_semver_three_part("no version here") is None
|
||||
|
||||
|
||||
def test_semver_to_tuple_handles_missing_parts() -> None:
|
||||
assert semver_to_tuple("1") == (1, 0, 0)
|
||||
assert semver_to_tuple("1.2") == (1, 2, 0)
|
||||
|
||||
|
||||
def test_semver_to_tuple_handles_suffixes() -> None:
|
||||
assert semver_to_tuple("1.2.3-beta.4") == (1, 2, 3)
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from integrations.llm_cli.timeout_utils import resolve_timeout_from_env
|
||||
|
||||
|
||||
def test_resolve_timeout_from_env_defaults_when_missing(monkeypatch) -> None:
|
||||
monkeypatch.delenv("X_TIMEOUT", raising=False)
|
||||
assert (
|
||||
resolve_timeout_from_env(
|
||||
env_key="X_TIMEOUT",
|
||||
default=120.0,
|
||||
minimum=30.0,
|
||||
maximum=600.0,
|
||||
)
|
||||
== 120.0
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_timeout_from_env_defaults_when_invalid(monkeypatch) -> None:
|
||||
monkeypatch.setenv("X_TIMEOUT", "oops")
|
||||
assert (
|
||||
resolve_timeout_from_env(
|
||||
env_key="X_TIMEOUT",
|
||||
default=120.0,
|
||||
minimum=30.0,
|
||||
maximum=600.0,
|
||||
)
|
||||
== 120.0
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_timeout_from_env_defaults_when_non_positive(monkeypatch) -> None:
|
||||
monkeypatch.setenv("X_TIMEOUT", "0")
|
||||
assert (
|
||||
resolve_timeout_from_env(
|
||||
env_key="X_TIMEOUT",
|
||||
default=120.0,
|
||||
minimum=30.0,
|
||||
maximum=600.0,
|
||||
)
|
||||
== 120.0
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_timeout_from_env_clamps(monkeypatch) -> None:
|
||||
monkeypatch.setenv("X_TIMEOUT", "1")
|
||||
assert (
|
||||
resolve_timeout_from_env(
|
||||
env_key="X_TIMEOUT",
|
||||
default=120.0,
|
||||
minimum=30.0,
|
||||
maximum=600.0,
|
||||
)
|
||||
== 30.0
|
||||
)
|
||||
|
||||
monkeypatch.setenv("X_TIMEOUT", "9999")
|
||||
assert (
|
||||
resolve_timeout_from_env(
|
||||
env_key="X_TIMEOUT",
|
||||
default=120.0,
|
||||
minimum=30.0,
|
||||
maximum=600.0,
|
||||
)
|
||||
== 600.0
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_timeout_from_env_uses_value(monkeypatch) -> None:
|
||||
monkeypatch.setenv("X_TIMEOUT", "180")
|
||||
assert (
|
||||
resolve_timeout_from_env(
|
||||
env_key="X_TIMEOUT",
|
||||
default=120.0,
|
||||
minimum=30.0,
|
||||
maximum=600.0,
|
||||
)
|
||||
== 180.0
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Cross-platform helpers for ``llm_cli`` tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def write_fake_runnable_cli_bin(tmp_path: Path, stem: str) -> Path:
|
||||
"""Create a minimal file that passes ``is_runnable_binary`` on this OS.
|
||||
|
||||
On Windows, extensionless scripts are rejected unless ``os.access`` claims
|
||||
execute — portable tests use ``.exe`` so resolution matches production rules.
|
||||
"""
|
||||
filename = f"{stem}.exe" if sys.platform == "win32" else stem
|
||||
path = tmp_path / filename
|
||||
path.write_bytes(b"")
|
||||
if sys.platform != "win32":
|
||||
os.chmod(path, 0o700)
|
||||
return path
|
||||
Reference in New Issue
Block a user