headroom/tests/test_cli/test_wrap_openhands.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

259 lines
9.7 KiB
Python
Raw Normal View History

fix(cli): wrap subcommands for cline, continue, goose, openhands Adds four new `headroom wrap <agent>` subcommands so the proxy can front-end Cline (VS Code), Continue (VS Code/JetBrains), Goose (Block CLI), and OpenHands (CLI), extending the existing claude/codex/aider/copilot/ cursor pattern. Phase G PR-G1 of the realignment work. Architectural decision: extended the existing `headroom/cli/wrap.py` module in-place rather than splitting it into a `headroom/cli/wrap/` package. The spec at REALIGNMENT/09-phase-G-rtk-observability.md mentions per-agent files under a package, but the existing five wrap subcommands all live in the single module and the extension is small relative to the file. Keeping the file together preserves the simple import surface used by tests (`from headroom.cli import wrap as wrap_mod`). Per-agent wiring: - cline → injects RTK block into `.clinerules` at project root (Cline is a VS Code extension; API base URL is configured in the UI, so the command prints config instructions and blocks on the proxy). - continue → injects RTK guidance into the `systemMessage` field of `.continue/config.json` (idempotent; refuses malformed JSON or non-object roots; supports `--config` for custom paths). - goose → injects RTK block into `.goosehints` at project root and launches the goose CLI with OPENAI_BASE_URL / OPENAI_API_BASE / ANTHROPIC_BASE_URL env vars pointed at the proxy. - openhands → injects RTK guidance via the `OPENHANDS_INSTRUCTIONS` env var at launch (no on-disk artifact) and sets OPENAI_BASE_URL / ANTHROPIC_BASE_URL / LLM_BASE_URL. Preserves any pre-existing OPENHANDS_INSTRUCTIONS content. Tests: - tests/test_cli/test_wrap_cline.py: 4 tests covering prepare-only injection, idempotence, --no-context-tool, and existing content preservation. - tests/test_cli/test_wrap_continue.py: 8 tests covering the new `_inject_continue_rtk_systemmessage` helper (new-file, existing keys, idempotence, malformed JSON, non-object roots) and the click command surface (default path, custom --config). - tests/test_cli/test_wrap_goose.py: 5 tests covering env-var wiring, `.goosehints` injection, idempotence, missing-binary error, and --no-context-tool. - tests/test_cli/test_wrap_openhands.py: 6 tests covering env-var wiring, OPENHANDS_INSTRUCTIONS injection, preservation of existing instructions, idempotence, missing-binary error, and --no-context-tool. E2E: extended `e2e/wrap/run.py` with `--prepare-only` smoke tests for all four new wrappers (full launches require agent CLIs not present in the e2e image; unit tests cover the env-var wiring).
2026-05-21 21:04:50 -07:00
"""Tests for `headroom wrap openhands` command (PR-G1, Phase G)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from headroom.cli import wrap as wrap_mod
from headroom.cli.main import main
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
def test_wrap_openhands_sets_provider_envs(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""OPENAI_BASE_URL, ANTHROPIC_BASE_URL, LLM_BASE_URL are set on launch."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="openhands"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(
main, ["wrap", "openhands", "--port", "9000", "--", "--task", "demo"]
)
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
assert env["OPENAI_BASE_URL"] == "http://127.0.0.1:9000/v1"
assert env["OPENAI_API_BASE"] == "http://127.0.0.1:9000/v1"
assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9000"
assert env["LLM_BASE_URL"] == "http://127.0.0.1:9000/v1"
assert captured["tool_label"] == "OPENHANDS"
assert captured["agent_type"] == "openhands"
assert captured["args"] == ("--task", "demo")
def test_wrap_openhands_injects_rtk_via_env_var(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""OPENHANDS_INSTRUCTIONS env var must contain the RTK block at launch."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
monkeypatch.delenv("OPENHANDS_INSTRUCTIONS", raising=False)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="openhands"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "openhands"])
assert result.exit_code == 0, result.output
env = captured["env"]
assert isinstance(env, dict)
instructions = env.get("OPENHANDS_INSTRUCTIONS", "")
assert wrap_mod._RTK_MARKER in instructions
assert "RTK (Rust Token Killer)" in instructions
def test_wrap_openhands_preserves_existing_openhands_instructions(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Pre-existing OPENHANDS_INSTRUCTIONS env content is preserved, rtk is appended."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
monkeypatch.setenv("OPENHANDS_INSTRUCTIONS", "Prefer typed Python.")
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="openhands"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "openhands"])
assert result.exit_code == 0, result.output
env = captured["env"]
instructions = env.get("OPENHANDS_INSTRUCTIONS", "")
assert "Prefer typed Python." in instructions
assert wrap_mod._RTK_MARKER in instructions
def test_wrap_openhands_idempotent_already_injected(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If OPENHANDS_INSTRUCTIONS already contains the marker, do not re-append."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
pre_existing = "Prefer typed Python.\n\n" + wrap_mod.RTK_INSTRUCTIONS_BLOCK
monkeypatch.setenv("OPENHANDS_INSTRUCTIONS", pre_existing)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="openhands"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "openhands"])
assert result.exit_code == 0, result.output
env = captured["env"]
instructions = env.get("OPENHANDS_INSTRUCTIONS", "")
assert instructions.count(wrap_mod._RTK_MARKER) == 1
def test_wrap_openhands_missing_binary_errors_clearly(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If the openhands binary is missing the command must fail with a clear error."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
with patch.object(wrap_mod.shutil, "which", return_value=None):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
result = runner.invoke(main, ["wrap", "openhands"])
assert result.exit_code == 1
assert "'openhands' not found in PATH" in result.output
def test_wrap_openhands_no_context_tool_does_not_inject(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""--no-context-tool must skip OPENHANDS_INSTRUCTIONS injection."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("OPENHANDS_INSTRUCTIONS", raising=False)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="openhands"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary") as ensure:
result = runner.invoke(main, ["wrap", "openhands", "--no-context-tool"])
assert result.exit_code == 0, result.output
ensure.assert_not_called()
env = captured["env"]
assert isinstance(env, dict)
assert "OPENHANDS_INSTRUCTIONS" not in env or env["OPENHANDS_INSTRUCTIONS"] == ""
fix(cli): G1 remediation — non-string clobber, per-model systemMessage, openhands gate Addresses 1 High + 4 Medium findings from the PR-G1 code review. H1: `_inject_continue_rtk_systemmessage` previously fell through to an unconditional `data["systemMessage"] = RTK_INSTRUCTIONS_BLOCK` when the existing value was non-string (dict / list / number), silently clobbering user data despite a docstring promising otherwise. Extracted a small helper `_apply_rtk_to_systemmessage_field` that returns `(changed, ok)` and refuses loudly on non-string user data with guidance to clear the field before re-running. The injecting helper reports `ok=False` on any refusal so the caller surfaces it as a warning instead of pretending the injection succeeded. Tests cover dict, list, and int values for both top-level and per-model sites. M2: Continue overrides top-level `systemMessage` with per-model `systemMessage` when set, so users with per-model configs were silently getting no RTK guidance. The helper now visits every `models[i]` dict in addition to the top-level field, applying the same idempotency and non- string-clobber rules at each site. Non-dict entries in `models[]` are skipped. M3: The openhands subcommand previously called `_ensure_rtk_binary()` and ignored the result, then proceeded to inject `OPENHANDS_INSTRUCTIONS` even when rtk install had failed. Mirrored the cline/continue/goose pattern — if rtk install fails (and `--no-context-tool` was not passed), exit 1 with a clear error explaining how to install rtk manually or skip rtk. No silent fallback to env-only injection. M4: Wrapped the marker-injection + rtk-setup prelude of all four new subcommands (cline, continue, goose, openhands) in a try/except for KeyboardInterrupt. On Ctrl-C between marker injection and proxy startup, we emit a clear "wrap was interrupted; marker file at <path> is on disk; rerun to retry — it's idempotent" message and exit 130. Pre-compute the marker path so the message can name it even if the interrupt fires before `_inject_rtk_instructions` returns. Introduces a small `_emit_wrap_ interrupted` helper. M1 + M5: Documented the uninstall procedure (hand-remove the `<!-- headroom:rtk-instructions -->` block) and the lean-ctx agent-name caveat in each of the four new subcommand docstrings. We chose docstring guidance over `unwrap cline|continue|goose|openhands` subcommands to keep the PR scoped. Also documented Continue's modern YAML-first config in the `continue` docstring so users on the YAML schema know this command only handles the JSON variant. Tests: +9 new tests across the 4 wrap test files exercising H1 refusal (dict/list/int parametrized × top-level + per-model), M2 per-model injection + idempotency + non-dict-entry skip, M3 rtk install failure abort + `--no-context-tool` bypass, and M4 KeyboardInterrupt-during- prelude flows for all four agents. Cosmetic: Removed the misleading "re-invocation in the same shell session" comment from openhands; the marker guard is for pre-existing env vars.
2026-05-25 11:54:06 -07:00
# ---------------------------------------------------------------------------
# M3: rtk install failure must fail loudly — no silent fallback to env
# injection without rtk on disk.
# ---------------------------------------------------------------------------
def test_wrap_openhands_rtk_install_failure_aborts_loudly(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If rtk install fails, command must exit non-zero with a clear error."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
monkeypatch.delenv("OPENHANDS_INSTRUCTIONS", raising=False)
launch_called: list[bool] = []
def fake_launch_tool(**kwargs): # noqa: ANN003
launch_called.append(True)
with patch.object(wrap_mod.shutil, "which", return_value="openhands"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=None):
result = runner.invoke(main, ["wrap", "openhands"])
assert result.exit_code == 1
assert "rtk install failed" in result.output
assert "--no-context-tool" in result.output
# _launch_tool must NOT have been invoked when rtk install fails.
assert launch_called == []
def test_wrap_openhands_rtk_install_failure_with_no_context_tool_still_launches(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""--no-context-tool bypasses rtk entirely — should still launch."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("OPENHANDS_INSTRUCTIONS", raising=False)
captured: dict[str, object] = {}
def fake_launch_tool(**kwargs): # noqa: ANN003
captured.update(kwargs)
with patch.object(wrap_mod.shutil, "which", return_value="openhands"):
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=None) as ensure:
result = runner.invoke(main, ["wrap", "openhands", "--no-context-tool"])
assert result.exit_code == 0, result.output
# rtk should never have been queried.
ensure.assert_not_called()
env = captured["env"]
assert "OPENHANDS_INSTRUCTIONS" not in env or env["OPENHANDS_INSTRUCTIONS"] == ""
# ---------------------------------------------------------------------------
# M4: Ctrl-C during prelude emits a clear "no on-disk changes" message.
# openhands never writes to disk (env-var injection only).
# ---------------------------------------------------------------------------
def test_wrap_openhands_keyboardinterrupt_during_prelude_emits_clear_message(
runner: CliRunner,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ctrl-C during the prelude must signal cleanly with no on-disk artifact."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
monkeypatch.delenv("OPENHANDS_INSTRUCTIONS", raising=False)
with patch.object(wrap_mod, "_ensure_rtk_binary", side_effect=KeyboardInterrupt):
result = runner.invoke(main, ["wrap", "openhands"])
assert result.exit_code == 130
assert "interrupted" in result.output.lower()
assert "idempotent" in result.output.lower()