- pack.py: single-call context pack, budgeted, sanitized, cached, with served-id tracking that turns a citation into POSITIVE feedback - assist.py: error-signature lookup (never raw stdout, which returned 0 results in v1) - maintain.py: transactional consolidation (add merged -> verify -> delete sources) - transcript.py: JSONL parsing into non-overlapping windows via a cursor - cli.py: the twelve commands the hooks invoke; detached assist queues a block that the next prompt hook drains, keeping the hot path network-free - hooks generated from one spec; plugin manifest, MCP wiring, five skills Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""The write path, exercised against a fake Api that records every call.
|
|
|
|
No network. The fake models the two platform behaviors this path depends on:
|
|
add() answers only with {event_id, status: PENDING}, and a session_state record
|
|
written with infer=False becomes visible to the next get_all().
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from mem0_agent.capture import CANDIDATES_FILE, Buffer, flush, observe, upsert_session_state
|
|
from mem0_agent.ctx import Ctx
|
|
from mem0_agent.settings import DEFAULTS, SessionState, Settings
|
|
|
|
# --------------------------------------------------------------------------
|
|
# fakes
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class FakeApi:
|
|
"""Records calls; returns the shapes the live API actually returns."""
|
|
|
|
def __init__(self, add_status: int = 200):
|
|
self.adds: list[dict] = []
|
|
self.updates: list[dict] = []
|
|
self.get_alls: list[dict] = []
|
|
self.add_status = add_status
|
|
self.rows: list[dict] = []
|
|
|
|
def add(self, messages, **kw):
|
|
self.adds.append({"messages": messages, **kw})
|
|
if self.add_status >= 300:
|
|
return self.add_status, {"error": "boom"}
|
|
# infer=False writes land immediately and are readable; infer=True does not.
|
|
if kw.get("infer") is False:
|
|
self.rows.append(
|
|
{
|
|
"id": f"mem-{len(self.rows) + 1}",
|
|
"memory": messages[0]["content"],
|
|
"metadata": kw.get("metadata", {}),
|
|
}
|
|
)
|
|
return self.add_status, {"event_id": f"evt-{len(self.adds)}", "status": "PENDING"}
|
|
|
|
def update(self, memory_id, **kw):
|
|
self.updates.append({"id": memory_id, **kw})
|
|
for row in self.rows:
|
|
if row["id"] == memory_id:
|
|
row["memory"] = kw.get("text", row["memory"])
|
|
return 200, {"message": "Memory updated successfully!"}
|
|
|
|
def get_all(self, filters, **kw):
|
|
self.get_alls.append({"filters": filters, **kw})
|
|
return 200, {"count": len(self.rows), "results": list(self.rows)}
|
|
|
|
|
|
def make_ctx(tmp_path, api=None, ready=True, capture="balanced") -> Ctx:
|
|
settings = Settings(data=dict(DEFAULTS), path=tmp_path / "settings.json")
|
|
settings.data["capture"] = capture
|
|
state = SessionState("sess-1", root=tmp_path / "sessions")
|
|
return Ctx(
|
|
api=api if api is not None else FakeApi(),
|
|
settings=settings,
|
|
state=state,
|
|
user_id="dev",
|
|
app_id="mem0ai-mem0",
|
|
session_id="sess-1",
|
|
branch="claude/mem0-agent-v2",
|
|
ready=ready,
|
|
)
|
|
|
|
|
|
def u(text: str) -> dict:
|
|
return {"role": "user", "content": text}
|
|
|
|
|
|
def a(text: str) -> dict:
|
|
return {"role": "assistant", "content": text}
|
|
|
|
|
|
PREFERENCE = [u("Remember this: I always want the linter run before you tell me a task is done.")]
|
|
DECISION = [
|
|
a("Postgres or DynamoDB for the event log?"),
|
|
u("Let's go with Postgres because our access patterns are relational."),
|
|
]
|
|
CONVENTION = [u("Always name migration files with a UTC timestamp prefix - that's the rule here.")]
|
|
|
|
TRAINING_HEARTBEAT = (
|
|
"Task notification (task-id bukn4vw5n): v4 train metrics at epoch 0.7381/2 "
|
|
"(37% complete) with loss 0.4727, gradient norm 0.4716, ETA 124 minutes."
|
|
)
|
|
CHUNK_PROGRESS = (
|
|
"Progress for task bnzbd1uay: 218 of 928 chunks processed (23% complete), "
|
|
"approximately 5,141 synthetic memories generated, 11 chunk failures, "
|
|
"ETA about 55 minutes."
|
|
)
|
|
FILE_INVENTORY = (
|
|
"I modified VERSION, chat.py, agent.py, types.py, chunking.py, the slack adapter, "
|
|
"the router, the tests, and several web components in this session."
|
|
)
|
|
REPO_PASTE = (
|
|
"Here are the contents of our CLAUDE.md so you have the rules:\n\n"
|
|
"# AGENTS.md\n\n## Repository Structure\n\nA polyglot monorepo.\n\n"
|
|
"## Coding Standards\n\n- snake_case.py for Python sources\n"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# observe / buffer
|
|
# --------------------------------------------------------------------------
|
|
def test_observe_buffers_a_flagged_window(tmp_path):
|
|
ctx = make_ctx(tmp_path)
|
|
result = observe(ctx, PREFERENCE, "balanced")
|
|
assert result.action == "flag"
|
|
|
|
pending = Buffer(ctx).pending()
|
|
assert len(pending) == 1
|
|
assert pending[0]["mtype"] == "preference"
|
|
assert pending[0]["window"] == PREFERENCE
|
|
assert pending[0]["ts"] > 0
|
|
|
|
|
|
@pytest.mark.parametrize("text", [TRAINING_HEARTBEAT, CHUNK_PROGRESS, FILE_INVENTORY])
|
|
def test_noise_never_reaches_the_buffer_or_the_api(tmp_path, text):
|
|
api = FakeApi()
|
|
ctx = make_ctx(tmp_path, api)
|
|
assert observe(ctx, [a(text)], "aggressive").action == "drop"
|
|
assert Buffer(ctx).pending() == []
|
|
assert flush(ctx)["sent"] == 0
|
|
assert api.adds == []
|
|
|
|
|
|
def test_repo_content_is_never_sent(tmp_path):
|
|
api = FakeApi()
|
|
ctx = make_ctx(tmp_path, api)
|
|
result = observe(ctx, [u(REPO_PASTE)], "aggressive")
|
|
assert result.action == "drop"
|
|
assert result.reason.startswith("repo_content:")
|
|
flush(ctx)
|
|
assert api.adds == [], "repo file content must never leave the machine"
|
|
|
|
|
|
def test_observing_the_same_window_twice_drops_the_repeat(tmp_path):
|
|
ctx = make_ctx(tmp_path)
|
|
assert observe(ctx, DECISION, "balanced").action == "flag"
|
|
second = observe(ctx, DECISION, "balanced")
|
|
assert second.action == "drop"
|
|
assert second.reason == "repeated_shape"
|
|
assert len(Buffer(ctx).pending()) == 1
|
|
|
|
|
|
def test_observe_takes_the_level_from_settings_when_unset(tmp_path):
|
|
conservative = make_ctx(tmp_path / "a", capture="conservative")
|
|
assert observe(conservative, DECISION).action == "skip"
|
|
|
|
balanced = make_ctx(tmp_path / "b", capture="balanced")
|
|
assert observe(balanced, DECISION).mtype == "decision"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# flush
|
|
# --------------------------------------------------------------------------
|
|
def test_flush_writes_preferences_at_user_scope_and_the_rest_with_app_id(tmp_path):
|
|
api = FakeApi()
|
|
ctx = make_ctx(tmp_path, api)
|
|
observe(ctx, PREFERENCE, "balanced")
|
|
observe(ctx, DECISION, "balanced")
|
|
observe(ctx, CONVENTION, "balanced")
|
|
|
|
summary = flush(ctx)
|
|
assert summary["sent"] == 3
|
|
assert summary["failed"] == 0
|
|
assert summary["types"] == {"preference": 1, "decision": 1, "convention": 1}
|
|
assert len(summary["events"]) == 3
|
|
|
|
by_type = {call["metadata"]["type"]: call for call in api.adds}
|
|
assert set(by_type) == {"preference", "decision", "convention"}
|
|
|
|
pref = by_type["preference"]
|
|
assert "app_id" not in pref, "preference must land at user scope, without app_id"
|
|
assert pref["user_id"] == "dev"
|
|
assert pref["infer"] is True
|
|
assert pref["metadata"]["session_id"] == "sess-1"
|
|
assert pref["metadata"]["branch"] == "claude/mem0-agent-v2"
|
|
|
|
for mtype in ("decision", "convention"):
|
|
assert by_type[mtype]["app_id"] == "mem0ai-mem0"
|
|
assert by_type[mtype]["user_id"] == "dev"
|
|
|
|
|
|
def test_flush_sends_the_window_as_role_content_messages(tmp_path):
|
|
api = FakeApi()
|
|
ctx = make_ctx(tmp_path, api)
|
|
observe(ctx, DECISION, "balanced")
|
|
flush(ctx)
|
|
|
|
messages = api.adds[0]["messages"]
|
|
assert messages == [
|
|
{"role": "assistant", "content": DECISION[0]["content"]},
|
|
{"role": "user", "content": DECISION[1]["content"]},
|
|
]
|
|
|
|
|
|
def test_flush_is_idempotent_within_a_session(tmp_path):
|
|
api = FakeApi()
|
|
ctx = make_ctx(tmp_path, api)
|
|
observe(ctx, PREFERENCE, "balanced")
|
|
|
|
assert flush(ctx)["sent"] == 1
|
|
assert flush(ctx)["sent"] == 0
|
|
assert len(api.adds) == 1
|
|
assert not (ctx.state.dir / CANDIDATES_FILE).exists() or ctx.state.read_lines(CANDIDATES_FILE) == []
|
|
|
|
|
|
def test_flush_never_reads_a_write_back(tmp_path):
|
|
api = FakeApi()
|
|
ctx = make_ctx(tmp_path, api)
|
|
observe(ctx, PREFERENCE, "balanced")
|
|
flush(ctx)
|
|
assert api.get_alls == [], "extraction is asynchronous; nothing may be read back in-session"
|
|
|
|
|
|
def test_flush_fails_open_on_api_errors(tmp_path):
|
|
api = FakeApi(add_status=500)
|
|
ctx = make_ctx(tmp_path, api)
|
|
observe(ctx, PREFERENCE, "balanced")
|
|
|
|
summary = flush(ctx)
|
|
assert summary["sent"] == 0
|
|
assert summary["failed"] == 1
|
|
assert summary["errors"]
|
|
|
|
|
|
def test_flush_no_ops_when_the_context_is_not_ready(tmp_path):
|
|
ctx = make_ctx(tmp_path, api=None, ready=False)
|
|
ctx.api = None
|
|
summary = flush(ctx)
|
|
assert summary["sent"] == 0
|
|
assert "reason" in summary
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# session state
|
|
# --------------------------------------------------------------------------
|
|
def test_session_state_creates_once_then_updates(tmp_path):
|
|
api = FakeApi()
|
|
ctx = make_ctx(tmp_path, api)
|
|
|
|
assert upsert_session_state(ctx, "Goal: wire the capture path. Next: flush on stop.") == "created"
|
|
assert upsert_session_state(ctx, "Goal: wire the capture path. Next: ship tests.") == "updated"
|
|
assert upsert_session_state(ctx, "Goal: wire the capture path. Next: open the PR.") == "updated"
|
|
|
|
assert len(api.adds) == 1, "one open-thread record per session, never a second"
|
|
assert len(api.updates) == 2
|
|
assert len(api.rows) == 1
|
|
assert api.rows[0]["memory"].endswith("open the PR.")
|
|
|
|
|
|
def test_session_state_is_a_single_user_role_message(tmp_path):
|
|
"""infer=False stores assistant-role messages too, so only one user message goes out."""
|
|
api = FakeApi()
|
|
ctx = make_ctx(tmp_path, api)
|
|
upsert_session_state(ctx, "Goal: finish WS2.")
|
|
|
|
call = api.adds[0]
|
|
assert call["messages"] == [{"role": "user", "content": "Goal: finish WS2."}]
|
|
assert call["infer"] is False
|
|
assert call["metadata"]["type"] == "session_state"
|
|
assert call["metadata"]["session_id"] == "sess-1"
|
|
assert call["user_id"] == "dev"
|
|
assert call["app_id"] == "mem0ai-mem0"
|
|
assert len(call["expiration_date"]) == len("2026-07-28")
|
|
|
|
|
|
def test_session_state_lookup_is_scoped_to_this_session(tmp_path):
|
|
api = FakeApi()
|
|
ctx = make_ctx(tmp_path, api)
|
|
upsert_session_state(ctx, "Goal: finish WS2.")
|
|
|
|
clauses = api.get_alls[0]["filters"]["AND"]
|
|
assert {"user_id": "dev"} in clauses
|
|
assert {"app_id": "mem0ai-mem0"} in clauses
|
|
assert {"metadata": {"type": "session_state"}} in clauses
|
|
assert {"metadata": {"session_id": "sess-1"}} in clauses
|
|
|
|
|
|
def test_session_state_no_ops_when_not_ready_or_empty(tmp_path):
|
|
api = FakeApi()
|
|
not_ready = make_ctx(tmp_path / "a", api, ready=False)
|
|
assert upsert_session_state(not_ready, "anything") == "skipped"
|
|
assert api.adds == []
|
|
|
|
ready = make_ctx(tmp_path / "b", api)
|
|
assert upsert_session_state(ready, " ") == "skipped"
|
|
assert api.adds == []
|