Files
hermes-webui/tests/test_3866_project_context_memory_section.py
T
Matt Van Horn b139404cdc fix: guard blank session workspace + agent parity for project-context tab
- Return None for blank session.workspace instead of Path('').resolve()
  (which resolved to the server CWD and could surface the install's own
  AGENTS.md/HERMES.md as project context).
- Match the agent's lowercase filename variants (agents.md / claude.md).
- Read .cursor/rules/*.mdc, not just the legacy .cursorrules file.
- Strip YAML frontmatter and cap each source at 20k, mirroring what the
  agent actually injects.
- Regression tests for the guard, lowercase match, and frontmatter strip.
2026-06-14 03:38:26 +00:00

170 lines
6.3 KiB
Python

import json
import pathlib
from types import SimpleNamespace
from urllib.parse import urlencode
import api.profiles
import api.routes as routes
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
def project_context_for(workspace):
return routes._read_active_project_context(pathlib.Path(workspace))
def test_project_context_reads_agents_md_from_active_workspace(tmp_path):
workspace = tmp_path / "agents-only"
workspace.mkdir()
(workspace / "AGENTS.md").write_text("# Agent Rules\n\nUse pytest here.", encoding="utf-8")
data = project_context_for(workspace)
assert "Use pytest here." in data["content"]
assert data["path"].endswith("AGENTS.md")
assert data["name"] == "AGENTS.md"
assert data["shadowed"] == []
def test_project_context_prefers_hermes_md_and_reports_shadowed_agents(tmp_path):
workspace = tmp_path / "priority"
workspace.mkdir()
(workspace / "HERMES.md").write_text("# Hermes Rules\n\nHermes wins.", encoding="utf-8")
(workspace / "AGENTS.md").write_text("# Agent Rules\n\nAgents lose.", encoding="utf-8")
data = project_context_for(workspace)
assert "Hermes wins." in data["content"]
assert "Agents lose." not in data["content"]
assert data["path"].endswith("HERMES.md")
assert [item["name"] for item in data["shadowed"]] == ["AGENTS.md"]
assert data["shadowed"][0]["shadowed_by"] == "HERMES.md"
def test_project_context_walks_hermes_md_to_git_root_but_not_agents_md(tmp_path):
root = tmp_path / "repo"
child = root / "src" / "pkg"
child.mkdir(parents=True)
(root / ".git").mkdir()
(root / ".hermes.md").write_text("# Root Hermes\n\nRoot project rules.", encoding="utf-8")
(root / "AGENTS.md").write_text("# Root Agents\n\nRoot AGENTS should not be cwd-loaded.", encoding="utf-8")
data = project_context_for(child)
assert "Root project rules." in data["content"]
assert data["path"].endswith(".hermes.md")
assert data["path"].startswith(str(root))
assert data["shadowed"] == []
def test_project_context_workspace_switch_re_resolves_same_session(tmp_path, monkeypatch):
first = tmp_path / "first"
second = tmp_path / "second"
first.mkdir()
second.mkdir()
(first / "AGENTS.md").write_text("First workspace rules.", encoding="utf-8")
(second / "AGENTS.md").write_text("Second workspace rules.", encoding="utf-8")
current = {"workspace": str(first)}
def fake_get_session(_sid):
return SimpleNamespace(workspace=current["workspace"])
monkeypatch.setattr(routes, "get_session", fake_get_session)
parsed = SimpleNamespace(query=urlencode({"session_id": "sid"}))
before = routes._read_active_project_context(routes._memory_project_context_workspace(parsed))
assert "First workspace rules." in before["content"]
current["workspace"] = str(second)
after = routes._read_active_project_context(routes._memory_project_context_workspace(parsed))
assert "Second workspace rules." in after["content"]
assert "First workspace rules." not in after["content"]
assert after["workspace"] == str(second.resolve())
def test_project_context_absent_returns_empty_fields(tmp_path):
workspace = tmp_path / "empty"
workspace.mkdir()
(workspace / ".git").mkdir()
data = project_context_for(workspace)
assert data["content"] == ""
assert data["path"] == ""
assert data["mtime"] is None
assert data["shadowed"] == []
def test_project_context_content_is_redacted_in_memory_response(tmp_path, monkeypatch):
home = tmp_path / "home"
workspace = tmp_path / "redacted"
(home / "memories").mkdir(parents=True)
workspace.mkdir()
secret = "ghp_TestFakeCredential1234567890ab"
(workspace / "AGENTS.md").write_text(
f"# Agent Rules\n\nGitHub PAT: {secret}\nNormal note: keep me.",
encoding="utf-8",
)
monkeypatch.setattr(api.profiles, "get_active_hermes_home", lambda: home)
monkeypatch.setattr(routes, "_memory_project_context_workspace", lambda _parsed: workspace)
monkeypatch.setattr(routes, "_external_notes_sources_enabled", lambda: False)
monkeypatch.setattr(routes, "j", lambda _handler, payload, **_kwargs: payload)
payload = routes._handle_memory_read(object(), SimpleNamespace(query=""))
dumped = json.dumps(payload)
assert secret not in dumped
assert "Normal note: keep me." in payload["project_context"]
def test_memory_panel_defines_read_only_project_context_section():
panels = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
assert "key: 'project_context'" in panels
assert "readOnly: true" in panels
assert "project_context_shadowed" in panels
assert "/api/memory?session_id=" in panels
def test_blank_session_workspace_does_not_resolve_to_server_cwd(monkeypatch):
# Regression for the empty-workspace guard: a session with a blank workspace
# (freshly-created/draft sessions) must return None rather than letting
# Path("").resolve() surface the server's own CWD as project context.
monkeypatch.setattr(routes, "get_session", lambda _sid: SimpleNamespace(workspace=""))
parsed = SimpleNamespace(query="session_id=draft-session")
assert routes._memory_project_context_workspace(parsed) is None
def test_project_context_reads_lowercase_agents_md(tmp_path):
# Parity with the agent, which matches lowercase filename variants.
workspace = tmp_path / "lowercase"
workspace.mkdir()
(workspace / "agents.md").write_text("# lower agents\n\nlowercase loads.", encoding="utf-8")
data = project_context_for(workspace)
# On case-insensitive filesystems the resolved name may normalize to the
# uppercase candidate; the point of the fix is that the content loads at all
# (on case-sensitive Linux CI, only the lowercase candidate matches).
assert "lowercase loads." in data["content"]
assert data["path"].lower().endswith("agents.md")
def test_project_context_strips_yaml_frontmatter(tmp_path):
# Parity with the agent, which strips frontmatter before injecting context.
workspace = tmp_path / "frontmatter"
workspace.mkdir()
(workspace / "AGENTS.md").write_text(
"---\ntitle: Rules\nowner: me\n---\n\nActual body the agent injects.",
encoding="utf-8",
)
data = project_context_for(workspace)
assert "Actual body the agent injects." in data["content"]
assert "title: Rules" not in data["content"]