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).
116 lines
6.1 KiB
Python
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
|