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 continue` command (PR-G1, Phase G)."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
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_inject_continue_rtk_systemmessage_new_file(tmp_path: Path) -> None:
|
|
|
|
|
"""Writing into a non-existent config.json creates parents + sets systemMessage."""
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
assert not config_file.exists()
|
|
|
|
|
|
|
|
|
|
assert wrap_mod._inject_continue_rtk_systemmessage(config_file) is True
|
|
|
|
|
|
|
|
|
|
data = json.loads(config_file.read_text())
|
|
|
|
|
assert wrap_mod._RTK_MARKER in data["systemMessage"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inject_continue_rtk_systemmessage_preserves_existing_keys(tmp_path: Path) -> None:
|
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
|
|
|
"""Pre-existing keys are not touched; per-model entries get systemMessage."""
|
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
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
config_file.parent.mkdir(parents=True)
|
|
|
|
|
config_file.write_text(json.dumps({"models": [{"title": "GPT-4o", "provider": "openai"}]}))
|
|
|
|
|
|
|
|
|
|
wrap_mod._inject_continue_rtk_systemmessage(config_file)
|
|
|
|
|
|
|
|
|
|
data = json.loads(config_file.read_text())
|
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
|
|
|
# Pre-existing fields on the model entry are preserved verbatim.
|
|
|
|
|
assert data["models"][0]["title"] == "GPT-4o"
|
|
|
|
|
assert data["models"][0]["provider"] == "openai"
|
|
|
|
|
# Top-level systemMessage is set.
|
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
|
|
|
assert wrap_mod._RTK_MARKER in data["systemMessage"]
|
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
|
|
|
# Per-model systemMessage is also populated (Continue overrides top-level
|
|
|
|
|
# with per-model when set, so we must visit each model).
|
|
|
|
|
assert wrap_mod._RTK_MARKER in data["models"][0]["systemMessage"]
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inject_continue_rtk_systemmessage_appends_to_existing_message(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Pre-existing systemMessage content is preserved; rtk block is appended."""
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
config_file.parent.mkdir(parents=True)
|
|
|
|
|
existing_msg = "You are a helpful assistant."
|
|
|
|
|
config_file.write_text(json.dumps({"systemMessage": existing_msg}))
|
|
|
|
|
|
|
|
|
|
wrap_mod._inject_continue_rtk_systemmessage(config_file)
|
|
|
|
|
|
|
|
|
|
data = json.loads(config_file.read_text())
|
|
|
|
|
assert data["systemMessage"].startswith(existing_msg)
|
|
|
|
|
assert wrap_mod._RTK_MARKER in data["systemMessage"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inject_continue_rtk_systemmessage_idempotent(tmp_path: Path) -> None:
|
|
|
|
|
"""Re-injection must not duplicate the marker."""
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
|
|
|
|
|
wrap_mod._inject_continue_rtk_systemmessage(config_file)
|
|
|
|
|
wrap_mod._inject_continue_rtk_systemmessage(config_file)
|
|
|
|
|
|
|
|
|
|
data = json.loads(config_file.read_text())
|
|
|
|
|
assert data["systemMessage"].count(wrap_mod._RTK_MARKER) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inject_continue_rtk_systemmessage_refuses_invalid_json(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Malformed JSON must be left untouched and the helper must return False."""
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
config_file.parent.mkdir(parents=True)
|
|
|
|
|
malformed = '{ "models": [ this is not valid json'
|
|
|
|
|
config_file.write_text(malformed)
|
|
|
|
|
|
|
|
|
|
result = wrap_mod._inject_continue_rtk_systemmessage(config_file)
|
|
|
|
|
|
|
|
|
|
assert result is False
|
|
|
|
|
assert config_file.read_text() == malformed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inject_continue_rtk_systemmessage_refuses_non_object_root(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A JSON array at the root is not a valid Continue config; leave untouched."""
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
config_file.parent.mkdir(parents=True)
|
|
|
|
|
config_file.write_text("[]")
|
|
|
|
|
|
|
|
|
|
result = wrap_mod._inject_continue_rtk_systemmessage(config_file)
|
|
|
|
|
|
|
|
|
|
assert result is False
|
|
|
|
|
assert config_file.read_text() == "[]"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wrap_continue_prepare_only_injects_systemmessage(
|
|
|
|
|
runner: CliRunner,
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""`wrap continue --prepare-only` injects into ./.continue/config.json by default."""
|
|
|
|
|
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", "continue", "--prepare-only"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
assert config_file.exists()
|
|
|
|
|
data = json.loads(config_file.read_text())
|
|
|
|
|
assert wrap_mod._RTK_MARKER in data["systemMessage"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wrap_continue_respects_custom_config_path(
|
|
|
|
|
runner: CliRunner,
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""--config writes to the user-specified path, not the cwd default."""
|
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
|
|
|
custom_config = tmp_path / "custom" / "my-continue.json"
|
|
|
|
|
|
|
|
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
|
|
|
result = runner.invoke(
|
|
|
|
|
main,
|
|
|
|
|
["wrap", "continue", "--prepare-only", "--config", str(custom_config)],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert custom_config.exists()
|
|
|
|
|
assert not (tmp_path / ".continue" / "config.json").exists()
|
|
|
|
|
data = json.loads(custom_config.read_text())
|
|
|
|
|
assert wrap_mod._RTK_MARKER in data["systemMessage"]
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# H1: non-string systemMessage must NOT be silently clobbered.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"non_string_value",
|
|
|
|
|
[
|
|
|
|
|
{"role": "system", "content": "You are helpful."}, # dict
|
|
|
|
|
["You are helpful.", "Respond in JSON."], # list
|
|
|
|
|
42, # int
|
|
|
|
|
],
|
|
|
|
|
ids=["dict", "list", "int"],
|
|
|
|
|
)
|
|
|
|
|
def test_inject_continue_rtk_systemmessage_refuses_non_string_top_level(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
non_string_value: object,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A non-string top-level systemMessage must NOT be overwritten."""
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
config_file.parent.mkdir(parents=True)
|
|
|
|
|
original_payload = {"systemMessage": non_string_value, "other": "untouched"}
|
|
|
|
|
config_file.write_text(json.dumps(original_payload))
|
|
|
|
|
original_bytes = config_file.read_bytes()
|
|
|
|
|
|
|
|
|
|
result = wrap_mod._inject_continue_rtk_systemmessage(config_file)
|
|
|
|
|
|
|
|
|
|
assert result is False, "must report refusal when user data would be clobbered"
|
|
|
|
|
# File must be byte-identical to before.
|
|
|
|
|
assert config_file.read_bytes() == original_bytes
|
|
|
|
|
data = json.loads(config_file.read_text())
|
|
|
|
|
assert data["systemMessage"] == non_string_value
|
|
|
|
|
assert data["other"] == "untouched"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"non_string_value",
|
|
|
|
|
[
|
|
|
|
|
{"role": "system", "content": "Per-model system."},
|
|
|
|
|
["List", "of", "strings"],
|
|
|
|
|
7,
|
|
|
|
|
],
|
|
|
|
|
ids=["dict", "list", "int"],
|
|
|
|
|
)
|
|
|
|
|
def test_inject_continue_rtk_systemmessage_refuses_non_string_per_model(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
non_string_value: object,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A non-string per-model systemMessage must NOT be overwritten."""
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
config_file.parent.mkdir(parents=True)
|
|
|
|
|
original_payload = {
|
|
|
|
|
"models": [
|
|
|
|
|
{"title": "GPT-4o", "provider": "openai", "systemMessage": non_string_value},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
config_file.write_text(json.dumps(original_payload))
|
|
|
|
|
|
|
|
|
|
result = wrap_mod._inject_continue_rtk_systemmessage(config_file)
|
|
|
|
|
|
|
|
|
|
assert result is False, "must report refusal when per-model user data would be clobbered"
|
|
|
|
|
data = json.loads(config_file.read_text())
|
|
|
|
|
# The non-string per-model value must be preserved.
|
|
|
|
|
assert data["models"][0]["systemMessage"] == non_string_value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# M2: per-model systemMessage handling.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inject_continue_rtk_systemmessage_visits_each_model(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Each models[i].systemMessage gets the RTK block."""
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
config_file.parent.mkdir(parents=True)
|
|
|
|
|
config_file.write_text(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"models": [
|
|
|
|
|
{"title": "A", "systemMessage": "user value"},
|
|
|
|
|
{"title": "B"}, # no systemMessage yet
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert wrap_mod._inject_continue_rtk_systemmessage(config_file) is True
|
|
|
|
|
|
|
|
|
|
data = json.loads(config_file.read_text())
|
|
|
|
|
# Pre-existing per-model systemMessage is preserved + RTK block appended.
|
|
|
|
|
assert "user value" in data["models"][0]["systemMessage"]
|
|
|
|
|
assert wrap_mod._RTK_MARKER in data["models"][0]["systemMessage"]
|
|
|
|
|
# Model with no systemMessage gets the RTK block fresh.
|
|
|
|
|
assert wrap_mod._RTK_MARKER in data["models"][1]["systemMessage"]
|
|
|
|
|
# Top-level also populated.
|
|
|
|
|
assert wrap_mod._RTK_MARKER in data["systemMessage"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inject_continue_rtk_systemmessage_per_model_idempotent(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Re-running must not duplicate per-model RTK blocks."""
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
config_file.parent.mkdir(parents=True)
|
|
|
|
|
config_file.write_text(
|
|
|
|
|
json.dumps({"models": [{"title": "A", "systemMessage": "user"}]}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
wrap_mod._inject_continue_rtk_systemmessage(config_file)
|
|
|
|
|
wrap_mod._inject_continue_rtk_systemmessage(config_file)
|
|
|
|
|
|
|
|
|
|
data = json.loads(config_file.read_text())
|
|
|
|
|
assert data["models"][0]["systemMessage"].count(wrap_mod._RTK_MARKER) == 1
|
|
|
|
|
assert data["systemMessage"].count(wrap_mod._RTK_MARKER) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_inject_continue_rtk_systemmessage_skips_non_dict_model_entries(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A models[] entry that isn't a dict must be left untouched."""
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
config_file.parent.mkdir(parents=True)
|
|
|
|
|
config_file.write_text(
|
|
|
|
|
json.dumps({"models": ["just a string entry", {"title": "B"}]}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
wrap_mod._inject_continue_rtk_systemmessage(config_file)
|
|
|
|
|
|
|
|
|
|
data = json.loads(config_file.read_text())
|
|
|
|
|
# Non-dict entry preserved verbatim.
|
|
|
|
|
assert data["models"][0] == "just a string entry"
|
|
|
|
|
# Dict entry got the RTK block.
|
|
|
|
|
assert wrap_mod._RTK_MARKER in data["models"][1]["systemMessage"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# M4: Ctrl-C during prelude emits a clear message.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wrap_continue_keyboardinterrupt_during_prelude_emits_clear_message(
|
|
|
|
|
runner: CliRunner,
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Ctrl-C between marker injection and proxy start must signal clearly."""
|
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
|
|
|
|
|
|
|
|
config_file = tmp_path / ".continue" / "config.json"
|
|
|
|
|
|
|
|
|
|
def raise_kbd_after_inject(*args, **kwargs): # noqa: ANN002, ANN003
|
|
|
|
|
# Simulate the user hitting Ctrl-C right after we wrote config.json
|
|
|
|
|
# but before the proxy started.
|
|
|
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
config_file.write_text('{"systemMessage": "marker block"}')
|
|
|
|
|
raise KeyboardInterrupt
|
|
|
|
|
|
|
|
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", side_effect=raise_kbd_after_inject):
|
|
|
|
|
result = runner.invoke(main, ["wrap", "continue", "--prepare-only"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 130
|
|
|
|
|
assert "interrupted" in result.output.lower()
|
|
|
|
|
assert "idempotent" in result.output.lower()
|
|
|
|
|
assert config_file.exists()
|
|
|
|
|
assert str(config_file) in result.output
|