Files
openworker/tests/test_memory_api.py
T
Devika Verma ef59b0f39a Memory V1: remembered facts, your instructions, one screen
Coworkers remember durable things you tell them and use them in future sessions.
One Settings screen lists everything remembered - edit, delete, or stop new saves; standing instructions ride along.
Knowledge is session-stable, the save switch is per-message; sqlite gains a summary column via in-place migration.
2026-07-28 20:32:14 +05:30

198 lines
8.6 KiB
Python

"""MEMORY-SPEC V1 — REST API + user journeys (memory screen, toast undo, on/off, rules).
The three UI moments (§5) rest on this surface: the toast's Undo is DELETE /v1/memory/{id},
the "What I remember about you" screen is GET/PATCH/DELETE /v1/memory, and the toggle +
User Rules textarea are GET/PUT /v1/memory/settings.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from coworker.memory.settings import MAX_USER_RULES_CHARS
from coworker.providers import ModelCapabilities, ProviderClient
from coworker.server import SessionManager, create_app
class _StubProvider(ProviderClient):
def complete(self, **kwargs): # pragma: no cover - engine never completes here
raise NotImplementedError
def capabilities(self, model):
return ModelCapabilities()
def _fixture(tmp_path):
manager = SessionManager(workspace=tmp_path, provider=_StubProvider())
return TestClient(create_app(manager)), manager
# -- the memory screen (§5.3): list · edit · delete · delete all ----------------
def test_memory_crud_journey(tmp_path):
client, _ = _fixture(tmp_path)
added = client.post(
"/v1/memory", json={"content": "prefers short replies", "scope": "global"}
).json()
listed = client.get("/v1/memory").json()["memory"]
assert [m["content"] for m in listed] == ["prefers short replies"]
# rows carry what the screen renders (plus summary/created_at for future use)
assert {"id", "scope", "content", "summary", "created_at"} <= set(listed[0])
# the user fixes the sentence in place
patched = client.patch(
f"/v1/memory/{added['id']}", json={"content": "prefers detailed replies"}
).json()
assert patched["ok"] is True
assert client.get("/v1/memory").json()["memory"][0]["content"] == "prefers detailed replies"
# then deletes the row
assert client.delete(f"/v1/memory/{added['id']}").json()["ok"] is True
assert client.get("/v1/memory").json()["memory"] == []
def test_memory_edit_rejects_empty_and_unknown(tmp_path):
client, _ = _fixture(tmp_path)
added = client.post("/v1/memory", json={"content": "a fact"}).json()
assert client.patch(f"/v1/memory/{added['id']}", json={"content": " "}).json()["ok"] is False
assert client.patch("/v1/memory/424242", json={"content": "x"}).json()["ok"] is False
assert client.delete("/v1/memory/424242").json()["ok"] is False
# empty adds are rejected too — a blank row on the screen would be meaningless
assert client.post("/v1/memory", json={"content": " "}).json()["ok"] is False
def test_delete_all_journey(tmp_path):
"""§5.3 'Forget everything': wipes every scope and reports the count."""
client, _ = _fixture(tmp_path)
client.post("/v1/memory", json={"content": "one", "scope": "global"})
client.post("/v1/memory", json={"content": "two"})
assert client.delete("/v1/memory").json() == {"ok": True, "deleted": 2}
assert client.get("/v1/memory").json()["memory"] == []
def test_toast_undo_journey(tmp_path):
"""§5.1: the toast's [Undo] deletes exactly the row the save created."""
client, manager = _fixture(tmp_path)
kept = client.post("/v1/memory", json={"content": "keep me", "scope": "global"}).json()
saved = client.post("/v1/memory", json={"content": "oops", "scope": "global"}).json()
assert client.delete(f"/v1/memory/{saved['id']}").json()["ok"] is True
remaining = client.get("/v1/memory").json()["memory"]
assert [m["id"] for m in remaining] == [kept["id"]]
# -- settings (§4.3/§6): toggle + user rules ------------------------------------
def test_settings_roundtrip_and_partial_updates(tmp_path):
client, _ = _fixture(tmp_path)
assert client.get("/v1/memory/settings").json() == {"enabled": True, "user_rules": ""}
# rules-only update leaves the toggle alone, and vice versa
out = client.put("/v1/memory/settings", json={"user_rules": "Reply in Hindi"}).json()
assert out == {"enabled": True, "user_rules": "Reply in Hindi"}
out = client.put("/v1/memory/settings", json={"enabled": False}).json()
assert out == {"enabled": False, "user_rules": "Reply in Hindi"}
def test_settings_path_never_parses_as_memory_id(tmp_path):
client, _ = _fixture(tmp_path)
assert client.get("/v1/memory/settings").status_code == 200
# PATCH targets an integer id; "settings" must not match it
assert client.patch("/v1/memory/settings", json={"content": "x"}).status_code == 422
def test_user_rules_clamped_server_side(tmp_path):
"""Security: a hostile/buggy client can't inflate every future system prompt."""
client, _ = _fixture(tmp_path)
client.put(
"/v1/memory/settings", json={"user_rules": "r" * (MAX_USER_RULES_CHARS + 9_000)}
)
assert len(client.get("/v1/memory/settings").json()["user_rules"]) == MAX_USER_RULES_CHARS
# -- engine wiring (§4.3/§6): what a session actually gets ----------------------
def test_disabled_memory_refuses_writes_and_says_so(tmp_path):
"""§4.3: off = stop learning. The write tools stay registered (the switch can flip
back on mid-conversation) but refuse, and the per-turn notice tells the model so it
reports the truth instead of bluffing a save."""
client, manager = _fixture(tmp_path)
client.put("/v1/memory/settings", json={"enabled": False})
engine = manager.get_engine("mem-off-session")
assert engine is not None
assert engine.registry.execute("remember", {"content": "x"})["saved"] is False
assert engine.registry.execute("memory_forget", {"memory_id": 1})["deleted"] is False
assert "Saving new memories is turned off" in engine.context_provider()
def test_existing_memories_stay_known_while_off(tmp_path):
"""§4.3 (owner decision 2026-07-28): off stops SAVING, it does not erase or
silence. What the user already approved keeps working — the toggle's label says
"remember NEW things", and "what I already know is kept" reads as still-in-use."""
client, manager = _fixture(tmp_path)
client.post("/v1/memory", json={"content": "kept fact", "scope": "global"})
client.put("/v1/memory/settings", json={"enabled": False})
engine = manager.get_engine("still-knows-session")
assert "kept fact" in engine.messages[0]["content"]
# ...and the screen still lists it, so the user can delete it if they want it gone
assert [m["content"] for m in client.get("/v1/memory").json()["memory"]] == ["kept fact"]
# turning saving back on restores the write tools for NEW sessions
client.put("/v1/memory/settings", json={"enabled": True})
engine2 = manager.get_engine("back-on-session")
assert "remember" in engine2.registry.names()
assert "kept fact" in engine2.messages[0]["content"]
def test_toggle_off_applies_to_a_running_session(tmp_path):
"""End to end for the live switch: a session built while saving was ON must stop
saving the moment the user flips it off — no restart, no new conversation."""
client, manager = _fixture(tmp_path)
engine = manager.get_engine("live-switch-session")
first = engine.registry.execute(
"remember", {"content": "saved while on", "scope": "global"}
)
assert first["saved"] is True
client.put("/v1/memory/settings", json={"enabled": False})
blocked = engine.registry.execute(
"remember", {"content": "must not persist", "scope": "global"}
)
assert blocked["saved"] is False
contents = [m["content"] for m in client.get("/v1/memory").json()["memory"]]
assert contents == ["saved while on"]
def test_user_rules_reach_new_sessions_and_outrank_memories(tmp_path):
client, manager = _fixture(tmp_path)
client.put("/v1/memory/settings", json={"user_rules": "Always reply in Hindi"})
client.post("/v1/memory", json={"content": "prefers English", "scope": "global"})
sys_prompt = manager.get_engine("rules-session").messages[0]["content"]
assert "Always reply in Hindi" in sys_prompt
assert "outrank" in sys_prompt # the rules block states its precedence
# rules are injected ABOVE learned memories (spec §6)
assert sys_prompt.index("Always reply in Hindi") < sys_prompt.index("prefers English")
def test_agent_saves_reach_the_screen(tmp_path):
"""Journey: the agent's `remember` (with summary) lands in the same store the
screen lists — one source of truth for chat and Settings."""
client, manager = _fixture(tmp_path)
engine = manager.get_engine("save-session")
engine.registry.execute(
"remember",
{"content": "user is not technical — avoid jargon", "summary": "avoid jargon", "scope": "global"},
)
rows = client.get("/v1/memory").json()["memory"]
assert [r["summary"] for r in rows] == ["avoid jargon"]