"""Behavior contracts for journey node edit/delete (agent.learning_mutations). Exercises the real on-disk resolution (skills dir + MEMORY.md/USER.md chunking) against a temp HERMES_HOME, never mocks — the id→file mapping is the whole point. """ from __future__ import annotations import threading import pytest from agent import learning_mutations as lm from hermes_constants import get_hermes_home _SKILL = """--- name: my-skill description: A test skill. --- # My Skill Body. """ @pytest.fixture def home(): base = get_hermes_home() (base / "memories").mkdir(parents=True, exist_ok=True) (base / "memories" / "MEMORY.md").write_text("alpha note\nline two\n§\nbeta note", encoding="utf-8") (base / "memories" / "USER.md").write_text("user profile note", encoding="utf-8") skill = base / "skills" / "my-skill" skill.mkdir(parents=True, exist_ok=True) (skill / "SKILL.md").write_text(_SKILL, encoding="utf-8") return base def test_parse_node_kind(): assert lm.parse_node_kind("memory:memory:0") == "memory" assert lm.parse_node_kind("memory:profile:3") == "memory" assert lm.parse_node_kind("debugging-hermes") == "skill" def test_edit_memory_replaces_chunk(home): assert lm.edit_node("memory:profile:2", "rewritten profile")["ok"] assert (home / "memories" / "USER.md").read_text(encoding="utf-8").strip() == "rewritten profile" def test_edit_memory_refuses_an_entry_over_the_memory_tools_limit(home): """Same char cap as the memory tool's replace: an over-limit entry reads as external drift to every later mutation, so the tool's own remove/replace would refuse (with a .bak) until the file is fixed by hand.""" from tools.memory_tool import MemoryStore result = lm.edit_node("memory:profile:2", "x" * 5000) assert not result["ok"] and "Shorten" in result["message"] assert (home / "memories" / "USER.md").read_text(encoding="utf-8") == "user profile note" assert MemoryStore().remove("user", "user profile note")["success"] # The cap gates what an edit adds, never a delete: pruning is the way out of a file that is # already over its total (lowered memory_char_limit, well-formed hand edits). from tools.memory_tool import ENTRY_DELIMITER path = home / "memories" / "USER.md" path.write_text(ENTRY_DELIMITER.join(f"entry {i} " + "y" * 500 for i in range(4)), encoding="utf-8") assert lm.delete_node("memory:profile:2")["ok"] assert len(MemoryStore._read_file(path)) == 3 def test_skill_detail_returns_skill_md(home): d = lm.node_detail("my-skill") assert d["ok"] and d["kind"] == "skill" assert "name: my-skill" in d["content"] def test_delete_pinned_skill_refused(home): from tools import skill_usage skill_usage.set_pinned("my-skill", True) res = lm.delete_node("my-skill") assert not res["ok"] assert "pinned" in res["message"] assert (home / "skills" / "my-skill").exists() def test_memory_writes_match_memory_tool_format(home): """A journey mutation must leave the file byte-identical to what the memory tool itself writes — same §-join, no trailing-newline drift — so the two surfaces never fight over format and indices stay aligned.""" from tools.memory_tool import ENTRY_DELIMITER, MemoryStore assert lm.edit_node("memory:memory:0", "alpha rewritten")["ok"] path = home / "memories" / "MEMORY.md" entries = MemoryStore._read_file(path) assert entries == ["alpha rewritten", "beta note"] assert path.read_text(encoding="utf-8") == ENTRY_DELIMITER.join(entries) # A Notepad BOM must not make the first card's id permanently "stale": the graph and the # store must parse the file the same way. path.write_text("alpha note\n§\nbeta note", encoding="utf-8-sig") from agent.learning_graph import build_learning_graph first = next(n for n in build_learning_graph()["nodes"] if n["kind"] == "memory") assert first["label"] == "alpha note" # the BOM is not part of the card's title assert lm.edit_node(first["id"], "alpha sans bom")["ok"] assert MemoryStore._read_file(path) == ["alpha sans bom", "beta note"] # ── Locking / drift (issue #119668) ───────────────────────────────────────── # A Journey mutation shares MEMORY.md with the live agent's memory tool, so it must # take the same lock, re-read under it and honour the drift guard — otherwise a # memory the agent stored in the meantime is rewritten away from a stale snapshot. def _race_memory_tool_add(monkeypatch, content: str) -> threading.Thread: """Start a lock-respecting ``memory_tool`` writer that appends *content* the moment the Journey mutation resolves its node id (the read half of its read-modify-write), then give it a moment to land. Under a correct lock the writer blocks until the mutation has written; without one it interleaves and the mutation's write clobbers it.""" from agent import learning_graph from tools.memory_tool import MemoryStore located, landed = threading.Event(), threading.Event() real_cards = learning_graph._memory_cards def _cards_then_let_writer_in(): cards = real_cards() located.set() landed.wait(timeout=0.5) return cards def _writer(): located.wait(timeout=5) MemoryStore().add("memory", content) landed.set() monkeypatch.setattr(learning_graph, "_memory_cards", _cards_then_let_writer_in) thread = threading.Thread(target=_writer, daemon=True) thread.start() return thread def test_delete_memory_keeps_concurrent_memory_tool_add(home, monkeypatch): from tools.memory_tool import MemoryStore writer = _race_memory_tool_add(monkeypatch, "gamma note") assert lm.delete_node("memory:memory:0")["ok"] writer.join(timeout=5) assert MemoryStore._read_file(home / "memories" / "MEMORY.md") == ["beta note", "gamma note"] def test_edit_memory_keeps_concurrent_memory_tool_add(home, monkeypatch): from tools.memory_tool import MemoryStore writer = _race_memory_tool_add(monkeypatch, "gamma note") assert lm.edit_node("memory:memory:0", "alpha rewritten")["ok"] writer.join(timeout=5) assert MemoryStore._read_file(home / "memories" / "MEMORY.md") == ["alpha rewritten", "beta note", "gamma note"] @pytest.mark.parametrize("mutate", [lambda: lm.delete_node("memory:memory:0"), lambda: lm.edit_node("memory:memory:0", "alpha rewritten")], ids=["delete", "edit"]) def test_memory_drift_is_refused_with_backup_like_memory_tool(home, mutate): """Free-form text appended without § delimiters (one "entry" past the whole-store limit) is what the memory tool's drift guard exists for: snapshot to .bak, refuse, leave the file untouched. A Journey mutation must not flatten it silently.""" from tools.memory_tool_store import _drift_error path = home / "memories" / "MEMORY.md" raw = "alpha note\n§\nbeta note\n\n## Vendor Master\n" + "x" * 2300 + "\n" path.write_text(raw, encoding="utf-8") res = mutate() assert not res["ok"] assert path.read_text(encoding="utf-8") == raw (backup,) = home.glob("memories/MEMORY.md.bak.*") assert backup.read_text(encoding="utf-8") == raw assert res["message"] == _drift_error(path, str(backup))["error"] def test_unreadable_memory_file_is_refused_unchanged(home): path = home / "memories" / "MEMORY.md" path.write_bytes(b"alpha note\n\xc3\x28\n\xc2\xa7\nbeta") res = lm.delete_node("memory:memory:0") assert not res["ok"] and "could not be read" in res["message"] assert path.read_bytes() == b"alpha note\n\xc3\x28\n\xc2\xa7\nbeta" # ── Node identity: the card the user clicked, not the index it sat at ──────── def _memory_entries(home) -> list[str]: from tools.memory_tool import MemoryStore return MemoryStore._read_file(home / "memories" / "MEMORY.md") def _write_memory_file(home, *entries): from tools.memory_tool import ENTRY_DELIMITER (home / "memories" / "MEMORY.md").write_text(ENTRY_DELIMITER.join(entries), encoding="utf-8") def _journey_id(home, label: str) -> str: """The node id Journey renders for the card whose text starts with *label*.""" from agent.learning_graph import build_learning_graph nodes = [n for n in build_learning_graph()["nodes"] if n["kind"] == "memory"] index = next(i for i, entry in enumerate(_memory_entries(home)) if entry.startswith(label)) return nodes[index]["id"] @pytest.mark.parametrize("op", ["edit", "delete"]) def test_mutation_targets_the_clicked_card_when_the_list_shifted_before_submit(home, op): """The window a displayed-index identity cannot see: an earlier entry is removed AFTER the graph is drawn and BEFORE the edit/delete is submitted, so by the time the mutation reads the file that index names somebody else's card.""" node_id = _journey_id(home, "beta") # what Journey showed the user _write_memory_file(home, "beta note", "gamma note") mutate = (lambda: lm.edit_node(node_id, "beta rewritten")) if op == "edit" else (lambda: lm.delete_node(node_id)) assert mutate()["ok"] assert _memory_entries(home) == (["beta rewritten", "gamma note"] if op == "edit" else ["gamma note"]) # The clicked card is gone now; its id must not fall back to whatever sits at that index. result = mutate() assert result["ok"] is False and "stale" in result["message"]