4b6817381b
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
263 lines
9.6 KiB
Python
263 lines
9.6 KiB
Python
"""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()
|