0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
183 lines
6.7 KiB
Python
183 lines
6.7 KiB
Python
"""Shared hint-file agent tests for `headroom wrap {cline,goose}` (PR-G1).
|
|
|
|
Cline and Goose are different wrap patterns (cline is proxy-only watcher,
|
|
goose launches a child binary) but both inject the RTK guidance into a
|
|
*hint file* at the project root — `.clinerules` and `.goosehints` —
|
|
through the same code path (`_inject_rtk_instructions` via the shared
|
|
`_setup_context_tool_for_agent` helper).
|
|
|
|
That hint-file plumbing is the same for both, so the tests covering it
|
|
parametrize over `(agent, hint_filename)` here. Agent-specific behavior
|
|
(goose's env-var fan-out, goose's binary discovery, cline's IDE setup
|
|
print-out) lives in `test_wrap_goose.py` / `test_wrap_cline.py`
|
|
respectively — those files keep only what genuinely diverges per agent.
|
|
"""
|
|
|
|
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
|
|
|
|
# (subcommand, hint-file basename) — used by every test below.
|
|
HINTFILE_AGENTS = [
|
|
pytest.param("cline", ".clinerules", id="cline"),
|
|
pytest.param("goose", ".goosehints", id="goose"),
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def runner() -> CliRunner:
|
|
return CliRunner()
|
|
|
|
|
|
@pytest.mark.parametrize("agent,hintfile", HINTFILE_AGENTS)
|
|
def test_prepare_only_injects_rtk_into_hintfile(
|
|
agent: str,
|
|
hintfile: str,
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""`wrap <agent> --prepare-only` writes the RTK block to the hint file at cwd."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", agent, "--prepare-only"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
marker = tmp_path / hintfile
|
|
assert marker.exists(), f"{hintfile} should be created"
|
|
content = marker.read_text(encoding="utf-8")
|
|
assert wrap_mod._RTK_MARKER in content
|
|
assert "RTK (Rust Token Killer)" in content
|
|
|
|
|
|
@pytest.mark.parametrize("agent,hintfile", HINTFILE_AGENTS)
|
|
def test_prepare_only_idempotent_no_duplicate_block(
|
|
agent: str,
|
|
hintfile: str,
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Running prepare-only twice must not duplicate the RTK block in the hint file."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
runner.invoke(main, ["wrap", agent, "--prepare-only"])
|
|
runner.invoke(main, ["wrap", agent, "--prepare-only"])
|
|
|
|
content = (tmp_path / hintfile).read_text(encoding="utf-8")
|
|
assert content.count(wrap_mod._RTK_MARKER) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("agent,hintfile", HINTFILE_AGENTS)
|
|
def test_no_context_tool_does_not_create_hintfile(
|
|
agent: str,
|
|
hintfile: str,
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""--no-context-tool must not create the hint file and must not invoke rtk."""
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary") as ensure:
|
|
result = runner.invoke(main, ["wrap", agent, "--prepare-only", "--no-context-tool"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert not (tmp_path / hintfile).exists()
|
|
ensure.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("agent,hintfile", HINTFILE_AGENTS)
|
|
def test_preserves_existing_hintfile_content(
|
|
agent: str,
|
|
hintfile: str,
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Pre-existing hint-file content must be preserved when RTK is appended."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
marker_path = tmp_path / hintfile
|
|
original = "# Project conventions\n\nAlways use Python 3.12.\n"
|
|
marker_path.write_text(original, encoding="utf-8")
|
|
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", agent, "--prepare-only"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
content = marker_path.read_text(encoding="utf-8")
|
|
assert "Always use Python 3.12." in content
|
|
assert wrap_mod._RTK_MARKER in content
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# M4: Ctrl-C during prelude emits a clear "interrupted, marker may be on disk"
|
|
# message and exits non-zero (130, the conventional shell signal-130 code).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("agent,hintfile", HINTFILE_AGENTS)
|
|
def test_keyboardinterrupt_during_prelude_emits_clear_message(
|
|
agent: str,
|
|
hintfile: str,
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Ctrl-C after marker injection but before proxy startup must report clearly."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
|
|
def raise_kbd(*args, **kwargs): # noqa: ANN002, ANN003
|
|
# Simulate the user hitting Ctrl-C right after the prelude wrote the
|
|
# hint-file marker but before _ensure_proxy returns. We trigger via
|
|
# _ensure_rtk_binary side-effect so the marker exists on disk.
|
|
marker_path = tmp_path / hintfile
|
|
marker_path.write_text(wrap_mod.RTK_INSTRUCTIONS_BLOCK, encoding="utf-8")
|
|
raise KeyboardInterrupt
|
|
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", side_effect=raise_kbd):
|
|
result = runner.invoke(main, ["wrap", agent, "--prepare-only"])
|
|
|
|
assert result.exit_code == 130
|
|
assert "interrupted" in result.output.lower()
|
|
assert "idempotent" in result.output.lower()
|
|
assert (tmp_path / hintfile).exists()
|
|
assert hintfile in result.output
|
|
|
|
|
|
@pytest.mark.parametrize("agent,hintfile", HINTFILE_AGENTS)
|
|
def test_inject_rtk_handles_utf8_content(
|
|
agent: str,
|
|
hintfile: str,
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Existing hint files with non-ASCII UTF-8 content must not crash (#1126)."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
marker_path = tmp_path / hintfile
|
|
original = "# Instructions\n\nUse “smart quotes” and an em dash — here.\n"
|
|
marker_path.write_text(original, encoding="utf-8")
|
|
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", agent, "--prepare-only"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
content = marker_path.read_text(encoding="utf-8")
|
|
assert "“smart quotes”" in content
|
|
assert wrap_mod._RTK_MARKER in content
|