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
- 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.
104 lines
4.1 KiB
Python
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()
|