Files
hermes-agent/tests/agent/test_context_file_sources.py
Teknium 35f8786971 feat(agent): truncated context files name the sections that were dropped
When a context file still overflows (small windows, other repositories), the marker
listed only char counts, so the agent could not tell whether what it lost mattered.
It now lists the markdown headings in the omitted middle (fenced # comments ignored).
2026-10-04 23:05:42 -07:00

116 lines
6.1 KiB
Python

"""Per-file context manifest (``agent/context_file_sources.py``) behind the ``/context`` Rules figure.
The manifest and ``build_context_files_prompt`` share one discovery walk, so the invariant under test is
parity: a file is reported ``loaded`` iff its content appears in the built prompt.
"""
from pathlib import Path
import pytest
from agent.context_file_sources import list_context_file_sources, render_context_file_lines
from agent.prompt_builder import build_context_files_prompt
@pytest.fixture()
def project(tmp_path):
(tmp_path / ".git").mkdir()
return tmp_path
def _by_label(sources):
return {s["label"]: s for s in sources}
def test_manifest_matches_what_the_prompt_actually_loads(project, tmp_path_factory):
"""Every context type present at once; the ladder picks .hermes.md, the chain lists both AGENTS files,
CLAUDE.md/.cursorrules/.cursor/rules/*.mdc are shadowed, an empty file never wins, SOUL.md rides along."""
(project / ".hermes.md").write_text("hermes rules")
(project / "AGENTS.md").write_text("root agents rules")
sub = project / "pkg"
sub.mkdir()
(sub / "AGENTS.override.md").write_text("") # empty: falls through to AGENTS.md in the same directory
(sub / "AGENTS.md").write_text("pkg agents rules")
(sub / "CLAUDE.md").write_text("claude rules")
(sub / ".cursorrules").write_text("cursor rules")
(sub / ".cursor" / "rules").mkdir(parents=True)
(sub / ".cursor" / "rules" / "a.mdc").write_text("mdc rule a")
home = tmp_path_factory.mktemp("home")
(home / "SOUL.md").write_text("identity text")
sources = list_context_file_sources(cwd=str(sub), home_override=home)
prompt = build_context_files_prompt(cwd=str(sub), home_override=home)
statuses = {s["label"]: s["status"] for s in sources}
assert statuses == {
".hermes.md": "loaded", "../AGENTS.md": "shadowed", "AGENTS.override.md": "empty", "AGENTS.md": "shadowed",
"CLAUDE.md": "shadowed", ".cursorrules": "shadowed", ".cursor/rules/a.mdc": "shadowed", "SOUL.md": "loaded",
}
for src in sources:
body = Path(src["path"]).read_text().strip() if src["chars"] else ""
assert src["loaded"] == (bool(body) and body in prompt), src
assert all(s["est_tokens"] > 0 for s in sources if s["chars"])
# Same walk, other winner: drop .hermes.md and the whole AGENTS chain loads while the rest stays shadowed.
(project / ".hermes.md").unlink()
statuses = {s["label"]: s["status"] for s in list_context_file_sources(cwd=str(sub), home_override=home)}
prompt = build_context_files_prompt(cwd=str(sub), home_override=home)
assert statuses["../AGENTS.md"] == statuses["AGENTS.md"] == "loaded" and "root agents rules" in prompt
assert statuses["CLAUDE.md"] == "shadowed" and "claude rules" not in prompt
def test_truncated_and_suppressed_statuses_follow_the_builder(project, monkeypatch, tmp_path_factory):
import agent.prompt_builder as pb
monkeypatch.setattr(pb, "_get_context_file_max_chars", lambda *_a: 40)
(project / "AGENTS.md").write_text("x" * 100)
home = tmp_path_factory.mktemp("home")
entry = _by_label(list_context_file_sources(cwd=str(project), home_override=home))["AGENTS.md"]
assert entry["status"] == "truncated" and entry["loaded"] is True
assert "[...truncated AGENTS.md" in build_context_files_prompt(cwd=str(project), home_override=home)
# Install-tree guard: a fallback cwd (cwd=None) inside the Hermes tree lists the file but never loads it.
monkeypatch.setattr("agent.runtime_cwd._is_install_tree", lambda _p: True)
monkeypatch.chdir(project)
entry = _by_label(list_context_file_sources(cwd=None, home_override=home))["AGENTS.md"]
assert entry["status"] == "suppressed" and entry["loaded"] is False
assert build_context_files_prompt(cwd=None, skip_soul=True) == ""
assert _by_label(list_context_file_sources(cwd=None, allow_install_tree_fallback=True, home_override=home))[
"AGENTS.md"]["status"] == "truncated"
lines = render_context_file_lines(list_context_file_sources(cwd=None, home_override=home))
assert lines[0] == "Context files" and "AGENTS.md" in lines[1] and "install tree" in lines[1]
assert render_context_file_lines([]) == []
# Injection scan: the builder swaps the body for a BLOCKED marker, so the manifest must not say "loaded".
monkeypatch.setattr(pb, "_get_context_file_max_chars", lambda *_a: 10_000)
monkeypatch.setattr(pb, "_scan_for_threats", lambda content, scope: ["fake-pattern"] if "evil" in content else [])
(project / "AGENTS.md").write_text("evil")
entry = _by_label(list_context_file_sources(cwd=str(project), home_override=home))["AGENTS.md"]
assert entry["status"] == "blocked" and entry["loaded"] is False
assert "[BLOCKED: AGENTS.md" in build_context_files_prompt(cwd=str(project), home_override=home)
# The user's own SOUL.md is flagged but loaded — the manifest must say so and the prompt must carry it.
(home / "SOUL.md").write_text("evil identity text")
entries = _by_label(list_context_file_sources(cwd=str(project), home_override=home))
assert entries["SOUL.md"]["status"] == "flagged" and entries["SOUL.md"]["loaded"] is True
assert entries["AGENTS.md"]["status"] == "blocked"
prompt = build_context_files_prompt(cwd=str(project), home_override=home)
assert "evil identity text" in prompt and "[BLOCKED: SOUL.md" not in prompt and "[BLOCKED: AGENTS.md" in prompt
assert any("SOUL.md" in line and "review the file" in line
for line in render_context_file_lines(list(entries.values())))
def test_truncation_marker_names_the_omitted_sections_only():
from agent.prompt_builder import _truncate_content
pad = "word " * 400
content = (
f"# Kept head\n{pad}\n## Lost section\n{pad}\n```bash\n# a shell comment\n```\n"
f"### Lost subsection\n{pad}\n{pad}\n## Kept tail\nshort\n"
)
out = _truncate_content(content, "AGENTS.md", max_chars=2500, queue_warning=False)
marker = out[out.index("[...truncated"):out.index("]", out.index("[...truncated"))]
assert "Lost section; Lost subsection" in marker
assert "Kept head" not in marker and "Kept tail" not in marker and "shell comment" not in marker