Found by running the CLI live against a scratch project: - hook_input() blocked forever on an open stdin pipe with no data. A real hook writes JSON and closes the pipe, but any manual invocation hung; now polled with select() and never blocks. - the open-thread snapshot was filling with progress lines and file-modification lists -- v1's exact failure -- so it now filters turns through the same hard-drop rules the capture gate uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
149 lines
4.8 KiB
Python
149 lines
4.8 KiB
Python
"""Consolidation must never lose data, and must never touch a pinned memory."""
|
|
|
|
|
|
from mem0_agent import maintain
|
|
from mem0_agent.maintain import jaccard, tokens
|
|
|
|
|
|
class FakeApi:
|
|
def __init__(self, rows, fail_add=False, fail_delete=False):
|
|
self.rows = rows
|
|
self.added, self.deleted, self.updated = [], [], []
|
|
self.fail_add = fail_add
|
|
self.fail_delete = fail_delete
|
|
|
|
def get_all(self, filters, page=1, page_size=50, **kw):
|
|
return (200, {"results": self.rows if page == 1 else []})
|
|
|
|
def add(self, messages, **kw):
|
|
if self.fail_add:
|
|
return (500, {"error": "boom"})
|
|
self.added.append((messages, kw))
|
|
return (200, {"event_id": "e1", "status": "PENDING"})
|
|
|
|
def delete(self, mid, **kw):
|
|
if self.fail_delete:
|
|
return (500, {"error": "boom"})
|
|
self.deleted.append(mid)
|
|
return (200, {"message": "ok"})
|
|
|
|
def update(self, mid, **kw):
|
|
self.updated.append((mid, kw))
|
|
return (200, {"message": "ok"})
|
|
|
|
|
|
class FakeCtx:
|
|
def __init__(self, api):
|
|
self.api = api
|
|
self.ready = True
|
|
self.user_id, self.app_id, self.session_id = "u", "app", "s"
|
|
|
|
def provenance(self, mtype):
|
|
return {"type": mtype, "session_id": self.session_id}
|
|
|
|
def log(self, *a, **k):
|
|
pass
|
|
|
|
|
|
def mem(mid, text, mtype="insight", pinned=False, created="2026-07-01T00:00:00"):
|
|
md = {"type": mtype}
|
|
if pinned:
|
|
md["pinned"] = True
|
|
return {"id": mid, "memory": text, "metadata": md, "created_at": created,
|
|
"updated_at": created, "categories": [mtype]}
|
|
|
|
|
|
HEARTBEATS = [
|
|
mem("h1", "Training reached epoch 0.73 of 2 with loss 0.47 and ETA 124 minutes"),
|
|
mem("h2", "Training reached epoch 0.72 of 2 with loss 0.43 and ETA 166 minutes"),
|
|
mem("h3", "Training reached epoch 0.71 of 2 with loss 0.44 and ETA 169 minutes"),
|
|
]
|
|
|
|
|
|
def test_jaccard_flags_the_real_heartbeat_cluster():
|
|
a, b = tokens(HEARTBEATS[0]["memory"]), tokens(HEARTBEATS[1]["memory"])
|
|
assert jaccard(a, b) >= maintain.NEAR_DUP_THRESHOLD
|
|
|
|
|
|
def test_plan_clusters_near_duplicates_transitively():
|
|
ctx = FakeCtx(FakeApi(HEARTBEATS))
|
|
p = maintain.plan(ctx)
|
|
assert len(p.merges) == 1
|
|
assert p.merges[0]["count"] == 3
|
|
assert set(p.merges[0]["sources"]) == {"h1", "h2", "h3"}
|
|
|
|
|
|
def test_distinct_memories_are_not_merged():
|
|
rows = [
|
|
mem("a", "pytest in server/ needs docker compose up first"),
|
|
mem("b", "the release tag prefix for the node CLI is cli-node-v"),
|
|
]
|
|
p = maintain.plan(FakeCtx(FakeApi(rows)))
|
|
assert p.merges == []
|
|
|
|
|
|
def test_different_types_never_merge_even_when_similar():
|
|
rows = [
|
|
mem("a", "always run the type checker before committing", "preference"),
|
|
mem("b", "always run the type checker before committing", "convention"),
|
|
]
|
|
p = maintain.plan(FakeCtx(FakeApi(rows)))
|
|
assert p.merges == []
|
|
|
|
|
|
def test_pinned_memories_are_never_planned():
|
|
rows = HEARTBEATS + [mem("p1", HEARTBEATS[0]["memory"], pinned=True)]
|
|
p = maintain.plan(FakeCtx(FakeApi(rows)))
|
|
assert all("p1" not in m["sources"] for m in p.merges)
|
|
assert p.scanned == 3
|
|
|
|
|
|
def test_dry_run_changes_nothing():
|
|
api = FakeApi(HEARTBEATS)
|
|
out = maintain.run(FakeCtx(api), dry_run=True)
|
|
assert out["dry_run"] is True
|
|
assert api.added == [] and api.deleted == []
|
|
|
|
|
|
def test_apply_adds_before_deleting():
|
|
api = FakeApi(list(HEARTBEATS))
|
|
ctx = FakeCtx(api)
|
|
out = maintain.run(ctx, dry_run=False)
|
|
assert out["merged"] == 1
|
|
assert out["deleted"] == 3
|
|
# the merged record is written with infer=False so it is stored verbatim
|
|
assert api.added[0][1]["infer"] is False
|
|
|
|
|
|
def test_failed_merge_leaves_sources_intact():
|
|
"""A crash mid-merge must leave a duplicate, never a hole."""
|
|
api = FakeApi(list(HEARTBEATS), fail_add=True)
|
|
out = maintain.run(FakeCtx(api), dry_run=False)
|
|
assert out["merged"] == 0
|
|
assert api.deleted == [], "sources must survive when the merged write fails"
|
|
assert out["skipped"] == 1
|
|
|
|
|
|
def test_stale_insights_are_expired_not_deleted():
|
|
old = mem("old", "a gotcha nobody has needed in a year", created="2025-01-01T00:00:00")
|
|
api = FakeApi([old])
|
|
out = maintain.run(FakeCtx(api), dry_run=False, stale_days=180)
|
|
assert out["expired"] == 1
|
|
assert api.deleted == [], "expiration hides; it must not delete"
|
|
assert "expiration_date" in api.updated[0][1]
|
|
|
|
|
|
def test_recent_insights_are_left_alone():
|
|
import time
|
|
recent = mem("new", "a gotcha found this week",
|
|
created=time.strftime("%Y-%m-%dT%H:%M:%S"))
|
|
p = maintain.plan(FakeCtx(FakeApi([recent])))
|
|
assert p.expiries == []
|
|
|
|
|
|
def test_unready_context_is_a_noop():
|
|
ctx = FakeCtx(FakeApi([]))
|
|
ctx.ready = False
|
|
p = maintain.plan(ctx)
|
|
assert p.scanned == 0 and p.errors
|