HERMES-GIELIE/tests/test_3866_project_context_memory_section.py

385 lines
14 KiB
Python
Raw Normal View History

import json
import pathlib
import shutil
import subprocess
from types import SimpleNamespace
from urllib.parse import urlencode
import api.profiles
import api.routes as routes
import pytest
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
NODE = shutil.which("node")
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_memory_panel_references_all_memory_path_fields():
panels = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
assert "function _memorySectionPath(key)" in panels
assert "_memoryData.memory_path" in panels
assert "_memoryData.user_path" in panels
assert "_memoryData.soul_path" in panels
assert "_memoryData.project_context_path" in panels
assert "const sectionPath = _memorySectionPath(s.key)" in panels
assert "if (sectionPath) el.title = sectionPath" in panels
def _memory_render_blocks():
panels = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
helper_start = panels.index("function _memorySectionContent(key)")
helper_end = panels.index("function _setMemoryHeaderButtons", helper_start)
render_start = panels.index("function _renderMemoryDetail(section)")
render_end = panels.index("function _renderMemoryEdit", render_start)
return panels[helper_start:helper_end], panels[render_start:render_end]
def _run_memory_render_harness():
helper_block, render_block = _memory_render_blocks()
script = (
"const helperBlock = "
+ json.dumps(helper_block)
+ ";\nconst renderBlock = "
+ json.dumps(render_block)
+ ";\n"
+ r"""
let _memoryData = {
memory: 'Primary memory body',
memory_path: 'C:/Users/Rod/.hermes/memories/MEMORY.md',
memory_mtime: 1712345678,
user: 'User memory body',
user_path: 'C:/Users/Rod/.hermes/memories/USER.md',
user_mtime: 1712345678,
soul: 'Soul memory body',
soul_path: 'C:/Users/Rod/.hermes/SOUL.md',
soul_mtime: 1712345678,
project_context: 'Project context body',
project_context_path: 'D:/Repos/hermes-webui/AGENTS.md',
project_context_name: 'AGENTS.md',
project_context_mtime: 1712345678,
project_context_shadowed: [{name: 'CLAUDE.md', shadowed_by: 'AGENTS.md'}],
};
let _memoryMode = '';
const nodes = {
memoryDetailTitle: {textContent: '', style: {}},
memoryDetailBody: {innerHTML: '', style: {}},
memoryDetailEmpty: {style: {}},
};
function $(id) { return nodes[id] || null; }
function _memorySectionMeta(section) {
return {key: section, label: section, emptyKey: section + '_empty'};
}
function _memorySectionLabel(meta) { return meta.label; }
function _memorySectionEmpty(meta) { return meta.emptyKey; }
function _setMemoryHeaderButtons() {}
function renderMd(content) { return 'rendered:' + content; }
function esc(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
eval(helperBlock + "\n" + renderBlock);
_renderMemoryDetail('memory');
const memoryHtml = nodes.memoryDetailBody.innerHTML;
_renderMemoryDetail('user');
const userHtml = nodes.memoryDetailBody.innerHTML;
_renderMemoryDetail('soul');
const soulHtml = nodes.memoryDetailBody.innerHTML;
_renderMemoryDetail('project_context');
const projectHtml = nodes.memoryDetailBody.innerHTML;
console.log(JSON.stringify({memoryHtml, userHtml, soulHtml, projectHtml, memoryMode: _memoryMode}));
"""
)
completed = subprocess.run(
[NODE, "-e", script],
capture_output=True,
text=True,
encoding="utf-8",
timeout=30,
check=True,
)
return json.loads(completed.stdout)
def _memory_button_render_blocks():
panels = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
sections_start = panels.index("const MEMORY_SECTIONS = [")
sections_end = panels.index("];", sections_start) + 2
helper_start = panels.index("function _memorySectionPath(key)")
helper_end = panels.index("function _setMemoryHeaderButtons", helper_start)
loop_start = panels.index("for (const s of MEMORY_SECTIONS) {")
loop_end = panels.index(" if (_currentMemorySection && _memoryMode !== 'edit') {", loop_start)
return (
panels[sections_start:sections_end],
panels[helper_start:helper_end],
panels[loop_start:loop_end].rsplit(" }", 1)[0],
)
def _run_memory_button_harness():
sections_block, helper_block, loop_block = _memory_button_render_blocks()
script = (
"const sectionsBlock = "
+ json.dumps(sections_block)
+ ";\nconst helperBlock = "
+ json.dumps(helper_block)
+ ";\nconst loopBlock = "
+ json.dumps(loop_block)
+ ";\n"
+ r"""
let _memoryData = {
memory_path: 'C:/Users/Rod/.hermes/memories/MEMORY.md',
user_path: 'C:/Users/Rod/.hermes/memories/USER.md',
soul_path: 'C:/Users/Rod/.hermes/SOUL.md',
project_context_path: 'D:/Repos/hermes-webui/AGENTS.md',
external_notes_enabled: true,
};
let _currentMemorySection = 'memory';
const nodes = {
memoryPanel: {
innerHTML: '',
appended: [],
appendChild(node) { this.appended.push(node); },
},
};
const document = {
createElement() {
return {
title: '',
type: '',
className: '',
innerHTML: '',
onclick: null,
classList: { add() {} },
setAttribute(name, value) { this[name] = value; },
};
},
};
function _memorySectionLabel(meta) { return meta.key; }
function li() { return ''; }
function esc(value) { return String(value); }
function openMemorySection() {}
eval(
sectionsBlock +
"\n" +
helperBlock +
"\nfunction renderButtons() {\nconst panel = nodes.memoryPanel;\n" +
loopBlock +
"\n}\nrenderButtons();"
);
const buttons = nodes.memoryPanel.appended.map(btn => ({
label: btn.innerHTML.replace(/<[^>]+>/g, ''),
title: btn.title || '',
}));
console.log(JSON.stringify(buttons));
"""
)
completed = subprocess.run(
[NODE, "-e", script],
capture_output=True,
text=True,
encoding="utf-8",
timeout=30,
check=True,
)
return json.loads(completed.stdout)
def test_memory_detail_renders_path_for_non_project_sections():
"""Base-fails/head-passes regression for issue #4999.
On base, `_renderMemoryDetail('memory')` ignores `memory_path`, so the
rendered header omits the path row entirely. On head, the same render must
show `MEMORY.md · <path>` using the existing pinned header row pattern.
"""
if NODE is None:
pytest.skip("node not on PATH")
rendered = _run_memory_render_harness()
assert "MEMORY.md" in rendered["memoryHtml"]
assert "C:/Users/Rod/.hermes/memories/MEMORY.md" in rendered["memoryHtml"]
assert "USER.md" in rendered["userHtml"]
assert "C:/Users/Rod/.hermes/memories/USER.md" in rendered["userHtml"]
assert "SOUL.md" in rendered["soulHtml"]
assert "C:/Users/Rod/.hermes/SOUL.md" in rendered["soulHtml"]
assert "AGENTS.md · D:/Repos/hermes-webui/AGENTS.md" in rendered["projectHtml"]
assert "CLAUDE.md present, shadowed by AGENTS.md" in rendered["projectHtml"]
def test_memory_section_list_renders_hover_path_titles():
"""Base-fails/head-passes regression for issue #5045."""
if NODE is None:
pytest.skip("node not on PATH")
rendered = {item["label"]: item["title"] for item in _run_memory_button_harness()}
assert rendered["memory"] == "C:/Users/Rod/.hermes/memories/MEMORY.md"
assert rendered["user"] == "C:/Users/Rod/.hermes/memories/USER.md"
assert rendered["soul"] == "C:/Users/Rod/.hermes/SOUL.md"
assert rendered["project_context"] == "D:/Repos/hermes-webui/AGENTS.md"
assert rendered["external_notes"] == ""
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"]