mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
feat(wrap): default code-memory to Serena (dashboard browser off) behind unified --code-memory (#2413)
## What
Two commits:
1. **Unify code-memory MCP selection behind `--code-memory
{tokensave|serena|none}`** (+ `HEADROOM_CODE_MEMORY`), collapsing the
`--serena`/`--no-serena`/`--no-tokensave` flag tangle into one selector.
Old flags remain as hidden deprecated aliases that map into it. Shared
across the code-memory-capable subcommands (claude/codex/grok).
2. **Default the engine to Serena**, with its **dashboard browser
suppressed**.
## Why Serena as default
Serena is a mature, offline, symbol-level code-navigation MCP with broad
language coverage (LSP-backed) — the strongest zero-account default for
reducing tokens by letting the agent query
symbols/definitions/references instead of reading whole files. It
attacks the *protected-reads* volume the proxy deliberately doesn't
compress, so it's complementary to the pipeline compressors.
## Browser suppression (in Serena's own settings)
`_ensure_serena_dashboard_disabled()` sets
`web_dashboard_open_on_launch: false` in `~/.serena/serena_config.yml`
when Serena is set up, so wrapped sessions don't spawn a browser tab.
The dashboard backend stays reachable manually at `localhost:24282`.
This lives in Serena's config (authoritative), not just a startup flag.
## Schema-overhead note
Serena injects tool schemas per request; that cost is deferred by the
tool-search deferral the coding profile already enables
(`HEADROOM_TOOL_SEARCH=1`), so tools load on demand — the navigation
benefit without a standing schema tax on turns that don't navigate.
## Selection / escape hatches
`--code-memory serena` (default) · `tokensave` (lighter/faster) · `none`
(disable). Deprecated `--serena`/`--no-serena`/`--no-tokensave` still
work.
## Testing
Updated the primary/backup policy test to the serena-primary default;
code-memory selector + serena disable/migrate tests pass. Local: 21
passed (policy + code-memory); ruff + mypy clean. Full suite in CI.
This commit is contained in:
parent
446ec26003
commit
6e4425a6bd
3 changed files with 288 additions and 36 deletions
115
tests/test_wrap_code_memory.py
Normal file
115
tests/test_wrap_code_memory.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Code-memory MCP is selectable via --code-memory (default tokensave).
|
||||
|
||||
Covers the resolver precedence (selector > deprecated flags > default), the
|
||||
orchestrator dispatch for each selection, and that --code-memory is exposed on
|
||||
the code-memory-capable subcommands (claude/codex/grok) but not others.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from headroom.cli import wrap
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
env.pop("HEADROOM_CODE_MEMORY", None)
|
||||
return env
|
||||
|
||||
|
||||
def test_default_is_serena() -> None:
|
||||
with patch.dict(os.environ, _clean_env(), clear=True):
|
||||
assert wrap._resolve_code_memory({}) == wrap._CODE_MEMORY_SERENA
|
||||
|
||||
|
||||
def test_selector_env_wins() -> None:
|
||||
for val in (wrap._CODE_MEMORY_SERENA, wrap._CODE_MEMORY_NONE, wrap._CODE_MEMORY_TOKENSAVE):
|
||||
with patch.dict(os.environ, {"HEADROOM_CODE_MEMORY": val}):
|
||||
# selector beats any legacy flag
|
||||
assert wrap._resolve_code_memory({"serena": True, "no_tokensave": True}) == val
|
||||
|
||||
|
||||
def test_deprecated_flags_map_into_selector() -> None:
|
||||
with patch.dict(os.environ, _clean_env(), clear=True):
|
||||
assert wrap._resolve_code_memory({"serena": True}) == wrap._CODE_MEMORY_SERENA
|
||||
assert wrap._resolve_code_memory({"no_tokensave": True}) == wrap._CODE_MEMORY_SERENA
|
||||
# --no-serena means "not serena" → the other real graph, tokensave
|
||||
assert wrap._resolve_code_memory({"no_serena": True}) == wrap._CODE_MEMORY_TOKENSAVE
|
||||
assert (
|
||||
wrap._resolve_code_memory({"no_tokensave": True, "no_serena": True})
|
||||
== wrap._CODE_MEMORY_NONE
|
||||
)
|
||||
|
||||
|
||||
def test_serena_dashboard_disabled_flips_existing_config(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
cfg = tmp_path / ".serena" / "serena_config.yml"
|
||||
cfg.parent.mkdir(parents=True)
|
||||
cfg.write_text(
|
||||
"web_dashboard: true\nweb_dashboard_open_on_launch: true\ngui_log_window: false\n"
|
||||
)
|
||||
wrap._ensure_serena_dashboard_disabled()
|
||||
text = cfg.read_text()
|
||||
assert "web_dashboard_open_on_launch: false" in text
|
||||
assert "web_dashboard: true" in text # other keys preserved
|
||||
|
||||
|
||||
def test_serena_dashboard_disabled_creates_config(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
wrap._ensure_serena_dashboard_disabled()
|
||||
cfg = tmp_path / ".serena" / "serena_config.yml"
|
||||
assert cfg.exists()
|
||||
assert "web_dashboard_open_on_launch: false" in cfg.read_text()
|
||||
|
||||
|
||||
def test_invalid_env_raises() -> None:
|
||||
import click
|
||||
|
||||
with patch.dict(os.environ, {"HEADROOM_CODE_MEMORY": "bogus"}):
|
||||
try:
|
||||
wrap._resolve_code_memory({})
|
||||
except click.ClickException:
|
||||
pass
|
||||
else: # pragma: no cover
|
||||
raise AssertionError("invalid HEADROOM_CODE_MEMORY should raise ClickException")
|
||||
|
||||
|
||||
def _dispatch_calls(selection: str, extra: dict | None = None) -> list[str]:
|
||||
"""Run the orchestrator with a given selection, recording which setup/disable
|
||||
helpers fire (all mocked)."""
|
||||
calls: list[str] = []
|
||||
env = _clean_env()
|
||||
env["HEADROOM_CODE_MEMORY"] = selection
|
||||
with (
|
||||
patch.dict(os.environ, env, clear=True),
|
||||
patch.object(
|
||||
wrap, "_setup_tokensave_mcp", lambda *a, **k: (calls.append("tokensave"), True)[1]
|
||||
),
|
||||
patch.object(wrap, "_setup_serena_mcp", lambda *a, **k: calls.append("serena")),
|
||||
patch.object(
|
||||
wrap, "_disable_tokensave_mcp", lambda *a, **k: calls.append("disable_tokensave")
|
||||
),
|
||||
patch.object(wrap, "_disable_serena_mcp", lambda *a, **k: calls.append("disable_serena")),
|
||||
):
|
||||
wrap._setup_coding_compressor(object(), serena_context="claude-code", **(extra or {}))
|
||||
return calls
|
||||
|
||||
|
||||
def test_orchestrator_dispatch() -> None:
|
||||
assert _dispatch_calls(wrap._CODE_MEMORY_TOKENSAVE) == ["tokensave", "disable_serena"]
|
||||
assert _dispatch_calls(wrap._CODE_MEMORY_SERENA) == ["disable_tokensave", "serena"]
|
||||
assert set(_dispatch_calls(wrap._CODE_MEMORY_NONE)) == {"disable_tokensave", "disable_serena"}
|
||||
|
||||
|
||||
def test_code_memory_option_present_only_on_code_memory_agents() -> None:
|
||||
runner = CliRunner()
|
||||
for tool in ("claude", "codex", "grok"):
|
||||
out = runner.invoke(wrap.wrap, [tool, "--help"]).output
|
||||
assert "--code-memory" in out, f"--code-memory missing from `wrap {tool} --help`"
|
||||
# aider does not register a code-memory MCP → no flag
|
||||
out = runner.invoke(wrap.wrap, ["aider", "--help"]).output
|
||||
assert "--code-memory" not in out
|
||||
Loading…
Add table
Add a link
Reference in a new issue