mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description `headroom/mcp_registry/install.py` (`build_serena_spec`) and the wrap-time Serena pre-index in `headroom/cli/wrap.py` both ran: ``` uvx --from git+https://github.com/oraios/serena serena ... ``` The git source forces a from-source build. On proot-based filesystems (Termux + proot-distro on Android, some restricted Linux) `uv` cannot hardlink build dependencies into a fresh build venv, so the build fails immediately and Serena's MCP server fails to start on every `headroom wrap codex` launch: ``` × Failed to download and build `serena-agent @ git+https://github.com/oraios/serena@<commit>` ╰─▶ failed to hardlink file ... Operation not permitted (os error 1) ``` Setting `UV_LINK_MODE=copy` fixes it in an interactive shell, but Codex strips most env vars from the MCP subprocesses it spawns, so that workaround does not reliably reach Serena's launch. Serena publishes the official `serena-agent` package to PyPI with prebuilt wheels, and it exposes the same `serena` console script (`serena = "serena.cli:top_level"` in the project's `pyproject.toml`), so `uvx --from serena-agent serena ...` runs the identical command without a build step. On platforms where the git build already worked there is no functional difference. Fixes #2871 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/mcp_registry/install.py` (`build_serena_spec`): `--from git+https://github.com/oraios/serena` -> `--from serena-agent`. - `headroom/cli/wrap.py` (Serena `project index` pre-warm): same swap. - `tests/test_mcp_registry/test_install.py`: updated the spec assertion and added `test_build_serena_spec_uses_pypi_not_git_source` (asserts `serena-agent` is used and no `git+` source remains). - `tests/test_cli/test_wrap_serena_boost.py`: the pre-index test now asserts `serena-agent` is in the command and the git source is not. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text # Fail-before (source swap stashed, updated tests kept): tests/test_mcp_registry/test_install.py::test_build_serena_spec_uses_agent_context FAILED tests/test_mcp_registry/test_install.py::test_build_serena_spec_uses_pypi_not_git_source FAILED tests/test_cli/test_wrap_serena_boost.py::test_preindex_runs_serena_in_cwd FAILED # Pass-after: tests/test_mcp_registry/ tests/test_cli/test_wrap_serena_boost.py tests/test_cli/test_serena_migrate.py tests/test_cli/test_serena_disable.py 135 passed # uvx ruff@0.15.17 check -> All checks passed! # uvx mypy@1.20.2 headroom/mcp_registry/install.py -> Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1, ruff 0.15.17 and mypy 1.20.2 via uvx. - Exact command / steps: confirmed `serena-agent` exists on PyPI (v1.6.1, homepage github.com/oraios/serena) and that its `pyproject.toml` declares `[project.scripts] serena = "serena.cli:top_level"`, so the `serena start-mcp-server ...` invocation is unchanged. Swapped both `--from` sources, then fail-before with `git stash push headroom/mcp_registry/install.py headroom/cli/wrap.py` (the two production-asserting tests fail on the old git source) and pass-after with `git stash pop` (135 serena-suite tests pass). Verified no `git+https://github.com/oraios/serena` references remain in `headroom/`. - Observed result: `build_serena_spec` and the pre-index command now install Serena from the `serena-agent` PyPI wheel, so a proot environment gets the prebuilt wheel instead of a from-source build that cannot hardlink. The migration/ledger tests, which use the old git spec as a deliberately-stale fixture, are unaffected. - Not tested: a live `headroom wrap codex` on a real proot/Termux device (not available here). The change is a package-source swap verified against Serena's own published package metadata and the existing spec/command tests. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md`: it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Additional Notes The git source was unpinned (tracked the repo default branch), so switching to `serena-agent` from PyPI does not lose a version pin; if anything it is more reproducible. The issue reporter also noted that `headroom wrap codex` force-rewrites the Serena block in `~/.codex/config.toml` from this template on every launch, which is why the fix has to live in the package source rather than a user config edit -- this PR puts it there.
188 lines
6.8 KiB
Python
188 lines
6.8 KiB
Python
"""Serena "boost" wrap-time helpers: prefer-Serena instruction injection,
|
|
repo-language scoping of ``.serena/project.yml``, and symbol-cache pre-indexing.
|
|
|
|
All Serena subprocess calls are mocked — these tests never invoke real ``uvx``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from headroom.cli import wrap as wrap_cli
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _inject_serena_instructions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _opt_in(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Enable the opt-in gate so injection actually writes.
|
|
|
|
Instruction injection rewrites the user's CLAUDE.md/AGENTS.md, so it is
|
|
off by default. Tests that exercise the write path must opt in via
|
|
``HEADROOM_SERENA_INSTRUCTIONS``.
|
|
"""
|
|
monkeypatch.setenv("HEADROOM_SERENA_INSTRUCTIONS", "1")
|
|
|
|
|
|
def test_inject_creates_file_and_mentions_tools(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_opt_in(monkeypatch)
|
|
target = tmp_path / "AGENTS.md"
|
|
assert wrap_cli._inject_serena_instructions(target) is True
|
|
|
|
content = target.read_text()
|
|
assert wrap_cli._SERENA_MARKER in content
|
|
# The whole point is steering the agent toward Serena's symbol tools.
|
|
for tool in ("get_symbols_overview", "find_symbol", "find_referencing_symbols"):
|
|
assert tool in content, f"{tool} missing from injected guidance"
|
|
|
|
|
|
def test_inject_is_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_opt_in(monkeypatch)
|
|
target = tmp_path / "AGENTS.md"
|
|
wrap_cli._inject_serena_instructions(target)
|
|
wrap_cli._inject_serena_instructions(target) # second call is a no-op
|
|
|
|
content = target.read_text()
|
|
assert content.count(wrap_cli._SERENA_MARKER) == 1
|
|
|
|
|
|
def test_inject_appends_to_existing_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_opt_in(monkeypatch)
|
|
target = tmp_path / "CLAUDE.md"
|
|
target.write_text("# Project notes\n\nkeep me\n")
|
|
wrap_cli._inject_serena_instructions(target)
|
|
|
|
content = target.read_text()
|
|
assert "keep me" in content # existing content preserved
|
|
assert wrap_cli._SERENA_MARKER in content
|
|
|
|
|
|
def test_inject_off_by_default_writes_nothing(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Without opting in, injection is a no-op: returns False and never touches
|
|
# the user's hint file (the default, so the two OpenCode AGENTS.md tests pass).
|
|
monkeypatch.delenv("HEADROOM_SERENA_INSTRUCTIONS", raising=False)
|
|
|
|
missing = tmp_path / "AGENTS.md"
|
|
assert wrap_cli._inject_serena_instructions(missing) is False
|
|
assert not missing.exists() # nothing created
|
|
|
|
existing = tmp_path / "CLAUDE.md"
|
|
existing.write_text("# Project notes\n\nkeep me\n")
|
|
assert wrap_cli._inject_serena_instructions(existing) is False
|
|
assert existing.read_text() == "# Project notes\n\nkeep me\n" # untouched
|
|
assert wrap_cli._SERENA_MARKER not in existing.read_text()
|
|
|
|
|
|
def test_instruction_file_target_per_agent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
class _Reg:
|
|
def __init__(self, name: str) -> None:
|
|
self.name = name
|
|
|
|
assert wrap_cli._serena_instruction_file(_Reg("claude")).name == "CLAUDE.md"
|
|
assert wrap_cli._serena_instruction_file(_Reg("codex")).name == "AGENTS.md"
|
|
assert wrap_cli._serena_instruction_file(_Reg("grok")).name == "AGENTS.md"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _index_serena_project — best-effort, timeout-guarded pre-index
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stub_uvx(monkeypatch: pytest.MonkeyPatch, present: bool = True) -> None:
|
|
monkeypatch.setattr(
|
|
wrap_cli.shutil,
|
|
"which",
|
|
lambda name, *a, **k: "/usr/bin/uvx" if (present and name == "uvx") else None,
|
|
)
|
|
|
|
|
|
def test_preindex_runs_serena_in_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_stub_uvx(monkeypatch)
|
|
mock_run = Mock(
|
|
return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
)
|
|
monkeypatch.setattr(wrap_cli, "run", mock_run)
|
|
|
|
wrap_cli._index_serena_project()
|
|
|
|
mock_run.assert_called_once()
|
|
args, kwargs = mock_run.call_args
|
|
cmd = args[0]
|
|
assert cmd[0] == "uvx"
|
|
assert cmd[-3:] == ["serena", "project", "index"]
|
|
# PyPI package with prebuilt wheels, not the git source (#2871).
|
|
assert "serena-agent" in cmd
|
|
assert "git+https://github.com/oraios/serena" not in cmd
|
|
assert kwargs["cwd"] == str(tmp_path) # invoked in the project cwd
|
|
assert "timeout" in kwargs # timeout-guarded
|
|
|
|
|
|
def test_preindex_skips_without_uvx(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_stub_uvx(monkeypatch, present=False)
|
|
mock_run = Mock(side_effect=AssertionError("run must not be called without uvx"))
|
|
monkeypatch.setattr(wrap_cli, "run", mock_run)
|
|
|
|
wrap_cli._index_serena_project() # no exception
|
|
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
def test_preindex_timeout_is_non_fatal(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_stub_uvx(monkeypatch)
|
|
monkeypatch.setattr(
|
|
wrap_cli,
|
|
"run",
|
|
Mock(side_effect=subprocess.TimeoutExpired(cmd="serena", timeout=1)),
|
|
)
|
|
# Must not propagate.
|
|
wrap_cli._index_serena_project(verbose=True)
|
|
|
|
|
|
def test_preindex_generic_error_is_non_fatal(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_stub_uvx(monkeypatch)
|
|
monkeypatch.setattr(wrap_cli, "run", Mock(side_effect=RuntimeError("boom")))
|
|
# Must not propagate.
|
|
wrap_cli._index_serena_project(verbose=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _serena_project_skip_reason — keep per-project setup off non-project roots
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_skip_reason_none_for_ordinary_project(tmp_path: Path) -> None:
|
|
assert wrap_cli._serena_project_skip_reason(tmp_path) is None
|
|
|
|
|
|
def test_skip_reason_none_for_normal_checkout(tmp_path: Path) -> None:
|
|
(tmp_path / ".git").mkdir() # real checkout: .git is a directory
|
|
|
|
assert wrap_cli._serena_project_skip_reason(tmp_path) is None
|
|
|
|
|
|
def test_skip_reason_flags_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
|
|
|
|
assert wrap_cli._serena_project_skip_reason(tmp_path) == "$HOME is not a project"
|
|
|
|
|
|
def test_skip_reason_flags_linked_worktree(tmp_path: Path) -> None:
|
|
(tmp_path / ".git").write_text("gitdir: /repo/.git/worktrees/wt\n")
|
|
|
|
assert wrap_cli._serena_project_skip_reason(tmp_path) == "linked git worktree"
|
|
|
|
|
|
def test_skip_reason_survives_unresolvable_root(tmp_path: Path) -> None:
|
|
assert wrap_cli._serena_project_skip_reason(tmp_path / "gone") is None
|