Files
rune/tests/test_message_graph.py
T
Nick 509f203906
local-review.yml (push) Invalid workflow file
Auto-version and Release / release (push) Successful in 10s
CI Pipeline / lint (push) Failing after 28s
CI Pipeline / go-surface (push) Failing after 18s
CI Pipeline / rust-surface (push) Failing after 9s
CI Pipeline / canary (push) Failing after 1m2s
CI Pipeline / best-practices (push) Successful in 6s
CI Pipeline / documentation (push) Successful in 6s
CI Pipeline / type-check (push) Failing after 33s
CI Pipeline / gitea-offline (push) Successful in 17s
CI Pipeline / unit-tests (push) Failing after 8m15s
deploy / deploy (push) Failing after 15m11s
CI Pipeline / required-checks (push) Failing after 0s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Failing after 9s
feat(tui): the transcript is a connected graph — #13 (contract BUILDER-2026-10-08T14:51:22Z-da0ce3)
- src/rune/message_graph.py: stable 12-char IDs + typed kinds (message/reply/
  tool_call/tool_result/revision/branch_head); explicit edges (parent,
  reply_to, calls, results_in, revises, supersedes, branches_from).
- Provenance survives iteration: revise() ADDS a node and marks the old one
  superseded — never deletes. history() walks the full revision trail.
- Lineage context: context for a selected message = its ancestors + itself,
  superseded nodes yielding to revisions — not unrelated-history replay.
- Flat-transcript migration (migrate_flat): arrival-order chains, no loss.
- ChannelView.add_message lands every message in the graph (best-effort,
  degrade-not-crash; the bubble mounts first); the message_id rides metadata.
- Quiet cues: one line per relationship (replaces/superseded by/branched
  from) — no clutter.
- tests/test_message_graph.py 9/9: integrity, selection, revisions,
  persistence round-trip, migration, cues.
2026-10-08 15:54:49 +01:00

104 lines
4.1 KiB
Python

"""test_message_graph.py — #13: graph integrity, context selection,
revision history, and persistence (incl. flat-transcript migration)."""
import json
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from rune import message_graph as mg # noqa: E402
class TestMessageGraph(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.g = mg.MessageGraph(path=Path(self._tmp.name) / "g.json")
def tearDown(self):
self._tmp.cleanup()
def test_stable_ids_and_typed_kinds(self):
a = self.g.add("user", "hello")
b = self.g.add("assistant", "hi", kind="reply", parent=a["id"], edge="reply_to")
self.assertEqual(len(a["id"]), 12)
self.assertEqual(b["parent"], a["id"])
self.assertEqual(b["edges"], [{"type": "reply_to", "to": a["id"]}])
with self.assertRaises(ValueError):
self.g.add("user", "x", kind="nonsense")
def test_tool_call_result_link(self):
c = self.g.tool_call("read", {"path": "a.py"})
r = self.g.tool_result(c["id"], "contents")
self.assertEqual(r["kind"], "tool_result")
self.assertEqual(r["edges"], [{"type": "results_in", "to": c["id"]}])
with self.assertRaises(KeyError):
self.g.tool_result("nope" * 3, "x")
def test_revision_preserves_provenance(self):
a = self.g.add("assistant", "first answer")
rev = self.g.revise(a["id"], "better answer")
self.assertEqual(self.g.nodes[a["id"]]["superseded_by"], rev["id"])
self.assertIn({"type": "supersedes", "to": a["id"]}, rev["edges"])
self.assertEqual(rev["content"], "better answer")
# the original content survives
self.assertEqual(self.g.nodes[a["id"]]["content"], "first answer")
def test_lineage_skips_superseded(self):
a = self.g.add("user", "q")
b = self.g.add("assistant", "v1", parent=a["id"])
rev = self.g.revise(b["id"], "v2")
lin = self.g.lineage()
ids = [n["id"] for n in lin]
self.assertIn(a["id"], ids)
self.assertIn(rev["id"], ids)
self.assertNotIn(b["id"], ids, "superseded nodes yield to their revision")
# unrelated branches excluded
other = self.g.add("user", "unrelated")
lin2 = self.g.lineage(a["id"])
self.assertNotIn(other["id"], [n["id"] for n in lin2])
def test_branch_from_any_message(self):
a = self.g.add("user", "root")
self.g.add("assistant", "answer", parent=a["id"])
head = self.g.branch(a["id"])
self.assertEqual(head["kind"], "branch_head")
lin = self.g.lineage(head["id"])
self.assertEqual([n["id"] for n in lin], [a["id"], head["id"]])
def test_revision_history_trail(self):
a = self.g.add("assistant", "v1")
r1 = self.g.revise(a["id"], "v2")
r2 = self.g.revise(r1["id"], "v3")
trail = self.g.history(r2["id"])
self.assertEqual([n["content"] for n in trail], ["v1", "v2", "v3"])
def test_persistence_round_trip(self):
a = self.g.add("user", "persist me")
self.g.save()
g2 = mg.MessageGraph(path=Path(self._tmp.name) / "g.json")
self.assertEqual(g2.nodes[a["id"]]["content"], "persist me")
self.assertEqual(g2.order, self.g.order)
def test_flat_migration(self):
flat = [{"role": "user", "content": "q1"}, {"role": "assistant", "content": "a1"}]
g = mg.MessageGraph.migrate_flat(flat, path=Path(self._tmp.name) / "m.json")
self.assertEqual(len(g.order), 2)
second = g.nodes[g.order[1]]
self.assertEqual(second["parent"], g.order[0])
self.assertEqual([n["kind"] for n in g.lineage()], ["message", "message"])
def test_quiet_cues(self):
a = self.g.add("assistant", "v1")
self.assertEqual(mg.MessageGraph.cue(a), "")
rev = self.g.revise(a["id"], "v2")
self.assertIn("replaces", mg.MessageGraph.cue(rev))
self.assertIn("superseded by", mg.MessageGraph.cue(self.g.nodes[a["id"]]))
if __name__ == "__main__":
unittest.main()